diff --git a/dd-sdk-android-core/api/apiSurface b/dd-sdk-android-core/api/apiSurface index 312e9564cf..cc8d62fe39 100644 --- a/dd-sdk-android-core/api/apiSurface +++ b/dd-sdk-android-core/api/apiSurface @@ -97,6 +97,8 @@ interface com.datadog.android.api.feature.Feature const val TRACING_FEATURE_NAME: String const val SESSION_REPLAY_FEATURE_NAME: String const val NDK_CRASH_REPORTS_FEATURE_NAME: String +interface com.datadog.android.api.feature.FeatureContextUpdateReceiver + fun onContextUpdate(String, Map) interface com.datadog.android.api.feature.FeatureEventReceiver fun onReceive(Any) interface com.datadog.android.api.feature.FeatureScope @@ -110,6 +112,8 @@ interface com.datadog.android.api.feature.FeatureSdkCore : com.datadog.android.a fun updateFeatureContext(String, (MutableMap) -> Unit) fun getFeatureContext(String): Map fun setEventReceiver(String, FeatureEventReceiver) + fun setContextUpdateReceiver(String, FeatureContextUpdateReceiver) + fun removeContextUpdateReceiver(String, FeatureContextUpdateReceiver) fun removeEventReceiver(String) fun createSingleThreadExecutorService(): java.util.concurrent.ExecutorService fun createScheduledExecutorService(): java.util.concurrent.ScheduledExecutorService diff --git a/dd-sdk-android-core/api/dd-sdk-android-core.api b/dd-sdk-android-core/api/dd-sdk-android-core.api index d563643369..b0c4d763f6 100644 --- a/dd-sdk-android-core/api/dd-sdk-android-core.api +++ b/dd-sdk-android-core/api/dd-sdk-android-core.api @@ -311,6 +311,10 @@ public final class com/datadog/android/api/feature/Feature$Companion { public static final field TRACING_FEATURE_NAME Ljava/lang/String; } +public abstract interface class com/datadog/android/api/feature/FeatureContextUpdateReceiver { + public abstract fun onContextUpdate (Ljava/lang/String;Ljava/util/Map;)V +} + public abstract interface class com/datadog/android/api/feature/FeatureEventReceiver { public abstract fun onReceive (Ljava/lang/Object;)V } @@ -332,7 +336,9 @@ public abstract interface class com/datadog/android/api/feature/FeatureSdkCore : public abstract fun getFeatureContext (Ljava/lang/String;)Ljava/util/Map; public abstract fun getInternalLogger ()Lcom/datadog/android/api/InternalLogger; public abstract fun registerFeature (Lcom/datadog/android/api/feature/Feature;)V + public abstract fun removeContextUpdateReceiver (Ljava/lang/String;Lcom/datadog/android/api/feature/FeatureContextUpdateReceiver;)V public abstract fun removeEventReceiver (Ljava/lang/String;)V + public abstract fun setContextUpdateReceiver (Ljava/lang/String;Lcom/datadog/android/api/feature/FeatureContextUpdateReceiver;)V public abstract fun setEventReceiver (Ljava/lang/String;Lcom/datadog/android/api/feature/FeatureEventReceiver;)V public abstract fun updateFeatureContext (Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V } diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/api/feature/FeatureContextUpdateReceiver.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/api/feature/FeatureContextUpdateReceiver.kt new file mode 100644 index 0000000000..ff8c0fa99d --- /dev/null +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/api/feature/FeatureContextUpdateReceiver.kt @@ -0,0 +1,23 @@ +/* + * 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.api.feature + +import androidx.annotation.AnyThread + +/** + * Receiver for feature context updates. + */ +fun interface FeatureContextUpdateReceiver { + + /** + * Called when the context for a feature is updated. + * @param featureName the name of the feature + * @param event the updated context + */ + @AnyThread + fun onContextUpdate(featureName: String, event: Map) +} diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/api/feature/FeatureSdkCore.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/api/feature/FeatureSdkCore.kt index d59abd5933..6b8d092252 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/api/feature/FeatureSdkCore.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/api/feature/FeatureSdkCore.kt @@ -67,6 +67,22 @@ interface FeatureSdkCore : SdkCore { */ fun setEventReceiver(featureName: String, receiver: FeatureEventReceiver) + /** + * Sets context update receiver for the given feature. + * + * @param featureName Feature name. + * @param listener Listener to remove. + */ + fun setContextUpdateReceiver(featureName: String, listener: FeatureContextUpdateReceiver) + + /** + * Removes context update listener for the given feature. + * + * @param featureName Feature name. + * @param listener Listener to remove. + */ + fun removeContextUpdateReceiver(featureName: String, listener: FeatureContextUpdateReceiver) + /** * Removes events receive for the given feature. * diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/DatadogCore.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/DatadogCore.kt index 3779f28a52..d1e76af58d 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/DatadogCore.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/DatadogCore.kt @@ -20,6 +20,7 @@ import com.datadog.android.api.context.NetworkInfo import com.datadog.android.api.context.TimeInfo import com.datadog.android.api.context.UserInfo import com.datadog.android.api.feature.Feature +import com.datadog.android.api.feature.FeatureContextUpdateReceiver import com.datadog.android.api.feature.FeatureEventReceiver import com.datadog.android.api.feature.FeatureScope import com.datadog.android.api.feature.FeatureSdkCore @@ -197,6 +198,11 @@ internal class DatadogCore( val mutableContext = featureContext.toMutableMap() updateCallback(mutableContext) it.setFeatureContext(featureName, mutableContext) + // notify all the other features + features.filter { it.key != featureName } + .forEach { (_, feature) -> + feature.notifyContextUpdated(featureName, mutableContext.toMap()) + } } } } @@ -227,6 +233,23 @@ internal class DatadogCore( } } + override fun setContextUpdateReceiver(featureName: String, listener: FeatureContextUpdateReceiver) { + val feature = features[featureName] + if (feature == null) { + internalLogger.log( + InternalLogger.Level.WARN, + InternalLogger.Target.USER, + { MISSING_FEATURE_FOR_CONTEXT_UPDATE_LISTENER.format(Locale.US, featureName) } + ) + } else { + feature.setContextUpdateListener(listener) + } + } + + override fun removeContextUpdateReceiver(featureName: String, listener: FeatureContextUpdateReceiver) { + features[featureName]?.removeContextUpdateListener(listener) + } + /** @inheritDoc */ override fun removeEventReceiver(featureName: String) { features[featureName]?.eventReceiver?.set(null) @@ -515,6 +538,8 @@ internal class DatadogCore( internal const val MISSING_FEATURE_FOR_EVENT_RECEIVER = "Cannot add event receiver for feature \"%s\", it is not registered." + internal const val MISSING_FEATURE_FOR_CONTEXT_UPDATE_LISTENER = + "Cannot add event listener for feature \"%s\", it is not registered." internal const val EVENT_RECEIVER_ALREADY_EXISTS = "Feature \"%s\" already has event receiver registered, overwriting it." diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/NoOpInternalSdkCore.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/NoOpInternalSdkCore.kt index 96f2ce5d83..ca8ec1aa7e 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/NoOpInternalSdkCore.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/NoOpInternalSdkCore.kt @@ -12,6 +12,7 @@ import com.datadog.android.api.context.DatadogContext import com.datadog.android.api.context.NetworkInfo import com.datadog.android.api.context.TimeInfo import com.datadog.android.api.feature.Feature +import com.datadog.android.api.feature.FeatureContextUpdateReceiver import com.datadog.android.api.feature.FeatureEventReceiver import com.datadog.android.api.feature.FeatureScope import com.datadog.android.core.internal.net.DefaultFirstPartyHostHeaderTypeResolver @@ -103,6 +104,16 @@ internal object NoOpInternalSdkCore : InternalSdkCore { override fun removeEventReceiver(featureName: String) = Unit + override fun setContextUpdateReceiver( + featureName: String, + listener: FeatureContextUpdateReceiver + ) = Unit + + override fun removeContextUpdateReceiver( + featureName: String, + listener: FeatureContextUpdateReceiver + ) = Unit + override fun createSingleThreadExecutorService(): ExecutorService { return NoOpExecutorService() } diff --git a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/SdkFeature.kt b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/SdkFeature.kt index 0ac30f8fcd..102f01e13c 100644 --- a/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/SdkFeature.kt +++ b/dd-sdk-android-core/src/main/kotlin/com/datadog/android/core/internal/SdkFeature.kt @@ -11,6 +11,7 @@ import android.content.Context import com.datadog.android.api.InternalLogger import com.datadog.android.api.context.DatadogContext import com.datadog.android.api.feature.Feature +import com.datadog.android.api.feature.FeatureContextUpdateReceiver import com.datadog.android.api.feature.FeatureEventReceiver import com.datadog.android.api.feature.FeatureScope import com.datadog.android.api.feature.StorageBackedFeature @@ -42,7 +43,9 @@ import com.datadog.android.core.internal.persistence.file.advanced.FeatureFileOr import com.datadog.android.core.internal.persistence.file.batch.BatchFileReaderWriter import com.datadog.android.core.persistence.PersistenceStrategy import com.datadog.android.privacy.TrackingConsentProviderCallback +import java.util.Collections import java.util.Locale +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference @@ -54,6 +57,10 @@ internal class SdkFeature( ) : FeatureScope { internal val initialized = AtomicBoolean(false) + + @Suppress("UnsafeThirdPartyFunctionCall") // the argument is always empty + internal val contextUpdateListeners = + Collections.newSetFromMap(ConcurrentHashMap()) internal val eventReceiver = AtomicReference(null) internal var storage: Storage = NoOpStorage() internal var uploader: DataUploader = NoOpDataUploader() @@ -166,6 +173,39 @@ internal class SdkFeature( // endregion + // region Context Update Listener + + internal fun notifyContextUpdated(featureName: String, context: Map) { + contextUpdateListeners.forEach { + it.onContextUpdate(featureName, context) + } + } + + internal fun setContextUpdateListener(listener: FeatureContextUpdateReceiver) { + synchronized(contextUpdateListeners) { + // the argument is always non - null, so we can suppress the warning + @Suppress("UnsafeThirdPartyFunctionCall") + if (contextUpdateListeners.contains(listener)) { + internalLogger.log( + InternalLogger.Level.WARN, + InternalLogger.Target.USER, + { CONTEXT_UPDATE_LISTENER_ALREADY_EXISTS.format(Locale.US, wrappedFeature.name) } + ) + } + contextUpdateListeners.add(listener) + } + } + + internal fun removeContextUpdateListener(listener: FeatureContextUpdateReceiver) { + synchronized(contextUpdateListeners) { + @Suppress("UnsafeThirdPartyFunctionCall") + // the argument is always non - null, so we can suppress the warning + contextUpdateListeners.remove(listener) + } + } + + // endregion + // region Internal private fun setupMetricsDispatcher( @@ -318,6 +358,8 @@ internal class SdkFeature( // endregion companion object { + internal const val CONTEXT_UPDATE_LISTENER_ALREADY_EXISTS = + "Feature \"%s\" already has this listener registered." const val NO_EVENT_RECEIVER = "Feature \"%s\" has no event receiver registered, ignoring event." } diff --git a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/DatadogCoreTest.kt b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/DatadogCoreTest.kt index 14e4c6b778..2028be8b86 100644 --- a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/DatadogCoreTest.kt +++ b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/DatadogCoreTest.kt @@ -13,6 +13,7 @@ import com.datadog.android.api.context.NetworkInfo import com.datadog.android.api.context.TimeInfo import com.datadog.android.api.context.UserInfo import com.datadog.android.api.feature.Feature +import com.datadog.android.api.feature.FeatureContextUpdateReceiver import com.datadog.android.api.feature.FeatureEventReceiver import com.datadog.android.core.configuration.Configuration import com.datadog.android.core.internal.ContextProvider @@ -62,10 +63,12 @@ import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock +import org.mockito.kotlin.never import org.mockito.kotlin.reset import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.verifyNoMoreInteractions import org.mockito.kotlin.whenever import org.mockito.quality.Strictness import java.util.Locale @@ -268,11 +271,17 @@ internal class DatadogCoreTest { @MapForgery( key = AdvancedForgery(string = [StringForgery(StringForgeryType.ALPHABETICAL)]), value = AdvancedForgery(string = [StringForgery(StringForgeryType.ALPHABETICAL)]) - ) context: Map + ) context: Map, + forge: Forge ) { // Given val mockContextProvider = mock() - testedCore.features[feature] = mock() + val mockFeature = mock() + val otherFeatures = mapOf( + forge.anAlphaNumericalString() to mock() + ) + testedCore.features[feature] = mockFeature + testedCore.features.putAll(otherFeatures) testedCore.coreFeature.contextProvider = mockContextProvider // When @@ -282,6 +291,11 @@ internal class DatadogCoreTest { // Then verify(mockContextProvider).setFeatureContext(feature, context) + otherFeatures.forEach { (_, otherFeature) -> + verify(otherFeature).notifyContextUpdated(feature, context) + verifyNoMoreInteractions(otherFeature) + } + verify(mockFeature, never()).notifyContextUpdated(feature, context) } @Test @@ -385,6 +399,56 @@ internal class DatadogCoreTest { verify(mockEventReceiverRef).set(null) } + @Test + fun `𝕄 set context update listener 𝕎 setContextUpdateListener()`( + @StringForgery feature: String + ) { + // Given + val mockFeature = mock() + val mockContextUpdateListener: FeatureContextUpdateReceiver = mock() + testedCore.features[feature] = mockFeature + + // When + testedCore.setContextUpdateReceiver(feature, mockContextUpdateListener) + + // Then + verify(mockFeature).setContextUpdateListener(mockContextUpdateListener) + } + + @Test + fun `𝕄 notify no feature registered 𝕎 setContextUpdateListener() { feature is not registered }`( + @StringForgery feature: String + ) { + // Given + val mockContextUpdateListener: FeatureContextUpdateReceiver = mock() + + // When + testedCore.setContextUpdateReceiver(feature, mockContextUpdateListener) + + // Then + mockInternalLogger.verifyLog( + InternalLogger.Level.WARN, + InternalLogger.Target.USER, + DatadogCore.MISSING_FEATURE_FOR_CONTEXT_UPDATE_LISTENER.format(Locale.US, feature) + ) + } + + @Test + fun `𝕄 remove context update listener 𝕎 removeContextUpdateListener()`( + @StringForgery feature: String + ) { + // Given + val mockFeature = mock() + val mockContextUpdateListener: FeatureContextUpdateReceiver = mock() + testedCore.features[feature] = mockFeature + + // When + testedCore.removeContextUpdateReceiver(feature, mockContextUpdateListener) + + // Then + verify(mockFeature).removeContextUpdateListener(mockContextUpdateListener) + } + @Test fun `𝕄 provide name 𝕎 name(){}`() { // When+Then diff --git a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/SdkFeatureTest.kt b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/SdkFeatureTest.kt index 37c3931d74..84248642a7 100644 --- a/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/SdkFeatureTest.kt +++ b/dd-sdk-android-core/src/test/kotlin/com/datadog/android/core/internal/SdkFeatureTest.kt @@ -11,6 +11,7 @@ import android.content.Context import com.datadog.android.api.InternalLogger import com.datadog.android.api.context.DatadogContext import com.datadog.android.api.feature.Feature +import com.datadog.android.api.feature.FeatureContextUpdateReceiver import com.datadog.android.api.feature.FeatureEventReceiver import com.datadog.android.api.feature.StorageBackedFeature import com.datadog.android.api.storage.EventBatchWriter @@ -549,6 +550,122 @@ internal class SdkFeatureTest { // endregion + // region Context Update Listener + + @Test + fun `M register listeners W setContextUpdateListener()`(forge: Forge) { + // Given + val mockListeners = forge.aList(size = forge.anInt(min = 1, max = 10)) { mock() } + + // When + mockListeners.map { + Thread { + testedFeature.setContextUpdateListener(it) + }.apply { start() } + }.forEach { it.join(5000) } + + // Then + assertThat(testedFeature.contextUpdateListeners.toTypedArray()) + .containsExactlyInAnyOrderElementsOf(mockListeners) + } + + fun `M register listener only once W setContextUpdateListener()`(forge: Forge) { + // Given + val mockListener = mock() + val mockListeners = forge.aList(size = forge.anInt(min = 1, max = 10)) { mockListener } + + // When + mockListeners.map { + Thread { + testedFeature.setContextUpdateListener(it) + }.apply { start() } + }.forEach { it.join(5000) } + + // Then + assertThat(testedFeature.contextUpdateListeners.toTypedArray()) + .containsExactlyInAnyOrderElementsOf(listOf(mockListener)) + mockInternalLogger.verifyLog( + InternalLogger.Level.WARN, + InternalLogger.Target.USER, + SdkFeature.CONTEXT_UPDATE_LISTENER_ALREADY_EXISTS.format( + Locale.US, + fakeFeatureName + ) + ) + } + + @Test + fun `M not throw W concurrent access to FeatureContextUpdateListeners{()`(forge: Forge) { + // Given + val mockListeners = forge.aList(size = forge.anInt(min = 2, max = 10)) { mock() } + val removeListeners = mockListeners.take(forge.anInt(min = 1, max = mockListeners.size)) + val fakeContext = forge.aMap { forge.anAlphabeticalString() to forge.anAlphabeticalString() } + val fakeFeatureName = forge.anAlphabeticalString() + val updateRepeats = forge.anInt(min = 1, max = 10) + + // When + mockListeners.map { + Thread { + testedFeature.setContextUpdateListener(it) + }.apply { start() } + }.forEach { it.join(5000) } + removeListeners.map { + Thread { + testedFeature.removeContextUpdateListener(it) + }.apply { start() } + }.forEach { it.join(5000) } + repeat(updateRepeats) { + Thread { + assertDoesNotThrow { + testedFeature.notifyContextUpdated(fakeFeatureName, fakeContext) + } + }.apply { start() }.join(5000) + } + + // Then + } + + @Test + fun `M remove listeners W removeContextUpdateListener()`(forge: Forge) { + // Given + val mockListeners = forge.aList(size = forge.anInt(min = 2, max = 10)) { mock() } + val removedListeners = mockListeners.take(forge.anInt(min = 1, max = mockListeners.size)) + val remainingListeners = mockListeners - removedListeners.toSet() + + // When + mockListeners.forEach { + testedFeature.setContextUpdateListener(it) + } + removedListeners.forEach { + testedFeature.removeContextUpdateListener(it) + } + + // Then + assertThat(testedFeature.contextUpdateListeners.toTypedArray()) + .containsExactlyInAnyOrderElementsOf(remainingListeners) + } + + @Test + fun `M update registered listeners W notifyContextUpdated()`(forge: Forge) { + // Given + val mockListeners = forge.aList(size = forge.anInt(min = 1, max = 10)) { mock() } + val fakeContext = forge.aMap { forge.anAlphabeticalString() to forge.anAlphabeticalString() } + val fakeFeatureName = forge.anAlphabeticalString() + mockListeners.forEach { + testedFeature.setContextUpdateListener(it) + } + + // When + testedFeature.notifyContextUpdated(fakeFeatureName, fakeContext) + + // Then + mockListeners.forEach { + verify(it).onContextUpdate(fakeFeatureName, fakeContext) + } + } + + // endregion + // region Feature fakes class FakeFeature(override val name: String) : Feature { diff --git a/detekt_custom.yml b/detekt_custom.yml index 2fd0f03630..e3e0688c5e 100644 --- a/detekt_custom.yml +++ b/detekt_custom.yml @@ -184,6 +184,8 @@ datadog: - "java.util.concurrent.BlockingQueue.drainTo(kotlin.collections.MutableCollection):java.lang.UnsupportedOperationException,java.lang.ClassCastException,java.lang.NullPointerException,java.lang.IllegalArgumentException" - "java.util.concurrent.BlockingQueue.drainTo(kotlin.collections.MutableCollection?):java.lang.UnsupportedOperationException,java.lang.ClassCastException,java.lang.NullPointerException,java.lang.IllegalArgumentException" - "java.util.concurrent.Callable.call():java.lang.Exception" + - "java.util.concurrent.ConcurrentHashMap.contains(kotlin.Any?):java.lang.NullPointerException" + - "java.util.concurrent.ConcurrentHashMap.remove(com.datadog.android.api.feature.FeatureContextUpdateReceiver):java.lang.NullPointerException" - "java.util.concurrent.ConcurrentHashMap.remove(kotlin.String):java.lang.NullPointerException" - "java.util.concurrent.ConcurrentLinkedQueue.offer(com.datadog.android.sessionreplay.internal.async.RecordedDataQueueItem):java.lang.NullPointerException" - "java.util.concurrent.CopyOnWriteArraySet.removeAll(kotlin.collections.Collection):java.lang.NullPointerException,java.lang.ClassCastException" @@ -224,8 +226,10 @@ datadog: - "java.util.TimeZone.getTimeZone(kotlin.String):java.lang.NullPointerException" # endregion # region Java Collections + - "java.util.Collections.newSetFromMap(kotlin.collections.MutableMap?):java.lang.IllegalArgumentException" - "java.util.LinkedList.offer(com.datadog.android.core.internal.data.upload.UploadWorker.UploadNextBatchTask):java.lang.ClassCastException,java.lang.NullPointerException,java.lang.IllegalArgumentException" - "java.util.LinkedList.removeFirst():java.util.NoSuchElementException" + - "java.util.LinkedList.removeLast():java.util.NoSuchElementException" - "java.util.Queue.offer(com.datadog.android.sessionreplay.internal.async.RecordedDataQueueItem?):java.lang.IllegalArgumentException,java.lang.ClassCastException,java.lang.NullPointerException" # endregion # region Java Zip @@ -531,6 +535,7 @@ datadog: - "com.google.gson.JsonArray.add(com.google.gson.JsonElement?)" - "com.google.gson.JsonArray.add(kotlin.String?)" - "com.google.gson.JsonArray.addAll(com.google.gson.JsonArray?)" + - "com.google.gson.JsonArray.any(kotlin.Function1)" - "com.google.gson.JsonArray.asSequence()" - "com.google.gson.JsonArray.constructor()" - "com.google.gson.JsonArray.constructor(kotlin.Int)" @@ -722,6 +727,7 @@ datadog: - "java.lang.Class.hashCode()" - "java.lang.IllegalArgumentException.constructor(kotlin.String)" - "java.lang.IllegalStateException.constructor(kotlin.String)" + - "java.lang.IllegalStateException.constructor(kotlin.String?)" - "java.lang.Object.constructor()" - "java.lang.Runtime.availableProcessors()" - "java.lang.Runtime.getRuntime()" @@ -754,6 +760,10 @@ datadog: - "java.security.SecureRandom.nextInt()" - "java.security.SecureRandom.nextLong()" - "java.util.HashSet.find(kotlin.Function1)" + - "java.util.LinkedList.addFirst(com.datadog.android.webview.internal.rum.domain.WebViewNativeRumViewsCache.ViewEntry?)" + - "java.util.LinkedList.peekLast()" + - "java.util.LinkedList.remove(com.datadog.android.webview.internal.rum.domain.WebViewNativeRumViewsCache.ViewEntry)" + - "java.util.LinkedList.iterator()" - "java.util.Properties.constructor()" - "java.util.Properties.setProperty(kotlin.String?, kotlin.String?)" - "java.util.UUID.constructor(kotlin.Long, kotlin.Long)" @@ -944,6 +954,7 @@ datadog: - "kotlin.collections.MutableMap.containsKey(com.datadog.android.api.storage.RawBatchEvent)" - "kotlin.collections.MutableMap.containsKey(kotlin.Long)" - "kotlin.collections.MutableMap.containsKey(kotlin.String)" + - "kotlin.collections.MutableMap.filter(kotlin.Function1)" - "kotlin.collections.MutableMap.filterKeys(kotlin.Function1)" - "kotlin.collections.MutableMap.filterValues(kotlin.Function1)" - "kotlin.collections.MutableMap.forEach(kotlin.Function1)" @@ -963,8 +974,10 @@ datadog: - "kotlin.collections.MutableMap.remove(kotlin.Long)" - "kotlin.collections.MutableMap.remove(kotlin.String)" - "kotlin.collections.MutableMap.safeMapValuesToJson(com.datadog.android.api.InternalLogger)" + - "kotlin.collections.MutableMap.toMap()" - "kotlin.collections.MutableMap.toMutableMap()" - "kotlin.collections.MutableMap?.forEach(kotlin.Function1)" + - "kotlin.collections.MutableSet.add(com.datadog.android.api.feature.FeatureContextUpdateReceiver?)" - "kotlin.collections.MutableSet.add(com.datadog.android.core.internal.persistence.ConsentAwareStorage.Batch)" - "kotlin.collections.MutableSet.add(com.datadog.android.telemetry.internal.TelemetryEventId)" - "kotlin.collections.MutableSet.add(java.io.File)" @@ -1002,10 +1015,11 @@ datadog: - "kotlin.collections.listOf(com.datadog.android.sessionreplay.internal.recorder.DefaultOptionSelectorDetector)" - "kotlin.collections.listOf(com.datadog.android.sessionreplay.material.MaterialOptionSelectorDetector)" - "kotlin.collections.listOf(com.datadog.android.sessionreplay.model.MobileSegment.MobileRecord.ViewEndRecord)" + - "kotlin.collections.listOf(com.datadog.android.sessionreplay.model.MobileSegment.Wireframe)" - "kotlin.collections.listOf(com.datadog.android.sessionreplay.model.MobileSegment.Wireframe.PlaceholderWireframe)" - "kotlin.collections.listOf(com.datadog.android.sessionreplay.model.MobileSegment.Wireframe.ShapeWireframe)" - "kotlin.collections.listOf(com.datadog.android.sessionreplay.model.MobileSegment.Wireframe.TextWireframe)" - - "kotlin.collections.listOf(com.datadog.android.sessionreplay.model.MobileSegment.Wireframe)" + - "kotlin.collections.listOf(com.datadog.android.sessionreplay.model.MobileSegment.Wireframe.WebviewWireframe)" - "kotlin.collections.listOf(java.io.File)" - "kotlin.collections.listOf(kotlin.Array)" - "kotlin.collections.listOf(kotlin.String)" @@ -1022,6 +1036,7 @@ datadog: - "kotlin.collections.setOf(kotlin.Int)" - "kotlin.collections.setOf(kotlin.String)" - "kotlin.emptyArray()" + - "kotlin.sequences.Sequence.groupBy(kotlin.Function1)" - "kotlin.sequences.Sequence.filter(kotlin.Function1)" - "kotlin.sequences.Sequence.fold(com.google.gson.JsonArray, kotlin.Function2)" - "kotlin.sequences.Sequence.forEach(kotlin.Function1)" 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 908390459e..04da2c54b9 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 @@ -641,7 +641,6 @@ internal class RumFeature( internal const val EVENT_ATTRIBUTES_PROPERTY = "attributes" internal const val EVENT_STACKTRACE_PROPERTY = "stacktrace" - internal const val VIEW_TIMESTAMP_OFFSET_IN_MS_KEY = "view_timestamp_offset" internal const val UNSUPPORTED_EVENT_TYPE = "RUM feature receive an event of unsupported type=%s." internal const val UNKNOWN_EVENT_TYPE_PROPERTY_VALUE = diff --git a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/RumContext.kt b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/RumContext.kt index 7b2b1eaf91..c1031de3ef 100644 --- a/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/RumContext.kt +++ b/features/dd-sdk-android-rum/src/main/kotlin/com/datadog/android/rum/internal/domain/RumContext.kt @@ -22,7 +22,10 @@ internal data class RumContext( val sessionStartReason: RumSessionScope.StartReason = RumSessionScope.StartReason.USER_APP_LAUNCH, val viewType: RumViewScope.RumViewType = RumViewScope.RumViewType.NONE, val syntheticsTestId: String? = null, - val syntheticsResultId: String? = null + val syntheticsResultId: String? = null, + val viewTimestamp: Long = 0L, + val viewTimestampOffset: Long = 0L, + val hasReplay: Boolean = false ) { fun toMap(): Map { @@ -38,7 +41,10 @@ internal data class RumContext( VIEW_TYPE to viewType.asString, ACTION_ID to actionId, SYNTHETICS_TEST_ID to syntheticsTestId, - SYNTHETICS_RESULT_ID to syntheticsResultId + SYNTHETICS_RESULT_ID to syntheticsResultId, + VIEW_TIMESTAMP to viewTimestamp, + HAS_REPLAY to hasReplay, + VIEW_TIMESTAMP_OFFSET to viewTimestampOffset ) } @@ -59,6 +65,9 @@ internal data class RumContext( const val ACTION_ID = "action_id" const val SYNTHETICS_TEST_ID = "synthetics_test_id" const val SYNTHETICS_RESULT_ID = "synthetics_result_id" + const val HAS_REPLAY = "view_has_replay" + const val VIEW_TIMESTAMP = "view_timestamp" + const val VIEW_TIMESTAMP_OFFSET = "view_timestamp_offset" fun fromFeatureContext(featureContext: Map): RumContext { val applicationId = featureContext[APPLICATION_ID] as? String @@ -77,6 +86,9 @@ internal data class RumContext( val actionId = featureContext[ACTION_ID] as? String val syntheticsTestId = featureContext[SYNTHETICS_TEST_ID] as? String val syntheticsResultId = featureContext[SYNTHETICS_RESULT_ID] as? String + val hasReplay = featureContext[HAS_REPLAY] as? Boolean ?: false + val viewTimestamp = featureContext[VIEW_TIMESTAMP] as? Long ?: 0L + val viewTimestampOffset = featureContext[VIEW_TIMESTAMP_OFFSET] as? Long ?: 0L return RumContext( applicationId = applicationId ?: NULL_UUID, @@ -90,7 +102,10 @@ internal data class RumContext( viewType = viewType ?: RumViewScope.RumViewType.NONE, actionId = actionId, syntheticsTestId = syntheticsTestId, - syntheticsResultId = syntheticsResultId + syntheticsResultId = syntheticsResultId, + viewTimestamp = viewTimestamp, + viewTimestampOffset = viewTimestampOffset, + hasReplay = hasReplay ) } } 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 a86ee2b9c1..3ec2360187 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 @@ -19,7 +19,6 @@ import com.datadog.android.rum.RumActionType import com.datadog.android.rum.RumAttributes import com.datadog.android.rum.RumPerformanceMetric import com.datadog.android.rum.internal.FeaturesContextResolver -import com.datadog.android.rum.internal.RumFeature import com.datadog.android.rum.internal.anr.ANRException import com.datadog.android.rum.internal.domain.RumContext import com.datadog.android.rum.internal.domain.Time @@ -142,7 +141,6 @@ internal open class RumViewScope( init { sdkCore.updateFeatureContext(Feature.RUM_FEATURE_NAME) { it.putAll(getRumContext().toMap()) - it[RumFeature.VIEW_TIMESTAMP_OFFSET_IN_MS_KEY] = serverTimeOffsetInMs } cpuVitalMonitor.register(cpuVitalListener) memoryVitalMonitor.register(memoryVitalListener) @@ -218,7 +216,10 @@ internal open class RumViewScope( viewName = key.name, viewUrl = url, actionId = (activeActionScope as? RumActionScope)?.actionId, - viewType = type + viewType = type, + viewTimestamp = eventTimestamp, + viewTimestampOffset = serverTimeOffsetInMs, + hasReplay = false ) } @@ -254,6 +255,9 @@ internal open class RumViewScope( delegateEventToChildren(event, writer) val shouldStop = (event.key.id == key.id) if (shouldStop && !stopped) { + // we should not reset the timestamp offset here as due to async nature of feature context update + // we still need a stable value for the view timestamp offset for WebView RUM events timestamp + // correction val newRumContext = getRumContext().copy( viewType = RumViewType.NONE, viewId = null, @@ -752,6 +756,9 @@ internal open class RumViewScope( datadogContext, currentViewId ) + sdkCore.updateFeatureContext(Feature.RUM_FEATURE_NAME) { currentRumContext -> + currentRumContext[RumContext.HAS_REPLAY] = hasReplay + } val sessionReplayRecordsCount = featuresContextResolver.resolveViewRecordsCount( datadogContext, currentViewId @@ -854,8 +861,7 @@ internal open class RumViewScope( service = datadogContext.service, version = datadogContext.version ) - } - .submit() + }.submit() } private fun updateGlobalAttributes(sdkCore: InternalSdkCore, event: RumRawEvent) { 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 b37fb92fcf..31a4560dee 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 @@ -28,7 +28,6 @@ import com.datadog.android.rum.assertj.LongTaskEventAssert.Companion.assertThat import com.datadog.android.rum.assertj.ViewEventAssert.Companion.assertThat import com.datadog.android.rum.internal.FeaturesContextResolver import com.datadog.android.rum.internal.RumErrorSourceType -import com.datadog.android.rum.internal.RumFeature import com.datadog.android.rum.internal.anr.ANRException import com.datadog.android.rum.internal.domain.RumContext import com.datadog.android.rum.internal.domain.Time @@ -406,11 +405,40 @@ internal class RumViewScopeTest { val rumContext = mutableMapOf() lastValue.invoke(rumContext) - assertThat(rumContext[RumFeature.VIEW_TIMESTAMP_OFFSET_IN_MS_KEY]) + assertThat(rumContext[RumContext.VIEW_TIMESTAMP_OFFSET]) .isEqualTo(fakeTimeInfoAtScopeStart.serverTimeOffsetMs) } } + @Test + fun `𝕄 update the feature context with the view timestamp W initializing`() { + argumentCaptor<(MutableMap) -> Unit> { + verify(rumMonitor.mockSdkCore).updateFeatureContext( + eq(Feature.RUM_FEATURE_NAME), + capture() + ) + + val rumContext = mutableMapOf() + lastValue.invoke(rumContext) + assertThat(rumContext[RumContext.VIEW_TIMESTAMP]) + .isEqualTo(fakeEventTime.timestamp + fakeTimeInfoAtScopeStart.serverTimeOffsetMs) + } + } + + @Test + fun `𝕄 reset the hasReplay attribute in feature context with the view timestamp W initializing`() { + argumentCaptor<(MutableMap) -> Unit> { + verify(rumMonitor.mockSdkCore).updateFeatureContext( + eq(Feature.RUM_FEATURE_NAME), + capture() + ) + + val rumContext = mutableMapOf() + lastValue.invoke(rumContext) + assertThat(rumContext[RumContext.HAS_REPLAY] as Boolean).isFalse() + } + } + @Test fun `𝕄 update the context with viewType NONE W handleEvent(StopView)`( forge: Forge @@ -426,7 +454,7 @@ internal class RumViewScopeTest { // Then argumentCaptor<(MutableMap) -> Unit> { - verify(rumMonitor.mockSdkCore, times(2)).updateFeatureContext( + verify(rumMonitor.mockSdkCore, times(3)).updateFeatureContext( eq(Feature.RUM_FEATURE_NAME), capture() ) @@ -440,6 +468,65 @@ internal class RumViewScopeTest { } } + @Test + fun `𝕄 keep the resolved hasReplay value in the context W handleEvent(StopView)`( + forge: Forge + ) { + // Given + val attributes = forge.exhaustiveAttributes(excludedKeys = fakeAttributes.keys) + + // When + testedScope.handleEvent( + RumRawEvent.StopView(fakeKey, attributes), + mockWriter + ) + + // Then + argumentCaptor<(MutableMap) -> Unit> { + verify(rumMonitor.mockSdkCore, times(3)).updateFeatureContext( + eq(Feature.RUM_FEATURE_NAME), + capture() + ) + + val rumContext = mutableMapOf() + allValues.fold(rumContext) { acc, function -> + function.invoke(acc) + acc + } + assertThat(rumContext[RumContext.HAS_REPLAY] as Boolean).isEqualTo(fakeHasReplay) + } + } + + @Test + fun `𝕄 keep the viewTimestamp value in the context W handleEvent(StopView)`( + forge: Forge + ) { + // Given + val attributes = forge.exhaustiveAttributes(excludedKeys = fakeAttributes.keys) + + // When + testedScope.handleEvent( + RumRawEvent.StopView(fakeKey, attributes), + mockWriter + ) + + // Then + argumentCaptor<(MutableMap) -> Unit> { + verify(rumMonitor.mockSdkCore, times(3)).updateFeatureContext( + eq(Feature.RUM_FEATURE_NAME), + capture() + ) + + val rumContext = mutableMapOf() + allValues.fold(rumContext) { acc, function -> + function.invoke(acc) + acc + } + assertThat(rumContext[RumContext.VIEW_TIMESTAMP] as Long) + .isEqualTo(fakeEventTime.timestamp + fakeTimeInfoAtScopeStart.serverTimeOffsetMs) + } + } + @Test fun `𝕄 not update the context with viewType NONE W handleEvent(StopView) { unknown key }`( forge: Forge @@ -499,7 +586,7 @@ internal class RumViewScopeTest { // Then argumentCaptor<(MutableMap) -> Unit> { // A scope init + B scope init + A scope stop - verify(rumMonitor.mockSdkCore, times(3)).updateFeatureContext( + verify(rumMonitor.mockSdkCore, times(4)).updateFeatureContext( eq(Feature.RUM_FEATURE_NAME), capture() ) @@ -544,7 +631,7 @@ internal class RumViewScopeTest { // Then argumentCaptor<(MutableMap) -> Unit> { - verify(rumMonitor.mockSdkCore, times(2)).updateFeatureContext( + verify(rumMonitor.mockSdkCore, times(3)).updateFeatureContext( eq(Feature.RUM_FEATURE_NAME), capture() ) @@ -586,7 +673,7 @@ internal class RumViewScopeTest { // Then argumentCaptor<(MutableMap) -> Unit> { // scope init + stop view + stop action - verify(rumMonitor.mockSdkCore, times(3)).updateFeatureContext( + verify(rumMonitor.mockSdkCore, times(4)).updateFeatureContext( eq(Feature.RUM_FEATURE_NAME), capture() ) @@ -684,7 +771,7 @@ internal class RumViewScopeTest { // Then argumentCaptor<(MutableMap) -> Unit> { // A scope init + onStopView + B scope init - verify(rumMonitor.mockSdkCore, times(3)).updateFeatureContext( + verify(rumMonitor.mockSdkCore, times(4)).updateFeatureContext( eq(Feature.RUM_FEATURE_NAME), capture() ) @@ -738,7 +825,7 @@ internal class RumViewScopeTest { // Then argumentCaptor<(MutableMap) -> Unit> { // A scope init + A scope stop + B scope init - verify(rumMonitor.mockSdkCore, times(3)).updateFeatureContext( + verify(rumMonitor.mockSdkCore, times(4)).updateFeatureContext( eq(Feature.RUM_FEATURE_NAME), capture() ) diff --git a/features/dd-sdk-android-session-replay/api/apiSurface b/features/dd-sdk-android-session-replay/api/apiSurface index 2cc233e6cb..fe45242bd3 100644 --- a/features/dd-sdk-android-session-replay/api/apiSurface +++ b/features/dd-sdk-android-session-replay/api/apiSurface @@ -125,28 +125,28 @@ data class com.datadog.android.sessionreplay.model.MobileSegment fun fromJson(kotlin.String): MobileIncrementalSnapshotRecord fun fromJsonObject(com.google.gson.JsonObject): MobileIncrementalSnapshotRecord data class MetaRecord : MobileRecord - constructor(kotlin.Long, Data1) + constructor(kotlin.Long, kotlin.String? = null, Data1) val type: kotlin.Long override fun toJson(): com.google.gson.JsonElement companion object fun fromJson(kotlin.String): MetaRecord fun fromJsonObject(com.google.gson.JsonObject): MetaRecord data class FocusRecord : MobileRecord - constructor(kotlin.Long, Data2) + constructor(kotlin.Long, kotlin.String? = null, Data2) val type: kotlin.Long override fun toJson(): com.google.gson.JsonElement companion object fun fromJson(kotlin.String): FocusRecord fun fromJsonObject(com.google.gson.JsonObject): FocusRecord data class ViewEndRecord : MobileRecord - constructor(kotlin.Long) + constructor(kotlin.Long, kotlin.String? = null) val type: kotlin.Long override fun toJson(): com.google.gson.JsonElement companion object fun fromJson(kotlin.String): ViewEndRecord fun fromJsonObject(com.google.gson.JsonObject): ViewEndRecord data class VisualViewportRecord : MobileRecord - constructor(kotlin.Long, Data3) + constructor(kotlin.Long, kotlin.String? = null, Data3) val type: kotlin.Long override fun toJson(): com.google.gson.JsonElement companion object @@ -242,6 +242,13 @@ data class com.datadog.android.sessionreplay.model.MobileSegment 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) + val type: kotlin.String + override fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): WebviewWireframe + fun fromJsonObject(com.google.gson.JsonObject): WebviewWireframe companion object fun fromJson(kotlin.String): Wireframe fun fromJsonObject(com.google.gson.JsonObject): Wireframe @@ -287,6 +294,13 @@ data class com.datadog.android.sessionreplay.model.MobileSegment companion object fun fromJson(kotlin.String): PlaceholderWireframeUpdate fun fromJsonObject(com.google.gson.JsonObject): PlaceholderWireframeUpdate + data class WebviewWireframeUpdate : WireframeUpdateMutation + constructor(kotlin.Long, kotlin.Long? = null, kotlin.Long? = null, kotlin.Long? = null, kotlin.Long? = null, WireframeClip? = null, ShapeStyle? = null, ShapeBorder? = null, kotlin.String, kotlin.Boolean? = null) + val type: kotlin.String + override fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): WebviewWireframeUpdate + fun fromJsonObject(com.google.gson.JsonObject): WebviewWireframeUpdate companion object fun fromJson(kotlin.String): WireframeUpdateMutation fun fromJsonObject(com.google.gson.JsonObject): WireframeUpdateMutation @@ -379,3 +393,16 @@ data class com.datadog.android.sessionreplay.model.MobileSegment fun toJson(): com.google.gson.JsonElement companion object fun fromJson(kotlin.String): Vertical +data class com.datadog.android.sessionreplay.model.ResourceMetadata + constructor(Application) + val type: kotlin.String + fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): ResourceMetadata + fun fromJsonObject(com.google.gson.JsonObject): ResourceMetadata + data class Application + constructor(kotlin.String) + fun toJson(): com.google.gson.JsonElement + companion object + fun fromJson(kotlin.String): Application + fun fromJsonObject(com.google.gson.JsonObject): Application 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 7c37c7bd66..72d526bc75 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 @@ -466,15 +466,18 @@ public final class com/datadog/android/sessionreplay/model/MobileSegment$MobileR public final class com/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$FocusRecord : com/datadog/android/sessionreplay/model/MobileSegment$MobileRecord { public static final field Companion Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$FocusRecord$Companion; - public fun (JLcom/datadog/android/sessionreplay/model/MobileSegment$Data2;)V + public fun (JLjava/lang/String;Lcom/datadog/android/sessionreplay/model/MobileSegment$Data2;)V + public synthetic fun (JLjava/lang/String;Lcom/datadog/android/sessionreplay/model/MobileSegment$Data2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()J - public final fun component2 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$Data2; - public final fun copy (JLcom/datadog/android/sessionreplay/model/MobileSegment$Data2;)Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$FocusRecord; - public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$FocusRecord;JLcom/datadog/android/sessionreplay/model/MobileSegment$Data2;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$FocusRecord; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$Data2; + public final fun copy (JLjava/lang/String;Lcom/datadog/android/sessionreplay/model/MobileSegment$Data2;)Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$FocusRecord; + public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$FocusRecord;JLjava/lang/String;Lcom/datadog/android/sessionreplay/model/MobileSegment$Data2;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$FocusRecord; public fun equals (Ljava/lang/Object;)Z public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$FocusRecord; public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$FocusRecord; public final fun getData ()Lcom/datadog/android/sessionreplay/model/MobileSegment$Data2; + public final fun getSlotId ()Ljava/lang/String; public final fun getTimestamp ()J public final fun getType ()J public fun hashCode ()I @@ -489,15 +492,18 @@ public final class com/datadog/android/sessionreplay/model/MobileSegment$MobileR public final class com/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$MetaRecord : com/datadog/android/sessionreplay/model/MobileSegment$MobileRecord { public static final field Companion Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$MetaRecord$Companion; - public fun (JLcom/datadog/android/sessionreplay/model/MobileSegment$Data1;)V + public fun (JLjava/lang/String;Lcom/datadog/android/sessionreplay/model/MobileSegment$Data1;)V + public synthetic fun (JLjava/lang/String;Lcom/datadog/android/sessionreplay/model/MobileSegment$Data1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()J - public final fun component2 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$Data1; - public final fun copy (JLcom/datadog/android/sessionreplay/model/MobileSegment$Data1;)Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$MetaRecord; - public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$MetaRecord;JLcom/datadog/android/sessionreplay/model/MobileSegment$Data1;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$MetaRecord; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$Data1; + public final fun copy (JLjava/lang/String;Lcom/datadog/android/sessionreplay/model/MobileSegment$Data1;)Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$MetaRecord; + public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$MetaRecord;JLjava/lang/String;Lcom/datadog/android/sessionreplay/model/MobileSegment$Data1;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$MetaRecord; public fun equals (Ljava/lang/Object;)Z public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$MetaRecord; public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$MetaRecord; public final fun getData ()Lcom/datadog/android/sessionreplay/model/MobileSegment$Data1; + public final fun getSlotId ()Ljava/lang/String; public final fun getTimestamp ()J public final fun getType ()J public fun hashCode ()I @@ -558,13 +564,16 @@ public final class com/datadog/android/sessionreplay/model/MobileSegment$MobileR public final class com/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$ViewEndRecord : com/datadog/android/sessionreplay/model/MobileSegment$MobileRecord { public static final field Companion Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$ViewEndRecord$Companion; - public fun (J)V + public fun (JLjava/lang/String;)V + public synthetic fun (JLjava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()J - public final fun copy (J)Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$ViewEndRecord; - public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$ViewEndRecord;JILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$ViewEndRecord; + public final fun component2 ()Ljava/lang/String; + public final fun copy (JLjava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$ViewEndRecord; + public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$ViewEndRecord;JLjava/lang/String;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$ViewEndRecord; public fun equals (Ljava/lang/Object;)Z public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$ViewEndRecord; public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$ViewEndRecord; + public final fun getSlotId ()Ljava/lang/String; public final fun getTimestamp ()J public final fun getType ()J public fun hashCode ()I @@ -579,15 +588,18 @@ public final class com/datadog/android/sessionreplay/model/MobileSegment$MobileR public final class com/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$VisualViewportRecord : com/datadog/android/sessionreplay/model/MobileSegment$MobileRecord { public static final field Companion Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$VisualViewportRecord$Companion; - public fun (JLcom/datadog/android/sessionreplay/model/MobileSegment$Data3;)V + public fun (JLjava/lang/String;Lcom/datadog/android/sessionreplay/model/MobileSegment$Data3;)V + public synthetic fun (JLjava/lang/String;Lcom/datadog/android/sessionreplay/model/MobileSegment$Data3;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()J - public final fun component2 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$Data3; - public final fun copy (JLcom/datadog/android/sessionreplay/model/MobileSegment$Data3;)Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$VisualViewportRecord; - public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$VisualViewportRecord;JLcom/datadog/android/sessionreplay/model/MobileSegment$Data3;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$VisualViewportRecord; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Lcom/datadog/android/sessionreplay/model/MobileSegment$Data3; + public final fun copy (JLjava/lang/String;Lcom/datadog/android/sessionreplay/model/MobileSegment$Data3;)Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$VisualViewportRecord; + public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$VisualViewportRecord;JLjava/lang/String;Lcom/datadog/android/sessionreplay/model/MobileSegment$Data3;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$VisualViewportRecord; public fun equals (Ljava/lang/Object;)Z public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$VisualViewportRecord; public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$MobileRecord$VisualViewportRecord; public final fun getData ()Lcom/datadog/android/sessionreplay/model/MobileSegment$Data3; + public final fun getSlotId ()Ljava/lang/String; public final fun getTimestamp ()J public final fun getType ()J public fun hashCode ()I @@ -1045,6 +1057,46 @@ public final class com/datadog/android/sessionreplay/model/MobileSegment$Wirefra public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$TextWireframe; } +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 final fun component1 ()J + public final fun component10 ()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 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; + public final fun getBorder ()Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder; + public final fun getClip ()Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip; + public final fun getHeight ()J + public final fun getId ()J + 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; + public final fun getWidth ()J + public final fun getX ()J + public final fun getY ()J + public fun hashCode ()I + public final fun isVisible ()Ljava/lang/Boolean; + public fun toJson ()Lcom/google/gson/JsonElement; + public fun toString ()Ljava/lang/String; +} + +public final class com/datadog/android/sessionreplay/model/MobileSegment$Wireframe$WebviewWireframe$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$WebviewWireframe; + public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$Wireframe$WebviewWireframe; +} + public final class com/datadog/android/sessionreplay/model/MobileSegment$WireframeClip { public static final field Companion Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip$Companion; public fun ()V @@ -1247,6 +1299,87 @@ public final class com/datadog/android/sessionreplay/model/MobileSegment$Wirefra public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeUpdateMutation$TextWireframeUpdate; } +public final class com/datadog/android/sessionreplay/model/MobileSegment$WireframeUpdateMutation$WebviewWireframeUpdate : com/datadog/android/sessionreplay/model/MobileSegment$WireframeUpdateMutation { + public static final field Companion Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeUpdateMutation$WebviewWireframeUpdate$Companion; + public fun (JLjava/lang/Long;Ljava/lang/Long;Ljava/lang/Long;Ljava/lang/Long;Lcom/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 (JLjava/lang/Long;Ljava/lang/Long;Ljava/lang/Long;Ljava/lang/Long;Lcom/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 final fun component1 ()J + public final fun component10 ()Ljava/lang/Boolean; + public final fun component2 ()Ljava/lang/Long; + public final fun component3 ()Ljava/lang/Long; + public final fun component4 ()Ljava/lang/Long; + public final fun component5 ()Ljava/lang/Long; + 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 (JLjava/lang/Long;Ljava/lang/Long;Ljava/lang/Long;Ljava/lang/Long;Lcom/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$WireframeUpdateMutation$WebviewWireframeUpdate; + public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeUpdateMutation$WebviewWireframeUpdate;JLjava/lang/Long;Ljava/lang/Long;Ljava/lang/Long;Ljava/lang/Long;Lcom/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$WireframeUpdateMutation$WebviewWireframeUpdate; + public fun equals (Ljava/lang/Object;)Z + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeUpdateMutation$WebviewWireframeUpdate; + public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeUpdateMutation$WebviewWireframeUpdate; + public final fun getBorder ()Lcom/datadog/android/sessionreplay/model/MobileSegment$ShapeBorder; + public final fun getClip ()Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeClip; + public final fun getHeight ()Ljava/lang/Long; + public final fun getId ()J + 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; + public final fun getWidth ()Ljava/lang/Long; + public final fun getX ()Ljava/lang/Long; + public final fun getY ()Ljava/lang/Long; + public fun hashCode ()I + public final fun isVisible ()Ljava/lang/Boolean; + public fun toJson ()Lcom/google/gson/JsonElement; + public fun toString ()Ljava/lang/String; +} + +public final class com/datadog/android/sessionreplay/model/MobileSegment$WireframeUpdateMutation$WebviewWireframeUpdate$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeUpdateMutation$WebviewWireframeUpdate; + public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/MobileSegment$WireframeUpdateMutation$WebviewWireframeUpdate; +} + +public final class com/datadog/android/sessionreplay/model/ResourceMetadata { + public static final field Companion Lcom/datadog/android/sessionreplay/model/ResourceMetadata$Companion; + public fun (Lcom/datadog/android/sessionreplay/model/ResourceMetadata$Application;)V + public final fun component1 ()Lcom/datadog/android/sessionreplay/model/ResourceMetadata$Application; + public final fun copy (Lcom/datadog/android/sessionreplay/model/ResourceMetadata$Application;)Lcom/datadog/android/sessionreplay/model/ResourceMetadata; + public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/model/ResourceMetadata;Lcom/datadog/android/sessionreplay/model/ResourceMetadata$Application;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/ResourceMetadata; + public fun equals (Ljava/lang/Object;)Z + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/ResourceMetadata; + public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/ResourceMetadata; + public final fun getApplication ()Lcom/datadog/android/sessionreplay/model/ResourceMetadata$Application; + public final fun getType ()Ljava/lang/String; + public fun hashCode ()I + public final fun toJson ()Lcom/google/gson/JsonElement; + public fun toString ()Ljava/lang/String; +} + +public final class com/datadog/android/sessionreplay/model/ResourceMetadata$Application { + public static final field Companion Lcom/datadog/android/sessionreplay/model/ResourceMetadata$Application$Companion; + public fun (Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/ResourceMetadata$Application; + public static synthetic fun copy$default (Lcom/datadog/android/sessionreplay/model/ResourceMetadata$Application;Ljava/lang/String;ILjava/lang/Object;)Lcom/datadog/android/sessionreplay/model/ResourceMetadata$Application; + public fun equals (Ljava/lang/Object;)Z + public static final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/ResourceMetadata$Application; + public static final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/ResourceMetadata$Application; + public final fun getId ()Ljava/lang/String; + public fun hashCode ()I + public final fun toJson ()Lcom/google/gson/JsonElement; + public fun toString ()Ljava/lang/String; +} + +public final class com/datadog/android/sessionreplay/model/ResourceMetadata$Application$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/ResourceMetadata$Application; + public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/ResourceMetadata$Application; +} + +public final class com/datadog/android/sessionreplay/model/ResourceMetadata$Companion { + public final fun fromJson (Ljava/lang/String;)Lcom/datadog/android/sessionreplay/model/ResourceMetadata; + public final fun fromJsonObject (Lcom/google/gson/JsonObject;)Lcom/datadog/android/sessionreplay/model/ResourceMetadata; +} + public class com/datadog/android/sessionreplay/utils/AndroidMDrawableToColorMapper : com/datadog/android/sessionreplay/utils/LegacyDrawableToColorMapper { public fun ()V protected fun resolveInsetDrawable (Landroid/graphics/drawable/InsetDrawable;)Ljava/lang/Integer; diff --git a/features/dd-sdk-android-session-replay/src/main/json/schemas/resource-metadata-schema.json b/features/dd-sdk-android-session-replay/src/main/json/schemas/resource-metadata-schema.json new file mode 100644 index 0000000000..ac59891f47 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/json/schemas/resource-metadata-schema.json @@ -0,0 +1,28 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "resource-metadata-schema.json", + "title": "ResourceMetadata", + "type": "object", + "description": "Schema of the resource metadata. The metadata to idenfify the asset's content uploaded to the backend", + "required": ["type", "application"], + "properties": { + "type": { + "type": "string", + "const": "resource" + }, + "application": { + "type": "object", + "description": "Application properties", + "required": ["id"], + "properties": { + "id": { + "type": "string", + "description": "UUID of the application", + "pattern": "^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$", + "readOnly": true + } + }, + "readOnly": true + } + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/common/_slot-supported-common-record-schema.json b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/common/_slot-supported-common-record-schema.json new file mode 100644 index 0000000000..1a9405d512 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/common/_slot-supported-common-record-schema.json @@ -0,0 +1,21 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "session-replay/common/_slot-supported-common-record-schema.json", + "title": "SlotSupportedCommonRecordSchema", + "type": "object", + "description": "Schema of common properties for a Record event type that is supported by slots.", + "allOf": [ + { + "$ref": "_common-record-schema.json" + }, + { + "properties": { + "slotId": { + "type": "string", + "description": "Unique ID of the slot that generated this record.", + "readOnly": true + } + } + } + ] +} diff --git a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/common/focus-record-schema.json b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/common/focus-record-schema.json index af31c2e4a4..c4e8e0150d 100644 --- a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/common/focus-record-schema.json +++ b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/common/focus-record-schema.json @@ -6,7 +6,7 @@ "description": "Schema of a Record type which contains focus information.", "allOf": [ { - "$ref": "_common-record-schema.json" + "$ref": "_slot-supported-common-record-schema.json" }, { "required": ["type", "data"], diff --git a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/common/meta-record-schema.json b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/common/meta-record-schema.json index b2a3101a47..d3bec64342 100644 --- a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/common/meta-record-schema.json +++ b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/common/meta-record-schema.json @@ -6,7 +6,7 @@ "description": "Schema of a Record which contains the screen properties.", "allOf": [ { - "$ref": "_common-record-schema.json" + "$ref": "_slot-supported-common-record-schema.json" }, { "required": ["type", "data"], diff --git a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/common/view-end-record-schema.json b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/common/view-end-record-schema.json index 650adc459b..4920430257 100644 --- a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/common/view-end-record-schema.json +++ b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/common/view-end-record-schema.json @@ -6,7 +6,7 @@ "description": "Schema of a Record which signifies that view lifecycle ended.", "allOf": [ { - "$ref": "_common-record-schema.json" + "$ref": "_slot-supported-common-record-schema.json" }, { "required": ["type"], diff --git a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/common/visual-viewport-record-schema.json b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/common/visual-viewport-record-schema.json index 5fcd271cbf..35ca325e80 100644 --- a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/common/visual-viewport-record-schema.json +++ b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/common/visual-viewport-record-schema.json @@ -6,7 +6,7 @@ "description": "Schema of a Record which signifies that the viewport properties have changed.", "allOf": [ { - "$ref": "_common-record-schema.json" + "$ref": "_slot-supported-common-record-schema.json" }, { "required": ["data", "type"], diff --git a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/webview-wireframe-schema.json b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/webview-wireframe-schema.json new file mode 100644 index 0000000000..fde2d526f1 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/webview-wireframe-schema.json @@ -0,0 +1,33 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "session-replay/mobile/webview-wireframe-schema.json", + "title": "WebviewWireframe", + "type": "object", + "description": "Schema of all properties of a WebviewWireframe.", + "allOf": [ + { + "$ref": "_common-shape-wireframe-schema.json" + }, + { + "required": ["type", "slotId"], + "properties": { + "type": { + "type": "string", + "description": "The type of the wireframe.", + "const": "webview", + "readOnly": true + }, + "slotId": { + "type": "string", + "description": "Unique Id of the slot containing this webview.", + "readOnly": true + }, + "isVisible": { + "type": "boolean", + "description": "Whether this web-view is visible or not.", + "readOnly": true + } + } + } + ] +} diff --git a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/webview-wireframe-update-schema.json b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/webview-wireframe-update-schema.json new file mode 100644 index 0000000000..33ad813e4d --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/webview-wireframe-update-schema.json @@ -0,0 +1,33 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "session-replay/mobile/webview-wireframe-update-schema.json", + "title": "WebviewWireframeUpdate", + "type": "object", + "description": "Schema of all properties of a WebviewWireframeUpdate.", + "allOf": [ + { + "$ref": "_common-shape-wireframe-update-schema.json" + }, + { + "required": ["type", "slotId"], + "properties": { + "type": { + "type": "string", + "description": "The type of the wireframe.", + "const": "webview", + "readOnly": true + }, + "slotId": { + "type": "string", + "description": "Unique Id of the slot containing this webview.", + "readOnly": true + }, + "isVisible": { + "type": "boolean", + "description": "Whether this web-view is visible or not.", + "readOnly": true + } + } + } + ] +} diff --git a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/wireframe-schema.json b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/wireframe-schema.json index eb448dd105..b3ba3a763f 100644 --- a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/wireframe-schema.json +++ b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/wireframe-schema.json @@ -16,6 +16,9 @@ }, { "$ref": "placeholder-wireframe-schema.json" + }, + { + "$ref": "webview-wireframe-schema.json" } ] } diff --git a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/wireframe-update-mutation-schema.json b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/wireframe-update-mutation-schema.json index dead090812..65f921351a 100644 --- a/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/wireframe-update-mutation-schema.json +++ b/features/dd-sdk-android-session-replay/src/main/json/schemas/session-replay/mobile/wireframe-update-mutation-schema.json @@ -16,6 +16,9 @@ }, { "$ref": "placeholder-wireframe-update-schema.json" + }, + { + "$ref": "webview-wireframe-update-schema.json" } ] } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/SessionReplayPrivacy.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/SessionReplayPrivacy.kt index 9d593cf024..5b07f04074 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/SessionReplayPrivacy.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/SessionReplayPrivacy.kt @@ -8,6 +8,7 @@ package com.datadog.android.sessionreplay import android.os.Build import android.view.View +import android.webkit.WebView import android.widget.Button import android.widget.CheckBox import android.widget.CheckedTextView @@ -38,6 +39,7 @@ import com.datadog.android.sessionreplay.internal.recorder.mapper.SeekBarWirefra import com.datadog.android.sessionreplay.internal.recorder.mapper.SwitchCompatMapper import com.datadog.android.sessionreplay.internal.recorder.mapper.TextViewMapper import com.datadog.android.sessionreplay.internal.recorder.mapper.UnsupportedViewMapper +import com.datadog.android.sessionreplay.internal.recorder.mapper.WebViewWireframeMapper import com.datadog.android.sessionreplay.internal.recorder.mapper.WireframeMapper import com.datadog.android.sessionreplay.internal.recorder.obfuscator.rules.AllowObfuscationRule import com.datadog.android.sessionreplay.internal.utils.ImageViewUtils @@ -109,6 +111,12 @@ enum class SessionReplayPrivacy { val switchCompatMapper: SwitchCompatMapper val seekBarMapper: SeekBarWireframeMapper? val numberPickerMapper: BasePickerMapper? + val webViewWireframeMapper = WebViewWireframeMapper( + viewIdentifierResolver, + colorStringFormatter, + viewBoundsResolver, + drawableToColorMapper + ) when (this) { ALLOW -> { textMapper = TextViewMapper( @@ -239,7 +247,8 @@ enum class SessionReplayPrivacy { MapperTypeWrapper(Button::class.java, buttonMapper.toGenericMapper()), MapperTypeWrapper(TextView::class.java, textMapper.toGenericMapper()), MapperTypeWrapper(ImageView::class.java, imageViewMapper.toGenericMapper()), - MapperTypeWrapper(AppCompatToolbar::class.java, unsupportedViewMapper.toGenericMapper()) + MapperTypeWrapper(AppCompatToolbar::class.java, unsupportedViewMapper.toGenericMapper()), + MapperTypeWrapper(WebView::class.java, webViewWireframeMapper.toGenericMapper()) ) mappersList.add( diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeature.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeature.kt index d9f4e2b824..810b50cee9 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeature.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeature.kt @@ -228,6 +228,9 @@ internal class SessionReplayFeature( internal fun startRecording() { // Check initialization again so we don't forget to do it when this method is made public if (checkIfInitialized() && !isRecording.getAndSet(true)) { + sdkCore.updateFeatureContext(SESSION_REPLAY_FEATURE_NAME) { + it[SESSION_REPLAY_ENABLED_KEY] = true + } @Suppress("ThreadSafety") // TODO RUM-1462 can be called from any thread sessionReplayRecorder.resumeRecorders() } @@ -243,6 +246,9 @@ internal class SessionReplayFeature( */ internal fun stopRecording() { if (isRecording.getAndSet(false)) { + sdkCore.updateFeatureContext(SESSION_REPLAY_FEATURE_NAME) { + it[SESSION_REPLAY_ENABLED_KEY] = false + } @Suppress("ThreadSafety") // TODO RUM-1462 can be called from any thread sessionReplayRecorder.stopRecorders() } @@ -301,5 +307,7 @@ internal class SessionReplayFeature( internal const val SESSION_REPLAY_PRIVACY_KEY = "session_replay_privacy" internal const val SESSION_REPLAY_MANUAL_RECORDING_KEY = "session_replay_requires_manual_recording" + internal const val SESSION_REPLAY_ENABLED_KEY = + "session_replay_is_enabled" } } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/net/BatchesToSegmentsMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/net/BatchesToSegmentsMapper.kt index 7fd16b20d2..72cdd65501 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/net/BatchesToSegmentsMapper.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/net/BatchesToSegmentsMapper.kt @@ -24,14 +24,14 @@ import com.google.gson.JsonParser */ internal class BatchesToSegmentsMapper(private val internalLogger: InternalLogger) { - fun map(batchData: List): Pair? { + fun map(batchData: List): List> { return groupBatchDataIntoSegments(batchData) } // region Internal - private fun groupBatchDataIntoSegments(batchData: List): Pair? { - val reducedEnrichedRecord = batchData + private fun groupBatchDataIntoSegments(batchData: List): List> { + return batchData .asSequence() .mapNotNull { @Suppress("SwallowedException") @@ -64,14 +64,17 @@ internal class BatchesToSegmentsMapper(private val internalLogger: InternalLogge Pair(rumContext, records) } } - .reduceOrNull { accumulator, pair -> - val records = accumulator.second - val newRecords = pair.second - records.addAll(newRecords) - Pair(accumulator.first, records) - } ?: return null - - return mapToSegment(reducedEnrichedRecord.first, reducedEnrichedRecord.second) + .groupBy { it.first } + .mapValues { + it.value.fold(JsonArray()) { acc, pair -> + acc.addAll(pair.second) + acc + } + } + .filter { !it.value.isEmpty } + .mapNotNull { + mapToSegment(it.key, it.value) + } } @Suppress("ReturnCount") @@ -139,10 +142,10 @@ internal class BatchesToSegmentsMapper(private val internalLogger: InternalLogge } private fun hasFullSnapshotRecord(records: JsonArray) = - records.firstOrNull { - it.asJsonObject.getAsJsonPrimitive(RECORD_TYPE_KEY)?.safeGetAsLong(internalLogger) == - FULL_SNAPSHOT_RECORD_TYPE - } != null + records.any { + val typeAsLong = it.asJsonObject.getAsJsonPrimitive(RECORD_TYPE_KEY)?.safeGetAsLong(internalLogger) + typeAsLong == FULL_SNAPSHOT_RECORD_TYPE_MOBILE || typeAsLong == FULL_SNAPSHOT_RECORD_TYPE_BROWSER + } private fun JsonObject.records(): JsonArray? { return get(EnrichedRecord.RECORDS_KEY)?.safeGetAsJsonArray(internalLogger) @@ -176,7 +179,10 @@ internal class BatchesToSegmentsMapper(private val internalLogger: InternalLogge // endregion companion object { - private const val FULL_SNAPSHOT_RECORD_TYPE = 10L + + private const val FULL_SNAPSHOT_RECORD_TYPE_MOBILE = 10L + private const val FULL_SNAPSHOT_RECORD_TYPE_BROWSER = 2L + internal const val RECORDS_KEY = "records" private const val RECORD_TYPE_KEY = "type" internal const val TIMESTAMP_KEY = "timestamp" diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/net/SegmentRequestBodyFactory.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/net/SegmentRequestBodyFactory.kt index 9903afb435..294dea857b 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/net/SegmentRequestBodyFactory.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/net/SegmentRequestBodyFactory.kt @@ -7,6 +7,7 @@ package com.datadog.android.sessionreplay.internal.net import com.datadog.android.sessionreplay.model.MobileSegment +import com.google.gson.JsonArray import com.google.gson.JsonObject import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody @@ -16,77 +17,46 @@ import okhttp3.RequestBody.Companion.toRequestBody internal class SegmentRequestBodyFactory( private val compressor: BytesCompressor = BytesCompressor() ) { - fun create( - segment: MobileSegment, - serializedSegment: JsonObject - ): RequestBody { - // we need to add a new line at the end of each segment for being able to format it - // as an Array when read by the player - val segmentAsBinary = (serializedSegment.toString() + "\n").toByteArray() - return buildSegmentRequestBody(segment, segmentAsBinary) - } - @Suppress("UnsafeThirdPartyFunctionCall") // Handled up in the caller chain - private fun buildSegmentRequestBody(segment: MobileSegment, segmentAsBinary: ByteArray): RequestBody { - val compressedData = compressor.compressBytes(segmentAsBinary) - return MultipartBody.Builder() - .setType(MultipartBody.FORM) - .addFormDataPart( - SEGMENT_FORM_KEY, - segment.session.id, - compressedData - .toRequestBody(CONTENT_TYPE_BINARY.toMediaTypeOrNull()) - ) - .addFormDataPart( - APPLICATION_ID_FORM_KEY, - segment.application.id - ) - .addFormDataPart( - SESSION_ID_FORM_KEY, - segment.session.id - ) - .addFormDataPart( - VIEW_ID_FORM_KEY, - segment.view.id - ) - .addFormDataPart( - HAS_FULL_SNAPSHOT_FORM_KEY, - segment.hasFullSnapshot.toString() - ) - .addFormDataPart( - RECORDS_COUNT_FORM_KEY, - segment.recordsCount.toString() - ) - .addFormDataPart( - RAW_SEGMENT_SIZE_FORM_KEY, - compressedData.size.toString() - ) - .addFormDataPart( - START_TIMESTAMP_FORM_KEY, - segment.start.toString() - ) - .addFormDataPart( - END_TIMESTAMP_FORM_KEY, - segment.end.toString() - ) - .addFormDataPart( - SOURCE_FORM_KEY, - segment.source.toJson().asString - ) - .build() + @Suppress("UnsafeThirdPartyFunctionCall") // Caught in the caller + fun create(serializedSegmentsPairs: List>): RequestBody { + val multipartBody = MultipartBody.Builder().setType(MultipartBody.FORM) + val metadata = JsonArray() + serializedSegmentsPairs.forEachIndexed { index, segment -> + // because of the way the compressed segments are concatenated in order to be + // decompressed when retrieved by the player, + // we need to add a new line at the end of each segment + val segmentAsByteArray = (segment.second.toString() + "\n").toByteArray() + val compressedData = compressor.compressBytes(segmentAsByteArray) + val segmentAsJson = segment.first.toJson().asJsonObject.apply { + addProperty(COMPRESSED_SEGMENT_SIZE_FORM_KEY, compressedData.size) + addProperty(RAW_SEGMENT_SIZE_FORM_KEY, segmentAsByteArray.size) + } + multipartBody.addFormDataPart( + name = SEGMENT_DATA_FORM_KEY, + filename = "${BINARY_FILENAME_PREFIX}$index", + body = compressedData.toRequestBody(CONTENT_TYPE_BINARY_TYPE) + ) + metadata.add(segmentAsJson) + } + + multipartBody.addFormDataPart( + name = EVENT_NAME_FORM_KEY, + filename = BLOB_FILENAME, + body = metadata.toString().toRequestBody(CONTENT_TYPE_JSON_TYPE) + ) + + return multipartBody.build() } companion object { - internal const val APPLICATION_ID_FORM_KEY = "application.id" - internal const val SESSION_ID_FORM_KEY = "session.id" - internal const val VIEW_ID_FORM_KEY = "view.id" - internal const val HAS_FULL_SNAPSHOT_FORM_KEY = "has_full_snapshot" - internal const val RECORDS_COUNT_FORM_KEY = "records_count" + internal const val BINARY_FILENAME_PREFIX = "file" + internal const val BLOB_FILENAME = "blob" + internal const val EVENT_NAME_FORM_KEY = "event" internal const val RAW_SEGMENT_SIZE_FORM_KEY = "raw_segment_size" - internal const val START_TIMESTAMP_FORM_KEY = "start" - internal const val END_TIMESTAMP_FORM_KEY = "end" - internal const val SOURCE_FORM_KEY = "source" - internal const val SEGMENT_FORM_KEY = "segment" - internal const val CONTENT_TYPE_BINARY = "application/octet-stream" + internal const val COMPRESSED_SEGMENT_SIZE_FORM_KEY = "compressed_segment_size" + internal const val SEGMENT_DATA_FORM_KEY = "segment" + internal val CONTENT_TYPE_BINARY_TYPE = "application/octet-stream".toMediaTypeOrNull() + internal val CONTENT_TYPE_JSON_TYPE = "application/json".toMediaTypeOrNull() } } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/net/SegmentRequestFactory.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/net/SegmentRequestFactory.kt index e54e635a75..db8966af37 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/net/SegmentRequestFactory.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/net/SegmentRequestFactory.kt @@ -26,19 +26,16 @@ internal class SegmentRequestFactory( context: DatadogContext, batchData: List, batchMetadata: ByteArray? - ): Request? { + ): Request { val serializedSegmentPair = batchToSegmentsMapper.map(batchData.map { it.data }) - if (serializedSegmentPair == null) { + if (serializedSegmentPair.isEmpty()) { @Suppress("ThrowingInternalException") throw InvalidPayloadFormatException( "The payload format was broken and an upload" + " request could not be created" ) } - val body = segmentRequestBodyFactory.create( - serializedSegmentPair.first, - serializedSegmentPair.second - ) + val body = segmentRequestBodyFactory.create(serializedSegmentPair) return resolveRequest(context, body) } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/BoundsUtils.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/BoundsUtils.kt index 23f8df4704..7df20d312b 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/BoundsUtils.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/BoundsUtils.kt @@ -16,6 +16,7 @@ internal object BoundsUtils { is MobileSegment.Wireframe.TextWireframe -> wireframe.bounds() is MobileSegment.Wireframe.ImageWireframe -> wireframe.bounds() is MobileSegment.Wireframe.PlaceholderWireframe -> wireframe.bounds() + is MobileSegment.Wireframe.WebviewWireframe -> wireframe.bounds() } } @@ -30,38 +31,30 @@ internal object BoundsUtils { } private fun MobileSegment.Wireframe.ShapeWireframe.bounds(): WireframeBounds { - return WireframeBounds( - left = x + (clip?.left ?: 0), - right = x + width - (clip?.right ?: 0), - top = y + (clip?.top ?: 0), - bottom = y + height - (clip?.bottom ?: 0), - width = width, - height = height - ) + return resolveBounds(x, y, width, height, clip) } private fun MobileSegment.Wireframe.TextWireframe.bounds(): WireframeBounds { - return WireframeBounds( - left = x + (clip?.left ?: 0), - right = x + width - (clip?.right ?: 0), - top = y + (clip?.top ?: 0), - bottom = y + height - (clip?.bottom ?: 0), - width = width, - height = height - ) + return resolveBounds(x, y, width, height, clip) } private fun MobileSegment.Wireframe.ImageWireframe.bounds(): WireframeBounds { - return WireframeBounds( - left = x + (clip?.left ?: 0), - right = x + width - (clip?.right ?: 0), - top = y + (clip?.top ?: 0), - bottom = y + height - (clip?.bottom ?: 0), - width = width, - height = height - ) + return resolveBounds(x, y, width, height, clip) } private fun MobileSegment.Wireframe.PlaceholderWireframe.bounds(): WireframeBounds { + return resolveBounds(x, y, width, height, clip) + } + private fun MobileSegment.Wireframe.WebviewWireframe.bounds(): WireframeBounds { + return resolveBounds(x, y, width, height, clip) + } + + private fun resolveBounds( + x: Long, + y: Long, + width: Long, + height: Long, + clip: MobileSegment.WireframeClip? + ): WireframeBounds { return WireframeBounds( left = x + (clip?.left ?: 0), right = x + width - (clip?.right ?: 0), diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/MobileSegmentExt.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/MobileSegmentExt.kt index fc1fca310a..433ed400ac 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/MobileSegmentExt.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/MobileSegmentExt.kt @@ -14,5 +14,6 @@ internal fun MobileSegment.Wireframe.copy(clip: MobileSegment.WireframeClip?): M is MobileSegment.Wireframe.TextWireframe -> this.copy(clip = clip) is MobileSegment.Wireframe.ImageWireframe -> this.copy(clip = clip) is MobileSegment.Wireframe.PlaceholderWireframe -> this.copy(clip = clip) + is MobileSegment.Wireframe.WebviewWireframe -> this.copy(clip = clip) } } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/MutationResolver.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/MutationResolver.kt index 172427e7fa..b91c8430ed 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/MutationResolver.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/MutationResolver.kt @@ -139,8 +139,21 @@ internal class MutationResolver(private val internalLogger: InternalLogger) { oa.forEachIndexed { index, entry -> removalOffsets[index] = runningOffset if (entry is Entry.Reference) { - // Old element was removed - removes.add(MobileSegment.Remove(oldSnapshot[index].id())) + val oldWireframe = oldSnapshot[index] + if (oldWireframe is MobileSegment.Wireframe.WebviewWireframe) { + if (oldWireframe.isVisible != false) { + updates.add( + MobileSegment.WireframeUpdateMutation.WebviewWireframeUpdate( + id = oldWireframe.id, + slotId = oldWireframe.slotId, + isVisible = false + ) + ) + } + } else { + // Old element was removed + removes.add(MobileSegment.Remove(oldWireframe.id())) + } runningOffset++ } } @@ -169,6 +182,7 @@ internal class MutationResolver(private val internalLogger: InternalLogger) { } } // else - element was not moved and not changed, so: skip } + is Entry.Reference -> { // New element was added: val previousId = if (index > 0) newSnapshot[index - 1].id() else null @@ -304,6 +318,39 @@ internal class MutationResolver(private val internalLogger: InternalLogger) { return mutation } + private fun resolveWebViewWireframeMutation( + prevWireframe: MobileSegment.Wireframe.WebviewWireframe, + currentWireframe: MobileSegment.Wireframe.WebviewWireframe + ): MobileSegment.WireframeUpdateMutation { + var mutation = MobileSegment.WireframeUpdateMutation + .WebviewWireframeUpdate(currentWireframe.id, slotId = currentWireframe.slotId) + if (prevWireframe.x != currentWireframe.x) { + mutation = mutation.copy(x = currentWireframe.x) + } + if (prevWireframe.y != currentWireframe.y) { + mutation = mutation.copy(y = currentWireframe.y) + } + if (prevWireframe.width != currentWireframe.width) { + mutation = mutation.copy(width = currentWireframe.width) + } + if (prevWireframe.height != currentWireframe.height) { + mutation = mutation.copy(height = currentWireframe.height) + } + if (prevWireframe.border != currentWireframe.border) { + mutation = mutation.copy(border = currentWireframe.border) + } + if (prevWireframe.shapeStyle != currentWireframe.shapeStyle) { + mutation = mutation.copy(shapeStyle = currentWireframe.shapeStyle) + } + if (prevWireframe.clip != currentWireframe.clip) { + mutation = mutation.copy( + clip = currentWireframe.clip + ?: MobileSegment.WireframeClip(0, 0, 0, 0) + ) + } + return mutation + } + private fun resolveUpdateMutation( currentWireframe: MobileSegment.Wireframe, prevWireframe: MobileSegment.Wireframe @@ -333,18 +380,26 @@ internal class MutationResolver(private val internalLogger: InternalLogger) { prevWireframe, currentWireframe as MobileSegment.Wireframe.TextWireframe ) + is MobileSegment.Wireframe.ShapeWireframe -> resolveShapeMutation( prevWireframe, currentWireframe as MobileSegment.Wireframe.ShapeWireframe ) + is MobileSegment.Wireframe.ImageWireframe -> resolveImageMutation( prevWireframe, currentWireframe as MobileSegment.Wireframe.ImageWireframe ) + is MobileSegment.Wireframe.PlaceholderWireframe -> resolvePlaceholderMutation( prevWireframe, currentWireframe as MobileSegment.Wireframe.PlaceholderWireframe ) + + is MobileSegment.Wireframe.WebviewWireframe -> resolveWebViewWireframeMutation( + prevWireframe, + currentWireframe as MobileSegment.Wireframe.WebviewWireframe + ) } } } @@ -402,6 +457,7 @@ internal class MutationResolver(private val internalLogger: InternalLogger) { is MobileSegment.Wireframe.TextWireframe -> this.id is MobileSegment.Wireframe.ImageWireframe -> this.id is MobileSegment.Wireframe.PlaceholderWireframe -> this.id + is MobileSegment.Wireframe.WebviewWireframe -> this.id } } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/RecordedDataProcessor.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/RecordedDataProcessor.kt index 5e370c089d..4bcbde48b5 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/RecordedDataProcessor.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/RecordedDataProcessor.kt @@ -100,12 +100,12 @@ internal class RecordedDataProcessor( handleViewEndRecord(timestamp) val screenBounds = systemInformation.screenBounds val metaRecord = MobileSegment.MobileRecord.MetaRecord( - timestamp, - MobileSegment.Data1(screenBounds.width, screenBounds.height) + timestamp = timestamp, + data = MobileSegment.Data1(screenBounds.width, screenBounds.height) ) val focusRecord = MobileSegment.MobileRecord.FocusRecord( - timestamp, - MobileSegment.Data2(true) + timestamp = timestamp, + data = MobileSegment.Data2(true) ) records.add(metaRecord) records.add(focusRecord) @@ -118,7 +118,7 @@ internal class RecordedDataProcessor( screenBounds.height ) val viewportRecord = MobileSegment.MobileRecord.MobileIncrementalSnapshotRecord( - timestamp, + timestamp = timestamp, data = viewPortResizeData ) records.add(viewportRecord) diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/WireframeUtils.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/WireframeUtils.kt index 10ac6f4589..d0460c4f28 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/WireframeUtils.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/processor/WireframeUtils.kt @@ -77,6 +77,7 @@ internal class WireframeUtils(private val boundsUtils: BoundsUtils = BoundsUtils is MobileSegment.Wireframe.TextWireframe -> this.clip is MobileSegment.Wireframe.ImageWireframe -> this.clip is MobileSegment.Wireframe.PlaceholderWireframe -> this.clip + is MobileSegment.Wireframe.WebviewWireframe -> this.clip } } } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/WebViewWireframeMapper.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/WebViewWireframeMapper.kt new file mode 100644 index 0000000000..3522de6914 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/WebViewWireframeMapper.kt @@ -0,0 +1,53 @@ +/* + * 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.mapper + +import android.webkit.WebView +import com.datadog.android.sessionreplay.internal.recorder.MappingContext +import com.datadog.android.sessionreplay.model.MobileSegment +import com.datadog.android.sessionreplay.utils.AsyncJobStatusCallback +import com.datadog.android.sessionreplay.utils.ColorStringFormatter +import com.datadog.android.sessionreplay.utils.DrawableToColorMapper +import com.datadog.android.sessionreplay.utils.ViewBoundsResolver +import com.datadog.android.sessionreplay.utils.ViewIdentifierResolver + +internal class WebViewWireframeMapper( + viewIdentifierResolver: ViewIdentifierResolver, + colorStringFormatter: ColorStringFormatter, + viewBoundsResolver: ViewBoundsResolver, + drawableToColorMapper: DrawableToColorMapper +) : + BaseWireframeMapper( + viewIdentifierResolver, + colorStringFormatter, + viewBoundsResolver, + drawableToColorMapper + ) { + + override fun map( + view: WebView, + mappingContext: MappingContext, + asyncJobStatusCallback: AsyncJobStatusCallback + ): List { + val viewGlobalBounds = viewBoundsResolver.resolveViewGlobalBounds( + view, + mappingContext.systemInformation.screenDensity + ) + val webViewId = resolveViewId(view) + return listOf( + MobileSegment.Wireframe.WebviewWireframe( + webViewId, + viewGlobalBounds.x, + viewGlobalBounds.y, + viewGlobalBounds.width, + viewGlobalBounds.height, + slotId = webViewId.toString(), + isVisible = true + ) + ) + } +} diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/SessionReplayRecordWriter.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/SessionReplayRecordWriter.kt index a4fa38b3a4..e08ab48a40 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/SessionReplayRecordWriter.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/storage/SessionReplayRecordWriter.kt @@ -16,15 +16,14 @@ internal class SessionReplayRecordWriter( private val sdkCore: FeatureSdkCore, private val recordCallback: RecordCallback ) : RecordWriter { - private var lastRumContextId: String = "" override fun write(record: EnrichedRecord) { - val forceNewBatch = resolveForceNewBatch(record) sdkCore.getFeature(SessionReplayFeature.SESSION_REPLAY_FEATURE_NAME) - ?.withWriteContext(forceNewBatch) { _, eventBatchWriter -> + ?.withWriteContext { _, eventBatchWriter -> val serializedRecord = record.toJson().toByteArray(Charsets.UTF_8) + val rawBatchEvent = RawBatchEvent(data = serializedRecord) synchronized(this) { @Suppress("ThreadSafety") // called from the worker thread - if (eventBatchWriter.write(RawBatchEvent(data = serializedRecord), batchMetadata = null)) { + if (eventBatchWriter.write(rawBatchEvent, batchMetadata = null)) { updateViewSent(record) } } @@ -40,15 +39,4 @@ internal class SessionReplayRecordWriter( */ recordCallback.onRecordForViewSent(record) } - - private fun resolveForceNewBatch(record: EnrichedRecord): Boolean { - val newRumContextId = resolveRumContextId(record) - val forceNewBatch = lastRumContextId != newRumContextId - lastRumContextId = newRumContextId - return forceNewBatch - } - - private fun resolveRumContextId(record: EnrichedRecord): String { - return "${record.applicationId}-${record.sessionId}-${record.viewId}" - } } diff --git a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/WireframeExt.kt b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/WireframeExt.kt index ef37f84dc0..7aeef128f7 100644 --- a/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/WireframeExt.kt +++ b/features/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/internal/utils/WireframeExt.kt @@ -16,6 +16,7 @@ internal fun MobileSegment.Wireframe.hasOpaqueBackground(): Boolean { is MobileSegment.Wireframe.ShapeWireframe -> this.hasOpaqueBackground() is MobileSegment.Wireframe.TextWireframe -> this.hasOpaqueBackground() is MobileSegment.Wireframe.PlaceholderWireframe -> true + is MobileSegment.Wireframe.WebviewWireframe -> true } } @@ -37,6 +38,7 @@ internal fun MobileSegment.Wireframe.shapeStyle(): MobileSegment.ShapeStyle? { is MobileSegment.Wireframe.ShapeWireframe -> this.shapeStyle is MobileSegment.Wireframe.ImageWireframe -> this.shapeStyle is MobileSegment.Wireframe.PlaceholderWireframe -> null + is MobileSegment.Wireframe.WebviewWireframe -> this.shapeStyle } } @@ -46,5 +48,6 @@ internal fun MobileSegment.Wireframe.copy(shapeStyle: MobileSegment.ShapeStyle?) is MobileSegment.Wireframe.ShapeWireframe -> this.copy(shapeStyle = shapeStyle) is MobileSegment.Wireframe.ImageWireframe -> this.copy(shapeStyle = shapeStyle) is MobileSegment.Wireframe.PlaceholderWireframe -> this + is MobileSegment.Wireframe.WebviewWireframe -> this.copy(shapeStyle = shapeStyle) } } diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/SessionReplayPrivacyTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/SessionReplayPrivacyTest.kt index f9c47d691d..d5cb971bfc 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/SessionReplayPrivacyTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/SessionReplayPrivacyTest.kt @@ -8,6 +8,7 @@ package com.datadog.android.sessionreplay import android.os.Build import android.view.View +import android.webkit.WebView import android.widget.Button import android.widget.CheckBox import android.widget.CheckedTextView @@ -38,6 +39,7 @@ import com.datadog.android.sessionreplay.internal.recorder.mapper.SeekBarWirefra import com.datadog.android.sessionreplay.internal.recorder.mapper.SwitchCompatMapper import com.datadog.android.sessionreplay.internal.recorder.mapper.TextViewMapper import com.datadog.android.sessionreplay.internal.recorder.mapper.UnsupportedViewMapper +import com.datadog.android.sessionreplay.internal.recorder.mapper.WebViewWireframeMapper import com.datadog.android.sessionreplay.internal.recorder.mapper.WireframeMapper import com.datadog.tools.unit.setStaticValue import fr.xgouchet.elmyr.junit5.ForgeConfiguration @@ -122,11 +124,13 @@ internal class SessionReplayPrivacyTest { private val mockButtonMapper: ButtonMapper = mock() private val mockUnsupportedViewMapper: UnsupportedViewMapper = mock() private val mockImageViewMapper: ImageViewMapper = mock() + private val mockWebViewWireframeMapper: WebViewWireframeMapper = mock() private val baseMappers = listOf( MapperTypeWrapper(Button::class.java, mockButtonMapper.toGenericMapper()), MapperTypeWrapper(ImageView::class.java, mockImageViewMapper.toGenericMapper()), - MapperTypeWrapper(AppCompatToolbar::class.java, mockUnsupportedViewMapper.toGenericMapper()) + MapperTypeWrapper(AppCompatToolbar::class.java, mockUnsupportedViewMapper.toGenericMapper()), + MapperTypeWrapper(WebView::class.java, mockWebViewWireframeMapper.toGenericMapper()) ) // ALLOW diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/FocusRecordForgeryFactory.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/FocusRecordForgeryFactory.kt index 1875006163..59b1d82cd5 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/FocusRecordForgeryFactory.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/FocusRecordForgeryFactory.kt @@ -14,8 +14,9 @@ internal class FocusRecordForgeryFactory : ForgeryFactory { override fun getForgery(forge: Forge): MobileSegment.MobileRecord.FocusRecord { return MobileSegment.MobileRecord.FocusRecord( - forge.aPositiveLong(), - MobileSegment.Data2(forge.aBool()) + timestamp = forge.aPositiveLong(), + data = MobileSegment.Data2(forge.aBool()), + slotId = forge.aNullable { aPositiveLong().toString() } ) } } diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/ForgeConfigurator.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/ForgeConfigurator.kt index 8a2dc672f1..2641cb3e3d 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/ForgeConfigurator.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/ForgeConfigurator.kt @@ -53,6 +53,7 @@ internal class ForgeConfigurator : BaseConfigurator() { forge.addFactory(ResourceRecordedDataQueueItemForgeryFactory()) forge.addFactory(TouchEventRecordedDataQueueItemForgeryFactory()) forge.addFactory(WireframeBoundsForgeryFactory()) + forge.addFactory(WebViewWireframeForgeryFactory()) forge.useJvmFactories() } diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/MetaRecordForgeryFactory.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/MetaRecordForgeryFactory.kt index f519fcddde..49db9e4d2f 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/MetaRecordForgeryFactory.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/MetaRecordForgeryFactory.kt @@ -14,12 +14,13 @@ internal class MetaRecordForgeryFactory : ForgeryFactory { override fun getForgery(forge: Forge): MobileSegment.MobileRecord.MetaRecord { return MobileSegment.MobileRecord.MetaRecord( - forge.aPositiveLong(), - MobileSegment.Data1( + timestamp = forge.aPositiveLong(), + data = MobileSegment.Data1( forge.aPositiveLong(), forge.aPositiveLong(), forge.aNullable { forge.aString() } - ) + ), + slotId = forge.aNullable { aPositiveLong().toString() } ) } } diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/WebViewWireframeForgeryFactory.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/WebViewWireframeForgeryFactory.kt new file mode 100644 index 0000000000..c5314bead8 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/forge/WebViewWireframeForgeryFactory.kt @@ -0,0 +1,26 @@ +/* + * 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.forge + +import com.datadog.android.sessionreplay.model.MobileSegment +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.ForgeryFactory + +internal class WebViewWireframeForgeryFactory : + ForgeryFactory { + override fun getForgery(forge: Forge): MobileSegment.Wireframe.WebviewWireframe { + return MobileSegment.Wireframe.WebviewWireframe( + id = forge.aPositiveInt().toLong(), + x = forge.aPositiveLong(), + y = forge.aPositiveLong(), + width = forge.aPositiveLong(strict = true), + height = forge.aPositiveLong(strict = true), + shapeStyle = forge.aNullable { getForgery() }, + slotId = forge.aPositiveLong().toString() + ) + } +} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeatureTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeatureTest.kt index d83961fb6c..8d92685163 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeatureTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/SessionReplayFeatureTest.kt @@ -225,6 +225,41 @@ internal class SessionReplayFeatureTest { assertThat(testedFeature.initialized.get()).isFalse } + @Test + fun `M stop all the recorders in the recorder W stopRecording()`() { + // Given + testedFeature.onInitialize(appContext.mockInstance) + testedFeature.startRecording() + + // When + testedFeature.stopRecording() + + // Then + verify(mockRecorder).stopRecorders() + } + + @Test + fun `M update the isEnabled flag into the context W stopRecording()`() { + // Given + testedFeature.onInitialize(appContext.mockInstance) + testedFeature.startRecording() + + // When + testedFeature.stopRecording() + + // Then + argumentCaptor<(context: MutableMap) -> Unit> { + val updatedContext = mutableMapOf() + verify(mockSdkCore, times(3)).updateFeatureContext( + eq(SessionReplayFeature.SESSION_REPLAY_FEATURE_NAME), + capture() + ) + allValues.forEach { it.invoke(updatedContext) } + assertThat(updatedContext[SessionReplayFeature.SESSION_REPLAY_ENABLED_KEY]) + .isEqualTo(false) + } + } + @Test fun `M do nothing W stopRecording() { was already stopped }`() { // Given @@ -271,6 +306,25 @@ internal class SessionReplayFeatureTest { .resumeRecorders() } + @Test + fun `M update the isEnabled flag into the context W startRecording()`() { + // When + testedFeature.onInitialize(appContext.mockInstance) + testedFeature.startRecording() + + // Then + argumentCaptor<(context: MutableMap) -> Unit> { + val updatedContext = mutableMapOf() + verify(mockSdkCore, times(2)).updateFeatureContext( + eq(SessionReplayFeature.SESSION_REPLAY_FEATURE_NAME), + capture() + ) + allValues.forEach { it.invoke(updatedContext) } + assertThat(updatedContext[SessionReplayFeature.SESSION_REPLAY_ENABLED_KEY]) + .isEqualTo(true) + } + } + @Test fun `M resume recorders only once W startRecording() { multi threads }`() { // Given diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/net/BatchesToSegmentsMapperTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/net/BatchesToSegmentsMapperTest.kt index 3dfb528f21..61337ce621 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/net/BatchesToSegmentsMapperTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/net/BatchesToSegmentsMapperTest.kt @@ -48,50 +48,42 @@ internal class BatchesToSegmentsMapperTest { } @Test - fun `M generate segment to jsonSegment pair W map`(forge: Forge) { + fun `M group the records into segments by context W map`(forge: Forge) { // Given - val rumContext: SessionReplayRumContext = forge.getForgery() val fakeRecordsSize = forge.anInt(min = 10, max = 20) - val fakeRecords: List = forge.aList(fakeRecordsSize) { + val fakeRecords = forge.aList(fakeRecordsSize) { forge.getForgery() - } - val fakeExpectedRecords = fakeRecords.sortedBy { it.timestamp() } + }.sortedBy { it.timestamp() } val fakeEnrichedRecords = fakeRecords .chunked(forge.anInt(min = 1, max = fakeRecordsSize)) .map { + val fakeRumContext: SessionReplayRumContext = forge.getForgery() EnrichedRecord( - rumContext.applicationId, - rumContext.sessionId, - rumContext.sessionId, + fakeRumContext.applicationId, + fakeRumContext.sessionId, + fakeRumContext.sessionId, it ) } - val fakeExpectedRecord = EnrichedRecord( - rumContext.applicationId, - rumContext.sessionId, - rumContext.sessionId, - fakeExpectedRecords - ) - - val expectedEmptySegment = fakeExpectedRecord.toSegment().copy(records = emptyList()) - val expectedSerializedSegment = fakeExpectedRecord.toSegment() - .toJson().asJsonObject.toString() + val fakeExpectedPairs = fakeEnrichedRecords.map { + Pair(it.toSegment().copy(records = emptyList()), it.toSegment().toJson()) + } val fakeBatchData = fakeEnrichedRecords.map { it.toJson().toByteArray() } // When - val mappedSegment = testedMapper.map(fakeBatchData) + val mappedSegments = testedMapper.map(fakeBatchData) // Then - val segment = mappedSegment?.first - val serializedSegment = mappedSegment?.second.toString() - assertThat(segment).isEqualTo(expectedEmptySegment) - assertThat(serializedSegment).isEqualTo(expectedSerializedSegment) + assertThat(mappedSegments.size).isEqualTo(fakeEnrichedRecords.size) + mappedSegments.forEachIndexed { index, pair -> + assertThat(pair.first).isEqualTo(fakeExpectedPairs[index].first) + assertThat(pair.second.toString()).isEqualTo(fakeExpectedPairs[index].second.toString()) + } } @Test fun `M keep the same order W map { records have same timestamps }`(forge: Forge) { // Given - val rumContext: SessionReplayRumContext = forge.getForgery() val fakeRecordsSize = forge.anInt(min = 10, max = 20) val fakeTimestamp = forge.aTimestamp() val fakeRecords: List = forge.aList(fakeRecordsSize) { @@ -100,6 +92,7 @@ internal class BatchesToSegmentsMapperTest { val fakeEnrichedRecords = fakeRecords .chunked(forge.anInt(min = 1, max = fakeRecordsSize)) .map { + val rumContext: SessionReplayRumContext = forge.getForgery() EnrichedRecord( rumContext.applicationId, rumContext.sessionId, @@ -107,43 +100,33 @@ internal class BatchesToSegmentsMapperTest { it ) } - val fakeExpectedEnrichedRecord = EnrichedRecord( - rumContext.applicationId, - rumContext.sessionId, - rumContext.sessionId, - fakeRecords - ) - - val expectedEmptySegment = - fakeExpectedEnrichedRecord.toSegment().copy(records = emptyList()) - val expectedSerializedSegment = fakeExpectedEnrichedRecord.toSegment() - .toJson().asJsonObject.toString() + val fakeExpectedPairs = fakeEnrichedRecords.map { + Pair(it.toSegment().copy(records = emptyList()), it.toSegment().toJson()) + } val fakeBatchData = fakeEnrichedRecords.map { it.toJson().toByteArray() } // When - val mappedSegment = testedMapper.map(fakeBatchData) + val mappedSegments = testedMapper.map(fakeBatchData) // Then - val segment = mappedSegment?.first - val serializedSegment = mappedSegment?.second.toString() - assertThat(segment).isEqualTo(expectedEmptySegment) - assertThat(serializedSegment).isEqualTo(expectedSerializedSegment) + mappedSegments.forEachIndexed { index, pair -> + assertThat(pair.first).isEqualTo(fakeExpectedPairs[index].first) + assertThat(pair.second.toString()).isEqualTo(fakeExpectedPairs[index].second.toString()) + } } @Test fun `M use the first record in the list as start timestamp W map`( forge: Forge ) { - // Given - val rumContext: SessionReplayRumContext = forge.getForgery() val fakeRecordsSize = forge.anInt(min = 10, max = 20) - val fakeRecords: List = forge.aList(fakeRecordsSize) { + val fakeRecords = forge.aList(fakeRecordsSize) { forge.getForgery() - } - val fakeExpectedRecords = fakeRecords.sortedBy { it.timestamp() } + }.sortedBy { it.timestamp() } val fakeEnrichedRecords = fakeRecords .chunked(forge.anInt(min = 1, max = fakeRecordsSize)) .map { + val rumContext: SessionReplayRumContext = forge.getForgery() EnrichedRecord( rumContext.applicationId, rumContext.sessionId, @@ -151,17 +134,22 @@ internal class BatchesToSegmentsMapperTest { it ) } + val fakeExpectedPairs = fakeEnrichedRecords.map { + Pair(it.toSegment().copy(records = emptyList()), it.toSegment().toJson()) + } val fakeBatchData = fakeEnrichedRecords.map { it.toJson().toByteArray() } - val expectedStartTimestamp = fakeExpectedRecords.first().timestamp() // When - val mappedSegment = testedMapper.map(fakeBatchData) + val mappedSegments = testedMapper.map(fakeBatchData) // Then - assertThat(mappedSegment?.first?.start).isEqualTo(expectedStartTimestamp) - val serializedRecordStartTimestamp = mappedSegment?.second - ?.getAsJsonPrimitive("start")?.asLong - assertThat(serializedRecordStartTimestamp).isEqualTo(expectedStartTimestamp) + mappedSegments.forEachIndexed { index, pair -> + val serializedRecordStartTimestamp = pair.second.getAsJsonPrimitive("start") + ?.asLong + assertThat(pair.first.start).isEqualTo(serializedRecordStartTimestamp) + assertThat(pair.first).isEqualTo(fakeExpectedPairs[index].first) + assertThat(pair.second.toString()).isEqualTo(fakeExpectedPairs[index].second.toString()) + } } @Test @@ -169,15 +157,13 @@ internal class BatchesToSegmentsMapperTest { forge: Forge ) { // Given - val rumContext: SessionReplayRumContext = forge.getForgery() val fakeRecordsSize = forge.anInt(min = 10, max = 20) - val fakeRecords: List = forge.aList(fakeRecordsSize) { - forge.getForgery() - } - val fakeExpectedRecords = fakeRecords.sortedBy { it.timestamp() } + val fakeRecords = forge.aList(fakeRecordsSize) { forge.getForgery() } + .sortedBy { it.timestamp() } val fakeEnrichedRecords = fakeRecords .chunked(forge.anInt(min = 1, max = fakeRecordsSize)) .map { + val rumContext: SessionReplayRumContext = forge.getForgery() EnrichedRecord( rumContext.applicationId, rumContext.sessionId, @@ -185,21 +171,26 @@ internal class BatchesToSegmentsMapperTest { it ) } + val fakeExpectedPairs = fakeEnrichedRecords.map { + Pair(it.toSegment().copy(records = emptyList()), it.toSegment().toJson()) + } val fakeBatchData = fakeEnrichedRecords.map { it.toJson().toByteArray() } - val expectedEndTimestamp = fakeExpectedRecords.last().timestamp() // When - val mappedSegment = testedMapper.map(fakeBatchData) + val mappedSegments = testedMapper.map(fakeBatchData) // Then - assertThat(mappedSegment?.first?.end).isEqualTo(expectedEndTimestamp) - val serializedRecordStartTimestamp = mappedSegment?.second - ?.getAsJsonPrimitive("end")?.asLong - assertThat(serializedRecordStartTimestamp).isEqualTo(expectedEndTimestamp) + mappedSegments.forEachIndexed { index, pair -> + val serializedRecordEndTimestamp = pair.second.getAsJsonPrimitive("end") + ?.asLong + assertThat(pair.first.end).isEqualTo(serializedRecordEndTimestamp) + assertThat(pair.first).isEqualTo(fakeExpectedPairs[index].first) + assertThat(pair.second.toString()).isEqualTo(fakeExpectedPairs[index].second.toString()) + } } @Test - fun `M return null W map{ empty records }`( + fun `M return empty list W map{ empty records }`( forge: Forge ) { // Given @@ -210,22 +201,22 @@ internal class BatchesToSegmentsMapperTest { val fakeBatchData = fakeEnrichedRecords.map { it.toJson().toByteArray() } // When - assertThat(testedMapper.map(fakeBatchData)).isNull() + assertThat(testedMapper.map(fakeBatchData)).isEmpty() } @Test - fun `M return empty null W map { broken serialized records }`( + fun `M return empty list W map { broken serialized records }`( forge: Forge ) { // Given val fakeBatchData = forge.aList { forge.anAlphabeticalString().toByteArray() } // When - assertThat(testedMapper.map(fakeBatchData)).isNull() + assertThat(testedMapper.map(fakeBatchData)).isEmpty() } @Test - fun `M return null W map { all records with missing timestamp key }`( + fun `M return empty list W map { all records with missing timestamp key }`( forge: Forge ) { // Given @@ -258,7 +249,7 @@ internal class BatchesToSegmentsMapperTest { .map { it.toString().toByteArray() } // When - assertThat(testedMapper.map(fakeBatchData)).isNull() + assertThat(testedMapper.map(fakeBatchData)).isEmpty() } @Test @@ -301,16 +292,17 @@ internal class BatchesToSegmentsMapperTest { .map { it.toString().toByteArray() } // When - val mappedSegment = testedMapper.map(fakeBatchData) + val mappedSegments = testedMapper.map(fakeBatchData) val expectedRecordsSize = fakeRecords.size - removedRecords - assertThat(mappedSegment?.first?.recordsCount?.toInt()).isEqualTo(expectedRecordsSize) - val recordsAsJsonArray = mappedSegment?.second - ?.getAsJsonArray(BatchesToSegmentsMapper.RECORDS_KEY) + assertThat(mappedSegments.size).isEqualTo(1) + assertThat(mappedSegments[0].first.recordsCount.toInt()).isEqualTo(expectedRecordsSize) + val recordsAsJsonArray = mappedSegments[0].second + .getAsJsonArray(BatchesToSegmentsMapper.RECORDS_KEY) assertThat(recordsAsJsonArray?.size()).isEqualTo(expectedRecordsSize) } @Test - fun `M return null W map { enriched record with missing application id key }`( + fun `M return empty list W map { enriched record with missing application id key }`( forge: Forge ) { // Given @@ -337,7 +329,7 @@ internal class BatchesToSegmentsMapperTest { } .map { it.toString().toByteArray() } // When - assertThat(testedMapper.map(fakeBatchData)).isNull() + assertThat(testedMapper.map(fakeBatchData)).isEmpty() mockInternalLogger.verifyLog( InternalLogger.Level.ERROR, InternalLogger.Target.TELEMETRY, @@ -384,16 +376,18 @@ internal class BatchesToSegmentsMapperTest { .map { it.toString().toByteArray() } // When - val mappedSegment = testedMapper.map(fakeBatchData) + val mappedSegments = testedMapper.map(fakeBatchData) + assertThat(mappedSegments.size).isEqualTo(1) val expectedRecordsSize = fakeRecords.size - removedRecords - assertThat(mappedSegment?.first?.recordsCount?.toInt()).isEqualTo(expectedRecordsSize) - val recordsAsJsonArray = mappedSegment?.second - ?.getAsJsonArray(BatchesToSegmentsMapper.RECORDS_KEY) + assertThat(mappedSegments[0].first.recordsCount.toInt()).isEqualTo(expectedRecordsSize) + val recordsAsJsonArray = mappedSegments[0].second.getAsJsonArray( + BatchesToSegmentsMapper.RECORDS_KEY + ) assertThat(recordsAsJsonArray?.size()).isEqualTo(expectedRecordsSize) } @Test - fun `M return null W map { enriched records with missing session id key }`( + fun `M return empty list W map { enriched records with missing session id key }`( forge: Forge ) { // Given @@ -421,7 +415,7 @@ internal class BatchesToSegmentsMapperTest { .map { it.toString().toByteArray() } // When - assertThat(testedMapper.map(fakeBatchData)).isNull() + assertThat(testedMapper.map(fakeBatchData)).isEmpty() mockInternalLogger.verifyLog( InternalLogger.Level.ERROR, InternalLogger.Target.TELEMETRY, @@ -468,16 +462,19 @@ internal class BatchesToSegmentsMapperTest { .map { it.toString().toByteArray() } // When - val mappedSegment = testedMapper.map(fakeBatchData) + val mappedSegments = testedMapper.map(fakeBatchData) + + // Then val expectedRecordsSize = fakeRecords.size - removedRecords - assertThat(mappedSegment?.first?.recordsCount?.toInt()).isEqualTo(expectedRecordsSize) - val recordsAsJsonArray = mappedSegment?.second - ?.getAsJsonArray(BatchesToSegmentsMapper.RECORDS_KEY) + assertThat(mappedSegments.size).isEqualTo(1) + assertThat(mappedSegments[0].first.recordsCount.toInt()).isEqualTo(expectedRecordsSize) + val recordsAsJsonArray = mappedSegments[0].second + .getAsJsonArray(BatchesToSegmentsMapper.RECORDS_KEY) assertThat(recordsAsJsonArray?.size()).isEqualTo(expectedRecordsSize) } @Test - fun `M return null W map { enriched records with missing view id key }`( + fun `M return empty list W map { enriched records with missing view id key }`( forge: Forge ) { // Given @@ -504,8 +501,8 @@ internal class BatchesToSegmentsMapperTest { } .map { it.toString().toByteArray() } - // When - assertThat(testedMapper.map(fakeBatchData)).isNull() + // Then + assertThat(testedMapper.map(fakeBatchData)).isEmpty() mockInternalLogger.verifyLog( InternalLogger.Level.ERROR, InternalLogger.Target.TELEMETRY, @@ -552,11 +549,14 @@ internal class BatchesToSegmentsMapperTest { .map { it.toString().toByteArray() } // When - val mappedSegment = testedMapper.map(fakeBatchData) + val mappedSegments = testedMapper.map(fakeBatchData) + + // Then val expectedRecordsSize = fakeRecords.size - removedRecords - assertThat(mappedSegment?.first?.recordsCount?.toInt()).isEqualTo(expectedRecordsSize) - val recordsAsJsonArray = mappedSegment?.second - ?.getAsJsonArray(BatchesToSegmentsMapper.RECORDS_KEY) + assertThat(mappedSegments.size).isEqualTo(1) + assertThat(mappedSegments[0].first.recordsCount.toInt()).isEqualTo(expectedRecordsSize) + val recordsAsJsonArray = mappedSegments[0].second + .getAsJsonArray(BatchesToSegmentsMapper.RECORDS_KEY) assertThat(recordsAsJsonArray?.size()).isEqualTo(expectedRecordsSize) } @@ -603,11 +603,14 @@ internal class BatchesToSegmentsMapperTest { .map { it.toString().toByteArray() } // When - val mappedSegment = testedMapper.map(fakeBatchData) + val mappedSegments = testedMapper.map(fakeBatchData) + + // Then val expectedRecordsSize = fakeRecords.size - removedRecords - assertThat(mappedSegment?.first?.recordsCount?.toInt()).isEqualTo(expectedRecordsSize) - val recordsAsJsonArray = mappedSegment?.second - ?.getAsJsonArray(BatchesToSegmentsMapper.RECORDS_KEY) + assertThat(mappedSegments.size).isEqualTo(1) + assertThat(mappedSegments[0].first.recordsCount.toInt()).isEqualTo(expectedRecordsSize) + val recordsAsJsonArray = mappedSegments[0].second + .getAsJsonArray(BatchesToSegmentsMapper.RECORDS_KEY) assertThat(recordsAsJsonArray?.size()).isEqualTo(expectedRecordsSize) } @@ -648,11 +651,13 @@ internal class BatchesToSegmentsMapperTest { .map { it.toString().toByteArray() } // When - val mappedSegment = testedMapper.map(fakeBatchData) + val mappedSegments = testedMapper.map(fakeBatchData) + + // Then val expectedRecordsSize = fakeRecords.size - removedRecords - assertThat(mappedSegment?.first?.recordsCount?.toInt()).isEqualTo(expectedRecordsSize) - val recordsAsJsonArray = mappedSegment?.second - ?.getAsJsonArray(BatchesToSegmentsMapper.RECORDS_KEY) + assertThat(mappedSegments[0].first.recordsCount.toInt()).isEqualTo(expectedRecordsSize) + val recordsAsJsonArray = mappedSegments[0].second + .getAsJsonArray(BatchesToSegmentsMapper.RECORDS_KEY) assertThat(recordsAsJsonArray?.size()).isEqualTo(expectedRecordsSize) } @@ -660,16 +665,16 @@ internal class BatchesToSegmentsMapperTest { private fun EnrichedRecord.toSegment(): MobileSegment { return MobileSegment( - MobileSegment.Application(applicationId), - MobileSegment.Session(sessionId), - MobileSegment.View(viewId), - startTimestamp(), - endTimestamp(), - records.size.toLong(), - null, - hasFullSnapshot(), - MobileSegment.Source.ANDROID, - records + application = MobileSegment.Application(applicationId), + session = MobileSegment.Session(sessionId), + view = MobileSegment.View(viewId), + start = startTimestamp(), + end = endTimestamp(), + recordsCount = records.size.toLong(), + indexInView = null, + hasFullSnapshot = hasFullSnapshot(), + source = MobileSegment.Source.ANDROID, + records = records ) } @@ -686,7 +691,7 @@ internal class BatchesToSegmentsMapperTest { } private fun List.hasFullSnapshot(): Boolean { - return firstOrNull { it is MobileSegment.MobileRecord.MobileFullSnapshotRecord } != null + return any { it is MobileSegment.MobileRecord.MobileFullSnapshotRecord } } private fun MobileSegment.MobileRecord.timestamp(): Long { diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/net/SegmentRequestBodyFactoryTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/net/SegmentRequestBodyFactoryTest.kt index 7f71cb004c..4b9d78c40a 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/net/SegmentRequestBodyFactoryTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/net/SegmentRequestBodyFactoryTest.kt @@ -8,12 +8,12 @@ package com.datadog.android.sessionreplay.internal.net import com.datadog.android.sessionreplay.forge.ForgeConfigurator import com.datadog.android.sessionreplay.model.MobileSegment +import com.google.gson.JsonArray import com.google.gson.JsonObject import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.Forgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension -import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody import okhttp3.MultipartBody.Part import okhttp3.RequestBody @@ -45,76 +45,66 @@ internal class SegmentRequestBodyFactoryTest { @Mock lateinit var mockCompressor: BytesCompressor - private lateinit var fakeCompressedData: ByteArray + lateinit var fakeGroupedSegments: List> - @Forgery - lateinit var fakeSegment: MobileSegment - - @Forgery - lateinit var fakeSegmentAsJson: JsonObject - - private lateinit var fakeSerializedSegmentWithNewLine: String + lateinit var fakeCompresseData: List @BeforeEach fun `set up`(forge: Forge) { - fakeSerializedSegmentWithNewLine = fakeSegmentAsJson.toString() + "\n" - fakeCompressedData = forge.aString().toByteArray() - whenever(mockCompressor.compressBytes(fakeSerializedSegmentWithNewLine.toByteArray())) - .thenReturn(fakeCompressedData) + fakeGroupedSegments = forge.aList(size = forge.anInt(min = 1, max = 10)) { + val segment = forge.getForgery() + val json = forge.getForgery() + Pair(segment, json) + } + fakeCompresseData = fakeGroupedSegments.map { forge.aString().toByteArray() } + fakeGroupedSegments.forEachIndexed { index, pair -> + val compressedData = fakeCompresseData[index] + whenever(mockCompressor.compressBytes((pair.second.toString() + "\n").toByteArray())) + .thenReturn(compressedData) + } testedRequestBodyFactory = SegmentRequestBodyFactory(mockCompressor) } @Test fun `M return a multipart body W create`() { + // Given + val expectedFormMetadata = fakeGroupedSegments + .mapIndexed { index, pair -> + pair.first.toJson().asJsonObject.apply { + addProperty( + SegmentRequestBodyFactory.COMPRESSED_SEGMENT_SIZE_FORM_KEY, + fakeCompresseData[index].size + ) + addProperty( + SegmentRequestBodyFactory.RAW_SEGMENT_SIZE_FORM_KEY, + (pair.second.toString() + "\n").toByteArray().size + ) + } + }.fold(JsonArray()) { acc, element -> + acc.add(element) + acc + } + // When - val body = testedRequestBodyFactory.create(fakeSegment, fakeSegmentAsJson) + val body = testedRequestBodyFactory.create(fakeGroupedSegments) // Then assertThat(body).isInstanceOf(MultipartBody::class.java) val multipartBody = body as MultipartBody assertThat(multipartBody.type).isEqualTo(MultipartBody.FORM) val parts = multipartBody.parts - val compressedSegmentPart = Part.createFormData( - SegmentRequestBodyFactory.SEGMENT_FORM_KEY, - fakeSegment.session.id, - fakeCompressedData - .toRequestBody(SegmentRequestBodyFactory.CONTENT_TYPE_BINARY.toMediaTypeOrNull()) - ) - val applicationIdPart = Part.createFormData( - SegmentRequestBodyFactory.APPLICATION_ID_FORM_KEY, - fakeSegment.application.id - ) - val sessionIdPart = Part.createFormData( - SegmentRequestBodyFactory.SESSION_ID_FORM_KEY, - fakeSegment.session.id - ) - val viewIdPart = Part.createFormData( - SegmentRequestBodyFactory.VIEW_ID_FORM_KEY, - fakeSegment.view.id - ) - val hasFullSnapshotPart = Part.createFormData( - SegmentRequestBodyFactory.HAS_FULL_SNAPSHOT_FORM_KEY, - fakeSegment.hasFullSnapshot.toString() - ) - val recordsCountPart = Part.createFormData( - SegmentRequestBodyFactory.RECORDS_COUNT_FORM_KEY, - fakeSegment.recordsCount.toString() - ) - val rawSegmentSizePart = Part.createFormData( - SegmentRequestBodyFactory.RAW_SEGMENT_SIZE_FORM_KEY, - fakeCompressedData.size.toString() - ) - val segmentsStartPart = Part.createFormData( - SegmentRequestBodyFactory.START_TIMESTAMP_FORM_KEY, - fakeSegment.start.toString() - ) - val segmentsEndPart = Part.createFormData( - SegmentRequestBodyFactory.END_TIMESTAMP_FORM_KEY, - fakeSegment.end.toString() - ) - val segmentSourcePart = Part.createFormData( - SegmentRequestBodyFactory.SOURCE_FORM_KEY, - fakeSegment.source.toJson().asString + val compressedSegmentParts = fakeCompresseData.mapIndexed { index, bytes -> + Part.createFormData( + SegmentRequestBodyFactory.SEGMENT_DATA_FORM_KEY, + "${SegmentRequestBodyFactory.BINARY_FILENAME_PREFIX}$index", + bytes.toRequestBody(SegmentRequestBodyFactory.CONTENT_TYPE_BINARY_TYPE) + ) + } + val metadataPart = Part.createFormData( + SegmentRequestBodyFactory.EVENT_NAME_FORM_KEY, + filename = SegmentRequestBodyFactory.BLOB_FILENAME, + expectedFormMetadata.toString() + .toRequestBody(SegmentRequestBodyFactory.CONTENT_TYPE_JSON_TYPE) ) assertThat(parts) @@ -128,16 +118,8 @@ internal class SegmentRequestBodyFactoryTest { } } .containsExactlyInAnyOrder( - compressedSegmentPart, - applicationIdPart, - sessionIdPart, - viewIdPart, - hasFullSnapshotPart, - recordsCountPart, - rawSegmentSizePart, - segmentsStartPart, - segmentsEndPart, - segmentSourcePart + *compressedSegmentParts.toTypedArray(), + metadataPart ) } @@ -147,7 +129,7 @@ internal class SegmentRequestBodyFactoryTest { whenever(mockCompressor.compressBytes(any())).thenThrow(fakeException) // Then - assertThatThrownBy { testedRequestBodyFactory.create(fakeSegment, fakeSegmentAsJson) } + assertThatThrownBy { testedRequestBodyFactory.create(fakeGroupedSegments) } .isEqualTo(fakeException) } diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/net/SegmentRequestFactoryTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/net/SegmentRequestFactoryTest.kt index 8518b78d83..260441fc5d 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/net/SegmentRequestFactoryTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/net/SegmentRequestFactoryTest.kt @@ -1,8 +1,8 @@ /* - * 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. - */ +* 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.net @@ -50,13 +50,7 @@ internal class SegmentRequestFactoryTest { @Mock lateinit var mockSegmentRequestBodyFactory: SegmentRequestBodyFactory - @Forgery - lateinit var fakeSegment: MobileSegment - - @Forgery - lateinit var fakeSerializedSegment: JsonObject - - private lateinit var fakeCompressedSegment: ByteArray + lateinit var fakeCompressedSegment: ByteArray @Forgery lateinit var fakeBatchData: List @@ -71,8 +65,15 @@ internal class SegmentRequestFactoryTest { private var fakeBatchMetadata: ByteArray? = null + lateinit var fakeDataGroup: List> + @BeforeEach fun `set up`(forge: Forge) { + fakeDataGroup = forge.aList(size = forge.anInt(min = 1, max = 10)) { + val segment = forge.getForgery() + val json = forge.getForgery() + Pair(segment, json) + } fakeMediaType = forge.anElementFrom( listOf( MultipartBody.FORM, @@ -84,10 +85,10 @@ internal class SegmentRequestFactoryTest { whenever(mockRequestBody.contentType()).thenReturn(fakeMediaType) fakeCompressedSegment = forge.aString().toByteArray() fakeBatchMetadata = forge.aNullable { forge.aString().toByteArray() } - whenever(mockSegmentRequestBodyFactory.create(fakeSegment, fakeSerializedSegment)) + whenever(mockSegmentRequestBodyFactory.create(fakeDataGroup)) .thenReturn(mockRequestBody) whenever(mockBatchesToSegmentsMapper.map(fakeBatchData.map { it.data })) - .thenReturn(Pair(fakeSegment, fakeSerializedSegment)) + .thenReturn(fakeDataGroup) testedRequestFactory = SegmentRequestFactory( customEndpointUrl = null, mockBatchesToSegmentsMapper, @@ -160,7 +161,7 @@ internal class SegmentRequestFactoryTest { fun `M throw exception W create(){ payload is broken }`() { // Given whenever(mockBatchesToSegmentsMapper.map(fakeBatchData.map { it.data })) - .thenReturn(null) + .thenReturn(emptyList()) // When assertThatThrownBy { diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/BoundUtilsTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/BoundUtilsTest.kt new file mode 100644 index 0000000000..d1f4603779 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/BoundUtilsTest.kt @@ -0,0 +1,218 @@ +/* + * 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.processor + +import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import com.datadog.android.sessionreplay.internal.utils.WireframeExtTest +import com.datadog.android.sessionreplay.model.MobileSegment +import fr.xgouchet.elmyr.Forge +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.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource + +@Extensions(ExtendWith(ForgeExtension::class)) +@ForgeConfiguration(ForgeConfigurator::class) +internal class BoundUtilsTest { + + @ParameterizedTest + @MethodSource("testWireframes") + fun`M return the correct bounds W resolveBounds() { with clipping }`( + fakeWireframe: MobileSegment.Wireframe, + forge: Forge + ) { + // Given + val clip = MobileSegment.WireframeClip( + top = forge.aLong(min = 0, max = 100), + bottom = forge.aLong(min = 0, max = 100), + left = forge.aLong(min = 0, max = 100), + right = forge.aLong(min = 0, max = 100) + ) + + val expectedLeft = fakeWireframe.x() + clip.left!! + val expectedTop = fakeWireframe.y() + clip.top!! + val expectedRight = fakeWireframe.x() + fakeWireframe.width() - clip.right!! + val expectedBottom = fakeWireframe.y() + fakeWireframe.height() - clip.bottom!! + val expectedBounds = WireframeBounds( + left = expectedLeft, + top = expectedTop, + right = expectedRight, + bottom = expectedBottom, + height = fakeWireframe.height(), + width = fakeWireframe.width() + ) + + // When + val bounds = BoundsUtils.resolveBounds(fakeWireframe.copy(clip = clip)) + + // Then + assertThat(bounds).isEqualTo(expectedBounds) + } + + @ParameterizedTest + @MethodSource("testWireframes") + fun`M return the correct bounds W resolveBounds() { without clipping }`( + fakeWireframe: MobileSegment.Wireframe + ) { + // Given + val expectedLeft = fakeWireframe.x() + val expectedTop = fakeWireframe.y() + val expectedRight = fakeWireframe.x() + fakeWireframe.width() + val expectedBottom = fakeWireframe.y() + fakeWireframe.height() + val expectedBounds = WireframeBounds( + left = expectedLeft, + top = expectedTop, + right = expectedRight, + bottom = expectedBottom, + height = fakeWireframe.height(), + width = fakeWireframe.width() + ) + + // When + val bounds = BoundsUtils.resolveBounds(fakeWireframe.copy(clip = null)) + + // Then + assertThat(bounds).isEqualTo(expectedBounds) + } + + @Test + fun `M return true W isCovering(){ top is covering bottom }`( + forge: Forge + ) { + // Given + val top = WireframeBounds( + left = forge.aLong(min = 0, max = 100), + top = forge.aLong(min = 0, max = 100), + right = forge.aLong(min = 0, max = 100), + bottom = forge.aLong(min = 0, max = 100), + height = forge.aLong(min = 0, max = 100), + width = forge.aLong(min = 0, max = 100) + ) + val bottom = WireframeBounds( + left = top.left + forge.aLong(min = 0, max = 100), + top = top.top + forge.aLong(min = 0, max = 100), + right = top.right - forge.aLong(min = 0, max = 100), + bottom = top.bottom - forge.aLong(min = 0, max = 100), + height = forge.aLong(min = 0, max = 100), + width = forge.aLong(min = 0, max = 100) + ) + + // When + val isCovering = BoundsUtils.isCovering(top, bottom) + + // Then + assertThat(isCovering).isTrue + } + + @Test + fun `M return false W isCovering() { bottom is covering top}`( + forge: Forge + ) { + // Given + val bottom = WireframeBounds( + left = forge.aLong(min = 0, max = 100), + top = forge.aLong(min = 0, max = 100), + right = forge.aLong(min = 0, max = 100), + bottom = forge.aLong(min = 0, max = 100), + height = forge.aLong(min = 0, max = 100), + width = forge.aLong(min = 0, max = 100) + ) + val top = WireframeBounds( + left = bottom.left + forge.aLong(min = 0, max = 100), + top = bottom.top + forge.aLong(min = 0, max = 100), + right = bottom.right - forge.aLong(min = 0, max = 100), + bottom = bottom.bottom - forge.aLong(min = 0, max = 100), + height = forge.aLong(min = 0, max = 100), + width = forge.aLong(min = 0, max = 100) + ) + + // When + val isCovering = BoundsUtils.isCovering(top, bottom) + + // Then + assertThat(isCovering).isFalse + } + + private fun MobileSegment.Wireframe.x(): Long { + return when (this) { + is MobileSegment.Wireframe.ShapeWireframe -> this.x + is MobileSegment.Wireframe.TextWireframe -> this.x + is MobileSegment.Wireframe.ImageWireframe -> this.x + is MobileSegment.Wireframe.PlaceholderWireframe -> this.x + is MobileSegment.Wireframe.WebviewWireframe -> this.x + } + } + + private fun MobileSegment.Wireframe.y(): Long { + return when (this) { + is MobileSegment.Wireframe.ShapeWireframe -> this.y + is MobileSegment.Wireframe.TextWireframe -> this.y + is MobileSegment.Wireframe.ImageWireframe -> this.y + is MobileSegment.Wireframe.PlaceholderWireframe -> this.y + is MobileSegment.Wireframe.WebviewWireframe -> this.y + } + } + + private fun MobileSegment.Wireframe.width(): Long { + return when (this) { + is MobileSegment.Wireframe.ShapeWireframe -> this.width + is MobileSegment.Wireframe.TextWireframe -> this.width + is MobileSegment.Wireframe.ImageWireframe -> this.width + is MobileSegment.Wireframe.PlaceholderWireframe -> this.width + is MobileSegment.Wireframe.WebviewWireframe -> this.width + } + } + + private fun MobileSegment.Wireframe.height(): Long { + return when (this) { + is MobileSegment.Wireframe.ShapeWireframe -> this.height + is MobileSegment.Wireframe.TextWireframe -> this.height + is MobileSegment.Wireframe.ImageWireframe -> this.height + is MobileSegment.Wireframe.PlaceholderWireframe -> this.height + is MobileSegment.Wireframe.WebviewWireframe -> this.height + } + } + + internal fun MobileSegment.Wireframe.copy( + clip: MobileSegment.WireframeClip?, + x: Long, + y: Long, + width: Long, + height: Long + ): MobileSegment.Wireframe { + return when (this) { + is MobileSegment.Wireframe.ShapeWireframe -> + this.copy(clip = clip, x = x, y = y, width = width, height = height) + is MobileSegment.Wireframe.TextWireframe -> + this.copy(clip = clip, x = x, y = y, width = width, height = height) + is MobileSegment.Wireframe.ImageWireframe -> + this.copy(clip = clip, x = x, y = y, width = width, height = height) + is MobileSegment.Wireframe.PlaceholderWireframe -> + this.copy(clip = clip, x = x, y = y, width = width, height = height) + is MobileSegment.Wireframe.WebviewWireframe -> + this.copy(clip = clip, x = x, y = y, width = width, height = height) + } + } + + companion object { + @JvmStatic + fun testWireframes(): List { + ForgeConfigurator().configure(WireframeExtTest.forge) + return listOf( + WireframeExtTest.forge.getForgery(), + WireframeExtTest.forge.getForgery(), + WireframeExtTest.forge.getForgery(), + WireframeExtTest.forge.getForgery(), + WireframeExtTest.forge.getForgery() + ) + } + } +} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/MutationResolverTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/MutationResolverTest.kt index c9d8aac9db..ddeee48b2d 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/MutationResolverTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/MutationResolverTest.kt @@ -27,7 +27,6 @@ import org.mockito.kotlin.eq import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.quality.Strictness -import java.util.ArrayList import java.util.LinkedList import java.util.Locale @@ -523,6 +522,66 @@ internal class MutationResolverTest { // endregion + // region WebView "remove" mutations + + @Test + fun `M identify the updated wireframes W resolveMutations {WebView removed at beginning}`(forge: Forge) { + // Given + val fakePrevSnapshot = forge.aList(size = forge.anInt(min = 3, max = 10)) { + forge.getForgery(MobileSegment.Wireframe.WebviewWireframe::class.java) + } + val fakeHiddenWebViews = forge.anInt(min = 1, max = fakePrevSnapshot.size - 1) + val fakeCurrentSnapshot = fakePrevSnapshot.drop(fakeHiddenWebViews) + val expectedUpdates = fakePrevSnapshot.take(fakeHiddenWebViews).map { + MobileSegment.WireframeUpdateMutation.WebviewWireframeUpdate( + id = it.id(), + slotId = it.slotId, + isVisible = false + ) + } + + // When + val mutations = testedMutationResolver.resolveMutations( + fakePrevSnapshot, + fakeCurrentSnapshot + ) + + // Then + assertThat(mutations?.adds).isNullOrEmpty() + assertThat(mutations?.removes).isNullOrEmpty() + assertThat(mutations?.updates).isEqualTo(expectedUpdates) + } + + @Test + fun `M identify the updated wireframes W resolveMutations {WebView removed at end}`(forge: Forge) { + // Given + val fakePrevSnapshot = forge.aList(size = forge.anInt(min = 3, max = 10)) { + forge.getForgery(MobileSegment.Wireframe.WebviewWireframe::class.java) + } + val fakeHiddenWebViews = forge.anInt(min = 1, max = fakePrevSnapshot.size - 1) + val fakeCurrentSnapshot = fakePrevSnapshot.dropLast(fakeHiddenWebViews) + val expectedUpdates = fakePrevSnapshot.takeLast(fakeHiddenWebViews).map { + MobileSegment.WireframeUpdateMutation.WebviewWireframeUpdate( + id = it.id(), + slotId = it.slotId, + isVisible = false + ) + } + + // When + val mutations = testedMutationResolver.resolveMutations( + fakePrevSnapshot, + fakeCurrentSnapshot + ) + + // Then + assertThat(mutations?.adds).isNullOrEmpty() + assertThat(mutations?.removes).isNullOrEmpty() + assertThat(mutations?.updates).isEqualTo(expectedUpdates) + } + + // endregion + // region no mutation @Test @@ -624,6 +683,7 @@ internal class MutationResolverTest { is MobileSegment.Wireframe.TextWireframe -> this.id is MobileSegment.Wireframe.ImageWireframe -> this.id is MobileSegment.Wireframe.PlaceholderWireframe -> this.id + is MobileSegment.Wireframe.WebviewWireframe -> this.id } } @@ -644,6 +704,7 @@ internal class MutationResolverTest { is MobileSegment.Wireframe.TextWireframe -> this.id is MobileSegment.Wireframe.ImageWireframe -> this.id is MobileSegment.Wireframe.PlaceholderWireframe -> this.id + is MobileSegment.Wireframe.WebviewWireframe -> this.id } } @@ -709,6 +770,22 @@ internal class MutationResolverTest { ) } + val fakePrevWebViewSnapshot = + forgePrevSnapshot() + val fakeWebViewUpdatedWireframes = forgeMutatedWireframes(fakePrevWebViewSnapshot) { + it.copy(x = forge.aLong(), y = forge.aLong()) + } + val fakeCurrentWebViewSnapshot = fakeWebViewUpdatedWireframes + + fakePrevWebViewSnapshot.drop(fakeWebViewUpdatedWireframes.size) + val expectedWebViewUpdates = fakeWebViewUpdatedWireframes.map { + MobileSegment.WireframeUpdateMutation.WebviewWireframeUpdate( + id = it.id(), + slotId = it.slotId, + x = it.x, + y = it.y + ) + } + return listOf( MutationTestData( prevSnapshot = fakePrevShapeSnapshot, @@ -729,6 +806,11 @@ internal class MutationResolverTest { prevSnapshot = fakePrevPlaceholderSnapshot, newSnapshot = fakeCurrentPlaceholderSnapshot, expectedMutation = expectedPlaceholderUpdates + ), + MutationTestData( + prevSnapshot = fakePrevWebViewSnapshot, + newSnapshot = fakeCurrentWebViewSnapshot, + expectedMutation = expectedWebViewUpdates ) ) } @@ -795,6 +877,22 @@ internal class MutationResolverTest { ) } + val fakePrevWebViewSnapshot = + forgePrevSnapshot() + val fakeWebViewUpdatedWireframes = forgeMutatedWireframes(fakePrevWebViewSnapshot) { + it.copy(width = forge.aLong(), height = forge.aLong()) + } + val fakeCurrentWebViewSnapshot = fakeWebViewUpdatedWireframes + + fakePrevWebViewSnapshot.drop(fakeWebViewUpdatedWireframes.size) + val expectedWebViewUpdates = fakeWebViewUpdatedWireframes.map { + MobileSegment.WireframeUpdateMutation.WebviewWireframeUpdate( + id = it.id(), + slotId = it.slotId, + width = it.width, + height = it.height + ) + } + return listOf( MutationTestData( prevSnapshot = fakePrevShapeSnapshot, @@ -815,6 +913,11 @@ internal class MutationResolverTest { prevSnapshot = fakePrevPlaceholderSnapshot, newSnapshot = fakeCurrentPlaceholderSnapshot, expectedMutation = expectedPlaceholderUpdates + ), + MutationTestData( + prevSnapshot = fakePrevWebViewSnapshot, + newSnapshot = fakeCurrentWebViewSnapshot, + expectedMutation = expectedWebViewUpdates ) ) } @@ -877,6 +980,21 @@ internal class MutationResolverTest { ) } + val fakePrevWebViewSnapshot = + forgePrevSnapshot() + val fakeWebViewUpdatedWireframes = forgeMutatedWireframes(fakePrevWebViewSnapshot) { + it.copy(clip = forge.forgeDifferent(it.clip)) + } + val fakeCurrentWebViewSnapshot = fakeWebViewUpdatedWireframes + + fakePrevWebViewSnapshot.drop(fakeWebViewUpdatedWireframes.size) + val expectedWebViewUpdates = fakeWebViewUpdatedWireframes.map { + MobileSegment.WireframeUpdateMutation.WebviewWireframeUpdate( + id = it.id(), + clip = it.clip, + slotId = it.slotId + ) + } + return listOf( MutationTestData( prevSnapshot = fakePrevShapeSnapshot, @@ -897,6 +1015,11 @@ internal class MutationResolverTest { prevSnapshot = fakePrevPlaceholderSnapshot, newSnapshot = fakeCurrentPlaceholderSnapshot, expectedMutation = expectedPlaceholderUpdates + ), + MutationTestData( + prevSnapshot = fakePrevWebViewSnapshot, + newSnapshot = fakeCurrentWebViewSnapshot, + expectedMutation = expectedWebViewUpdates ) ) } @@ -963,6 +1086,22 @@ internal class MutationResolverTest { ) } + val fakePrevWebViewSnapshot = + forgePrevSnapshot() + .map { it.copy(clip = forge.getForgery()) } + val fakeWebViewUpdatedWireframes = forgeMutatedWireframes(fakePrevWebViewSnapshot) { + it.copy(clip = null) + } + val fakeCurrentWebViewSnapshot = fakeWebViewUpdatedWireframes + + fakePrevWebViewSnapshot.drop(fakeWebViewUpdatedWireframes.size) + val expectedWebViewUpdates = fakeWebViewUpdatedWireframes.map { + MobileSegment.WireframeUpdateMutation.WebviewWireframeUpdate( + id = it.id(), + clip = nullClipWireframe, + slotId = it.slotId + ) + } + return listOf( MutationTestData( prevSnapshot = fakePrevShapeSnapshot, @@ -983,6 +1122,11 @@ internal class MutationResolverTest { prevSnapshot = fakePrevPlaceholderSnapshot, newSnapshot = fakeCurrentPlaceholderSnapshot, expectedMutation = expectedPlaceholderUpdates + ), + MutationTestData( + prevSnapshot = fakePrevWebViewSnapshot, + newSnapshot = fakeCurrentWebViewSnapshot, + expectedMutation = expectedWebViewUpdates ) ) } @@ -1092,6 +1236,21 @@ internal class MutationResolverTest { ) } + val fakePrevWebViewSnapshot = + forgePrevSnapshot() + val fakeWebViewUpdatedWireframes = forgeMutatedWireframes(fakePrevWebViewSnapshot) { + it.copy(shapeStyle = forge.getForgery()) + } + val fakeCurrentWebViewSnapshot = fakeWebViewUpdatedWireframes + + fakePrevWebViewSnapshot.drop(fakeWebViewUpdatedWireframes.size) + val expectedWebViewUpdates = fakeWebViewUpdatedWireframes.map { + MobileSegment.WireframeUpdateMutation.WebviewWireframeUpdate( + id = it.id(), + shapeStyle = it.shapeStyle, + slotId = it.slotId + ) + } + return listOf( MutationTestData( prevSnapshot = fakePrevShapeSnapshot, @@ -1107,6 +1266,11 @@ internal class MutationResolverTest { prevSnapshot = fakePrevImageSnapshot, newSnapshot = fakeCurrentImageSnapshot, expectedMutation = expectedImageUpdates + ), + MutationTestData( + prevSnapshot = fakePrevWebViewSnapshot, + newSnapshot = fakeCurrentWebViewSnapshot, + expectedMutation = expectedWebViewUpdates ) ) } @@ -1136,6 +1300,12 @@ internal class MutationResolverTest { forge.getForgery().copy(id = it.id) } + val fakePrevWebViewSnapshot = forgePrevSnapshot() + val fakeCurrentWebViewSnapshot = fakePrevWebViewSnapshot.map { + forge.getForgery().copy( + id = it.id + ) + } return listOf( MutationTestData( prevSnapshot = fakePrevShapeSnapshot, @@ -1156,6 +1326,11 @@ internal class MutationResolverTest { prevSnapshot = fakePrevPlaceholderSnapshot, newSnapshot = fakeCurrentPlaceholderSnapshot, expectedMutation = emptyList() + ), + MutationTestData( + prevSnapshot = fakePrevWebViewSnapshot, + newSnapshot = fakeCurrentWebViewSnapshot, + expectedMutation = emptyList() ) ) } diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/NodeFlattenerTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/NodeFlattenerTest.kt index 5767b6fec2..e786c2ef1a 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/NodeFlattenerTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/NodeFlattenerTest.kt @@ -208,6 +208,8 @@ internal class NodeFlattenerTest { this.copy(id = id) is MobileSegment.Wireframe.PlaceholderWireframe -> this.copy(id = id) + is MobileSegment.Wireframe.WebviewWireframe -> + this.copy(id = id) } } @@ -279,6 +281,14 @@ internal class NodeFlattenerTest { height = height, clip = null ) + is MobileSegment.Wireframe.WebviewWireframe -> fakeWireframe.copy( + id = id, + x = x, + y = y, + width = width, + height = height, + clip = null + ) } } @@ -292,6 +302,8 @@ internal class NodeFlattenerTest { Bounds(this.x, this.y, this.width, this.height) is MobileSegment.Wireframe.PlaceholderWireframe -> Bounds(this.x, this.y, this.width, this.height) + is MobileSegment.Wireframe.WebviewWireframe -> + Bounds(this.x, this.y, this.width, this.height) } } @@ -305,6 +317,8 @@ internal class NodeFlattenerTest { clip is MobileSegment.Wireframe.PlaceholderWireframe -> clip + is MobileSegment.Wireframe.WebviewWireframe -> + clip } } @@ -316,6 +330,7 @@ internal class NodeFlattenerTest { is MobileSegment.Wireframe.TextWireframe -> this.id is MobileSegment.Wireframe.ImageWireframe -> this.id is MobileSegment.Wireframe.PlaceholderWireframe -> this.id + is MobileSegment.Wireframe.WebviewWireframe -> this.id } } diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/RecordedDataProcessorTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/RecordedDataProcessorTest.kt index ee32128097..a37cf592a7 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/RecordedDataProcessorTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/RecordedDataProcessorTest.kt @@ -1250,6 +1250,8 @@ internal class RecordedDataProcessorTest { this.copy(id = id) is MobileSegment.Wireframe.PlaceholderWireframe -> this.copy(id = id) + is MobileSegment.Wireframe.WebviewWireframe -> + this.copy(id = id) } } diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/WireframeUtilsTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/WireframeUtilsTest.kt index 16f11e2afe..9744561f56 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/WireframeUtilsTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/processor/WireframeUtilsTest.kt @@ -376,11 +376,9 @@ internal class WireframeUtilsTest { val topWireframes = forge.opaqueWireframes() topWireframes.forEach { val topWireframeBounds: WireframeBounds = forge.getForgery() - it.copy(shapeStyle = forge.forgeNonTransparentShapeStyle()).apply { - whenever(mockBoundsUtils.resolveBounds(this)).thenReturn(topWireframeBounds) - whenever(mockBoundsUtils.isCovering(topWireframeBounds, fakeWireframeBounds)) - .thenReturn(true) - } + whenever(mockBoundsUtils.resolveBounds(it)).thenReturn(topWireframeBounds) + whenever(mockBoundsUtils.isCovering(topWireframeBounds, fakeWireframeBounds)) + .thenReturn(true) } // Then @@ -420,16 +418,12 @@ internal class WireframeUtilsTest { whenever(mockBoundsUtils.resolveBounds(fakeWireframe)).thenReturn(this) } val topWireframes = forge.wireframesWithNoBackgroundColor() + topWireframes.forEach { val topWireframeBounds: WireframeBounds = forge.getForgery() - it.copy( - shapeStyle = forge.forgeNonTransparentShapeStyle() - .copy(backgroundColor = null) - ).apply { - whenever(mockBoundsUtils.resolveBounds(this)).thenReturn(topWireframeBounds) - whenever(mockBoundsUtils.isCovering(topWireframeBounds, fakeWireframeBounds)) - .thenReturn(true) - } + whenever(mockBoundsUtils.resolveBounds(it)).thenReturn(topWireframeBounds) + whenever(mockBoundsUtils.isCovering(topWireframeBounds, fakeWireframeBounds)) + .thenReturn(true) } // Then @@ -470,8 +464,32 @@ internal class WireframeUtilsTest { } val topWireframes = forge.aList { forge.getForgery() + }.map { + val topWireframeBounds: WireframeBounds = forge.getForgery() + it.copy(shapeStyle = null).apply { + whenever(mockBoundsUtils.resolveBounds(this)).thenReturn(topWireframeBounds) + whenever(mockBoundsUtils.isCovering(topWireframeBounds, fakeWireframeBounds)) + .thenReturn(true) + } } - topWireframes.forEach { + + // Then + assertThat(testedWireframeUtils.checkWireframeIsCovered(fakeWireframe, topWireframes)) + .isTrue + } + + @Test + fun `M return true W checkWireframeIsCovered(){top WebView wireframe, no shapeStyle}`( + @Forgery fakeWireframe: MobileSegment.Wireframe, + forge: Forge + ) { + // Given + val fakeWireframeBounds: WireframeBounds = forge.getForgery().apply { + whenever(mockBoundsUtils.resolveBounds(fakeWireframe)).thenReturn(this) + } + val topWireframes = forge.aList { + forge.getForgery() + }.map { val topWireframeBounds: WireframeBounds = forge.getForgery() it.copy(shapeStyle = null).apply { whenever(mockBoundsUtils.resolveBounds(this)).thenReturn(topWireframeBounds) @@ -485,6 +503,32 @@ internal class WireframeUtilsTest { .isTrue } + @Test + fun `M return true W checkWireframeIsCovered(){top WebView wireframe, transparent shapeStyle}`( + @Forgery fakeWireframe: MobileSegment.Wireframe, + forge: Forge + ) { + // Given + val fakeWireframeBounds: WireframeBounds = forge.getForgery().apply { + whenever(mockBoundsUtils.resolveBounds(fakeWireframe)).thenReturn(this) + } + val topWireframes = forge.aList { + forge.getForgery() + }.map { + val topWireframeBounds: WireframeBounds = forge.getForgery() + val shapeStyle = forge.forgeNonTransparentShapeStyle().copy(opacity = 0) + it.copy(shapeStyle = shapeStyle).apply { + whenever(mockBoundsUtils.resolveBounds(this)).thenReturn(topWireframeBounds) + whenever(mockBoundsUtils.isCovering(topWireframeBounds, fakeWireframeBounds)) + .thenReturn(true) + } + } + + // Then + assertThat(testedWireframeUtils.checkWireframeIsCovered(fakeWireframe, topWireframes)) + .isTrue + } + @Test fun `M return false W checkWireframeIsCovered { top empty ImageWireframe }`( @Forgery fakeWireframe: MobileSegment.Wireframe.ImageWireframe, @@ -496,9 +540,7 @@ internal class WireframeUtilsTest { } val topWireframes = forge.aList { forge.getForgery() - .copy(shapeStyle = null, resourceId = null) - } - topWireframes.forEach { + }.map { val topWireframeBounds: WireframeBounds = forge.getForgery() it.copy(shapeStyle = null).apply { whenever(mockBoundsUtils.resolveBounds(this)) @@ -528,12 +570,10 @@ internal class WireframeUtilsTest { } topWireframes.forEach { val topWireframeBounds: WireframeBounds = forge.getForgery() - it.copy(shapeStyle = null).apply { - whenever(mockBoundsUtils.resolveBounds(this)) - .thenReturn(topWireframeBounds) - whenever(mockBoundsUtils.isCovering(topWireframeBounds, fakeWireframeBounds)) - .thenReturn(true) - } + whenever(mockBoundsUtils.resolveBounds(it)) + .thenReturn(topWireframeBounds) + whenever(mockBoundsUtils.isCovering(topWireframeBounds, fakeWireframeBounds)) + .thenReturn(true) } // Then @@ -655,6 +695,7 @@ internal class WireframeUtilsTest { is MobileSegment.Wireframe.TextWireframe -> clip?.normalized() is MobileSegment.Wireframe.ImageWireframe -> clip?.normalized() is MobileSegment.Wireframe.PlaceholderWireframe -> clip?.normalized() + is MobileSegment.Wireframe.WebviewWireframe -> clip?.normalized() } } @@ -696,6 +737,7 @@ internal class WireframeUtilsTest { ) is MobileSegment.Wireframe.PlaceholderWireframe -> this + is MobileSegment.Wireframe.WebviewWireframe -> this } } @@ -709,7 +751,8 @@ internal class WireframeUtilsTest { shapeStyle = getForgery(), border = getForgery() ), - getForgery() + getForgery(), + getForgery() ) } @@ -760,7 +803,7 @@ internal class WireframeUtilsTest { getForgery() .copy( shapeStyle = forgeNonTransparentShapeStyle() - .copy(backgroundColor = null) + .copy(backgroundColor = aStringMatching("#[0-9A-Fa-f]{6}[a-eA-E]{2}")) ), getForgery() .copy( @@ -771,7 +814,7 @@ internal class WireframeUtilsTest { .copy( shapeStyle = forgeNonTransparentShapeStyle() .copy(backgroundColor = null), - resourceId = null + base64 = null ) ) } diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/WebViewWireframeMapperTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/WebViewWireframeMapperTest.kt new file mode 100644 index 0000000000..13b70bbdf7 --- /dev/null +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/recorder/mapper/WebViewWireframeMapperTest.kt @@ -0,0 +1,92 @@ +/* + * 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.mapper + +import android.webkit.WebView +import com.datadog.android.sessionreplay.forge.ForgeConfigurator +import com.datadog.android.sessionreplay.model.MobileSegment +import com.datadog.android.sessionreplay.utils.GlobalBounds +import fr.xgouchet.elmyr.annotation.Forgery +import fr.xgouchet.elmyr.annotation.LongForgery +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +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.whenever +import org.mockito.quality.Strictness + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(ForgeConfigurator::class) +internal class WebViewWireframeMapperTest : BaseWireframeMapperTest() { + + private lateinit var testedWebViewWireframeMapper: WebViewWireframeMapper + + @Mock + lateinit var mockWebView: WebView + + @Forgery + lateinit var fakeViewGlobalBounds: GlobalBounds + + @LongForgery + var fakeWireframeId: Long = 0L + + @BeforeEach + fun `set up`() { + whenever( + mockViewBoundsResolver.resolveViewGlobalBounds( + mockWebView, + fakeMappingContext.systemInformation.screenDensity + ) + ).thenReturn(fakeViewGlobalBounds) + whenever( + mockViewIdentifierResolver.resolveViewId(mockWebView) + ) doReturn fakeWireframeId + + testedWebViewWireframeMapper = WebViewWireframeMapper( + mockViewIdentifierResolver, + mockColorStringFormatter, + mockViewBoundsResolver, + mockDrawableToColorMapper + ) + } + + @Test + fun `M resolve a WebViewWireframe W map()`() { + // Given + val expectedWireframe = MobileSegment.Wireframe.WebviewWireframe( + id = fakeWireframeId, + x = fakeViewGlobalBounds.x, + y = fakeViewGlobalBounds.y, + width = fakeViewGlobalBounds.width, + height = fakeViewGlobalBounds.height, + slotId = fakeWireframeId.toString(), + isVisible = true + ) + + // When + val mappedWireframes = testedWebViewWireframeMapper.map( + mockWebView, + fakeMappingContext, + mockAsyncJobStatusCallback + ) + + // Then + assertThat(mappedWireframes).hasSize(1) + .contains(expectedWireframe) + } +} diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/storage/SessionReplayRecordWriterTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/storage/SessionReplayRecordWriterTest.kt index 33c4e70c07..d51bd8ec95 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/storage/SessionReplayRecordWriterTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/storage/SessionReplayRecordWriterTest.kt @@ -72,10 +72,10 @@ internal class SessionReplayRecordWriterTest { } @Test - fun `M write the record in a new batch W write { first view in session }`(forge: Forge) { + fun `M write the record in a batch W write`(forge: Forge) { // Given val fakeRecord = forge.forgeEnrichedRecord() - whenever(mockSessionReplayFeature.withWriteContext(eq(true), any())) doAnswer { + whenever(mockSessionReplayFeature.withWriteContext(eq(false), any())) doAnswer { val callback = it.getArgument<(DatadogContext, EventBatchWriter) -> Unit>(1) callback.invoke(fakeDatadogContext, mockEventBatchWriter) } @@ -91,49 +91,6 @@ internal class SessionReplayRecordWriterTest { verifyNoMoreInteractions(mockRecordCallback) } - @Test - fun `M write the record in the same batch W write { when same view }`(forge: Forge) { - // Given - val fakeRecord1 = forge.forgeEnrichedRecord() - val fakeRecord2 = fakeRecord1.copy() - - testedWriter.write(fakeRecord1) - - // When - testedWriter.write(fakeRecord2) - - // Then - verify(mockSessionReplayFeature) - .withWriteContext(eq(true), any()) - verify(mockSessionReplayFeature) - .withWriteContext(eq(false), any()) - verifyNoMoreInteractions(mockSessionReplayFeature) - } - - @Test - fun `M write the record in new batch W write { when different view }`(forge: Forge) { - // Given - val fakeRecord1 = forge.forgeEnrichedRecord() - val fakeRecord2 = forge.forgeEnrichedRecord() - whenever(mockSessionReplayFeature.withWriteContext(eq(true), any())) doAnswer { - val callback = it.getArgument<(DatadogContext, EventBatchWriter) -> Unit>(1) - callback.invoke(fakeDatadogContext, mockEventBatchWriter) - } - testedWriter.write(fakeRecord1) - - // When - testedWriter.write(fakeRecord2) - - // Then - verify(mockEventBatchWriter).write(RawBatchEvent(data = fakeRecord1.toJson().toByteArray()), null) - verify(mockEventBatchWriter).write(RawBatchEvent(data = fakeRecord2.toJson().toByteArray()), null) - verifyNoMoreInteractions(mockEventBatchWriter) - - verify(mockRecordCallback).onRecordForViewSent(fakeRecord1) - verify(mockRecordCallback).onRecordForViewSent(fakeRecord2) - verifyNoMoreInteractions(mockRecordCallback) - } - @Test fun `M do nothing W write { feature not properly initialized }`(forge: Forge) { // Given @@ -156,7 +113,7 @@ internal class SessionReplayRecordWriterTest { .thenReturn(false) val fakeRecord = forge.forgeEnrichedRecord() - whenever(mockSessionReplayFeature.withWriteContext(eq(true), any())) doAnswer { + whenever(mockSessionReplayFeature.withWriteContext(eq(false), any())) doAnswer { val callback = it.getArgument<(DatadogContext, EventBatchWriter) -> Unit>(1) callback.invoke(fakeDatadogContext, mockEventBatchWriter) } diff --git a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/utils/WireframeExtTest.kt b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/utils/WireframeExtTest.kt index 340dcac25a..de2e50290f 100644 --- a/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/utils/WireframeExtTest.kt +++ b/features/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/internal/utils/WireframeExtTest.kt @@ -146,6 +146,24 @@ internal class WireframeExtTest { assertThat(fakeWireframe.hasOpaqueBackground()).isTrue } + @Test + fun `M return true W hasOpaqueBackground { WebViewWireframe}`( + @Forgery fakeWireframe: MobileSegment.Wireframe.WebviewWireframe + ) { + assertThat(fakeWireframe.hasOpaqueBackground()).isTrue + } + + @Test + fun `M return true W hasOpaqueBackground { WebViewWireframe no shapeStyle}`( + @Forgery fakeWireframe: MobileSegment.Wireframe.WebviewWireframe + ) { + // Given + val fakeTestWireframe = fakeWireframe.copy(shapeStyle = null) + + // Then + assertThat(fakeTestWireframe.hasOpaqueBackground()).isTrue + } + // endregion // region shapeStyle @@ -188,6 +206,7 @@ internal class WireframeExtTest { is MobileSegment.Wireframe.ShapeWireframe -> this.copy(shapeStyle = shapeStyle) is MobileSegment.Wireframe.ImageWireframe -> this.copy(shapeStyle = shapeStyle) is MobileSegment.Wireframe.PlaceholderWireframe -> this + is MobileSegment.Wireframe.WebviewWireframe -> this.copy(shapeStyle = shapeStyle) } } diff --git a/features/dd-sdk-android-webview/api/apiSurface b/features/dd-sdk-android-webview/api/apiSurface index 7ec6454aaf..ac4c7d8f9f 100644 --- a/features/dd-sdk-android-webview/api/apiSurface +++ b/features/dd-sdk-android-webview/api/apiSurface @@ -1,5 +1,5 @@ object com.datadog.android.webview.WebViewTracking fun enable(android.webkit.WebView, List, Float = 100f, com.datadog.android.api.SdkCore = Datadog.getInstance()) class _InternalWebViewProxy - constructor(com.datadog.android.api.SdkCore) + constructor(com.datadog.android.api.SdkCore, String? = null) fun consumeWebviewEvent(String) diff --git a/features/dd-sdk-android-webview/api/dd-sdk-android-webview.api b/features/dd-sdk-android-webview/api/dd-sdk-android-webview.api index 7bdea4c965..5f73cdf17d 100644 --- a/features/dd-sdk-android-webview/api/dd-sdk-android-webview.api +++ b/features/dd-sdk-android-webview/api/dd-sdk-android-webview.api @@ -7,7 +7,8 @@ public final class com/datadog/android/webview/WebViewTracking { } public final class com/datadog/android/webview/WebViewTracking$_InternalWebViewProxy { - public fun (Lcom/datadog/android/api/SdkCore;)V + public fun (Lcom/datadog/android/api/SdkCore;Ljava/lang/String;)V + public synthetic fun (Lcom/datadog/android/api/SdkCore;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun consumeWebviewEvent (Ljava/lang/String;)V } diff --git a/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/WebViewTracking.kt b/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/WebViewTracking.kt index 97f3d127c4..72830af4f6 100644 --- a/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/WebViewTracking.kt +++ b/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/WebViewTracking.kt @@ -23,9 +23,15 @@ import com.datadog.android.webview.internal.NoOpWebViewEventConsumer import com.datadog.android.webview.internal.WebViewEventConsumer import com.datadog.android.webview.internal.log.WebViewLogEventConsumer import com.datadog.android.webview.internal.log.WebViewLogsFeature +import com.datadog.android.webview.internal.replay.WebViewReplayEventConsumer +import com.datadog.android.webview.internal.replay.WebViewReplayEventMapper +import com.datadog.android.webview.internal.replay.WebViewReplayFeature +import com.datadog.android.webview.internal.rum.TimestampOffsetProvider import com.datadog.android.webview.internal.rum.WebViewRumEventConsumer import com.datadog.android.webview.internal.rum.WebViewRumEventContextProvider +import com.datadog.android.webview.internal.rum.WebViewRumEventMapper import com.datadog.android.webview.internal.rum.WebViewRumFeature +import com.datadog.android.webview.internal.rum.domain.NoOpNativeRumViewsCache /** * An entry point to Datadog WebView Tracking feature. @@ -72,23 +78,78 @@ object WebViewTracking { { JAVA_SCRIPT_NOT_ENABLED_WARNING_MESSAGE } ) } - val webViewEventConsumer = buildWebViewEventConsumer(sdkCore as FeatureSdkCore, logsSampleRate) + val featureSdkCore = sdkCore as FeatureSdkCore + val featureContext = sdkCore.getFeatureContext(Feature.SESSION_REPLAY_FEATURE_NAME) + val privacyLevel = (featureContext[SESSION_REPLAY_PRIVACY_KEY] as? String) + ?: SESSION_REPLAY_MASK_ALL_PRIVACY + val webViewEventConsumer = buildWebViewEventConsumer( + featureSdkCore, + logsSampleRate, + System.identityHashCode(webView).toString() + ) webView.addJavascriptInterface( - DatadogEventBridge(webViewEventConsumer, allowedHosts), + DatadogEventBridge(webViewEventConsumer, allowedHosts, privacyLevel), DATADOG_EVENT_BRIDGE_NAME ) } private fun buildWebViewEventConsumer( sdkCore: FeatureSdkCore, - logsSampleRate: Float + logsSampleRate: Float, + webViewId: String? ): WebViewEventConsumer { + val webViewRumFeature = resolveRumFeature(sdkCore) + val webViewLogsFeature = resolveLogsFeature(sdkCore) + val webViewReplayFeature = resolveReplayFeature(sdkCore) + if (webViewLogsFeature == null && webViewRumFeature == null) { + return NoOpWebViewEventConsumer() + } else { + // it is very important that the timestamp offset provider is shared between the + // different consumers, otherwise we might end up with different offsets for the replay + // and rum browser events for the same view id. + val timestampOffsetProvider = TimestampOffsetProvider(sdkCore.internalLogger) + val contextProvider = WebViewRumEventContextProvider(sdkCore.internalLogger) + val nativeRumActivityHandler = webViewRumFeature?.nativeRumViewsCache ?: NoOpNativeRumViewsCache() + return MixedWebViewEventConsumer( + WebViewRumEventConsumer( + sdkCore = sdkCore, + offsetProvider = timestampOffsetProvider, + dataWriter = webViewRumFeature?.dataWriter ?: NoOpDataWriter(), + webViewRumEventMapper = WebViewRumEventMapper(nativeRumActivityHandler), + contextProvider = contextProvider + ), + webViewId?.let { + WebViewReplayEventConsumer( + sdkCore = sdkCore, + dataWriter = webViewReplayFeature?.dataWriter ?: NoOpDataWriter(), + contextProvider = contextProvider, + webViewReplayEventMapper = WebViewReplayEventMapper( + it, + timestampOffsetProvider + ) + ) + } ?: NoOpWebViewEventConsumer(), + WebViewLogEventConsumer( + sdkCore = sdkCore, + userLogsWriter = webViewLogsFeature?.dataWriter ?: NoOpDataWriter(), + rumContextProvider = contextProvider, + sampleRate = logsSampleRate + ), + sdkCore.internalLogger + ) + } + } + + private fun resolveRumFeature(sdkCore: FeatureSdkCore): WebViewRumFeature? { + ( + sdkCore.getFeature(WebViewRumFeature.WEB_RUM_FEATURE_NAME) + ?.unwrap() as? WebViewRumFeature + )?.let { + return it + } val rumFeature = sdkCore.getFeature(Feature.RUM_FEATURE_NAME) ?.unwrap() - val logsFeature = sdkCore.getFeature(Feature.LOGS_FEATURE_NAME) - ?.unwrap() - - val webViewRumFeature = if (rumFeature != null) { + return if (rumFeature != null) { WebViewRumFeature(sdkCore, rumFeature.requestFactory) .apply { sdkCore.registerFeature(this) } } else { @@ -99,38 +160,47 @@ object WebViewTracking { ) null } - - val webViewLogsFeature = if (logsFeature != null) { - WebViewLogsFeature(sdkCore, logsFeature.requestFactory) + } + private fun resolveReplayFeature(sdkCore: FeatureSdkCore): WebViewReplayFeature? { + ( + sdkCore.getFeature(WebViewReplayFeature.WEB_REPLAY_FEATURE_NAME) + ?.unwrap() as? WebViewReplayFeature + )?.let { + return it + } + val sessionReplayFeature = sdkCore.getFeature(Feature.SESSION_REPLAY_FEATURE_NAME) + ?.unwrap() + return if (sessionReplayFeature != null) { + WebViewReplayFeature(sdkCore, sessionReplayFeature.requestFactory) .apply { sdkCore.registerFeature(this) } } else { sdkCore.internalLogger.log( InternalLogger.Level.INFO, InternalLogger.Target.USER, - { LOGS_FEATURE_MISSING_INFO } + { SESSION_REPLAY_FEATURE_MISSING_INFO } ) null } - - val contextProvider = WebViewRumEventContextProvider(sdkCore.internalLogger) - - if (webViewLogsFeature == null && webViewRumFeature == null) { - return NoOpWebViewEventConsumer() + } + private fun resolveLogsFeature(sdkCore: FeatureSdkCore): WebViewLogsFeature? { + ( + sdkCore.getFeature(WebViewLogsFeature.WEB_LOGS_FEATURE_NAME) + ?.unwrap() as? WebViewLogsFeature + )?.let { + return it + } + val logsFeature = sdkCore.getFeature(Feature.LOGS_FEATURE_NAME) + ?.unwrap() + return if (logsFeature != null) { + WebViewLogsFeature(sdkCore, logsFeature.requestFactory) + .apply { sdkCore.registerFeature(this) } } else { - return MixedWebViewEventConsumer( - WebViewRumEventConsumer( - sdkCore = sdkCore, - dataWriter = webViewRumFeature?.dataWriter ?: NoOpDataWriter(), - contextProvider = contextProvider - ), - WebViewLogEventConsumer( - sdkCore = sdkCore, - userLogsWriter = webViewLogsFeature?.dataWriter ?: NoOpDataWriter(), - rumContextProvider = contextProvider, - sampleRate = logsSampleRate - ), - sdkCore.internalLogger + sdkCore.internalLogger.log( + InternalLogger.Level.INFO, + InternalLogger.Target.USER, + { LOGS_FEATURE_MISSING_INFO } ) + null } } @@ -141,10 +211,11 @@ object WebViewTracking { "ClassName", "ClassNaming" ) - class _InternalWebViewProxy(sdkCore: SdkCore) { - private val consumer = buildWebViewEventConsumer( + class _InternalWebViewProxy(sdkCore: SdkCore, webViewId: String? = null) { + internal val consumer = buildWebViewEventConsumer( sdkCore as FeatureSdkCore, - WebViewLogEventConsumer.DEFAULT_SAMPLE_RATE + WebViewLogEventConsumer.DEFAULT_SAMPLE_RATE, + webViewId ) fun consumeWebviewEvent(event: String) { @@ -152,6 +223,9 @@ object WebViewTracking { } } + internal const val SESSION_REPLAY_PRIVACY_KEY = "session_replay_privacy" + internal const val SESSION_REPLAY_MASK_ALL_PRIVACY = "mask" + internal const val JAVA_SCRIPT_NOT_ENABLED_WARNING_MESSAGE = "You are trying to enable the WebView" + "tracking but the java script capability was not enabled for the given WebView." @@ -161,4 +235,6 @@ object WebViewTracking { "RUM feature is not registered, will ignore RUM events from WebView." internal const val LOGS_FEATURE_MISSING_INFO = "Logs feature is not registered, will ignore Log events from WebView." + internal const val SESSION_REPLAY_FEATURE_MISSING_INFO = + "Session replay feature is not registered, will ignore replay records from WebView." } diff --git a/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/DatadogEventBridge.kt b/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/DatadogEventBridge.kt index 1d272acdce..5d89ac6826 100644 --- a/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/DatadogEventBridge.kt +++ b/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/DatadogEventBridge.kt @@ -19,7 +19,8 @@ import com.google.gson.JsonArray */ internal class DatadogEventBridge( internal val webViewEventConsumer: WebViewEventConsumer, - private val allowedHosts: List + private val allowedHosts: List, + private val privacyLevel: String ) { // region Bridge @@ -51,9 +52,31 @@ internal class DatadogEventBridge( return origins.toString() } + /** + * Called from the browser-sdk to get the privacy level of the session replay feature. + * @return the privacy level as a String ("allow", "mask", "mask_user_input") + */ + @JavascriptInterface + fun getPrivacyLevel(): String { + return privacyLevel + } + + /** + * Called from the browser-sdk to know the capabilities supported by this version of the bridge. + * @return the capabilities as an array of Strings. + */ + @JavascriptInterface + fun getCapabilities(): String { + return capabilities.toString() + } + // endregion companion object { internal const val WEB_VIEW_TRACKING_FEATURE_NAME = "WebView" + + internal val capabilities = JsonArray().apply { + add("records") + } } } diff --git a/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/MixedWebViewEventConsumer.kt b/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/MixedWebViewEventConsumer.kt index 2cc7d24a96..9c7bb60c80 100644 --- a/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/MixedWebViewEventConsumer.kt +++ b/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/MixedWebViewEventConsumer.kt @@ -9,6 +9,7 @@ package com.datadog.android.webview.internal import androidx.annotation.WorkerThread import com.datadog.android.api.InternalLogger import com.datadog.android.webview.internal.log.WebViewLogEventConsumer +import com.datadog.android.webview.internal.replay.WebViewReplayEventConsumer import com.datadog.android.webview.internal.rum.WebViewRumEventConsumer import com.google.gson.JsonObject import com.google.gson.JsonParseException @@ -17,6 +18,7 @@ import java.util.Locale.US internal class MixedWebViewEventConsumer( internal val rumEventConsumer: WebViewEventConsumer, + internal val replayEventConsumer: WebViewEventConsumer, internal val logsEventConsumer: WebViewEventConsumer>, private val internalLogger: InternalLogger ) : WebViewEventConsumer { @@ -56,6 +58,9 @@ internal class MixedWebViewEventConsumer( in (WebViewRumEventConsumer.RUM_EVENT_TYPES) -> { rumEventConsumer.consume(wrappedEvent) } + in (WebViewReplayEventConsumer.REPLAY_EVENT_TYPES) -> { + replayEventConsumer.consume(webEvent) + } else -> { internalLogger.log( InternalLogger.Level.ERROR, diff --git a/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/replay/WebViewReplayEventConsumer.kt b/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/replay/WebViewReplayEventConsumer.kt new file mode 100644 index 0000000000..1cd4060e50 --- /dev/null +++ b/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/replay/WebViewReplayEventConsumer.kt @@ -0,0 +1,99 @@ +/* + * 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.webview.internal.replay + +import com.datadog.android.api.InternalLogger +import com.datadog.android.api.context.DatadogContext +import com.datadog.android.api.feature.Feature +import com.datadog.android.api.feature.FeatureSdkCore +import com.datadog.android.api.storage.DataWriter +import com.datadog.android.webview.internal.WebViewEventConsumer +import com.datadog.android.webview.internal.rum.WebViewRumEventContextProvider +import com.datadog.android.webview.internal.rum.domain.RumContext +import com.google.gson.JsonObject +import java.lang.IllegalStateException +import java.lang.NumberFormatException +import java.lang.UnsupportedOperationException + +internal class WebViewReplayEventConsumer( + private val sdkCore: FeatureSdkCore, + internal val dataWriter: DataWriter, + private val contextProvider: WebViewRumEventContextProvider, + internal val webViewReplayEventMapper: WebViewReplayEventMapper +) : WebViewEventConsumer { + + override fun consume(event: JsonObject) { + sdkCore.getFeature(WebViewReplayFeature.WEB_REPLAY_FEATURE_NAME) + ?.withWriteContext { datadogContext, eventBatchWriter -> + val rumContext = contextProvider.getRumContext(datadogContext) + val sessionReplayFeatureContext = datadogContext.featuresContext[ + Feature.SESSION_REPLAY_FEATURE_NAME + ] + val sessionReplayEnabled = sessionReplayFeatureContext?.get( + SESSION_REPLAY_ENABLED_KEY + ) as? Boolean ?: false + if (rumContext != null && + rumContext.sessionState == "TRACKED" && + sessionReplayEnabled + ) { + map(event, datadogContext, rumContext)?.let { mappedEvent -> + @Suppress("ThreadSafety") // inside worker thread context + dataWriter.write(eventBatchWriter, mappedEvent) + } + } + } + } + + private fun map( + event: JsonObject, + datadogContext: DatadogContext, + rumContext: RumContext + ): JsonObject? { + try { + return webViewReplayEventMapper.mapEvent(event, rumContext, datadogContext) + } catch (e: ClassCastException) { + sdkCore.internalLogger.log( + InternalLogger.Level.ERROR, + listOf(InternalLogger.Target.MAINTAINER, InternalLogger.Target.TELEMETRY), + { JSON_PARSING_ERROR_MESSAGE }, + e + ) + } catch (e: NumberFormatException) { + sdkCore.internalLogger.log( + InternalLogger.Level.ERROR, + listOf(InternalLogger.Target.MAINTAINER, InternalLogger.Target.TELEMETRY), + { JSON_PARSING_ERROR_MESSAGE }, + e + ) + } catch (e: IllegalStateException) { + sdkCore.internalLogger.log( + InternalLogger.Level.ERROR, + listOf(InternalLogger.Target.MAINTAINER, InternalLogger.Target.TELEMETRY), + { JSON_PARSING_ERROR_MESSAGE }, + e + ) + } catch (e: UnsupportedOperationException) { + sdkCore.internalLogger.log( + InternalLogger.Level.ERROR, + listOf(InternalLogger.Target.MAINTAINER, InternalLogger.Target.TELEMETRY), + { JSON_PARSING_ERROR_MESSAGE }, + e + ) + } + return null + } + + companion object { + private const val RECORD_KEY = "record" + const val SESSION_TRACKED_STATE = "TRACKED" + val REPLAY_EVENT_TYPES = setOf(RECORD_KEY) + const val JSON_PARSING_ERROR_MESSAGE = + "The bundled web Replay event could not be deserialized" + internal const val SESSION_REPLAY_ENABLED_KEY = + "session_replay_is_enabled" + } +} diff --git a/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/replay/WebViewReplayEventMapper.kt b/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/replay/WebViewReplayEventMapper.kt new file mode 100644 index 0000000000..65380e1dc1 --- /dev/null +++ b/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/replay/WebViewReplayEventMapper.kt @@ -0,0 +1,77 @@ +/* + * 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.webview.internal.replay + +import com.datadog.android.api.context.DatadogContext +import com.datadog.android.webview.internal.rum.TimestampOffsetProvider +import com.datadog.android.webview.internal.rum.domain.RumContext +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import java.lang.ClassCastException +import java.lang.IllegalStateException +import java.lang.NumberFormatException + +internal class WebViewReplayEventMapper( + private val webViewId: String, + internal val offsetProvider: TimestampOffsetProvider +) { + + @Throws( + ClassCastException::class, + IllegalStateException::class, + NumberFormatException::class + ) + @SuppressWarnings("ThrowingInternalException") + fun mapEvent( + event: JsonObject, + rumContext: RumContext, + datadogContext: DatadogContext + ): JsonObject { + val viewDataObject = event.get(VIEW_OBJECT_KEY)?.asJsonObject + val viewId = viewDataObject?.get(VIEW_ID_KEY)?.asString + ?: throw IllegalStateException(BROWSER_EVENT_MISSING_VIEW_DATA_ERROR_MESSAGE) + event.get(EVENT_KEY)?.asJsonObject?.let { record -> + val timeOffset = offsetProvider.getOffset(viewId, datadogContext) + record.get(TIMESTAMP_KEY)?.let { timestamp -> + val asLong = timestamp.asLong + val correctedTimestamp = asLong + timeOffset + record.addProperty(TIMESTAMP_KEY, correctedTimestamp) + } + record.addProperty(SLOT_ID_KEY, webViewId) + return bundleIntoEnrichedRecord(record, viewId, rumContext) + } ?: throw IllegalStateException(BROWSER_EVENT_MISSING_RECORD_ERROR_MESSAGE) + } + + private fun bundleIntoEnrichedRecord( + record: JsonObject, + browserViewId: String, + rumContext: RumContext + ): JsonObject { + return JsonObject().apply { + addProperty(ENRICHED_RECORD_APPLICATION_ID_KEY, rumContext.applicationId) + addProperty(ENRICHED_RECORD_SESSION_ID_KEY, rumContext.sessionId) + addProperty(ENRICHED_RECORD_VIEW_ID_KEY, browserViewId) + add(RECORDS_KEY, JsonArray().apply { add(record) }) + } + } + + companion object { + const val EVENT_KEY = "event" + const val VIEW_OBJECT_KEY = "view" + const val VIEW_ID_KEY = "id" + const val TIMESTAMP_KEY = "timestamp" + const val ENRICHED_RECORD_APPLICATION_ID_KEY = "application_id" + const val ENRICHED_RECORD_SESSION_ID_KEY = "session_id" + const val ENRICHED_RECORD_VIEW_ID_KEY = "view_id" + const val RECORDS_KEY = "records" + const val SLOT_ID_KEY = "slotId" + const val BROWSER_EVENT_MISSING_VIEW_DATA_ERROR_MESSAGE = + "The bundled web Replay event does not contain the mandatory view data" + const val BROWSER_EVENT_MISSING_RECORD_ERROR_MESSAGE = + "The bundled web Replay event does not contain the record data" + } +} diff --git a/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/replay/WebViewReplayFeature.kt b/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/replay/WebViewReplayFeature.kt new file mode 100644 index 0000000000..26604ed791 --- /dev/null +++ b/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/replay/WebViewReplayFeature.kt @@ -0,0 +1,59 @@ +/* + * 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.webview.internal.replay + +import android.content.Context +import com.datadog.android.api.InternalLogger +import com.datadog.android.api.feature.FeatureSdkCore +import com.datadog.android.api.feature.StorageBackedFeature +import com.datadog.android.api.net.RequestFactory +import com.datadog.android.api.storage.DataWriter +import com.datadog.android.api.storage.FeatureStorageConfiguration +import com.datadog.android.api.storage.NoOpDataWriter +import com.datadog.android.webview.internal.storage.WebViewDataWriter +import com.datadog.android.webview.internal.storage.WebViewEventSerializer +import com.google.gson.JsonObject +import java.util.concurrent.atomic.AtomicBoolean + +internal class WebViewReplayFeature( + private val sdkCore: FeatureSdkCore, + override val requestFactory: RequestFactory +) : StorageBackedFeature { + + internal var dataWriter: DataWriter = NoOpDataWriter() + internal val initialized = AtomicBoolean(false) + + // region Feature + + override val name: String = WEB_REPLAY_FEATURE_NAME + + override fun onInitialize(appContext: Context) { + dataWriter = createDataWriter(sdkCore.internalLogger) + initialized.set(true) + } + + override val storageConfiguration: FeatureStorageConfiguration = + FeatureStorageConfiguration.DEFAULT + + override fun onStop() { + dataWriter = NoOpDataWriter() + initialized.set(false) + } + + // endregion + + private fun createDataWriter(internalLogger: InternalLogger): DataWriter { + return WebViewDataWriter( + serializer = WebViewEventSerializer(), + internalLogger = internalLogger + ) + } + + companion object { + internal const val WEB_REPLAY_FEATURE_NAME = "web-replay" + } +} diff --git a/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/rum/TimestampOffsetProvider.kt b/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/rum/TimestampOffsetProvider.kt new file mode 100644 index 0000000000..89b94da20f --- /dev/null +++ b/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/rum/TimestampOffsetProvider.kt @@ -0,0 +1,51 @@ +/* + * 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.webview.internal.rum + +import com.datadog.android.api.InternalLogger +import com.datadog.android.api.context.DatadogContext + +internal class TimestampOffsetProvider(private val internalLogger: InternalLogger) { + + internal val offsets: LinkedHashMap = LinkedHashMap() + + @Synchronized + internal fun getOffset(viewId: String, datadogContext: DatadogContext): Long { + var offset = offsets[viewId] + if (offset == null) { + offset = datadogContext.time.serverTimeOffsetMs + offsets[viewId] = offset + } + purgeOffsets() + return offset + } + + private fun purgeOffsets() { + while (offsets.entries.size > MAX_VIEW_TIME_OFFSETS_RETAIN) { + try { + val viewId = offsets.entries.first() + offsets.remove(viewId.key) + } catch (e: NoSuchElementException) { + // it should not happen but just in case. + internalLogger.log( + InternalLogger.Level.ERROR, + listOf( + InternalLogger.Target.MAINTAINER, + InternalLogger.Target.TELEMETRY + ), + { "Trying to remove offset from an empty map." }, + e + ) + break + } + } + } + + companion object { + const val MAX_VIEW_TIME_OFFSETS_RETAIN = 3 + } +} diff --git a/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/rum/WebViewRumEventConsumer.kt b/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/rum/WebViewRumEventConsumer.kt index bc12d6bf38..5247126bb2 100644 --- a/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/rum/WebViewRumEventConsumer.kt +++ b/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/rum/WebViewRumEventConsumer.kt @@ -22,12 +22,12 @@ import java.lang.UnsupportedOperationException internal class WebViewRumEventConsumer( private val sdkCore: FeatureSdkCore, internal val dataWriter: DataWriter, - private val webViewRumEventMapper: WebViewRumEventMapper = WebViewRumEventMapper(), + internal val offsetProvider: TimestampOffsetProvider, + private val webViewRumEventMapper: WebViewRumEventMapper, private val contextProvider: WebViewRumEventContextProvider = WebViewRumEventContextProvider(sdkCore.internalLogger) -) : WebViewEventConsumer { - internal val offsets: LinkedHashMap = LinkedHashMap() +) : WebViewEventConsumer { @WorkerThread override fun consume(event: JsonObject) { @@ -55,7 +55,7 @@ internal class WebViewRumEventConsumer( ): JsonObject { try { val timeOffset = event.get(VIEW_KEY_NAME)?.asJsonObject?.get(VIEW_ID_KEY_NAME) - ?.asString?.let { getOffset(it, datadogContext) } ?: 0L + ?.asString?.let { offsetProvider.getOffset(it, datadogContext) } ?: 0L return webViewRumEventMapper.mapEvent(event, rumContext, timeOffset) } catch (e: ClassCastException) { sdkCore.internalLogger.log( @@ -89,41 +89,7 @@ internal class WebViewRumEventConsumer( return event } - private fun getOffset(viewId: String, datadogContext: DatadogContext): Long { - var offset = offsets[viewId] - if (offset == null) { - offset = datadogContext.time.serverTimeOffsetMs - synchronized(offsets) { offsets[viewId] = offset } - } - purgeOffsets() - return offset - } - - private fun purgeOffsets() { - while (offsets.entries.size > MAX_VIEW_TIME_OFFSETS_RETAIN) { - try { - synchronized(offsets) { - val viewId = offsets.entries.first() - offsets.remove(viewId.key) - } - } catch (e: NoSuchElementException) { - // it should not happen but just in case. - sdkCore.internalLogger.log( - InternalLogger.Level.ERROR, - listOf( - InternalLogger.Target.MAINTAINER, - InternalLogger.Target.TELEMETRY - ), - { "Trying to remove offset from an empty map." }, - e - ) - break - } - } - } - companion object { - const val MAX_VIEW_TIME_OFFSETS_RETAIN = 3 const val VIEW_EVENT_TYPE = "view" const val ACTION_EVENT_TYPE = "action" const val RESOURCE_EVENT_TYPE = "resource" diff --git a/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/rum/WebViewRumEventMapper.kt b/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/rum/WebViewRumEventMapper.kt index 1738ea2d70..52ca376bc3 100644 --- a/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/rum/WebViewRumEventMapper.kt +++ b/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/rum/WebViewRumEventMapper.kt @@ -6,10 +6,13 @@ package com.datadog.android.webview.internal.rum +import com.datadog.android.webview.internal.rum.domain.NativeRumViewsCache import com.datadog.android.webview.internal.rum.domain.RumContext import com.google.gson.JsonObject -internal class WebViewRumEventMapper { +internal class WebViewRumEventMapper( + private val nativeRumViewsCache: NativeRumViewsCache +) { @Throws( ClassCastException::class, @@ -21,9 +24,21 @@ internal class WebViewRumEventMapper { rumContext: RumContext?, timeOffset: Long ): JsonObject { - event.get(DATE_KEY_NAME)?.asLong?.let { - event.addProperty(DATE_KEY_NAME, it + timeOffset) + val containerObject = JsonObject().apply { + addProperty(SOURCE_KEY_NAME, SOURCE_VALUE) } + event.get(DATE_KEY_NAME)?.asLong?.let { eventDate -> + nativeRumViewsCache.resolveLastParentIdForBrowserEvent(eventDate)?.let { + containerObject.add( + VIEW_KEY_NAME, + JsonObject().apply { + addProperty(ID_KEY_NAME, it) + } + ) + } + event.addProperty(DATE_KEY_NAME, eventDate + timeOffset) + } + event.add(CONTAINER_KEY_NAME, containerObject) val dd = event.get(DD_KEY_NAME)?.asJsonObject if (dd != null) { val ddSession = dd.get(DD_SESSION_KEY_NAME)?.asJsonObject ?: JsonObject() @@ -42,6 +57,7 @@ internal class WebViewRumEventMapper { event.add(APPLICATION_KEY_NAME, application) event.add(SESSION_KEY_NAME, session) } + return event } @@ -54,5 +70,9 @@ internal class WebViewRumEventMapper { internal const val DATE_KEY_NAME = "date" internal const val ID_KEY_NAME = "id" internal const val SESSION_PLAN_VALUE = 1 + internal const val VIEW_KEY_NAME = "view" + internal const val CONTAINER_KEY_NAME = "container" + internal const val SOURCE_KEY_NAME = "source" + internal const val SOURCE_VALUE = "android" } } diff --git a/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/rum/WebViewRumFeature.kt b/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/rum/WebViewRumFeature.kt index 13cc303a5d..c37fa630c3 100644 --- a/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/rum/WebViewRumFeature.kt +++ b/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/rum/WebViewRumFeature.kt @@ -8,12 +8,16 @@ package com.datadog.android.webview.internal.rum import android.content.Context import com.datadog.android.api.InternalLogger +import com.datadog.android.api.feature.Feature +import com.datadog.android.api.feature.FeatureContextUpdateReceiver import com.datadog.android.api.feature.FeatureSdkCore import com.datadog.android.api.feature.StorageBackedFeature import com.datadog.android.api.net.RequestFactory import com.datadog.android.api.storage.DataWriter import com.datadog.android.api.storage.FeatureStorageConfiguration import com.datadog.android.api.storage.NoOpDataWriter +import com.datadog.android.webview.internal.rum.domain.NativeRumViewsCache +import com.datadog.android.webview.internal.rum.domain.WebViewNativeRumViewsCache import com.datadog.android.webview.internal.storage.WebViewDataWriter import com.datadog.android.webview.internal.storage.WebViewEventSerializer import com.google.gson.JsonObject @@ -21,10 +25,12 @@ import java.util.concurrent.atomic.AtomicBoolean internal class WebViewRumFeature( private val sdkCore: FeatureSdkCore, - override val requestFactory: RequestFactory -) : StorageBackedFeature { + override val requestFactory: RequestFactory, + internal val nativeRumViewsCache: NativeRumViewsCache = WebViewNativeRumViewsCache() +) : StorageBackedFeature, FeatureContextUpdateReceiver { internal var dataWriter: DataWriter = NoOpDataWriter() + internal val initialized = AtomicBoolean(false) // region Feature @@ -32,14 +38,24 @@ internal class WebViewRumFeature( override val name: String = WEB_RUM_FEATURE_NAME override fun onInitialize(appContext: Context) { + sdkCore.setContextUpdateReceiver(WEB_RUM_FEATURE_NAME, this) dataWriter = createDataWriter(sdkCore.internalLogger) initialized.set(true) + val currentRumContext = sdkCore.getFeatureContext(Feature.RUM_FEATURE_NAME) + nativeRumViewsCache.addToCache(currentRumContext) + } + + override fun onContextUpdate(featureName: String, event: Map) { + if (featureName == Feature.RUM_FEATURE_NAME) { + nativeRumViewsCache.addToCache(event) + } } override val storageConfiguration: FeatureStorageConfiguration = FeatureStorageConfiguration.DEFAULT override fun onStop() { + sdkCore.removeContextUpdateReceiver(WEB_RUM_FEATURE_NAME, this) dataWriter = NoOpDataWriter() initialized.set(false) } diff --git a/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/rum/domain/NativeRumViewsCache.kt b/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/rum/domain/NativeRumViewsCache.kt new file mode 100644 index 0000000000..42a8ff2b42 --- /dev/null +++ b/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/rum/domain/NativeRumViewsCache.kt @@ -0,0 +1,19 @@ +/* + * 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.webview.internal.rum.domain + +import com.datadog.tools.annotation.NoOpImplementation + +@NoOpImplementation +internal interface NativeRumViewsCache { + + fun resolveLastParentIdForBrowserEvent( + browserEventTimestampInMs: Long + ): String? + + fun addToCache(event: Map) +} diff --git a/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/rum/domain/WebViewNativeRumViewsCache.kt b/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/rum/domain/WebViewNativeRumViewsCache.kt new file mode 100644 index 0000000000..a80757b870 --- /dev/null +++ b/features/dd-sdk-android-webview/src/main/kotlin/com/datadog/android/webview/internal/rum/domain/WebViewNativeRumViewsCache.kt @@ -0,0 +1,106 @@ +/* + * 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.webview.internal.rum.domain + +import java.util.LinkedList +import java.util.concurrent.TimeUnit + +internal class WebViewNativeRumViewsCache( + private val entriesTtlLimitInMs: Long = DATA_PURGE_TTL_LIMIT_IN_MS +) : NativeRumViewsCache { + + internal val parentViewsHistoryQueue: LinkedList = LinkedList() + + @Synchronized + override fun resolveLastParentIdForBrowserEvent(browserEventTimestampInMs: Long): String? { + // iterate the history stack to find the first entry that is older than the current event + // and hasReplay == true + // in case we do not find one return the first candidate that is older than the current event + val iterator = parentViewsHistoryQueue.iterator() + var backupCandidate: String? = null + while (iterator.hasNext()) { + // the function is synchronized and we are checking hasNext() before calling next() + @Suppress("UnsafeThirdPartyFunctionCall") + val entry = iterator.next() + if (entry.timestamp <= browserEventTimestampInMs) { + if (backupCandidate == null) { + backupCandidate = entry.viewId + } + if (entry.hasReplay) { + return entry.viewId + } + } + } + return backupCandidate + } + + @Synchronized + override fun addToCache(event: Map) { + val activeViewId = event[VIEW_ID_KEY] as? String + val eventTimestamp = event[VIEW_TIMESTAMP_KEY] as? Long + val hasReplay = event[VIEW_HAS_REPLAY_KEY] as? Boolean ?: false + + if (activeViewId != null && + activeViewId != RumContext.NULL_UUID && + eventTimestamp != null + ) { + val newEntry = ViewEntry(activeViewId, eventTimestamp, hasReplay) + addToCache(newEntry) + } + purgeHistory() + } + + private fun addToCache( + entry: ViewEntry + ) { + if (parentViewsHistoryQueue.isEmpty() || + ( + parentViewsHistoryQueue.first.viewId != entry.viewId && + parentViewsHistoryQueue.first.timestamp <= entry.timestamp + ) + ) { + parentViewsHistoryQueue.addFirst(entry) + } else if (parentViewsHistoryQueue.first.viewId == entry.viewId) { + // the function is synchronized and we are checking the size before + @Suppress("UnsafeThirdPartyFunctionCall") + parentViewsHistoryQueue.removeFirst() + parentViewsHistoryQueue.addFirst(entry) + } + } + + private fun purgeHistory() { + var cursor = parentViewsHistoryQueue.peekLast() + while (cursor != null) { + val timeSinceLastSnapshot = System.currentTimeMillis() - cursor.timestamp + if (timeSinceLastSnapshot > entriesTtlLimitInMs) { + parentViewsHistoryQueue.remove(cursor) + cursor = parentViewsHistoryQueue.peekLast() + } else { + break + } + } + while (parentViewsHistoryQueue.size > DATA_CACHE_ENTRIES_LIMIT) { + // the function is synchronized and we are checking the size before + @Suppress("UnsafeThirdPartyFunctionCall") + parentViewsHistoryQueue.removeLast() + } + } + + internal data class ViewEntry( + val viewId: String, + val timestamp: Long, + val hasReplay: Boolean + ) + + companion object { + internal const val VIEW_ID_KEY = "view_id" + internal const val VIEW_TIMESTAMP_KEY = "view_timestamp" + internal const val VIEW_HAS_REPLAY_KEY = "view_has_replay" + internal val DATA_PURGE_TTL_LIMIT_IN_MS = TimeUnit.HOURS.toMillis(2) + internal const val DATA_CACHE_ENTRIES_LIMIT = 30 + } +} diff --git a/features/dd-sdk-android-webview/src/test/kotlin/com/datadog/android/webview/WebViewTrackingTest.kt b/features/dd-sdk-android-webview/src/test/kotlin/com/datadog/android/webview/WebViewTrackingTest.kt index e3d5c34a0a..34f56c8dcb 100644 --- a/features/dd-sdk-android-webview/src/test/kotlin/com/datadog/android/webview/WebViewTrackingTest.kt +++ b/features/dd-sdk-android-webview/src/test/kotlin/com/datadog/android/webview/WebViewTrackingTest.kt @@ -22,13 +22,17 @@ import com.datadog.android.utils.forge.Configurator import com.datadog.android.utils.verifyLog import com.datadog.android.webview.internal.DatadogEventBridge import com.datadog.android.webview.internal.MixedWebViewEventConsumer +import com.datadog.android.webview.internal.NoOpWebViewEventConsumer import com.datadog.android.webview.internal.log.WebViewLogEventConsumer import com.datadog.android.webview.internal.log.WebViewLogsFeature +import com.datadog.android.webview.internal.replay.WebViewReplayEventConsumer +import com.datadog.android.webview.internal.replay.WebViewReplayFeature import com.datadog.android.webview.internal.rum.WebViewRumEventConsumer import com.datadog.android.webview.internal.rum.WebViewRumFeature import com.google.gson.JsonObject import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.Forgery +import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension import org.assertj.core.api.Assertions.assertThat @@ -80,15 +84,24 @@ internal class WebViewTrackingTest { @Mock lateinit var mockLogsFeature: StorageBackedFeature + @Mock + lateinit var mockReplayFeature: StorageBackedFeature + @Mock lateinit var mockRumRequestFactory: RequestFactory @Mock lateinit var mockLogsRequestFactory: RequestFactory + @Mock + lateinit var mockReplayRequestFactory: RequestFactory + @Mock lateinit var mockWebView: WebView + @Mock + lateinit var mockReplayFeatureScope: FeatureScope + @BeforeEach fun `set up`() { whenever( @@ -97,17 +110,23 @@ internal class WebViewTrackingTest { whenever( mockCore.getFeature(Feature.LOGS_FEATURE_NAME) ) doReturn mockLogsFeatureScope - + whenever( + mockCore.getFeature(Feature.SESSION_REPLAY_FEATURE_NAME) + ) doReturn mockReplayFeatureScope whenever( mockRumFeatureScope.unwrap() ) doReturn mockRumFeature whenever( mockLogsFeatureScope.unwrap() ) doReturn mockLogsFeature + whenever( + mockReplayFeatureScope.unwrap() + ) doReturn mockReplayFeature whenever(mockCore.internalLogger) doReturn mockInternalLogger whenever(mockRumFeature.requestFactory) doReturn mockRumRequestFactory whenever(mockLogsFeature.requestFactory) doReturn mockLogsRequestFactory + whenever(mockReplayFeature.requestFactory) doReturn mockReplayRequestFactory val mockWebViewSettings = mock() whenever(mockWebViewSettings.javaScriptEnabled) doReturn true @@ -135,6 +154,66 @@ internal class WebViewTrackingTest { ) } + @Test + fun `M extract and provide the SR privacy level W enable {privacy level provided}`( + @Forgery fakeUrls: List, + @StringForgery fakePrivacyLevel: String + ) { + // Given + val mockSrFeatureContext = mapOf( + WebViewTracking.SESSION_REPLAY_PRIVACY_KEY to fakePrivacyLevel + ) + whenever(mockCore.getFeatureContext(Feature.SESSION_REPLAY_FEATURE_NAME)) doReturn + mockSrFeatureContext + val fakeHosts = fakeUrls.map { it.host } + val mockSettings: WebSettings = mock { + whenever(it.javaScriptEnabled).thenReturn(true) + } + val mockWebView: WebView = mock { + whenever(it.settings).thenReturn(mockSettings) + } + val argumentCaptor = argumentCaptor() + + // When + WebViewTracking.enable(mockWebView, fakeHosts, sdkCore = mockCore) + + // Then + verify(mockWebView).addJavascriptInterface( + argumentCaptor.capture(), + eq(WebViewTracking.DATADOG_EVENT_BRIDGE_NAME) + ) + assertThat(argumentCaptor.firstValue.getPrivacyLevel()).isEqualTo(fakePrivacyLevel) + } + + @Test + fun `M used the default SR privacy level W enable {privacy level not provided}`( + @Forgery fakeUrls: List + ) { + // Given + val mockSrFeatureContext = mapOf() + whenever(mockCore.getFeatureContext(Feature.SESSION_REPLAY_FEATURE_NAME)) doReturn + mockSrFeatureContext + val fakeHosts = fakeUrls.map { it.host } + val mockSettings: WebSettings = mock { + whenever(it.javaScriptEnabled).thenReturn(true) + } + val mockWebView: WebView = mock { + whenever(it.settings).thenReturn(mockSettings) + } + val argumentCaptor = argumentCaptor() + + // When + WebViewTracking.enable(mockWebView, fakeHosts, sdkCore = mockCore) + + // Then + verify(mockWebView).addJavascriptInterface( + argumentCaptor.capture(), + eq(WebViewTracking.DATADOG_EVENT_BRIDGE_NAME) + ) + assertThat(argumentCaptor.firstValue.getPrivacyLevel()) + .isEqualTo(WebViewTracking.SESSION_REPLAY_MASK_ALL_PRIVACY) + } + @Test fun `M attach the bridge and send a warn log W enable { javascript not enabled }`( @Forgery fakeUrls: List @@ -190,21 +269,61 @@ internal class WebViewTrackingTest { .isInstanceOf(WebViewLogEventConsumer::class.java) assertThat(mixedConsumer.rumEventConsumer) .isInstanceOf(WebViewRumEventConsumer::class.java) + assertThat(mixedConsumer.replayEventConsumer) + .isInstanceOf(WebViewReplayEventConsumer::class.java) argumentCaptor { - verify(mockCore, times(2)).registerFeature(capture()) + verify(mockCore, times(3)).registerFeature(capture()) val webViewRumFeature = firstValue val webViewLogsFeature = secondValue + val webViewReplayFeature = thirdValue assertThat((webViewRumFeature as WebViewRumFeature).requestFactory) .isSameAs(mockRumRequestFactory) assertThat((webViewLogsFeature as WebViewLogsFeature).requestFactory) .isSameAs(mockLogsRequestFactory) + assertThat((webViewReplayFeature as WebViewReplayFeature).requestFactory) + .isSameAs(mockReplayRequestFactory) } } } + fun `M share the same TimestampOffsetProvider W enable()`( + @Forgery fakeUrls: List + ) { + // Given + val fakeHosts = fakeUrls.map { it.host } + whenever(mockCore.registerFeature(any())) doAnswer { + val feature = it.getArgument(0) + feature.onInitialize(mock()) + } + + // When + WebViewTracking.enable(mockWebView, fakeHosts, sdkCore = mockCore) + + // Then + argumentCaptor { + verify(mockWebView).addJavascriptInterface( + capture(), + eq(WebViewTracking.DATADOG_EVENT_BRIDGE_NAME) + ) + val consumer = lastValue.webViewEventConsumer + assertThat(consumer).isInstanceOf(MixedWebViewEventConsumer::class.java) + val mixedConsumer = consumer as MixedWebViewEventConsumer + assertThat(mixedConsumer.rumEventConsumer) + .isInstanceOf(WebViewRumEventConsumer::class.java) + assertThat(mixedConsumer.replayEventConsumer) + .isInstanceOf(WebViewReplayEventConsumer::class.java) + val webViewReplayEventConsumer = mixedConsumer.replayEventConsumer + as WebViewReplayEventConsumer + val webViewRumEventConsumer = mixedConsumer.rumEventConsumer + as WebViewRumEventConsumer + assertThat(webViewReplayEventConsumer.webViewReplayEventMapper.offsetProvider) + .isSameAs(webViewRumEventConsumer.offsetProvider) + } + } + @Test fun `M create a default WebEventConsumer W enable() {RUM feature is not registered}`( @Forgery fakeUrls: List @@ -239,12 +358,15 @@ internal class WebViewTrackingTest { .isInstanceOf(NoOpDataWriter::class.java) argumentCaptor { - verify(mockCore, times(1)).registerFeature(capture()) + verify(mockCore, times(2)).registerFeature(capture()) val webViewLogsFeature = firstValue + val webViewReplayFeature = secondValue assertThat((webViewLogsFeature as WebViewLogsFeature).requestFactory) .isSameAs(mockLogsRequestFactory) + assertThat((webViewReplayFeature as WebViewReplayFeature).requestFactory) + .isSameAs(mockReplayRequestFactory) } mockInternalLogger.verifyLog( @@ -289,12 +411,15 @@ internal class WebViewTrackingTest { .isNotInstanceOf(NoOpDataWriter::class.java) argumentCaptor { - verify(mockCore, times(1)).registerFeature(capture()) + verify(mockCore, times(2)).registerFeature(capture()) val webViewRumFeature = firstValue + val webViewReplayFeature = secondValue assertThat((webViewRumFeature as WebViewRumFeature).requestFactory) .isSameAs(mockRumRequestFactory) + assertThat((webViewReplayFeature as WebViewReplayFeature).requestFactory) + .isSameAs(mockReplayRequestFactory) } mockInternalLogger.verifyLog( @@ -305,6 +430,83 @@ internal class WebViewTrackingTest { } } + @Test + fun `M create a default WebEventConsumer W init() { SR feature not registered }()`( + @Forgery fakeUrls: List + ) { + // Given + val fakeHosts = fakeUrls.map { it.host } + whenever(mockCore.registerFeature(any())) doAnswer { + val feature = it.getArgument(0) + feature.onInitialize(mock()) + } + whenever(mockCore.getFeature(Feature.SESSION_REPLAY_FEATURE_NAME)) doReturn null + + // When + WebViewTracking.enable(mockWebView, fakeHosts, sdkCore = mockCore) + + // Then + argumentCaptor { + verify(mockWebView).addJavascriptInterface( + capture(), + eq(WebViewTracking.DATADOG_EVENT_BRIDGE_NAME) + ) + val consumer = lastValue.webViewEventConsumer + assertThat(consumer).isInstanceOf(MixedWebViewEventConsumer::class.java) + val mixedConsumer = consumer as MixedWebViewEventConsumer + assertThat(mixedConsumer.logsEventConsumer) + .isInstanceOf(WebViewLogEventConsumer::class.java) + assertThat(mixedConsumer.rumEventConsumer) + .isInstanceOf(WebViewRumEventConsumer::class.java) + assertThat((mixedConsumer.replayEventConsumer as WebViewReplayEventConsumer).dataWriter) + .isInstanceOf(NoOpDataWriter::class.java) + + argumentCaptor { + verify(mockCore, times(2)).registerFeature(capture()) + + val webViewRumFeature = firstValue + val webViewLogsFeature = secondValue + assertThat((webViewRumFeature as WebViewRumFeature).requestFactory) + .isSameAs(mockRumRequestFactory) + assertThat((webViewLogsFeature as WebViewLogsFeature).requestFactory) + .isSameAs(mockLogsRequestFactory) + } + + mockInternalLogger.verifyLog( + InternalLogger.Level.INFO, + InternalLogger.Target.USER, + WebViewTracking.SESSION_REPLAY_FEATURE_MISSING_INFO + ) + } + } + + @Test + fun `M create a default NoOpEventConsumer W init() {Logs and Rum feature is not registered}`( + @Forgery fakeUrls: List + ) { + // Given + val fakeHosts = fakeUrls.map { it.host } + whenever(mockCore.getFeature(Feature.LOGS_FEATURE_NAME)) doReturn null + whenever(mockCore.getFeature(Feature.RUM_FEATURE_NAME)) doReturn null + whenever(mockCore.registerFeature(any())) doAnswer { + val feature = it.getArgument(0) + feature.onInitialize(mock()) + } + + // When + WebViewTracking.enable(mockWebView, fakeHosts, sdkCore = mockCore) + + // Then + argumentCaptor { + verify(mockWebView).addJavascriptInterface( + capture(), + eq(WebViewTracking.DATADOG_EVENT_BRIDGE_NAME) + ) + val consumer = lastValue.webViewEventConsumer + assertThat(consumer).isInstanceOf(NoOpWebViewEventConsumer::class.java) + } + } + @Test fun `M pass web view event to RumWebEventConsumer W consumeWebViewEvent()`( forge: Forge @@ -318,6 +520,7 @@ internal class WebViewTrackingTest { val mockWebViewRumFeature = mock() val mockWebViewLogsFeature = mock() val expectedEvent = fakeBundledEvent.deepCopy().apply { + add("container", JsonObject().apply { addProperty("source", "android") }) add("application", JsonObject().apply { addProperty("id", fakeApplicationId) }) add("session", JsonObject().apply { addProperty("id", fakeSessionId) }) } @@ -343,7 +546,10 @@ internal class WebViewTrackingTest { val mockDatadogContext = mock() whenever(mockDatadogContext.featuresContext) doReturn fakeFeaturesContext val mockEventBatchWriter = mock() - val proxy = WebViewTracking._InternalWebViewProxy(mockCore) + val proxy = WebViewTracking._InternalWebViewProxy( + mockCore, + System.identityHashCode(mockWebView).toString() + ) // When proxy.consumeWebviewEvent(fakeWebEvent.toString()) @@ -360,6 +566,16 @@ internal class WebViewTrackingTest { } } + @Test + fun `M use a NoOpWebViewReplayEventConsumer W consumeWebViewEvent{WebView id is missing}`() { + // When + val proxy = WebViewTracking._InternalWebViewProxy(mockCore) + + // When + assertThat((proxy.consumer as MixedWebViewEventConsumer).replayEventConsumer) + .isInstanceOf(NoOpWebViewEventConsumer::class.java) + } + private fun bundleWebEvent( fakeBundledEvent: JsonObject?, eventType: String? diff --git a/features/dd-sdk-android-webview/src/test/kotlin/com/datadog/android/webview/internal/DatadogEventBridgeTest.kt b/features/dd-sdk-android-webview/src/test/kotlin/com/datadog/android/webview/internal/DatadogEventBridgeTest.kt index d891553203..78ccc42584 100644 --- a/features/dd-sdk-android-webview/src/test/kotlin/com/datadog/android/webview/internal/DatadogEventBridgeTest.kt +++ b/features/dd-sdk-android-webview/src/test/kotlin/com/datadog/android/webview/internal/DatadogEventBridgeTest.kt @@ -19,7 +19,6 @@ import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings import org.mockito.kotlin.mock -import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.quality.Strictness import java.net.URL @@ -37,11 +36,15 @@ internal class DatadogEventBridgeTest { @Mock lateinit var mockWebViewEventConsumer: MixedWebViewEventConsumer + @StringForgery + lateinit var fakePrivacyLevel: String + @BeforeEach fun `set up`() { testedDatadogEventBridge = DatadogEventBridge( mockWebViewEventConsumer, - emptyList() + emptyList(), + fakePrivacyLevel ) } @@ -63,7 +66,7 @@ internal class DatadogEventBridgeTest { ) { // Given val expectedHosts = hosts.joinToString(",", prefix = "[", postfix = "]") { "\"$it\"" } - testedDatadogEventBridge = DatadogEventBridge(mock(), hosts) + testedDatadogEventBridge = DatadogEventBridge(mock(), hosts, fakePrivacyLevel) // When val allowedWebViewHosts = testedDatadogEventBridge.getAllowedWebViewHosts() @@ -81,7 +84,11 @@ internal class DatadogEventBridgeTest { ) { // Given val expectedHosts = hosts.joinToString(",", prefix = "[", postfix = "]") { "\"$it\"" } - testedDatadogEventBridge = DatadogEventBridge(mockWebViewEventConsumer, hosts) + testedDatadogEventBridge = DatadogEventBridge( + mockWebViewEventConsumer, + hosts, + fakePrivacyLevel + ) // When val allowedWebViewHosts = testedDatadogEventBridge.getAllowedWebViewHosts() @@ -99,7 +106,11 @@ internal class DatadogEventBridgeTest { // Given val expectedHosts = hosts.map { URL(it).host } .joinToString(",", prefix = "[", postfix = "]") { "\"$it\"" } - testedDatadogEventBridge = DatadogEventBridge(mockWebViewEventConsumer, hosts) + testedDatadogEventBridge = DatadogEventBridge( + mockWebViewEventConsumer, + hosts, + fakePrivacyLevel + ) // When val allowedWebViewHosts = testedDatadogEventBridge.getAllowedWebViewHosts() @@ -107,4 +118,25 @@ internal class DatadogEventBridgeTest { // Then assertThat(allowedWebViewHosts).isEqualTo(expectedHosts) } + + @Test + fun `M return the provided privacy level W getPrivacyLevel()`() { + // When + val privacyLevel = testedDatadogEventBridge.getPrivacyLevel() + + // Then + assertThat(privacyLevel).isEqualTo(fakePrivacyLevel) + } + + @Test + fun `M return the supported capabilities W getCapabilities()`() { + // Given + val expectedCapabilities = "[\"records\"]" + + // When + val capabilities = testedDatadogEventBridge.getCapabilities() + + // Then + assertThat(capabilities).isEqualTo(expectedCapabilities) + } } diff --git a/features/dd-sdk-android-webview/src/test/kotlin/com/datadog/android/webview/internal/MixedWebViewEventConsumerTest.kt b/features/dd-sdk-android-webview/src/test/kotlin/com/datadog/android/webview/internal/MixedWebViewEventConsumerTest.kt index d4cca6bb29..d1335135d1 100644 --- a/features/dd-sdk-android-webview/src/test/kotlin/com/datadog/android/webview/internal/MixedWebViewEventConsumerTest.kt +++ b/features/dd-sdk-android-webview/src/test/kotlin/com/datadog/android/webview/internal/MixedWebViewEventConsumerTest.kt @@ -10,6 +10,7 @@ import com.datadog.android.api.InternalLogger import com.datadog.android.utils.forge.Configurator import com.datadog.android.utils.verifyLog import com.datadog.android.webview.internal.log.WebViewLogEventConsumer +import com.datadog.android.webview.internal.replay.WebViewReplayEventConsumer import com.datadog.android.webview.internal.rum.WebViewRumEventConsumer import com.google.gson.JsonObject import com.google.gson.JsonParseException @@ -46,6 +47,9 @@ internal class MixedWebViewEventConsumerTest { @Mock lateinit var mockLogsEventConsumer: WebViewLogEventConsumer + @Mock + lateinit var mockReplayEventConsumer: WebViewReplayEventConsumer + @Mock lateinit var mockInternalLogger: InternalLogger @@ -53,6 +57,7 @@ internal class MixedWebViewEventConsumerTest { fun `set up`() { testedWebViewEventConsumer = MixedWebViewEventConsumer( mockRumEventConsumer, + mockReplayEventConsumer, mockLogsEventConsumer, mockInternalLogger ) @@ -99,6 +104,25 @@ internal class MixedWebViewEventConsumerTest { } } + @Test + fun `M delegate to ReplayEventConsumer W consume() { REPLAY eventType }`(forge: Forge) { + // Given + val fakeBundledEvent = forge.getForgery() + val fakeReplayEventType = forge.anElementFrom(WebViewReplayEventConsumer.REPLAY_EVENT_TYPES) + val fakeWebEvent = bundleWebEvent(fakeBundledEvent, fakeReplayEventType) + + // When + testedWebViewEventConsumer.consume(fakeWebEvent.toString()) + + // Then + argumentCaptor { + verify(mockReplayEventConsumer).consume(capture()) + // toString call because of how Gson is comparing Float/Double vs LazilyParsedNumber + // for JsonPrimitive https://github.com/google/gson/issues/1864 + assertThat(firstValue.toString()).isEqualTo(fakeWebEvent.toString()) + } + } + @Test fun `M do nothing W consume() { unknown event type }`(forge: Forge) { // Given diff --git a/features/dd-sdk-android-webview/src/test/kotlin/com/datadog/android/webview/internal/replay/WebViewReplayEventConsumerTest.kt b/features/dd-sdk-android-webview/src/test/kotlin/com/datadog/android/webview/internal/replay/WebViewReplayEventConsumerTest.kt new file mode 100644 index 0000000000..113d3481e5 --- /dev/null +++ b/features/dd-sdk-android-webview/src/test/kotlin/com/datadog/android/webview/internal/replay/WebViewReplayEventConsumerTest.kt @@ -0,0 +1,285 @@ +/* + * 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.webview.internal.replay + +import com.datadog.android.api.InternalLogger +import com.datadog.android.api.context.DatadogContext +import com.datadog.android.api.feature.Feature +import com.datadog.android.api.feature.FeatureScope +import com.datadog.android.api.storage.DataWriter +import com.datadog.android.api.storage.EventBatchWriter +import com.datadog.android.core.InternalSdkCore +import com.datadog.android.utils.forge.Configurator +import com.datadog.android.utils.verifyLog +import com.datadog.android.webview.internal.rum.WebViewRumEventContextProvider +import com.datadog.android.webview.internal.rum.domain.RumContext +import com.google.gson.JsonObject +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.annotation.Forgery +import fr.xgouchet.elmyr.annotation.LongForgery +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +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.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.junit.jupiter.MockitoSettings +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +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 WebViewReplayEventConsumerTest { + + private lateinit var testedConsumer: WebViewReplayEventConsumer + + @Forgery + lateinit var fakeMappedEvent: JsonObject + + @LongForgery + var fakeServerTimeOffsetInMillis: Long = 0L + + @Mock + lateinit var mockWebViewReplayMapper: WebViewReplayEventMapper + + @Mock + lateinit var mockRumContextProvider: WebViewRumEventContextProvider + + @Mock + lateinit var mockSdkCore: InternalSdkCore + + @Mock + lateinit var mockInternalLogger: InternalLogger + + @Mock + lateinit var mockSessionReplayFeatureScope: FeatureScope + + @Forgery + lateinit var fakeDatadogContext: DatadogContext + + @Forgery + lateinit var fakeRumContext: RumContext + + lateinit var fakeSessionReplayFeatureContext: Map + + lateinit var fakeValidBrowserEvent: JsonObject + + @Mock + lateinit var mockDataWriter: DataWriter + + @Mock + lateinit var mockEventBatchWriter: EventBatchWriter + + @BeforeEach + fun `set up`(forge: Forge) { + fakeSessionReplayFeatureContext = forge.aMap { + WebViewReplayEventConsumer.SESSION_REPLAY_ENABLED_KEY to true + } + fakeValidBrowserEvent = forge.getForgery() + fakeRumContext = fakeRumContext.copy( + sessionState = + WebViewReplayEventConsumer.SESSION_TRACKED_STATE + ) + fakeDatadogContext = fakeDatadogContext.copy( + time = fakeDatadogContext.time.copy( + serverTimeOffsetMs = fakeServerTimeOffsetInMillis + ), + featuresContext = forge.aMap { + Feature.SESSION_REPLAY_FEATURE_NAME to fakeSessionReplayFeatureContext + } + ) + whenever( + mockRumContextProvider.getRumContext(any()) + ) doReturn fakeRumContext + + whenever( + mockSdkCore.getFeature(WebViewReplayFeature.WEB_REPLAY_FEATURE_NAME) + ) doReturn mockSessionReplayFeatureScope + whenever(mockSessionReplayFeatureScope.withWriteContext(any(), any())) doAnswer { + val callback = it.getArgument<(DatadogContext, EventBatchWriter) -> Unit>(1) + callback.invoke(fakeDatadogContext, mockEventBatchWriter) + } + whenever(mockSdkCore.internalLogger) doReturn mockInternalLogger + + testedConsumer = WebViewReplayEventConsumer( + mockSdkCore, + mockDataWriter, + mockRumContextProvider, + mockWebViewReplayMapper + ) + } + + @Test + fun `M send the event W consume() { valid event }`() { + // Given + whenever( + mockWebViewReplayMapper.mapEvent( + fakeValidBrowserEvent, + fakeRumContext, + fakeDatadogContext + ) + ).thenReturn(fakeMappedEvent) + + // When + testedConsumer.consume(fakeValidBrowserEvent) + + // Then + verify(mockDataWriter).write(mockEventBatchWriter, fakeMappedEvent) + } + + @Test + fun `M do nothing W consume() { sr feature not registered }`() { + // Given + whenever( + mockSdkCore.getFeature(Feature.SESSION_REPLAY_FEATURE_NAME) + ) doReturn null + + // When + testedConsumer.consume(fakeValidBrowserEvent) + + // Then + verifyNoInteractions(mockDataWriter) + } + + @Test + fun `M do nothing W consume() { sr feature not enabled }`(forge: Forge) { + // Given + fakeDatadogContext = fakeDatadogContext.copy( + featuresContext = forge.aMap { + Feature.SESSION_REPLAY_FEATURE_NAME to forge.aMap { + WebViewReplayEventConsumer.SESSION_REPLAY_ENABLED_KEY to false + } + } + ) + + // When + testedConsumer.consume(fakeValidBrowserEvent) + + // Then + verifyNoInteractions(mockDataWriter) + } + + @Test + fun `M do nothing W consume() { sr feature context does not exist }`() { + // Given + fakeDatadogContext = fakeDatadogContext.copy(featuresContext = mapOf()) + + // When + testedConsumer.consume(fakeValidBrowserEvent) + + // Then + verifyNoInteractions(mockDataWriter) + } + + @Test + fun `M do nothing W consume() { sr feature enabled entry does not exist }`(forge: Forge) { + // Given + fakeDatadogContext = fakeDatadogContext.copy( + featuresContext = forge.aMap { + Feature.SESSION_REPLAY_FEATURE_NAME to mapOf() + } + ) + + // When + testedConsumer.consume(fakeValidBrowserEvent) + + // Then + verifyNoInteractions(mockDataWriter) + } + + @Test + fun `M do nothing W consume() { datadogContext not there }`() { + // Given + whenever( + mockSdkCore.getDatadogContext() + ) doReturn null + + // When + testedConsumer.consume(fakeValidBrowserEvent) + + // Then + verifyNoInteractions(mockDataWriter) + } + + @Test + fun `M do nothing W consume() { rumContext not there }`() { + // Given + whenever( + mockRumContextProvider.getRumContext(fakeDatadogContext) + ) doReturn null + + // When + testedConsumer.consume(fakeValidBrowserEvent) + + // Then + verifyNoInteractions(mockDataWriter) + } + + @Test + fun `M do nothing W consume() { rum session sampled out }`(forge: Forge) { + // Given + whenever( + mockRumContextProvider.getRumContext(fakeDatadogContext) + ) doReturn fakeRumContext.copy(sessionState = forge.anAlphabeticalString()) + + // When + testedConsumer.consume(fakeValidBrowserEvent) + + // Then + verifyNoInteractions(mockDataWriter) + } + + @ParameterizedTest + @MethodSource("mapperThrowsException") + fun `M log an sdk error W consume { mapper throws }`(fakeException: Throwable) { + // Given + whenever( + mockWebViewReplayMapper.mapEvent( + fakeValidBrowserEvent, + fakeRumContext, + fakeDatadogContext + ) + ).thenThrow(fakeException) + + // When + testedConsumer.consume(fakeValidBrowserEvent) + + // Then + mockInternalLogger.verifyLog( + InternalLogger.Level.ERROR, + targets = listOf(InternalLogger.Target.MAINTAINER, InternalLogger.Target.TELEMETRY), + WebViewReplayEventConsumer.JSON_PARSING_ERROR_MESSAGE, + fakeException + ) + } + + companion object { + + @JvmStatic + fun mapperThrowsException(): List { + return listOf( + ClassCastException(), + NumberFormatException(), + IllegalStateException(), + UnsupportedOperationException() + ) + } + } +} diff --git a/features/dd-sdk-android-webview/src/test/kotlin/com/datadog/android/webview/internal/replay/WebViewReplayEventMapperTest.kt b/features/dd-sdk-android-webview/src/test/kotlin/com/datadog/android/webview/internal/replay/WebViewReplayEventMapperTest.kt new file mode 100644 index 0000000000..c1defe8f66 --- /dev/null +++ b/features/dd-sdk-android-webview/src/test/kotlin/com/datadog/android/webview/internal/replay/WebViewReplayEventMapperTest.kt @@ -0,0 +1,185 @@ +/* + * 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.webview.internal.replay + +import com.datadog.android.api.context.DatadogContext +import com.datadog.android.utils.forge.Configurator +import com.datadog.android.webview.internal.rum.TimestampOffsetProvider +import com.datadog.android.webview.internal.rum.domain.RumContext +import com.google.gson.JsonObject +import com.google.gson.JsonPrimitive +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.annotation.Forgery +import fr.xgouchet.elmyr.annotation.LongForgery +import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +import fr.xgouchet.elmyr.junit5.ForgeExtension +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatThrownBy +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.whenever +import org.mockito.quality.Strictness + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(Configurator::class) +internal class WebViewReplayEventMapperTest { + + private lateinit var testedWebViewReplayEventMapper: WebViewReplayEventMapper + + @LongForgery + var fakeServerTimeOffset: Long = 0L + + @Forgery + lateinit var fakeRumContext: RumContext + + @Forgery + lateinit var fakeDatadogContext: DatadogContext + + @StringForgery(regex = "^[a-z0-9]{32}$") + lateinit var fakeWebViewId: String + + @Mock + lateinit var mockOffsetProvider: TimestampOffsetProvider + + @StringForgery(regex = "^[a-z0-9]{32}$") + lateinit var fakeBrowserRumViewId: String + + @BeforeEach + fun `set up`() { + testedWebViewReplayEventMapper = WebViewReplayEventMapper(fakeWebViewId, mockOffsetProvider) + } + + @Test + fun `M throw W mapEvent {event is missing view data container}`(forge: Forge) { + // Given + val fakeEvent: JsonObject = forge.getForgery() + + // Then + assertThatThrownBy { + testedWebViewReplayEventMapper.mapEvent( + fakeEvent, + fakeRumContext, + fakeDatadogContext + ) + }.isInstanceOf(IllegalStateException::class.java) + .hasMessage( + WebViewReplayEventMapper.BROWSER_EVENT_MISSING_VIEW_DATA_ERROR_MESSAGE + ) + } + + @Test + fun `M throw W mapEvent {event is missing view id container}`(forge: Forge) { + // Given + val fakeEvent: JsonObject = stubValidReplayEvent(fakeBrowserRumViewId, forge.aLong()).apply { + get(WebViewReplayEventMapper.VIEW_OBJECT_KEY) + .asJsonObject + .remove(WebViewReplayEventMapper.VIEW_ID_KEY) + } + + // Then + assertThatThrownBy { + testedWebViewReplayEventMapper.mapEvent( + fakeEvent, + fakeRumContext, + fakeDatadogContext + ) + }.isInstanceOf(IllegalStateException::class.java) + .hasMessage( + WebViewReplayEventMapper.BROWSER_EVENT_MISSING_VIEW_DATA_ERROR_MESSAGE + ) + } + + @Test + fun `M throw W mapEvent {event is broken}`(forge: Forge) { + // Given + val fakeEvent: JsonObject = stubValidReplayEvent(fakeBrowserRumViewId, forge.aLong()) + val fakeBrokenWrappedEvent: JsonPrimitive = forge.getForgery() + fakeEvent.add("event", fakeBrokenWrappedEvent) + + // Then + assertThatThrownBy { + testedWebViewReplayEventMapper.mapEvent( + fakeEvent, + fakeRumContext, + fakeDatadogContext + ) + }.isInstanceOf(IllegalStateException::class.java) + } + + @Test + fun `M throw W mapEvent {event is missing the record}`(forge: Forge) { + // Given + val fakeEvent: JsonObject = stubValidReplayEvent(fakeBrowserRumViewId, forge.aLong()) + .apply { remove(WebViewReplayEventMapper.EVENT_KEY) } + + // Then + assertThatThrownBy { + testedWebViewReplayEventMapper.mapEvent( + fakeEvent, + fakeRumContext, + fakeDatadogContext + ) + }.isInstanceOf(IllegalStateException::class.java) + .hasMessage( + WebViewReplayEventMapper.BROWSER_EVENT_MISSING_RECORD_ERROR_MESSAGE + ) + } + + @Test + fun `M map the event to an EnrichedRecord W mapEvent`(forge: Forge) { + // Given + val fakeTimestamp = forge.aLong() + val fakeEvent = stubValidReplayEvent(fakeBrowserRumViewId, fakeTimestamp) + whenever(mockOffsetProvider.getOffset(fakeBrowserRumViewId, fakeDatadogContext)) + .thenReturn(fakeServerTimeOffset) + + // When + val result = testedWebViewReplayEventMapper.mapEvent( + fakeEvent, + fakeRumContext, + fakeDatadogContext + ) + + // Then + val mappedWrappedEvent = result.get("records").asJsonArray[0].asJsonObject + assertThat(mappedWrappedEvent.get(WebViewReplayEventMapper.TIMESTAMP_KEY).asLong) + .isEqualTo(fakeTimestamp + fakeServerTimeOffset) + assertThat(mappedWrappedEvent.get(WebViewReplayEventMapper.SLOT_ID_KEY).asString) + .isEqualTo(fakeWebViewId.toString()) + assertThat(result.get(WebViewReplayEventMapper.ENRICHED_RECORD_APPLICATION_ID_KEY).asString) + .isEqualTo(fakeRumContext.applicationId) + assertThat(result.get(WebViewReplayEventMapper.ENRICHED_RECORD_SESSION_ID_KEY).asString) + .isEqualTo(fakeRumContext.sessionId) + assertThat(result.get(WebViewReplayEventMapper.ENRICHED_RECORD_VIEW_ID_KEY).asString) + .isEqualTo(fakeBrowserRumViewId) + } + + private fun stubValidReplayEvent( + fakeBrowserViewId: String, + fakeTimestamp: Long + ): JsonObject { + val fakeEvent = JsonObject() + val fakeViewDataContainer: JsonObject = JsonObject().apply { + addProperty(WebViewReplayEventMapper.VIEW_ID_KEY, fakeBrowserViewId) + } + val fakeWrappedRecord = JsonObject() + fakeEvent.add(WebViewReplayEventMapper.EVENT_KEY, fakeWrappedRecord) + fakeEvent.add(WebViewReplayEventMapper.VIEW_OBJECT_KEY, fakeViewDataContainer) + fakeWrappedRecord.addProperty(WebViewReplayEventMapper.TIMESTAMP_KEY, fakeTimestamp) + return fakeEvent + } +} diff --git a/features/dd-sdk-android-webview/src/test/kotlin/com/datadog/android/webview/internal/rum/TimestampOffsetProviderTest.kt b/features/dd-sdk-android-webview/src/test/kotlin/com/datadog/android/webview/internal/rum/TimestampOffsetProviderTest.kt new file mode 100644 index 0000000000..e82cb001b3 --- /dev/null +++ b/features/dd-sdk-android-webview/src/test/kotlin/com/datadog/android/webview/internal/rum/TimestampOffsetProviderTest.kt @@ -0,0 +1,197 @@ +/* + * 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.webview.internal.rum + +import com.datadog.android.api.InternalLogger +import com.datadog.android.api.context.DatadogContext +import com.datadog.android.utils.forge.Configurator +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.annotation.Forgery +import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +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.quality.Strictness +import java.util.LinkedList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(Configurator::class) +internal class TimestampOffsetProviderTest { + + lateinit var testedProvider: TimestampOffsetProvider + + @Mock + lateinit var mockInternalLogger: InternalLogger + + @Forgery + lateinit var fakeDatadogContext: DatadogContext + + @StringForgery(regex = "[a-z0-9]{32}") + lateinit var fakeViewId: String + + @BeforeEach + fun `set up`() { + testedProvider = TimestampOffsetProvider(mockInternalLogger) + } + + @Test + fun `M return current server offset W getTimeOffset`() { + // When + val fakeOffset = testedProvider.getOffset(fakeViewId, fakeDatadogContext) + + // Then + assertThat(fakeOffset).isEqualTo(fakeDatadogContext.time.serverTimeOffsetMs) + } + + @Test + fun `M be consistent W getTimeOffset { same view Id }`(forge: Forge) { + // Given + val fakeCounts = forge.anInt(min = 5, max = 10) + val fakeOffset = testedProvider.getOffset(fakeViewId, fakeDatadogContext) + + // Then + assertThat(fakeOffset).isEqualTo(fakeDatadogContext.time.serverTimeOffsetMs) + repeat(fakeCounts) { + val newOffset = testedProvider.getOffset(fakeViewId, forge.getForgery()) + assertThat(newOffset).isEqualTo(fakeOffset) + } + } + + @Test + fun `M be consistent W getTimeOffset { same view Id, concurrent }`(forge: Forge) { + // Given + val fakeCounts = forge.anInt(min = 5, max = 10) + val fakeOffset = testedProvider.getOffset(fakeViewId, fakeDatadogContext) + + // Then + assertThat(fakeOffset).isEqualTo(fakeDatadogContext.time.serverTimeOffsetMs) + val countDownLatch = CountDownLatch(fakeCounts) + repeat(fakeCounts) { + Thread { + val newOffset = testedProvider.getOffset(fakeViewId, forge.getForgery()) + assertThat(newOffset).isEqualTo(fakeOffset) + countDownLatch.countDown() + }.start() + } + countDownLatch.await(10000, TimeUnit.MILLISECONDS) + assertThat(testedProvider.offsets.entries).hasSize(1) + } + + @Test + fun `M return the new offset W getTimeOffset { different view Ids }`(forge: Forge) { + // Given + val fakeCounts = forge.anInt(min = 5, max = 10) + val fakeOffset = testedProvider.getOffset(fakeViewId, fakeDatadogContext) + + // Then + assertThat(fakeOffset).isEqualTo(fakeDatadogContext.time.serverTimeOffsetMs) + repeat(fakeCounts) { + val fakeNewContext = forge.getForgery() + val fakeNewId = forge.aStringMatching("[a-z0-9]{32}") + val newOffset = testedProvider.getOffset(fakeNewId, fakeNewContext) + assertThat(newOffset).isEqualTo(fakeNewContext.time.serverTimeOffsetMs) + } + } + + @Test + fun `M purge the last used view W consume{ consecutive different views }`(forge: Forge) { + // Given + val size = forge.anInt(min = 3, max = 10) + val fakeContexts = forge.aList(size) { + forge.getForgery() + } + val fakeViewIds = forge.aList(size) { + forge.aStringMatching("[a-z0-9]{32}") + } + + val expectedCachedOffsets = LinkedHashMap() + val expectedCachedOffsetsKeys = + fakeViewIds + .takeLast(TimestampOffsetProvider.MAX_VIEW_TIME_OFFSETS_RETAIN) + val expectedCachedOffsetsValues = + fakeContexts + .takeLast(TimestampOffsetProvider.MAX_VIEW_TIME_OFFSETS_RETAIN) + .map { it.time.serverTimeOffsetMs } + expectedCachedOffsetsKeys.forEachIndexed { index, key -> + expectedCachedOffsets[key] = expectedCachedOffsetsValues[index] + } + val expectedOffsets = fakeContexts.map { it.time.serverTimeOffsetMs } + + // When + val returnedOffsets = mutableListOf() + repeat(size) { + val fakeNewContext = fakeContexts[it] + val fakeNewId = fakeViewIds[it] + returnedOffsets.add(testedProvider.getOffset(fakeNewId, fakeNewContext)) + } + + // Then + assertThat(testedProvider.offsets.entries) + .containsExactlyElementsOf(expectedCachedOffsets.entries) + assertThat(returnedOffsets).containsExactlyElementsOf(expectedOffsets) + } + + @Test + fun `M purge the last used view W consume{ consecutive different views, concurrent }`( + forge: Forge + ) { + // Given + val size = forge.anInt(min = 3, max = 10) + val fakeContexts = forge.aList(size) { + forge.getForgery() + } + val fakeViewIds = forge.aList(size) { + forge.aStringMatching("[a-z0-9]{32}") + } + + val expectedCachedOffsets = LinkedHashMap() + val expectedCachedOffsetsKeys = + fakeViewIds + .takeLast(TimestampOffsetProvider.MAX_VIEW_TIME_OFFSETS_RETAIN) + val expectedCachedOffsetsValues = + fakeContexts + .takeLast(TimestampOffsetProvider.MAX_VIEW_TIME_OFFSETS_RETAIN) + .map { it.time.serverTimeOffsetMs } + expectedCachedOffsetsKeys.forEachIndexed { index, key -> + expectedCachedOffsets[key] = expectedCachedOffsetsValues[index] + } + val expectedOffsets = fakeContexts.map { it.time.serverTimeOffsetMs } + + // When + val countDownLatch = CountDownLatch(size) + val returnedOffsets = LinkedList() + repeat(size) { + val fakeNewContext = fakeContexts[it] + val fakeNewId = fakeViewIds[it] + Thread { + synchronized(returnedOffsets) { + returnedOffsets.add(testedProvider.getOffset(fakeNewId, fakeNewContext)) + } + countDownLatch.countDown() + }.start() + } + + // Then + countDownLatch.await(10000, TimeUnit.MILLISECONDS) + assertThat(testedProvider.offsets.entries.size) + .isEqualTo(TimestampOffsetProvider.MAX_VIEW_TIME_OFFSETS_RETAIN) + assertThat(returnedOffsets).containsExactlyInAnyOrder(*expectedOffsets.toTypedArray()) + } +} diff --git a/features/dd-sdk-android-webview/src/test/kotlin/com/datadog/android/webview/internal/rum/WebViewRumEventConsumerTest.kt b/features/dd-sdk-android-webview/src/test/kotlin/com/datadog/android/webview/internal/rum/WebViewRumEventConsumerTest.kt index 919eeb9052..b6963fb5a6 100644 --- a/features/dd-sdk-android-webview/src/test/kotlin/com/datadog/android/webview/internal/rum/WebViewRumEventConsumerTest.kt +++ b/features/dd-sdk-android-webview/src/test/kotlin/com/datadog/android/webview/internal/rum/WebViewRumEventConsumerTest.kt @@ -30,7 +30,6 @@ import fr.xgouchet.elmyr.annotation.Forgery import fr.xgouchet.elmyr.annotation.LongForgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration 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 @@ -45,12 +44,9 @@ import org.mockito.kotlin.doAnswer import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.never -import org.mockito.kotlin.times import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.mockito.quality.Strictness -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit @Extensions( ExtendWith(MockitoExtension::class), @@ -107,6 +103,9 @@ internal class WebViewRumEventConsumerTest { @Forgery lateinit var fakeRumContext: RumContext + @Mock + lateinit var mockOffsetProvider: TimestampOffsetProvider + @BeforeEach fun `set up`(forge: Forge) { fakeRumContext = fakeRumContext.copy(sessionState = "TRACKED") @@ -137,10 +136,16 @@ internal class WebViewRumEventConsumerTest { callback.invoke(fakeDatadogContext, mockEventBatchWriter) } whenever(mockSdkCore.internalLogger) doReturn mockInternalLogger - + whenever( + mockOffsetProvider.getOffset( + any(), + eq(fakeDatadogContext) + ) + ) doReturn fakeServerTimeOffsetInMillis testedConsumer = WebViewRumEventConsumer( mockSdkCore, mockDataWriter, + mockOffsetProvider, mockWebViewRumEventMapper, mockRumContextProvider ) @@ -642,226 +647,6 @@ internal class WebViewRumEventConsumerTest { // region Offset Correction - @Test - fun `M use same offset correct W consume { consecutive event updates }`(forge: Forge) { - // Given - var invocationCount = 0 - whenever(mockWebViewRumFeatureScope.withWriteContext(any(), any())) doAnswer { - val callback = it.getArgument<(DatadogContext, EventBatchWriter) -> Unit>(1) - callback.invoke( - fakeDatadogContext.copy( - time = fakeDatadogContext.time.copy( - serverTimeOffsetMs = if (invocationCount == 0) { - fakeServerTimeOffsetInMillis - } else { - forge.aLong() - } - ) - ), - mockEventBatchWriter - ) - invocationCount++ - Unit - } - - val fakeEvent = forge.aRumEventAsJson() - whenever( - mockWebViewRumEventMapper.mapEvent( - any(), - eq(fakeRumContext), - eq(fakeServerTimeOffsetInMillis) - ) - ).thenReturn(fakeMappedViewEvent) - - // When - testedConsumer.consume(fakeEvent) - testedConsumer.consume(fakeEvent) - testedConsumer.consume(fakeEvent) - - // Then - verify(mockWebViewRumEventMapper, times(3)).mapEvent( - fakeEvent, - fakeRumContext, - fakeServerTimeOffsetInMillis - ) - } - - @Test - fun `M use dedicated offset correction W consume { consecutive different views }`( - forge: Forge - ) { - // Given - val fakeSecondServerTimeOffset = forge.aLong() - var invocationCount = 0 - whenever(mockWebViewRumFeatureScope.withWriteContext(any(), any())) doAnswer { - val callback = it.getArgument<(DatadogContext, EventBatchWriter) -> Unit>(1) - callback.invoke( - fakeDatadogContext.copy( - time = fakeDatadogContext.time.copy( - serverTimeOffsetMs = if (invocationCount == 0) { - fakeServerTimeOffsetInMillis - } else { - fakeSecondServerTimeOffset - } - ) - ), - mockEventBatchWriter - ) - invocationCount++ - Unit - } - - val fakeEvent = forge.aRumEventAsJson() - val fakeEvent2 = forge.aRumEventAsJson() - whenever( - mockWebViewRumEventMapper.mapEvent( - fakeEvent, - fakeRumContext, - fakeServerTimeOffsetInMillis - ) - ).thenReturn(fakeEvent) - whenever( - mockWebViewRumEventMapper.mapEvent( - fakeEvent2, - fakeRumContext, - fakeSecondServerTimeOffset - ) - ).thenReturn(fakeEvent2) - - // When - testedConsumer.consume(fakeEvent) - testedConsumer.consume(fakeEvent2) - - // Then - verify(mockWebViewRumEventMapper).mapEvent( - fakeEvent, - fakeRumContext, - fakeServerTimeOffsetInMillis - ) - verify(mockWebViewRumEventMapper).mapEvent( - fakeEvent2, - fakeRumContext, - fakeSecondServerTimeOffset - ) - } - - @Test - fun `M purge the last used view W consume{ consecutive different views }`(forge: Forge) { - // Given - val size = forge.anInt(min = 1, max = 10) - val fakeServerOffsets = forge.aList(size) { - forge.aLong() - } - val fakeViewEvents = forge.aList(size) { - forge.getForgery(ViewEvent::class.java) - } - - var invocationCount = 0 - whenever(mockWebViewRumFeatureScope.withWriteContext(any(), any())) doAnswer { - val callback = it.getArgument<(DatadogContext, EventBatchWriter) -> Unit>(1) - callback.invoke( - fakeDatadogContext.copy( - time = fakeDatadogContext.time.copy( - serverTimeOffsetMs = fakeServerOffsets[invocationCount] - ) - ), - mockEventBatchWriter - ) - invocationCount++ - Unit - } - - val expectedOffsets = LinkedHashMap() - val expectedOffsetsKeys = - fakeViewEvents - .takeLast(WebViewRumEventConsumer.MAX_VIEW_TIME_OFFSETS_RETAIN) - .map { it.view.id } - val expectedOffsetsValues = - fakeServerOffsets.takeLast(WebViewRumEventConsumer.MAX_VIEW_TIME_OFFSETS_RETAIN) - expectedOffsetsKeys.forEachIndexed { index, key -> - expectedOffsets[key] = expectedOffsetsValues[index] - } - whenever( - mockWebViewRumEventMapper.mapEvent( - any(), - eq(fakeRumContext), - any() - ) - ).thenReturn(fakeMappedViewEvent) - - // When - fakeViewEvents.forEach { - testedConsumer.consume(it.toJson().asJsonObject) - } - - // Then - val rumEventConsumer = testedConsumer as WebViewRumEventConsumer - assertThat(rumEventConsumer.offsets.entries) - .containsExactlyElementsOf(expectedOffsets.entries) - } - - @Test - fun `M purge the last used view W consume{ consecutive different views, async EWC }`( - forge: Forge - ) { - // Given - val size = forge.anInt(min = 1, max = 10) - val fakeServerOffsets = forge.aList(size) { - forge.aLong() - } - val fakeViewEvents = forge.aList(size) { - forge.getForgery(ViewEvent::class.java) - } - - var invocationCount = 0 - val latch = CountDownLatch(size) - whenever(mockWebViewRumFeatureScope.withWriteContext(any(), any())) doAnswer { - val callback = it.getArgument<(DatadogContext, EventBatchWriter) -> Unit>(1) - val invocation = invocationCount - Thread { - callback.invoke( - fakeDatadogContext.copy( - time = fakeDatadogContext.time.copy( - serverTimeOffsetMs = fakeServerOffsets[invocation] - ) - ), - mockEventBatchWriter - ) - latch.countDown() - }.start() - invocationCount++ - Unit - } - - val expectedOffsets = LinkedHashMap() - val expectedOffsetsKeys = fakeViewEvents.map { it.view.id } - expectedOffsetsKeys.forEachIndexed { index, key -> - expectedOffsets[key] = fakeServerOffsets[index] - } - whenever( - mockWebViewRumEventMapper.mapEvent( - any(), - eq(fakeRumContext), - any() - ) - ).thenReturn(fakeMappedViewEvent) - - // When - fakeViewEvents.forEach { - testedConsumer.consume(it.toJson().asJsonObject) - } - latch.await(1, TimeUnit.SECONDS) - - // Then - val rumEventConsumer = testedConsumer as WebViewRumEventConsumer - // Because the threads are processed in any order, - // we can't guarantee the order of the entries, and can only assert - // the size of the offsets, and the pairs of key values in it - assertThat(rumEventConsumer.offsets.entries) - .containsAnyElementsOf(expectedOffsets.entries) - .hasSizeLessThanOrEqualTo(WebViewRumEventConsumer.MAX_VIEW_TIME_OFFSETS_RETAIN) - } - // endregion companion object { diff --git a/features/dd-sdk-android-webview/src/test/kotlin/com/datadog/android/webview/internal/rum/WebViewRumEventMapperTest.kt b/features/dd-sdk-android-webview/src/test/kotlin/com/datadog/android/webview/internal/rum/WebViewRumEventMapperTest.kt index 29ec191188..b11a5d317b 100644 --- a/features/dd-sdk-android-webview/src/test/kotlin/com/datadog/android/webview/internal/rum/WebViewRumEventMapperTest.kt +++ b/features/dd-sdk-android-webview/src/test/kotlin/com/datadog/android/webview/internal/rum/WebViewRumEventMapperTest.kt @@ -13,20 +13,24 @@ import com.datadog.android.rum.model.ResourceEvent import com.datadog.android.rum.model.ViewEvent import com.datadog.android.utils.forge.Configurator import com.datadog.android.utils.forge.aRumEventAsJson +import com.datadog.android.webview.internal.rum.domain.NativeRumViewsCache import com.datadog.android.webview.internal.rum.domain.RumContext import com.datadog.tools.unit.assertj.JsonObjectAssert.Companion.assertThat import com.google.gson.JsonObject import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.Forgery import fr.xgouchet.elmyr.annotation.LongForgery +import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension 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.whenever import org.mockito.quality.Strictness @Extensions( @@ -45,6 +49,12 @@ internal class WebViewRumEventMapperTest { @Forgery lateinit var fakeRumContext: RumContext + @Mock + lateinit var mockNativeRumViewsCache: NativeRumViewsCache + + @StringForgery + lateinit var fakeResolvedNativeViewId: String + lateinit var fakeTags: Map @BeforeEach @@ -56,13 +66,15 @@ internal class WebViewRumEventMapperTest { } else { emptyMap() } - testedWebViewRumEventMapper = WebViewRumEventMapper() + testedWebViewRumEventMapper = WebViewRumEventMapper(mockNativeRumViewsCache) } @Test fun `M map the event W mapEvent { ViewEvent }()`(forge: Forge) { // Given val fakeViewEvent = forge.getForgery() + whenever(mockNativeRumViewsCache.resolveLastParentIdForBrowserEvent(fakeViewEvent.date)) + .thenReturn(fakeResolvedNativeViewId) val fakeRumJsonObject = fakeViewEvent.toJson().asJsonObject // When @@ -85,6 +97,8 @@ internal class WebViewRumEventMapperTest { // Given val fakeActionEvent = forge.getForgery() val fakeRumJsonObject = fakeActionEvent.toJson().asJsonObject + whenever(mockNativeRumViewsCache.resolveLastParentIdForBrowserEvent(fakeActionEvent.date)) + .thenReturn(fakeResolvedNativeViewId) // When val mappedEvent = testedWebViewRumEventMapper.mapEvent( @@ -106,6 +120,8 @@ internal class WebViewRumEventMapperTest { // Given val fakeErrorEvent = forge.getForgery() val fakeRumJsonObject = fakeErrorEvent.toJson().asJsonObject + whenever(mockNativeRumViewsCache.resolveLastParentIdForBrowserEvent(fakeErrorEvent.date)) + .thenReturn(fakeResolvedNativeViewId) // When val mappedEvent = testedWebViewRumEventMapper.mapEvent( @@ -127,6 +143,8 @@ internal class WebViewRumEventMapperTest { // Given val fakeResourceEvent = forge.getForgery() val fakeRumJsonObject = fakeResourceEvent.toJson().asJsonObject + whenever(mockNativeRumViewsCache.resolveLastParentIdForBrowserEvent(fakeResourceEvent.date)) + .thenReturn(fakeResolvedNativeViewId) // When val mappedEvent = testedWebViewRumEventMapper.mapEvent( @@ -148,6 +166,8 @@ internal class WebViewRumEventMapperTest { // Given val fakeLongTaskEvent = forge.getForgery() val fakeRumJsonObject = fakeLongTaskEvent.toJson().asJsonObject + whenever(mockNativeRumViewsCache.resolveLastParentIdForBrowserEvent(fakeLongTaskEvent.date)) + .thenReturn(fakeResolvedNativeViewId) // When val mappedEvent = testedWebViewRumEventMapper.mapEvent( @@ -172,6 +192,8 @@ internal class WebViewRumEventMapperTest { remove(WebViewRumEventMapper.APPLICATION_KEY_NAME) remove(WebViewRumEventMapper.SESSION_KEY_NAME) } + whenever(mockNativeRumViewsCache.resolveLastParentIdForBrowserEvent(fakeLongTaskEvent.date)) + .thenReturn(fakeResolvedNativeViewId) // When val mappedEvent = testedWebViewRumEventMapper.mapEvent( @@ -192,7 +214,8 @@ internal class WebViewRumEventMapperTest { fun `M map the event W mapEvent { RumContext is missing }`(forge: Forge) { // Given val fakeRumJsonObject = forge.aRumEventAsJson() - val expectedDate = fakeRumJsonObject.get(WebViewRumEventMapper.DATE_KEY_NAME).asLong + + val fakeEventDate = fakeRumJsonObject.get(WebViewRumEventMapper.DATE_KEY_NAME).asLong + val expectedDate = fakeEventDate + fakeServerTimeOffset val expectedApplicationId = fakeRumJsonObject .getAsJsonObject(WebViewRumEventMapper.APPLICATION_KEY_NAME) @@ -202,6 +225,8 @@ internal class WebViewRumEventMapperTest { .getAsJsonObject(WebViewRumEventMapper.SESSION_KEY_NAME) .getAsJsonPrimitive(WebViewRumEventMapper.ID_KEY_NAME) .asString + whenever(mockNativeRumViewsCache.resolveLastParentIdForBrowserEvent(fakeEventDate)) + .thenReturn(fakeResolvedNativeViewId) // When val mappedEvent = testedWebViewRumEventMapper.mapEvent( @@ -217,7 +242,8 @@ internal class WebViewRumEventMapperTest { WebViewRumEventMapper.APPLICATION_KEY_NAME, WebViewRumEventMapper.SESSION_KEY_NAME, WebViewRumEventMapper.DATE_KEY_NAME, - WebViewRumEventMapper.DD_KEY_NAME + WebViewRumEventMapper.DD_KEY_NAME, + WebViewRumEventMapper.CONTAINER_KEY_NAME ) .isEqualTo(fakeRumJsonObject) assertThat(mappedEvent.getAsJsonObject(WebViewRumEventMapper.APPLICATION_KEY_NAME)) @@ -235,6 +261,48 @@ internal class WebViewRumEventMapperTest { WebViewRumEventMapper.SESSION_PLAN_KEY_NAME, ViewEvent.Plan.PLAN_1.toJson().asLong ) + val container = mappedEvent.getAsJsonObject(WebViewRumEventMapper.CONTAINER_KEY_NAME) + assertThat(container).hasField( + WebViewRumEventMapper.SOURCE_KEY_NAME, + WebViewRumEventMapper.SOURCE_VALUE + ) + assertThat(container.getAsJsonObject(WebViewRumEventMapper.VIEW_KEY_NAME)) + .hasField(WebViewRumEventMapper.ID_KEY_NAME, fakeResolvedNativeViewId) + } + + @Test + fun `M map the event W mapEvent { parent native id could not be resolved }`(forge: Forge) { + // Given + val fakeRumJsonObject = forge.aRumEventAsJson() + val fakeEventDate = fakeRumJsonObject.get(WebViewRumEventMapper.DATE_KEY_NAME).asLong + whenever(mockNativeRumViewsCache.resolveLastParentIdForBrowserEvent(fakeEventDate)) + .thenReturn(null) + + // When + val mappedEvent = testedWebViewRumEventMapper.mapEvent( + fakeRumJsonObject, + null, + fakeServerTimeOffset + ) + + // Then + assertThat(mappedEvent) + .usingRecursiveComparison() + .ignoringFields( + WebViewRumEventMapper.APPLICATION_KEY_NAME, + WebViewRumEventMapper.SESSION_KEY_NAME, + WebViewRumEventMapper.DATE_KEY_NAME, + WebViewRumEventMapper.DD_KEY_NAME, + WebViewRumEventMapper.CONTAINER_KEY_NAME + ) + .isEqualTo(fakeRumJsonObject) + val container = mappedEvent + .getAsJsonObject(WebViewRumEventMapper.CONTAINER_KEY_NAME) + assertThat(container).hasField( + WebViewRumEventMapper.SOURCE_KEY_NAME, + WebViewRumEventMapper.SOURCE_VALUE + ) + assertThat(container).doesNotHaveField(WebViewRumEventMapper.VIEW_KEY_NAME) } private fun assertMappedEvent( @@ -267,5 +335,12 @@ internal class WebViewRumEventMapperTest { WebViewRumEventMapper.SESSION_PLAN_KEY_NAME, ViewEvent.Plan.PLAN_1.toJson().asLong ) + val container = mappedEvent.getAsJsonObject(WebViewRumEventMapper.CONTAINER_KEY_NAME) + assertThat(container).hasField( + WebViewRumEventMapper.SOURCE_KEY_NAME, + WebViewRumEventMapper.SOURCE_VALUE + ) + assertThat(container.getAsJsonObject(WebViewRumEventMapper.VIEW_KEY_NAME)) + .hasField(WebViewRumEventMapper.ID_KEY_NAME, fakeResolvedNativeViewId) } } diff --git a/features/dd-sdk-android-webview/src/test/kotlin/com/datadog/android/webview/internal/rum/WebViewRumFeatureTest.kt b/features/dd-sdk-android-webview/src/test/kotlin/com/datadog/android/webview/internal/rum/WebViewRumFeatureTest.kt index ed17ffdb71..b8af42ebce 100644 --- a/features/dd-sdk-android-webview/src/test/kotlin/com/datadog/android/webview/internal/rum/WebViewRumFeatureTest.kt +++ b/features/dd-sdk-android-webview/src/test/kotlin/com/datadog/android/webview/internal/rum/WebViewRumFeatureTest.kt @@ -6,13 +6,16 @@ package com.datadog.android.webview.internal.rum +import com.datadog.android.api.feature.Feature import com.datadog.android.api.feature.FeatureSdkCore import com.datadog.android.api.net.RequestFactory import com.datadog.android.api.storage.FeatureStorageConfiguration import com.datadog.android.api.storage.NoOpDataWriter import com.datadog.android.utils.forge.Configurator +import com.datadog.android.webview.internal.rum.domain.NativeRumViewsCache import com.datadog.android.webview.internal.storage.WebViewDataWriter import com.datadog.tools.unit.extensions.ApiLevelExtension +import fr.xgouchet.elmyr.annotation.StringForgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension import org.assertj.core.api.Assertions.assertThat @@ -25,6 +28,8 @@ 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.verify +import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever import org.mockito.quality.Strictness @@ -45,9 +50,12 @@ internal class WebViewRumFeatureTest { @Mock lateinit var mockSdkCore: FeatureSdkCore + @Mock + lateinit var mockNativeRumViewsCache: NativeRumViewsCache + @BeforeEach fun `set up`() { - testedFeature = WebViewRumFeature(mockSdkCore, mockRequestFactory) + testedFeature = WebViewRumFeature(mockSdkCore, mockRequestFactory, mockNativeRumViewsCache) whenever(mockSdkCore.internalLogger) doReturn mock() } @@ -93,4 +101,28 @@ internal class WebViewRumFeatureTest { // Then assertThat(testedFeature.dataWriter).isInstanceOf(NoOpDataWriter::class.java) } + + @Test + fun `M register the context to the native cache W onContextUpdate { RUM }`() { + // Given + val fakeContext = mock>() + + // When + testedFeature.onContextUpdate(Feature.RUM_FEATURE_NAME, fakeContext) + + // Then + verify(mockNativeRumViewsCache).addToCache(fakeContext) + } + + @Test + fun `M do nothing W onContextUpdate { no RUM feature }`(@StringForgery fakeFeatureName: String) { + // Given + val fakeContext = mock>() + + // When + testedFeature.onContextUpdate(fakeFeatureName, fakeContext) + + // Then + verifyNoInteractions(mockNativeRumViewsCache) + } } diff --git a/features/dd-sdk-android-webview/src/test/kotlin/com/datadog/android/webview/internal/rum/domain/WebViewNativeRumViewsCacheTest.kt b/features/dd-sdk-android-webview/src/test/kotlin/com/datadog/android/webview/internal/rum/domain/WebViewNativeRumViewsCacheTest.kt new file mode 100644 index 0000000000..0375ddebf2 --- /dev/null +++ b/features/dd-sdk-android-webview/src/test/kotlin/com/datadog/android/webview/internal/rum/domain/WebViewNativeRumViewsCacheTest.kt @@ -0,0 +1,335 @@ +/* + * 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.webview.internal.rum.domain + +import com.datadog.android.utils.forge.Configurator +import fr.xgouchet.elmyr.Forge +import fr.xgouchet.elmyr.junit5.ForgeConfiguration +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.assertDoesNotThrow +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.quality.Strictness +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicLong + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(Configurator::class) +internal class WebViewNativeRumViewsCacheTest { + + private lateinit var fakeIdGenerator: FakeIdGenerator + private lateinit var fakeClock: FakeClock + private lateinit var testedCache: WebViewNativeRumViewsCache + + // region Unit Tests + + @BeforeEach + fun `set up`(forge: Forge) { + fakeClock = FakeClock() + fakeIdGenerator = FakeIdGenerator(forge) + testedCache = WebViewNativeRumViewsCache() + } + + @Test + fun `M return the first entry from cache that matches the criteria W resolveLastParentIdForBrowserEvent()`( + forge: Forge + ) { + // Given + val fakeEntries = forge.aList(size = forge.anInt(min = 1, max = 10)) { + mapOf( + WebViewNativeRumViewsCache.VIEW_ID_KEY to fakeIdGenerator.generateId(), + WebViewNativeRumViewsCache.VIEW_TIMESTAMP_KEY to fakeClock.nextCurrentTimeMillis(), + WebViewNativeRumViewsCache.VIEW_HAS_REPLAY_KEY to true + ) + } + val fakeNoReplayEntries = forge.aList(size = forge.anInt(min = 1, max = 10)) { + mapOf( + WebViewNativeRumViewsCache.VIEW_ID_KEY to fakeIdGenerator.generateId(), + WebViewNativeRumViewsCache.VIEW_TIMESTAMP_KEY to fakeClock.nextCurrentTimeMillis(), + WebViewNativeRumViewsCache.VIEW_HAS_REPLAY_KEY to false + ) + } + val fakeBrowserEventTimestampInMs = fakeClock.nextCurrentTimeMillis() + fakeEntries.forEach { + testedCache.addToCache(it) + } + fakeNoReplayEntries.forEach { + testedCache.addToCache(it) + } + + // When + val resolvedParentId = testedCache.resolveLastParentIdForBrowserEvent(fakeBrowserEventTimestampInMs) + + // Then + assertThat(resolvedParentId).isEqualTo(fakeEntries.last()[WebViewNativeRumViewsCache.VIEW_ID_KEY]) + } + + @Test + fun `M return the first entry from cache W resolveLastParentIdForBrowserEvent() { hasReplay is false }`( + forge: Forge + ) { + // Given + val fakeEntries = forge.aList { + mapOf( + WebViewNativeRumViewsCache.VIEW_ID_KEY to fakeIdGenerator.generateId(), + WebViewNativeRumViewsCache.VIEW_TIMESTAMP_KEY to fakeClock.nextCurrentTimeMillis(), + WebViewNativeRumViewsCache.VIEW_HAS_REPLAY_KEY to false + ) + } + fakeEntries.forEach { + testedCache.addToCache(it) + } + val fakeBrowserEventTimestampInMs = fakeClock.nextCurrentTimeMillis() + + // When + val resolvedParentId = testedCache.resolveLastParentIdForBrowserEvent(fakeBrowserEventTimestampInMs) + + // Then + assertThat(resolvedParentId).isEqualTo(fakeEntries.last()[WebViewNativeRumViewsCache.VIEW_ID_KEY]) + } + + @Test + fun `M return null W resolveLastParentIdForBrowserEvent() { no matching candidate }`( + forge: Forge + ) { + // Given + val fakeBrowserEventTimestampInMs = fakeClock.nextCurrentTimeMillis() + val fakeEntries = forge.aList { + mapOf( + WebViewNativeRumViewsCache.VIEW_ID_KEY to fakeIdGenerator.generateId(), + WebViewNativeRumViewsCache.VIEW_TIMESTAMP_KEY to fakeClock.nextCurrentTimeMillis(), + WebViewNativeRumViewsCache.VIEW_HAS_REPLAY_KEY to forge.aBool() + ) + } + fakeEntries.forEach { + testedCache.addToCache(it) + } + + // When + val resolvedParentId = testedCache.resolveLastParentIdForBrowserEvent(fakeBrowserEventTimestampInMs) + + // Then + assertThat(resolvedParentId).isNull() + } + + @Test + fun `M return null W resolveLastParentIdForBrowserEvent() { no data in cache }`() { + // Given + val fakeBrowserEventTimestampInMs = fakeClock.nextCurrentTimeMillis() + + // When + val resolvedParentId = testedCache.resolveLastParentIdForBrowserEvent(fakeBrowserEventTimestampInMs) + + // Then + assertThat(resolvedParentId).isNull() + } + + @Test + fun `M not throw W resolveLastParentIdForBrowserEvent() { concurrent access }`( + forge: Forge + ) { + // Given + val fakeEntries = forge.aList { + mapOf( + WebViewNativeRumViewsCache.VIEW_ID_KEY to fakeIdGenerator.generateId(), + WebViewNativeRumViewsCache.VIEW_TIMESTAMP_KEY to fakeClock.nextCurrentTimeMillis(), + WebViewNativeRumViewsCache.VIEW_HAS_REPLAY_KEY to forge.aBool() + ) + } + fakeEntries.forEach { + Thread { testedCache.addToCache(it) }.apply { start() }.join(5000) + } + val fakeBrowserEventTimestampInMs = fakeClock.nextCurrentTimeMillis() + + // Then + assertDoesNotThrow { testedCache.resolveLastParentIdForBrowserEvent(fakeBrowserEventTimestampInMs) } + } + + @Test + fun `M purge the entries with expired TTL W addToCache()`( + forge: Forge + ) { + // Given + val entriesTtlLimitInMs = TimeUnit.SECONDS.toMillis(1) + testedCache = WebViewNativeRumViewsCache(entriesTtlLimitInMs) + val fakeOldEntries = forge.aList(size = forge.anInt(min = 1, max = 10)) { + mapOf( + WebViewNativeRumViewsCache.VIEW_ID_KEY to fakeIdGenerator.generateId(), + WebViewNativeRumViewsCache.VIEW_TIMESTAMP_KEY to System.currentTimeMillis(), + WebViewNativeRumViewsCache.VIEW_HAS_REPLAY_KEY to forge.aBool() + ) + } + fakeOldEntries.forEach { testedCache.addToCache(it) } + + // When + Thread.sleep(entriesTtlLimitInMs) + val fakeNewEntries = forge.aList( + size = forge.anInt( + min = 1, + max = WebViewNativeRumViewsCache.DATA_CACHE_ENTRIES_LIMIT + ) + ) { + mapOf( + WebViewNativeRumViewsCache.VIEW_ID_KEY to fakeIdGenerator.generateId(), + WebViewNativeRumViewsCache.VIEW_TIMESTAMP_KEY to System.currentTimeMillis(), + WebViewNativeRumViewsCache.VIEW_HAS_REPLAY_KEY to forge.aBool() + ) + } + val expectedCachedEntries = fakeNewEntries.reversed().map { + WebViewNativeRumViewsCache.ViewEntry( + it[WebViewNativeRumViewsCache.VIEW_ID_KEY] as String, + it[WebViewNativeRumViewsCache.VIEW_TIMESTAMP_KEY] as Long, + it[WebViewNativeRumViewsCache.VIEW_HAS_REPLAY_KEY] as Boolean + ) + } + fakeNewEntries.forEach { testedCache.addToCache(it) } + + // Then + assertThat(testedCache.parentViewsHistoryQueue).containsExactlyElementsOf(expectedCachedEntries) + } + + @Test + fun `M purge the old entries W addToCache(){ cache size limit reached }`( + forge: Forge + ) { + // Given + var index = 0 + val fakeEntries = forge.aList( + size = forge.anInt( + min = WebViewNativeRumViewsCache.DATA_CACHE_ENTRIES_LIMIT, + max = WebViewNativeRumViewsCache.DATA_CACHE_ENTRIES_LIMIT * 2 + ) + ) { + mapOf( + WebViewNativeRumViewsCache.VIEW_ID_KEY to fakeIdGenerator.generateId() + index++, + WebViewNativeRumViewsCache.VIEW_TIMESTAMP_KEY to fakeClock.nextCurrentTimeMillis(), + WebViewNativeRumViewsCache.VIEW_HAS_REPLAY_KEY to forge.aBool() + ) + } + + // When + val expectedCachedEntries = fakeEntries + .takeLast(WebViewNativeRumViewsCache.DATA_CACHE_ENTRIES_LIMIT) + .reversed() + .map { + WebViewNativeRumViewsCache.ViewEntry( + it[WebViewNativeRumViewsCache.VIEW_ID_KEY] as String, + it[WebViewNativeRumViewsCache.VIEW_TIMESTAMP_KEY] as Long, + it[WebViewNativeRumViewsCache.VIEW_HAS_REPLAY_KEY] as Boolean + ) + } + fakeEntries.forEach { testedCache.addToCache(it) } + + // Then + assertThat(testedCache.parentViewsHistoryQueue).containsExactlyElementsOf(expectedCachedEntries) + } + + @Test + fun `M keep only one entry W addToCache() { same view id }`( + forge: Forge + ) { + // Given + val fakeViewId = fakeIdGenerator.generateId() + val fakeEntries = forge.aList(size = forge.anInt(min = 1, max = 10)) { + mapOf( + WebViewNativeRumViewsCache.VIEW_ID_KEY to fakeViewId, + WebViewNativeRumViewsCache.VIEW_TIMESTAMP_KEY to System.currentTimeMillis(), + WebViewNativeRumViewsCache.VIEW_HAS_REPLAY_KEY to forge.aBool() + ) + } + fakeEntries.forEach { testedCache.addToCache(it) } + + // When + // Then + assertThat(testedCache.parentViewsHistoryQueue).containsOnly( + WebViewNativeRumViewsCache.ViewEntry( + fakeViewId, + fakeEntries.last()[WebViewNativeRumViewsCache.VIEW_TIMESTAMP_KEY] as Long, + fakeEntries.last()[WebViewNativeRumViewsCache.VIEW_HAS_REPLAY_KEY] as Boolean + ) + ) + } + + @Test + fun `M drop the entry W addToCache { timestamp is older }`(forge: Forge) { + // Given + val fakeOldEntries = forge.aList( + size = forge.anInt( + min = 1, + max = 10 + ) + ) { + mapOf( + WebViewNativeRumViewsCache.VIEW_ID_KEY to fakeIdGenerator.generateId(), + WebViewNativeRumViewsCache.VIEW_TIMESTAMP_KEY to fakeClock.nextCurrentTimeMillis(), + WebViewNativeRumViewsCache.VIEW_HAS_REPLAY_KEY to forge.aBool() + ) + } + val fakeEntries = forge.aList( + size = forge.anInt( + min = 1, + max = 10 + ) + ) { + mapOf( + WebViewNativeRumViewsCache.VIEW_ID_KEY to fakeIdGenerator.generateId(), + WebViewNativeRumViewsCache.VIEW_TIMESTAMP_KEY to fakeClock.nextCurrentTimeMillis(), + WebViewNativeRumViewsCache.VIEW_HAS_REPLAY_KEY to forge.aBool() + ) + } + + // When + fakeEntries.forEach { testedCache.addToCache(it) } + fakeOldEntries.forEach { testedCache.addToCache(it) } + + // Then + assertThat(testedCache.parentViewsHistoryQueue).containsExactlyElementsOf( + fakeEntries + .reversed() + .map { + WebViewNativeRumViewsCache.ViewEntry( + it[WebViewNativeRumViewsCache.VIEW_ID_KEY] as String, + it[WebViewNativeRumViewsCache.VIEW_TIMESTAMP_KEY] as Long, + it[WebViewNativeRumViewsCache.VIEW_HAS_REPLAY_KEY] as Boolean + ) + } + ) + } + + // endregion + + // region Utils + + private class FakeClock { + + val initialTimeMillis = AtomicLong(System.currentTimeMillis()) + + fun nextCurrentTimeMillis(): Long { + return initialTimeMillis.incrementAndGet() + } + } + + private class FakeIdGenerator(private val forge: Forge) { + var index = 0 + fun generateId(): String { + val newId = forge.anAlphabeticalString() + index + index++ + return newId + } + } + + // endregion +} diff --git a/instrumented/nightly-tests/src/androidTest/kotlin/com/datadog/android/nightly/webview/WebViewTrackingE2ETests.kt b/instrumented/nightly-tests/src/androidTest/kotlin/com/datadog/android/nightly/webview/WebViewTrackingE2ETests.kt index b5e3c4bcb4..2b53a95829 100644 --- a/instrumented/nightly-tests/src/androidTest/kotlin/com/datadog/android/nightly/webview/WebViewTrackingE2ETests.kt +++ b/instrumented/nightly-tests/src/androidTest/kotlin/com/datadog/android/nightly/webview/WebViewTrackingE2ETests.kt @@ -47,7 +47,7 @@ internal class WebViewTrackingE2ETests { /** * apiMethodSignature: com.datadog.android.webview.WebViewTracking#fun enable(android.webkit.WebView, List, Float = 100f, com.datadog.android.api.SdkCore = Datadog.getInstance()) - * apiMethodSignature: com.datadog.android.webview.WebViewTracking$_InternalWebViewProxy#constructor(com.datadog.android.api.SdkCore) + * apiMethodSignature: com.datadog.android.webview.WebViewTracking$_InternalWebViewProxy#constructor(com.datadog.android.api.SdkCore, String? = null) * apiMethodSignature: com.datadog.android.webview.WebViewTracking$_InternalWebViewProxy#fun consumeWebviewEvent(String) */ @Test @@ -64,7 +64,7 @@ internal class WebViewTrackingE2ETests { /** * apiMethodSignature: com.datadog.android.webview.WebViewTracking#fun enable(android.webkit.WebView, List, Float = 100f, com.datadog.android.api.SdkCore = Datadog.getInstance()) - * apiMethodSignature: com.datadog.android.webview.WebViewTracking$_InternalWebViewProxy#constructor(com.datadog.android.api.SdkCore) + * apiMethodSignature: com.datadog.android.webview.WebViewTracking$_InternalWebViewProxy#constructor(com.datadog.android.api.SdkCore, String? = null) * apiMethodSignature: com.datadog.android.webview.WebViewTracking$_InternalWebViewProxy#fun consumeWebviewEvent(String) */ @Test @@ -86,7 +86,7 @@ internal class WebViewTrackingE2ETests { /** * apiMethodSignature: com.datadog.android.webview.WebViewTracking#fun enable(android.webkit.WebView, List, Float = 100f, com.datadog.android.api.SdkCore = Datadog.getInstance()) - * apiMethodSignature: com.datadog.android.webview.WebViewTracking$_InternalWebViewProxy#constructor(com.datadog.android.api.SdkCore) + * apiMethodSignature: com.datadog.android.webview.WebViewTracking$_InternalWebViewProxy#constructor(com.datadog.android.api.SdkCore, String? = null) * apiMethodSignature: com.datadog.android.webview.WebViewTracking$_InternalWebViewProxy#fun consumeWebviewEvent(String) */ @Test @@ -104,7 +104,7 @@ internal class WebViewTrackingE2ETests { /** * apiMethodSignature: com.datadog.android.webview.WebViewTracking#fun enable(android.webkit.WebView, List, Float = 100f, com.datadog.android.api.SdkCore = Datadog.getInstance()) - * apiMethodSignature: com.datadog.android.webview.WebViewTracking$_InternalWebViewProxy#constructor(com.datadog.android.api.SdkCore) + * apiMethodSignature: com.datadog.android.webview.WebViewTracking$_InternalWebViewProxy#constructor(com.datadog.android.api.SdkCore, String? = null) * apiMethodSignature: com.datadog.android.webview.WebViewTracking$_InternalWebViewProxy#fun consumeWebviewEvent(String) */ @Test @@ -117,7 +117,7 @@ internal class WebViewTrackingE2ETests { /** * apiMethodSignature: com.datadog.android.webview.WebViewTracking#fun enable(android.webkit.WebView, List, Float = 100f, com.datadog.android.api.SdkCore = Datadog.getInstance()) - * apiMethodSignature: com.datadog.android.webview.WebViewTracking$_InternalWebViewProxy#constructor(com.datadog.android.api.SdkCore) + * apiMethodSignature: com.datadog.android.webview.WebViewTracking$_InternalWebViewProxy#constructor(com.datadog.android.api.SdkCore, String? = null) * apiMethodSignature: com.datadog.android.webview.WebViewTracking$_InternalWebViewProxy#fun consumeWebviewEvent(String) */ @Test @@ -131,7 +131,7 @@ internal class WebViewTrackingE2ETests { /** * apiMethodSignature: com.datadog.android.webview.WebViewTracking#fun enable(android.webkit.WebView, List, Float = 100f, com.datadog.android.api.SdkCore = Datadog.getInstance()) - * apiMethodSignature: com.datadog.android.webview.WebViewTracking$_InternalWebViewProxy#constructor(com.datadog.android.api.SdkCore) + * apiMethodSignature: com.datadog.android.webview.WebViewTracking$_InternalWebViewProxy#constructor(com.datadog.android.api.SdkCore, String? = null) * apiMethodSignature: com.datadog.android.webview.WebViewTracking$_InternalWebViewProxy#fun consumeWebviewEvent(String) */ @Test diff --git a/sample/kotlin/src/main/kotlin/com/datadog/android/sample/ViewPagerFragment.kt b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/ViewPagerFragment.kt index 661706fc3e..b755c5c466 100644 --- a/sample/kotlin/src/main/kotlin/com/datadog/android/sample/ViewPagerFragment.kt +++ b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/ViewPagerFragment.kt @@ -52,15 +52,9 @@ class ViewPagerFragment : Fragment() { override fun createFragment(position: Int): Fragment { return when (position) { - 0 -> FragmentA() - 1 -> FragmentB() - else -> FragmentC() - }.apply { - val args = Bundle().apply { - putString("fragmentClassName", this::class.java.simpleName) - putInt("adapterPosition", position) - } - arguments = args + 0 -> FragmentA.newInstance() + 1 -> FragmentB.newInstance() + else -> FragmentC.newInstance() } } } diff --git a/sample/kotlin/src/main/kotlin/com/datadog/android/sample/picture/PictureFragment.kt b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/picture/PictureFragment.kt index d8acec1240..69e94bcdbd 100644 --- a/sample/kotlin/src/main/kotlin/com/datadog/android/sample/picture/PictureFragment.kt +++ b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/picture/PictureFragment.kt @@ -53,6 +53,7 @@ internal class PictureFragment : } } + @Deprecated("Deprecated in Java") override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { val currentType = viewModel.getImageLoader() inflater.inflate(R.menu.image_loader, menu) @@ -65,6 +66,7 @@ internal class PictureFragment : menu.findItem(disabled).isEnabled = false } + @Deprecated("Deprecated in Java") override fun onOptionsItemSelected(item: MenuItem): Boolean { val type = when (item.itemId) { R.id.image_loader_coil -> ImageLoaderType.COIL diff --git a/sample/kotlin/src/main/kotlin/com/datadog/android/sample/sessionreplay/SessionReplayFragment.kt b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/sessionreplay/SessionReplayFragment.kt index 9a91c0063a..4f26167117 100644 --- a/sample/kotlin/src/main/kotlin/com/datadog/android/sample/sessionreplay/SessionReplayFragment.kt +++ b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/sessionreplay/SessionReplayFragment.kt @@ -62,6 +62,7 @@ internal class SessionReplayFragment : R.id.navigation_unsupported_views -> R.id.fragment_unsupported_views R.id.navigation_image_components -> R.id.fragment_image_components R.id.navigation_image_scaling -> R.id.fragment_image_scaling + R.id.navigation_webview_recording -> R.id.fragment_webview_record else -> null } diff --git a/sample/kotlin/src/main/kotlin/com/datadog/android/sample/sessionreplay/WebViewRecordFragment.kt b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/sessionreplay/WebViewRecordFragment.kt new file mode 100644 index 0000000000..4d98dd60e5 --- /dev/null +++ b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/sessionreplay/WebViewRecordFragment.kt @@ -0,0 +1,90 @@ +/* + * 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.sample.sessionreplay + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.Button +import androidx.fragment.app.Fragment +import com.datadog.android.rum.GlobalRumMonitor +import com.datadog.android.sample.R +import com.datadog.android.webview.WebViewTracking + +internal class WebViewRecordFragment : Fragment() { + + private lateinit var webView: WebView + private lateinit var startCustomRumViewButton: Button + private val webViewTrackingHosts = listOf( + "datadoghq.dev" + ) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setHasOptionsMenu(true) + } + + @SuppressLint("SetJavaScriptEnabled") + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + val rootView = inflater.inflate( + R.layout.fragment_webview_with_replay_support, + container, + false + ) + startCustomRumViewButton = rootView.findViewById(R.id.start_custom_rum_view_button) + webView = rootView.findViewById(R.id.webview) + webView.webViewClient = WebViewClient() + webView.settings.javaScriptEnabled = true + WebViewTracking.enable(webView, webViewTrackingHosts) + startCustomRumViewButton.setOnClickListener { + GlobalRumMonitor.get().startView(this, "Custom RUM View") + } + return rootView + } + + override fun onResume() { + super.onResume() + webView.loadUrl("https://datadoghq.dev/browser-sdk-test-playground/webview-support/#click_event") + } + + @Deprecated("Deprecated in Java") + override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { + inflater.inflate(R.menu.sr_webview, menu) + if (webView.visibility == View.VISIBLE) { + menu.findItem(R.id.webview_show).isVisible = false + } else { + menu.findItem(R.id.webview_hide).isVisible = false + } + } + + @Deprecated("Deprecated in Java") + override fun onOptionsItemSelected(item: MenuItem): Boolean { + val newVisibility = when (item.itemId) { + R.id.webview_show -> View.VISIBLE + R.id.webview_hide -> View.GONE + else -> null + } + + return if (newVisibility == null) { + super.onOptionsItemSelected(item) + } else { + webView.visibility = newVisibility + activity?.invalidateOptionsMenu() + true + } + } +} diff --git a/sample/kotlin/src/main/kotlin/com/datadog/android/sample/viewpager/FragmentA.kt b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/viewpager/FragmentA.kt index c36e84fbde..fa35f4feeb 100644 --- a/sample/kotlin/src/main/kotlin/com/datadog/android/sample/viewpager/FragmentA.kt +++ b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/viewpager/FragmentA.kt @@ -6,27 +6,19 @@ package com.datadog.android.sample.viewpager -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.TextView -import androidx.fragment.app.Fragment -import com.datadog.android.sample.R +import androidx.core.os.bundleOf -internal class FragmentA : Fragment() { - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - val view = inflater.inflate(R.layout.view_pager_child_fragment_layout, container, false) - view.findViewById(R.id.textView).text = NAME - return view - } +internal class FragmentA : PagerChildFragment() { companion object { - const val NAME = "Fragment A" + fun newInstance(): FragmentA { + return FragmentA().apply { + arguments = bundleOf( + ARG_PAGE_NAME to "Fragment A", + ARG_WEB_VIEW_URL to "https://datadoghq.dev/browser-sdk-test-playground/webview-support/", + "fragmentClassName" to FragmentA::class.java.simpleName + ) + } + } } } diff --git a/sample/kotlin/src/main/kotlin/com/datadog/android/sample/viewpager/FragmentB.kt b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/viewpager/FragmentB.kt index 722474c29c..2aa54c92fd 100644 --- a/sample/kotlin/src/main/kotlin/com/datadog/android/sample/viewpager/FragmentB.kt +++ b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/viewpager/FragmentB.kt @@ -6,27 +6,19 @@ package com.datadog.android.sample.viewpager -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.TextView -import androidx.fragment.app.Fragment -import com.datadog.android.sample.R +import androidx.core.os.bundleOf -internal class FragmentB : Fragment() { - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - val view = inflater.inflate(R.layout.view_pager_child_fragment_layout, container, false) - view.findViewById(R.id.textView).text = NAME - return view - } +internal class FragmentB : PagerChildFragment() { companion object { - const val NAME = "Fragment B" + fun newInstance(): FragmentB { + return FragmentB().apply { + arguments = bundleOf( + ARG_PAGE_NAME to "Fragment B", + ARG_WEB_VIEW_URL to "https://datadoghq.dev/browser-sdk-test-playground/webview-support/", + "fragmentClassName" to FragmentB::class.java.simpleName + ) + } + } } } diff --git a/sample/kotlin/src/main/kotlin/com/datadog/android/sample/viewpager/FragmentC.kt b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/viewpager/FragmentC.kt index 9ce5b13e84..50948664ac 100644 --- a/sample/kotlin/src/main/kotlin/com/datadog/android/sample/viewpager/FragmentC.kt +++ b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/viewpager/FragmentC.kt @@ -6,27 +6,24 @@ package com.datadog.android.sample.viewpager -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.TextView -import androidx.fragment.app.Fragment -import com.datadog.android.sample.R +import androidx.core.os.bundleOf +import com.datadog.android.sample.BuildConfig -internal class FragmentC : Fragment() { - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View? { - val view = inflater.inflate(R.layout.view_pager_child_fragment_layout, container, false) - view.findViewById(R.id.textView).text = NAME - return view - } +internal class FragmentC : PagerChildFragment() { companion object { - const val NAME = "Fragment C" + fun newInstance(): FragmentC { + return FragmentC().apply { + arguments = bundleOf( + ARG_PAGE_NAME to "Fragment C", + ARG_WEB_VIEW_URL to + "https://datadoghq.dev/browser-sdk-test-playground/" + + "?client_token=${BuildConfig.DD_CLIENT_TOKEN}" + + "&application_id=${BuildConfig.DD_RUM_APPLICATION_ID}" + + "&site=datadoghq.com", + "fragmentClassName" to FragmentC::class.java.simpleName + ) + } + } } } diff --git a/sample/kotlin/src/main/kotlin/com/datadog/android/sample/viewpager/PagerChildFragment.kt b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/viewpager/PagerChildFragment.kt new file mode 100644 index 0000000000..f3b511ef9d --- /dev/null +++ b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/viewpager/PagerChildFragment.kt @@ -0,0 +1,63 @@ +/* + * 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.sample.viewpager + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.webkit.WebView +import android.webkit.WebViewClient +import android.widget.TextView +import androidx.fragment.app.Fragment +import com.datadog.android.sample.R +import com.datadog.android.webview.WebViewTracking + +internal open class PagerChildFragment : Fragment() { + + private val pageName: String + get() = requireArguments().getString(ARG_PAGE_NAME) ?: "UNKNOWN" + + private val webViewUrl: String + get() = requireArguments().getString(ARG_WEB_VIEW_URL) ?: "https://datadoghq.com" + + private val webViewTrackingHosts = listOf( + "datadoghq.dev" + ) + private lateinit var webView: WebView + + @SuppressLint("SetJavaScriptEnabled") + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + val view = inflater.inflate(R.layout.view_pager_child_fragment_layout, container, false) + view.findViewById(R.id.textView).text = pageName + + webView = view.findViewById(R.id.webview) + + webView.webViewClient = WebViewClient() + webView.settings.javaScriptEnabled = true + WebViewTracking.enable(webView, webViewTrackingHosts) + + return view + } + + override fun onResume() { + super.onResume() + webView.loadUrl(webViewUrl) + } + + companion object { + internal const val ARG_PAGE_NAME: String = "com.datadog.android.sample.viewpager.PagerChildFragment.PAGE_NAME" + + internal const val ARG_WEB_VIEW_URL: String = + "com.datadog.android.sample.viewpager.PagerChildFragment.WEB_VIEW_URL" + } +} diff --git a/sample/kotlin/src/main/kotlin/com/datadog/android/sample/webview/DatadogSiteExt.kt b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/webview/DatadogSiteExt.kt new file mode 100644 index 0000000000..97011fa44b --- /dev/null +++ b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/webview/DatadogSiteExt.kt @@ -0,0 +1,35 @@ +/* + * 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.sample.webview + +import com.datadog.android.DatadogSite +import com.datadog.android.sample.BuildConfig +import timber.log.Timber + +internal val BROWSER_SITE: String + get() { + return try { + DatadogSite.valueOf(BuildConfig.DD_SITE_NAME) + } catch (e: IllegalArgumentException) { + Timber.e("Error setting site to ${BuildConfig.DD_SITE_NAME}") + null + }.browserSite() + } + +private fun DatadogSite?.browserSite(): String { + return when (this) { + DatadogSite.US1, + DatadogSite.STAGING, + null -> "datadoghq.com" + + DatadogSite.US3 -> "us3.datadoghq.com" + DatadogSite.US5 -> "us5.datadoghq.com" + DatadogSite.EU1 -> "datadoghq.eu" + DatadogSite.AP1 -> "ap1.datadoghq.com" + DatadogSite.US1_FED -> "ddog-gov.com" + } +} diff --git a/sample/kotlin/src/main/kotlin/com/datadog/android/sample/webview/WebViewModel.kt b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/webview/WebViewModel.kt index 9b74528e0f..f819ce96d0 100644 --- a/sample/kotlin/src/main/kotlin/com/datadog/android/sample/webview/WebViewModel.kt +++ b/sample/kotlin/src/main/kotlin/com/datadog/android/sample/webview/WebViewModel.kt @@ -20,7 +20,7 @@ internal class WebViewModel( "https://datadoghq.dev/browser-sdk-test-playground/" + "?client_token=${BuildConfig.DD_CLIENT_TOKEN}" + "&application_id=${BuildConfig.DD_RUM_APPLICATION_ID}" + - "&site=datadoghq.com" + "&site=${BROWSER_SITE}" ) } diff --git a/sample/kotlin/src/main/res/layout/fragment_session_replay.xml b/sample/kotlin/src/main/res/layout/fragment_session_replay.xml index bd8e14ba03..5f3aed2da5 100644 --- a/sample/kotlin/src/main/res/layout/fragment_session_replay.xml +++ b/sample/kotlin/src/main/res/layout/fragment_session_replay.xml @@ -120,5 +120,15 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/navigation_image_components"/> + + diff --git a/sample/kotlin/src/main/res/layout/fragment_webview_with_replay_support.xml b/sample/kotlin/src/main/res/layout/fragment_webview_with_replay_support.xml new file mode 100644 index 0000000000..18b9c36a65 --- /dev/null +++ b/sample/kotlin/src/main/res/layout/fragment_webview_with_replay_support.xml @@ -0,0 +1,29 @@ + + + + + + +