diff --git a/dd-sdk-android/src/test/kotlin/com/datadog/android/v2/core/DatadogCoreTest.kt b/dd-sdk-android/src/test/kotlin/com/datadog/android/v2/core/DatadogCoreTest.kt index d6afda3afe..63ac4746f7 100644 --- a/dd-sdk-android/src/test/kotlin/com/datadog/android/v2/core/DatadogCoreTest.kt +++ b/dd-sdk-android/src/test/kotlin/com/datadog/android/v2/core/DatadogCoreTest.kt @@ -67,6 +67,7 @@ import org.mockito.junit.jupiter.MockitoSettings import org.mockito.quality.Strictness import java.util.Locale import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference /** @@ -159,6 +160,7 @@ internal class DatadogCoreTest { ) { // Given testedCore.coreFeature = mock() + whenever(testedCore.coreFeature.initialized).thenReturn(AtomicBoolean()) val mockUserInfoProvider = mock() whenever(testedCore.coreFeature.userInfoProvider) doReturn mockUserInfoProvider @@ -360,6 +362,7 @@ internal class DatadogCoreTest { ) { // Given testedCore.coreFeature = mock() + whenever(testedCore.coreFeature.initialized).thenReturn(AtomicBoolean()) val mockTimeProvider = mock() whenever(testedCore.coreFeature.timeProvider) doReturn mockTimeProvider whenever(mockTimeProvider.getServerOffsetNanos()) doReturn TimeUnit.MILLISECONDS.toNanos( @@ -391,6 +394,7 @@ internal class DatadogCoreTest { fun `𝕄 provide time info without correction 𝕎 time() {NoOpTimeProvider}`() { // Given testedCore.coreFeature = mock() + whenever(testedCore.coreFeature.initialized).thenReturn(AtomicBoolean()) whenever(testedCore.coreFeature.timeProvider) doReturn NoOpTimeProvider() // When diff --git a/library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/SessionReplayLifecycleCallback.kt b/library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/SessionReplayLifecycleCallback.kt index 50dc9c9e21..6ea9abef80 100644 --- a/library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/SessionReplayLifecycleCallback.kt +++ b/library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/SessionReplayLifecycleCallback.kt @@ -9,13 +9,14 @@ package com.datadog.android.sessionreplay import android.app.Activity import android.app.Application import android.os.Bundle +import androidx.fragment.app.FragmentActivity import com.datadog.android.sessionreplay.processor.RecordedDataProcessor import com.datadog.android.sessionreplay.recorder.Recorder import com.datadog.android.sessionreplay.recorder.ScreenRecorder import com.datadog.android.sessionreplay.recorder.SnapshotProducer +import com.datadog.android.sessionreplay.recorder.callback.RecorderFragmentLifecycleCallback import com.datadog.android.sessionreplay.utils.RumContextProvider import com.datadog.android.sessionreplay.utils.TimeProvider -import java.util.WeakHashMap import java.util.concurrent.LinkedBlockingDeque import java.util.concurrent.ThreadPoolExecutor import java.util.concurrent.TimeUnit @@ -29,7 +30,7 @@ class SessionReplayLifecycleCallback( privacy: SessionReplayPrivacy, recordWriter: RecordWriter, timeProvider: TimeProvider, - private val recordCallback: RecordCallback = NoOpRecordCallback() + recordCallback: RecordCallback = NoOpRecordCallback() ) : LifecycleCallback { @Suppress("UnsafeThirdPartyFunctionCall") // workQueue can't be null @@ -48,14 +49,20 @@ class SessionReplayLifecycleCallback( recordWriter ), SnapshotProducer(privacy.mapper()), - timeProvider + timeProvider, + recordCallback ) - internal val resumedActivities: WeakHashMap = WeakHashMap() // region callback override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { // No Op + if (activity is FragmentActivity) { + activity.supportFragmentManager.registerFragmentLifecycleCallbacks( + RecorderFragmentLifecycleCallback(recorder), + true + ) + } } override fun onActivityStarted(activity: Activity) { @@ -63,15 +70,15 @@ class SessionReplayLifecycleCallback( } override fun onActivityResumed(activity: Activity) { - recorder.startRecording(activity) - recordCallback.onStartRecording() - resumedActivities[activity] = null + activity.window?.let { + recorder.startRecording(listOf(it), activity) + } } override fun onActivityPaused(activity: Activity) { - recorder.stopRecording(activity) - recordCallback.onStopRecording() - resumedActivities.remove(activity) + activity.window?.let { + recorder.stopRecording(listOf(it)) + } } override fun onActivityStopped(activity: Activity) { @@ -96,12 +103,7 @@ class SessionReplayLifecycleCallback( override fun unregisterAndStopRecorders(appContext: Application) { appContext.unregisterActivityLifecycleCallbacks(this) - resumedActivities.keys.forEach { - it?.let { activity -> - recorder.stopRecording(activity) - } - } - resumedActivities.clear() + recorder.stopRecording() } // endregion diff --git a/library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/processor/Processor.kt b/library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/processor/Processor.kt index 209d3f6859..9b16fe0fd5 100644 --- a/library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/processor/Processor.kt +++ b/library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/processor/Processor.kt @@ -11,7 +11,8 @@ import com.datadog.android.sessionreplay.recorder.Node import com.datadog.android.sessionreplay.recorder.OrientationChanged internal interface Processor { - fun processScreenSnapshot(node: Node, orientationChanged: OrientationChanged? = null) + + fun processScreenSnapshots(nodes: List, orientationChanged: OrientationChanged? = null) fun processTouchEventsRecords(touchEventsRecords: List) } diff --git a/library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/processor/RecordedDataProcessor.kt b/library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/processor/RecordedDataProcessor.kt index 82d7476f12..f9c391652a 100644 --- a/library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/processor/RecordedDataProcessor.kt +++ b/library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/processor/RecordedDataProcessor.kt @@ -36,11 +36,20 @@ internal class RecordedDataProcessor( private var lastSnapshotTimestamp = 0L @MainThread - override fun processScreenSnapshot(node: Node, orientationChanged: OrientationChanged?) { + override fun processScreenSnapshots( + nodes: List, + orientationChanged: OrientationChanged? + ) { buildRunnable { timestamp, newContext, currentContext -> Runnable { @Suppress("ThreadSafety") // this runs inside an executor - handleSnapshot(newContext, currentContext, timestamp, node, orientationChanged) + handleSnapshots( + newContext, + currentContext, + timestamp, + nodes, + orientationChanged + ) } }?.let { executeRunnable(it) } } @@ -67,14 +76,14 @@ internal class RecordedDataProcessor( } @WorkerThread - private fun handleSnapshot( + private fun handleSnapshots( newRumContext: SessionReplayRumContext, prevRumContext: SessionReplayRumContext, timestamp: Long, - snapshot: Node, + snapshots: List, orientationChanged: OrientationChanged? ) { - val wireframes = nodeFlattener.flattenNode(snapshot) + val wireframes = snapshots.flatMap { nodeFlattener.flattenNode(it) } if (wireframes.isEmpty()) { // TODO: RUMM-2397 Add the proper logs here once the sdkLogger will be added diff --git a/library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/Recorder.kt b/library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/Recorder.kt index 913a981b50..53c27d8dba 100644 --- a/library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/Recorder.kt +++ b/library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/Recorder.kt @@ -7,10 +7,13 @@ package com.datadog.android.sessionreplay.recorder import android.app.Activity +import android.view.Window internal interface Recorder { - fun startRecording(activity: Activity) + fun startRecording(windows: List, ownerActivity: Activity) - fun stopRecording(activity: Activity) + fun stopRecording(windows: List) + + fun stopRecording() } diff --git a/library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/RecorderOnDrawListener.kt b/library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/RecorderOnDrawListener.kt deleted file mode 100644 index 2becf64ec8..0000000000 --- a/library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/RecorderOnDrawListener.kt +++ /dev/null @@ -1,54 +0,0 @@ -/* - * 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.recorder - -import android.app.Activity -import android.content.res.Configuration -import android.view.View -import android.view.ViewTreeObserver -import com.datadog.android.sessionreplay.processor.Processor -import java.lang.ref.WeakReference - -internal class RecorderOnDrawListener( - activity: Activity, - private val pixelsDensity: Float, - private val processor: Processor, - private val snapshotProducer: SnapshotProducer, - private val debouncer: Debouncer = Debouncer() -) : ViewTreeObserver.OnDrawListener { - private var currentOrientation = Configuration.ORIENTATION_UNDEFINED - private val trackedActivity: WeakReference = WeakReference(activity) - - override fun onDraw() { - debouncer.debounce(takeSnapshotRunnable) - } - - private val takeSnapshotRunnable: Runnable = Runnable { - trackedActivity.get()?.let { activity -> - activity.window?.let { - snapshotProducer.produce(it.decorView, pixelsDensity)?.let { node -> - processor.processScreenSnapshot(node, resolveOrientationChange(activity, it.decorView)) - } - } - } - } - - private fun resolveOrientationChange(activity: Activity, decorView: View): OrientationChanged? { - val orientation = activity.resources.configuration.orientation - val orientationChanged = - if (currentOrientation != orientation) { - OrientationChanged( - decorView.width.densityNormalized(pixelsDensity), - decorView.height.densityNormalized(pixelsDensity) - ) - } else { - null - } - currentOrientation = orientation - return orientationChanged - } -} diff --git a/library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/ScreenRecorder.kt b/library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/ScreenRecorder.kt index e77742ad78..c180481362 100644 --- a/library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/ScreenRecorder.kt +++ b/library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/ScreenRecorder.kt @@ -9,46 +9,64 @@ package com.datadog.android.sessionreplay.recorder import android.app.Activity import android.view.ViewTreeObserver import android.view.Window +import com.datadog.android.sessionreplay.NoOpRecordCallback +import com.datadog.android.sessionreplay.RecordCallback import com.datadog.android.sessionreplay.processor.Processor +import com.datadog.android.sessionreplay.recorder.callback.NoOpWindowCallback +import com.datadog.android.sessionreplay.recorder.callback.RecorderWindowCallback +import com.datadog.android.sessionreplay.recorder.listener.WindowsOnDrawListener import com.datadog.android.sessionreplay.utils.TimeProvider +import java.util.WeakHashMap internal class ScreenRecorder( private val processor: Processor, private val snapshotProducer: SnapshotProducer, - private val timeProvider: TimeProvider + private val timeProvider: TimeProvider, + private val recordCallback: RecordCallback = NoOpRecordCallback() ) : Recorder { - internal val drawListeners: MutableMap = HashMap() + internal val windowsListeners: WeakHashMap = + WeakHashMap() - override fun startRecording(activity: Activity) { - if (activity.window != null) { - with( - RecorderOnDrawListener( - activity, - activity.resources.displayMetrics.density, - processor, - snapshotProducer - ) - ) { - drawListeners[activity.hashCode()] = this - activity.window.decorView.viewTreeObserver?.addOnDrawListener(this) - } - - wrapWindowCallback( - activity.window, - activity.resources.displayMetrics.density - ) + override fun startRecording(windows: List, ownerActivity: Activity) { + // first we make sure we don't record a window multiple times + stopRecordingAndRemove(windows) + val screenDensity = ownerActivity.resources.displayMetrics.density + val onDrawListener = WindowsOnDrawListener( + ownerActivity, + windows, + screenDensity, + processor, + snapshotProducer + ) + windows.forEach { window -> + val decorView = window.decorView + windowsListeners[window] = onDrawListener + decorView.viewTreeObserver?.addOnDrawListener(onDrawListener) + wrapWindowCallback(window, screenDensity) } + recordCallback.onStartRecording() } - override fun stopRecording(activity: Activity) { - activity.hashCode().let { windowHashCode -> - drawListeners.remove(windowHashCode)?.let { - activity.window.decorView.viewTreeObserver.removeOnDrawListener(it) - } + override fun stopRecording(windows: List) { + stopRecordingAndRemove(windows) + recordCallback.onStopRecording() + } + + override fun stopRecording() { + windowsListeners.entries.forEach { + it.key.decorView.viewTreeObserver.removeOnDrawListener(it.value) + unwrapWindowCallback(it.key) } + windowsListeners.clear() + recordCallback.onStopRecording() + } - if (activity.window != null) { - unwrapWindowCallback(activity.window) + private fun stopRecordingAndRemove(windows: List) { + windows.forEach { window -> + windowsListeners.remove(window)?.let { + window.decorView.viewTreeObserver.removeOnDrawListener(it) + } + unwrapWindowCallback(window) } } diff --git a/library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/callback/MotionEventUtils.kt b/library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/callback/MotionEventUtils.kt new file mode 100644 index 0000000000..cffb672933 --- /dev/null +++ b/library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/callback/MotionEventUtils.kt @@ -0,0 +1,33 @@ +/* + * 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.recorder.callback + +import android.os.Build +import android.view.MotionEvent + +internal class MotionEventUtils { + + // For Android SDK < 29 we will have to know the target View on which this event happened + // and then to compute the absolute as target.absoluteX,Y + event.getX,Y(pointerIndex) + // This will not be handled now as it is too complex and not very optimised. For now we will + // not support multi finger gestures in the player for version < 29. + fun getPointerAbsoluteX(event: MotionEvent, pointerIndex: Int): Float { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + event.getRawX(pointerIndex) + } else { + return event.rawX + } + } + + fun getPointerAbsoluteY(event: MotionEvent, pointerIndex: Int): Float { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + event.getRawY(pointerIndex) + } else { + return event.rawY + } + } +} diff --git a/library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/NoOpWindowCallback.kt b/library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/callback/NoOpWindowCallback.kt similarity index 98% rename from library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/NoOpWindowCallback.kt rename to library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/callback/NoOpWindowCallback.kt index 85754ddfab..f787b0877e 100644 --- a/library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/NoOpWindowCallback.kt +++ b/library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/callback/NoOpWindowCallback.kt @@ -4,7 +4,7 @@ * Copyright 2016-Present Datadog, Inc. */ -package com.datadog.android.sessionreplay.recorder +package com.datadog.android.sessionreplay.recorder.callback import android.view.ActionMode import android.view.KeyEvent diff --git a/library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/callback/RecorderFragmentLifecycleCallback.kt b/library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/callback/RecorderFragmentLifecycleCallback.kt new file mode 100644 index 0000000000..f817e0e85b --- /dev/null +++ b/library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/callback/RecorderFragmentLifecycleCallback.kt @@ -0,0 +1,67 @@ +/* + * 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.recorder.callback + +import android.view.Window +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.FragmentManager.FragmentLifecycleCallbacks +import com.datadog.android.sessionreplay.recorder.Recorder + +internal class RecorderFragmentLifecycleCallback( + private val recorder: Recorder +) : FragmentLifecycleCallbacks() { + + override fun onFragmentResumed(fm: FragmentManager, f: Fragment) { + super.onFragmentResumed(fm, f) + f.asValidDialogFragment()?.let { + val ownerActivity = it.dialog?.ownerActivity ?: return + it.getWindowsToRecord()?.let { windows -> + recorder.startRecording(windows, ownerActivity) + } + } + } + + override fun onFragmentPaused(fm: FragmentManager, f: Fragment) { + super.onFragmentPaused(fm, f) + f.asValidDialogFragment()?.let { + it.getWindowsToRecord()?.let { windows -> + recorder.stopRecording(windows) + } + } + } + + private fun DialogFragment.getWindowsToRecord(): List? { + // a dialog can be displayed in the same activity as main UI but in a different window + // using the Activity WindowManager. In order to correctly record the dialog whenever + // this is displayed we will stop recording the main activity window and start recording + // the dialog window + main activity window in the same time. We will cover here the + // case where the Dialog window might be the same as the ownerActivity window. In this case + // we will only record the ownerActivity window. + val dialogWindow = dialog?.window + val dialogOwnerActivity = dialog?.ownerActivity + val ownerActivityWindow = dialogOwnerActivity?.window + if (dialogWindow == null || dialogOwnerActivity == null || ownerActivityWindow == null) { + return null + } + return if (dialogWindow != ownerActivityWindow) { + // the order is very important here as it must have the activity window at the bottom + listOf(ownerActivityWindow, dialogWindow) + } else { + listOf(dialogWindow) + } + } + + private fun Fragment.asValidDialogFragment(): DialogFragment? { + return if (this is DialogFragment && context != null) { + this + } else { + null + } + } +} diff --git a/library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/RecorderWindowCallback.kt b/library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/callback/RecorderWindowCallback.kt similarity index 85% rename from library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/RecorderWindowCallback.kt rename to library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/callback/RecorderWindowCallback.kt index 3deeef997f..84b251d85f 100644 --- a/library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/RecorderWindowCallback.kt +++ b/library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/callback/RecorderWindowCallback.kt @@ -4,12 +4,13 @@ * Copyright 2016-Present Datadog, Inc. */ -package com.datadog.android.sessionreplay.recorder +package com.datadog.android.sessionreplay.recorder.callback import android.view.MotionEvent import android.view.Window import com.datadog.android.sessionreplay.model.MobileSegment import com.datadog.android.sessionreplay.processor.Processor +import com.datadog.android.sessionreplay.recorder.densityNormalized import com.datadog.android.sessionreplay.utils.TimeProvider import java.util.LinkedList import java.util.concurrent.TimeUnit @@ -24,6 +25,7 @@ internal class RecorderWindowCallback( @Suppress("UnsafeThirdPartyFunctionCall") // NPE cannot happen here MotionEvent.obtain(it) }, + private val motionEventUtils: MotionEventUtils = MotionEventUtils(), private val motionUpdateThresholdInNs: Long = MOTION_UPDATE_DELAY_THRESHOLD_NS, private val flushPositionBufferThresholdInNs: Long = FLUSH_BUFFER_THRESHOLD_NS ) : Window.Callback by wrappedCallback { @@ -93,19 +95,19 @@ internal class RecorderWindowCallback( } private fun updatePositions(event: MotionEvent, eventType: MobileSegment.PointerEventType) { - for (i in 0 until event.pointerCount) { - val pointerId = event.getPointerId(i).toLong() - val pointerCoordinates = MotionEvent.PointerCoords() - event.getPointerCoords(i, pointerCoordinates) + for (pointerIndex in 0 until event.pointerCount) { + val pointerId = event.getPointerId(pointerIndex).toLong() + val pointerAbsoluteX = motionEventUtils.getPointerAbsoluteX(event, pointerIndex) + val pointerAbsoluteY = motionEventUtils.getPointerAbsoluteY(event, pointerIndex) pointerInteractions.add( MobileSegment.MobileRecord.MobileIncrementalSnapshotRecord( timestamp = timeProvider.getDeviceTimestamp(), data = MobileSegment.MobileIncrementalData.PointerInteractionData( - eventType, - MobileSegment.PointerType.TOUCH, - pointerId, - pointerCoordinates.x.toLong().densityNormalized(pixelsDensity), - pointerCoordinates.y.toLong().densityNormalized(pixelsDensity) + pointerEventType = eventType, + pointerType = MobileSegment.PointerType.TOUCH, + pointerId = pointerId, + x = pointerAbsoluteX.toLong().densityNormalized(pixelsDensity), + y = pointerAbsoluteY.toLong().densityNormalized(pixelsDensity) ) ) ) diff --git a/library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/listener/WindowsOnDrawListener.kt b/library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/listener/WindowsOnDrawListener.kt new file mode 100644 index 0000000000..39acbbba2e --- /dev/null +++ b/library/dd-sdk-android-session-replay/src/main/kotlin/com/datadog/android/sessionreplay/recorder/listener/WindowsOnDrawListener.kt @@ -0,0 +1,81 @@ +/* + * 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.recorder.listener + +import android.app.Activity +import android.content.res.Configuration +import android.view.View +import android.view.ViewTreeObserver +import android.view.Window +import com.datadog.android.sessionreplay.processor.Processor +import com.datadog.android.sessionreplay.recorder.Debouncer +import com.datadog.android.sessionreplay.recorder.OrientationChanged +import com.datadog.android.sessionreplay.recorder.SnapshotProducer +import com.datadog.android.sessionreplay.recorder.densityNormalized +import java.lang.ref.WeakReference + +internal class WindowsOnDrawListener( + ownerActivity: Activity, + zOrderedWindows: List, + private val pixelsDensity: Float, + private val processor: Processor, + private val snapshotProducer: SnapshotProducer, + private val debouncer: Debouncer = Debouncer() +) : ViewTreeObserver.OnDrawListener { + + private var currentOrientation = Configuration.ORIENTATION_UNDEFINED + internal val ownerActivityReference: WeakReference = WeakReference(ownerActivity) + internal val weakReferencedWindows: List> + + init { + weakReferencedWindows = zOrderedWindows.map { WeakReference(it) } + } + + override fun onDraw() { + debouncer.debounce(resolveTakeSnapshotRunnable()) + } + + private fun resolveTakeSnapshotRunnable(): Runnable = Runnable { + if (weakReferencedWindows.isEmpty()) { + return@Runnable + } + val ownerActivity = ownerActivityReference.get() ?: return@Runnable + val ownerActivityWindow = ownerActivity.window ?: return@Runnable + + // we will always consider the ownerActivityWindow as the root + val orientationChanged = resolveOrientationChange( + ownerActivity, + ownerActivityWindow.decorView + ) + // is is very important to have the windows sorted by their z-order + val nodes = weakReferencedWindows + .mapNotNull { it.get() } + .mapNotNull { + val windowDecorView = it.decorView + snapshotProducer.produce(windowDecorView, pixelsDensity) + } + if (nodes.isNotEmpty()) { + processor.processScreenSnapshots(nodes, orientationChanged) + } + } + + private fun resolveOrientationChange(activity: Activity, decorView: View): + OrientationChanged? { + val orientation = activity.resources.configuration.orientation + val orientationChanged = + if (currentOrientation != orientation) { + OrientationChanged( + decorView.width.densityNormalized(pixelsDensity), + decorView.height.densityNormalized(pixelsDensity) + ) + } else { + null + } + currentOrientation = orientation + return orientationChanged + } +} diff --git a/library/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/SessionReplayLifecycleCallbackTest.kt b/library/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/SessionReplayLifecycleCallbackTest.kt index 0845f7a593..c0be9c4a13 100644 --- a/library/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/SessionReplayLifecycleCallbackTest.kt +++ b/library/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/SessionReplayLifecycleCallbackTest.kt @@ -8,11 +8,20 @@ package com.datadog.android.sessionreplay import android.app.Activity import android.app.Application +import android.view.Window +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.FragmentManager.FragmentLifecycleCallbacks import com.datadog.android.sessionreplay.recorder.Recorder +import com.datadog.android.sessionreplay.recorder.callback.RecorderFragmentLifecycleCallback import com.datadog.android.sessionreplay.utils.RumContextProvider import com.datadog.android.sessionreplay.utils.TimeProvider +import com.nhaarman.mockitokotlin2.argumentCaptor +import com.nhaarman.mockitokotlin2.eq import com.nhaarman.mockitokotlin2.mock import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.verifyZeroInteractions +import com.nhaarman.mockitokotlin2.whenever import fr.xgouchet.elmyr.annotation.Forgery import fr.xgouchet.elmyr.junit5.ForgeExtension import org.assertj.core.api.Assertions.assertThat @@ -52,8 +61,15 @@ class SessionReplayLifecycleCallbackTest { @Mock private lateinit var mockTimeProvider: TimeProvider + @Mock + private lateinit var mockActivity: Activity + + @Mock + private lateinit var mockWindow: Window + @BeforeEach fun `set up`() { + whenever(mockActivity.window).thenReturn(mockWindow) testedCallback = SessionReplayLifecycleCallback( mockRumContextProvider, fakePrivacy, @@ -65,43 +81,63 @@ class SessionReplayLifecycleCallbackTest { } @Test - fun `M start recording activity W onActivityResumed()`() { + fun `M register fragment lifecycle W onActivityCreated(){FragmentActivity}`() { // Given - val mockActivity: Activity = mock() + val mockFragmentManager: FragmentManager = mock() + val mockFragmentActivity: FragmentActivity = mock { + whenever(it.supportFragmentManager).thenReturn(mockFragmentManager) + } + + // When + testedCallback.onActivityCreated(mockFragmentActivity, mock()) + + // Then + val argumentCaptor = argumentCaptor() + verify(mockFragmentManager).registerFragmentLifecycleCallbacks( + argumentCaptor.capture(), + eq(true) + ) + assertThat(argumentCaptor.firstValue) + .isInstanceOf(RecorderFragmentLifecycleCallback::class.java) + } + + @Test + fun `M do nothing W onActivityCreated(){no FragmentActivity}`() { + // When + testedCallback.onActivityCreated(mockActivity, mock()) + + // Then + verifyZeroInteractions(mockActivity) + } + @Test + fun `M start recording activity W onActivityResumed()`() { // When testedCallback.onActivityResumed(mockActivity) // Then - verify(mockRecoder).startRecording(mockActivity) - assertThat(testedCallback.resumedActivities).containsKey(mockActivity) + verify(mockRecoder).startRecording(listOf(mockWindow), mockActivity) } @Test fun `M stop recording activity W onActivityPaused() { activity previously resumed }`() { // Given - val mockActivity: Activity = mock() testedCallback.onActivityResumed(mockActivity) // When testedCallback.onActivityPaused(mockActivity) // Then - verify(mockRecoder).stopRecording(mockActivity) - assertThat(testedCallback.resumedActivities).doesNotContainKey(mockActivity) + verify(mockRecoder).stopRecording(listOf(mockWindow)) } @Test fun `M stop recording activity W onActivityPaused() { activity not previously resumed }`() { - // Given - val mockActivity: Activity = mock() - // When testedCallback.onActivityPaused(mockActivity) // Then - verify(mockRecoder).stopRecording(mockActivity) - assertThat(testedCallback.resumedActivities).doesNotContainKey(mockActivity) + verify(mockRecoder).stopRecording(listOf(mockWindow)) } @Test @@ -132,8 +168,14 @@ class SessionReplayLifecycleCallbackTest { fun `M stop recording all resumed activities W unregisterAndStopRecorders()`() { // Given val mockApplication: Application = mock() - val mockActivity1: Activity = mock() - val mockActivity2: Activity = mock() + val mockWindow1: Window = mock() + val mockWindow2: Window = mock() + val mockActivity1: Activity = mock { + whenever(it.window).thenReturn(mockWindow1) + } + val mockActivity2: Activity = mock { + whenever(it.window).thenReturn(mockWindow2) + } testedCallback.onActivityResumed(mockActivity1) testedCallback.onActivityResumed(mockActivity2) @@ -141,34 +183,6 @@ class SessionReplayLifecycleCallbackTest { testedCallback.unregisterAndStopRecorders(mockApplication) // Then - verify(mockRecoder).stopRecording(mockActivity1) - verify(mockRecoder).stopRecording(mockActivity2) - assertThat(testedCallback.resumedActivities).isEmpty() - } - - @Test - fun `M notify the record callback W startedRecording`() { - // Given - val mockActivity: Activity = mock() - - // When - testedCallback.onActivityResumed(mockActivity) - - // Then - verify(mockRecoder).startRecording(mockActivity) - verify(mockRecordCallback).onStartRecording() - } - - @Test - fun `M notify the record callback W stoppedRecording`() { - // Given - val mockActivity: Activity = mock() - - // When - testedCallback.onActivityPaused(mockActivity) - - // Then - verify(mockRecoder).stopRecording(mockActivity) - verify(mockRecordCallback).onStopRecording() + verify(mockRecoder).stopRecording() } } diff --git a/library/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/processor/RecordedDataProcessorTest.kt b/library/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/processor/RecordedDataProcessorTest.kt index e9ef5ff0be..1ae1860c0a 100644 --- a/library/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/processor/RecordedDataProcessorTest.kt +++ b/library/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/processor/RecordedDataProcessorTest.kt @@ -1,4 +1,4 @@ -/* + /* * 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. @@ -39,7 +39,6 @@ import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.junit.jupiter.MockitoSettings import org.mockito.quality.Strictness -import java.lang.NullPointerException import java.util.concurrent.ExecutorService import java.util.concurrent.Future import java.util.concurrent.RejectedExecutionException @@ -104,10 +103,10 @@ internal class RecordedDataProcessorTest { @Test fun `M send to the writer as EnrichedRecord W process { snapshot }`(forge: Forge) { // Given - val fakeSnapshot = forge.aSingleLevelSnapshot() + val fakeSnapshots = forge.aList { aSingleLevelSnapshot() } // When - testedProcessor.processScreenSnapshot(fakeSnapshot) + testedProcessor.processScreenSnapshots(fakeSnapshots) // Then val captor = argumentCaptor() @@ -123,14 +122,17 @@ internal class RecordedDataProcessorTest { @Test fun `M send FullSnapshot W process`(forge: Forge) { // Given - val fakeSnapshot = forge.aSingleLevelSnapshot() - val fakeFlattenedSnapshot = forge.aList { - getForgery(MobileSegment.Wireframe::class.java) - } + val fakeSnapshots = forge.aList { aSingleLevelSnapshot() } + val fakeFlattenedSnapshots = fakeSnapshots.map { + val fakeFlattenedSnapshot = forge.aList { + getForgery(MobileSegment.Wireframe::class.java) + } + whenever(mockNodeFlattener.flattenNode(it)).thenReturn(fakeFlattenedSnapshot) + fakeFlattenedSnapshot + }.flatten() // When - whenever(mockNodeFlattener.flattenNode(fakeSnapshot)).thenReturn(fakeFlattenedSnapshot) - testedProcessor.processScreenSnapshot(fakeSnapshot) + testedProcessor.processScreenSnapshots(fakeSnapshots) // Then val captor = argumentCaptor() @@ -139,7 +141,7 @@ internal class RecordedDataProcessorTest { val fullSnapshotRecord = captor.firstValue.records[2] as MobileSegment.MobileRecord.MobileFullSnapshotRecord assertThat(fullSnapshotRecord.timestamp).isEqualTo(fakeTimestamp) - assertThat(fullSnapshotRecord.data.wireframes).isEqualTo(fakeFlattenedSnapshot) + assertThat(fullSnapshotRecord.data.wireframes).isEqualTo(fakeFlattenedSnapshots) } @Test @@ -149,22 +151,24 @@ internal class RecordedDataProcessorTest { whenever(mockRumContextProvider.getRumContext()) .thenReturn(fakeRumContext) .thenReturn(fakeRumContext2) - val fakeSnapshotView1 = forge.aSingleLevelSnapshot() - val fakeSnapshotView2 = forge.aSingleLevelSnapshot() - val fakeFlattenedSnapshotView1 = forge.aList { - getForgery(MobileSegment.Wireframe::class.java) + val fakeSnapshotView1 = forge.aList { aSingleLevelSnapshot() } + val fakeSnapshotView2 = forge.aList { aSingleLevelSnapshot() } + fakeSnapshotView1.forEach { + val fakeFlattenedSnapshot = forge.aList { + getForgery(MobileSegment.Wireframe::class.java) + } + whenever(mockNodeFlattener.flattenNode(it)).thenReturn(fakeFlattenedSnapshot) } - val fakeFlattenedSnapshotView2 = forge.aList { - getForgery(MobileSegment.Wireframe::class.java) + fakeSnapshotView2.forEach { + val fakeFlattenedSnapshot = forge.aList { + getForgery(MobileSegment.Wireframe::class.java) + } + whenever(mockNodeFlattener.flattenNode(it)).thenReturn(fakeFlattenedSnapshot) } - whenever(mockNodeFlattener.flattenNode(fakeSnapshotView1)) - .thenReturn(fakeFlattenedSnapshotView1) - whenever(mockNodeFlattener.flattenNode(fakeSnapshotView2)) - .thenReturn(fakeFlattenedSnapshotView2) // When - testedProcessor.processScreenSnapshot(fakeSnapshotView1) - testedProcessor.processScreenSnapshot(fakeSnapshotView2) + testedProcessor.processScreenSnapshots(fakeSnapshotView1) + testedProcessor.processScreenSnapshots(fakeSnapshotView2) // Then val captor = argumentCaptor() @@ -191,21 +195,27 @@ internal class RecordedDataProcessorTest { forge: Forge ) { // Given - val fakeSnapshot1 = forge.aSingleLevelSnapshot() - val fakeSnapshot2 = forge.aSingleLevelSnapshot() - val fakeFlattenedSnapshot1 = forge.aList { - getForgery(MobileSegment.Wireframe::class.java) - } - val fakeFlattenedSnapshot2 = forge.aList { - getForgery(MobileSegment.Wireframe::class.java) + val fakeSnapshot1 = forge.aList { aSingleLevelSnapshot() } + val fakeSnapshot2 = forge.aList { aSingleLevelSnapshot() } + fakeSnapshot1.map { + val fakeFlattenedSnapshot = forge.aList { + getForgery(MobileSegment.Wireframe::class.java) + } + whenever(mockNodeFlattener.flattenNode(it)).thenReturn(fakeFlattenedSnapshot) + fakeFlattenedSnapshot } - whenever(mockNodeFlattener.flattenNode(fakeSnapshot1)).thenReturn(fakeFlattenedSnapshot1) - whenever(mockNodeFlattener.flattenNode(fakeSnapshot2)).thenReturn(fakeFlattenedSnapshot2) - testedProcessor.processScreenSnapshot(fakeSnapshot1) + val fakeFlattenedSnapshot2 = fakeSnapshot2.map { + val fakeFlattenedSnapshot = forge.aList { + getForgery(MobileSegment.Wireframe::class.java) + } + whenever(mockNodeFlattener.flattenNode(it)).thenReturn(fakeFlattenedSnapshot) + fakeFlattenedSnapshot + }.flatten() + testedProcessor.processScreenSnapshots(fakeSnapshot1) Thread.sleep(TimeUnit.NANOSECONDS.toMillis(RecordedDataProcessor.FULL_SNAPSHOT_INTERVAL_IN_NS)) // When - testedProcessor.processScreenSnapshot(fakeSnapshot2) + testedProcessor.processScreenSnapshots(fakeSnapshot2) // Then val captor = argumentCaptor() @@ -221,28 +231,34 @@ internal class RecordedDataProcessorTest { forge: Forge ) { // Given - val fakeSnapshot1 = forge.aSingleLevelSnapshot() - val fakeSnapshot2 = forge.aSingleLevelSnapshot() - val fakeFlattenedSnapshot1 = forge.aList { - getForgery(MobileSegment.Wireframe::class.java) - } - val fakeFlattenedSnapshot2 = forge.aList { - getForgery(MobileSegment.Wireframe::class.java) - } + val fakeSnapshot1 = forge.aList { aSingleLevelSnapshot() } + val fakeSnapshot2 = forge.aList { aSingleLevelSnapshot() } + val fakeFlattenedSnapshot1 = fakeSnapshot1.map { + val fakeFlattenedSnapshot = forge.aList { + getForgery(MobileSegment.Wireframe::class.java) + } + whenever(mockNodeFlattener.flattenNode(it)).thenReturn(fakeFlattenedSnapshot) + fakeFlattenedSnapshot + }.flatten() + val fakeFlattenedSnapshot2 = fakeSnapshot2.map { + val fakeFlattenedSnapshot = forge.aList { + getForgery(MobileSegment.Wireframe::class.java) + } + whenever(mockNodeFlattener.flattenNode(it)).thenReturn(fakeFlattenedSnapshot) + fakeFlattenedSnapshot + }.flatten() val fakeMutationData: MobileSegment.MobileIncrementalData.MobileMutationData = forge.getForgery() - whenever(mockNodeFlattener.flattenNode(fakeSnapshot1)).thenReturn(fakeFlattenedSnapshot1) - whenever(mockNodeFlattener.flattenNode(fakeSnapshot2)).thenReturn(fakeFlattenedSnapshot2) whenever( mockMutationResolver.resolveMutations( fakeFlattenedSnapshot1, fakeFlattenedSnapshot2 ) ).thenReturn(fakeMutationData) - testedProcessor.processScreenSnapshot(fakeSnapshot1) + testedProcessor.processScreenSnapshots(fakeSnapshot1) // When - testedProcessor.processScreenSnapshot(fakeSnapshot2) + testedProcessor.processScreenSnapshots(fakeSnapshot2) // Then val captor = argumentCaptor() @@ -265,11 +281,11 @@ internal class RecordedDataProcessorTest { fakeRootWidth, fakeRootHeight ) - val fakeSnapshot = Node(wireframe = rootWireframe) - whenever(mockNodeFlattener.flattenNode(fakeSnapshot)).thenReturn(listOf(rootWireframe)) + val fakeSnapshots = listOf(Node(wireframe = rootWireframe)) + whenever(mockNodeFlattener.flattenNode(fakeSnapshots[0])).thenReturn(listOf(rootWireframe)) // When - testedProcessor.processScreenSnapshot(fakeSnapshot) + testedProcessor.processScreenSnapshots(fakeSnapshots) // Then val captor = argumentCaptor() @@ -286,19 +302,21 @@ internal class RecordedDataProcessorTest { // Given val fakeRootWidth = forge.aLong(min = 400) val fakeRootHeight = forge.aLong(min = 700) - val fakeSnapshot = Node( - wireframe = - MobileSegment.Wireframe.ShapeWireframe( - 0, - 0, - 0, - fakeRootWidth, - fakeRootHeight + val fakeSnapshots = listOf( + Node( + wireframe = + MobileSegment.Wireframe.ShapeWireframe( + 0, + 0, + 0, + fakeRootWidth, + fakeRootHeight + ) ) ) // When - testedProcessor.processScreenSnapshot(fakeSnapshot) + testedProcessor.processScreenSnapshots(fakeSnapshots) // Then val captor = argumentCaptor() @@ -312,12 +330,13 @@ internal class RecordedDataProcessorTest { @Test fun `M not send MetaRecord W process { snapshot 2 on same view }`(forge: Forge) { // Given - val fakeSnapshot1 = forge.aSingleLevelSnapshot() - val fakeSnapshot2 = forge.aSingleLevelSnapshot() - testedProcessor.processScreenSnapshot(fakeSnapshot1) + val fakeSnapshot1 = forge.aList { aSingleLevelSnapshot() } + val fakeSnapshot2 = forge.aList { aSingleLevelSnapshot() } + + testedProcessor.processScreenSnapshots(fakeSnapshot1) // When - testedProcessor.processScreenSnapshot(fakeSnapshot2) + testedProcessor.processScreenSnapshots(fakeSnapshot2) // Then val captor = argumentCaptor() @@ -332,12 +351,12 @@ internal class RecordedDataProcessorTest { @Test fun `M not send FocusRecord W process { snapshot 2 on same view }`(forge: Forge) { // Given - val fakeSnapshot1 = forge.aSingleLevelSnapshot() - val fakeSnapshot2 = forge.aSingleLevelSnapshot() - testedProcessor.processScreenSnapshot(fakeSnapshot1) + val fakeSnapshot1 = forge.aList { aSingleLevelSnapshot() } + val fakeSnapshot2 = forge.aList { aSingleLevelSnapshot() } + testedProcessor.processScreenSnapshots(fakeSnapshot1) // When - testedProcessor.processScreenSnapshot(fakeSnapshot2) + testedProcessor.processScreenSnapshots(fakeSnapshot2) // Then val captor = argumentCaptor() @@ -354,8 +373,8 @@ internal class RecordedDataProcessorTest { // Given val fakeRootWidth = forge.aLong(min = 400) val fakeRootHeight = forge.aLong(min = 700) - val fakeSnapshot1 = forge.aSingleLevelSnapshot() - val fakeSnapshot2 = forge.aSingleLevelSnapshot() + val fakeSnapshot1 = listOf(forge.aSingleLevelSnapshot()) + val fakeSnapshot2 = listOf(forge.aSingleLevelSnapshot()) val rootWireframe = MobileSegment.Wireframe.ShapeWireframe( 0, 0, @@ -363,15 +382,15 @@ internal class RecordedDataProcessorTest { fakeRootWidth, fakeRootHeight ) - val fakeSnapshot3 = Node(rootWireframe) - whenever(mockNodeFlattener.flattenNode(fakeSnapshot3)).thenReturn(listOf(rootWireframe)) + val fakeSnapshot3 = listOf(Node(rootWireframe)) + whenever(mockNodeFlattener.flattenNode(fakeSnapshot3[0])).thenReturn(listOf(rootWireframe)) - testedProcessor.processScreenSnapshot(fakeSnapshot1) - testedProcessor.processScreenSnapshot(fakeSnapshot2) + testedProcessor.processScreenSnapshots(fakeSnapshot1) + testedProcessor.processScreenSnapshots(fakeSnapshot2) whenever(mockRumContextProvider.getRumContext()).thenReturn(forge.getForgery()) // When - testedProcessor.processScreenSnapshot(fakeSnapshot3) + testedProcessor.processScreenSnapshots(fakeSnapshot3) // Then val captor = argumentCaptor() @@ -386,15 +405,15 @@ internal class RecordedDataProcessorTest { @Test fun `M send FocusRecord W process { snapshot 3 on new view }`(forge: Forge) { // Given - val fakeSnapshot1 = forge.aSingleLevelSnapshot() - val fakeSnapshot2 = forge.aSingleLevelSnapshot() - val fakeSnapshot3 = forge.aSingleLevelSnapshot() - testedProcessor.processScreenSnapshot(fakeSnapshot1) - testedProcessor.processScreenSnapshot(fakeSnapshot2) + val fakeSnapshot1 = listOf(forge.aSingleLevelSnapshot()) + val fakeSnapshot2 = listOf(forge.aSingleLevelSnapshot()) + val fakeSnapshot3 = listOf(forge.aSingleLevelSnapshot()) + testedProcessor.processScreenSnapshots(fakeSnapshot1) + testedProcessor.processScreenSnapshots(fakeSnapshot2) whenever(mockRumContextProvider.getRumContext()).thenReturn(forge.getForgery()) // When - testedProcessor.processScreenSnapshot(fakeSnapshot3) + testedProcessor.processScreenSnapshots(fakeSnapshot3) // Then val captor = argumentCaptor() @@ -408,15 +427,15 @@ internal class RecordedDataProcessorTest { @Test fun `M send ViewEndRecord on prev view W process { snapshot 3 on new view }`(forge: Forge) { // Given - val fakeSnapshot1 = forge.aSingleLevelSnapshot() - val fakeSnapshot2 = forge.aSingleLevelSnapshot() - val fakeSnapshot3 = forge.aSingleLevelSnapshot() - testedProcessor.processScreenSnapshot(fakeSnapshot1) - testedProcessor.processScreenSnapshot(fakeSnapshot2) + val fakeSnapshot1 = listOf(forge.aSingleLevelSnapshot()) + val fakeSnapshot2 = listOf(forge.aSingleLevelSnapshot()) + val fakeSnapshot3 = listOf(forge.aSingleLevelSnapshot()) + testedProcessor.processScreenSnapshots(fakeSnapshot1) + testedProcessor.processScreenSnapshots(fakeSnapshot2) whenever(mockRumContextProvider.getRumContext()).thenReturn(forge.getForgery()) // When - testedProcessor.processScreenSnapshot(fakeSnapshot3) + testedProcessor.processScreenSnapshots(fakeSnapshot3) // Then val captor = argumentCaptor() @@ -438,28 +457,34 @@ internal class RecordedDataProcessorTest { forge: Forge ) { // Given - val fakeSnapshot1 = forge.aSingleLevelSnapshot() - val fakeSnapshot2 = forge.aSingleLevelSnapshot() - val fakeFlattenedSnapshot1 = forge.aList { - getForgery(MobileSegment.Wireframe::class.java) - } - val fakeFlattenedSnapshot2 = forge.aList { - getForgery(MobileSegment.Wireframe::class.java) - } + val fakeSnapshot1 = forge.aList { aSingleLevelSnapshot() } + val fakeSnapshot2 = forge.aList { aSingleLevelSnapshot() } + val fakeFlattenedSnapshot1 = fakeSnapshot1.map { + val fakeFlattenedSnapshot = forge.aList { + getForgery(MobileSegment.Wireframe::class.java) + } + whenever(mockNodeFlattener.flattenNode(it)).thenReturn(fakeFlattenedSnapshot) + fakeFlattenedSnapshot + }.flatten() + val fakeFlattenedSnapshot2 = fakeSnapshot2.map { + val fakeFlattenedSnapshot = forge.aList { + getForgery(MobileSegment.Wireframe::class.java) + } + whenever(mockNodeFlattener.flattenNode(it)).thenReturn(fakeFlattenedSnapshot) + fakeFlattenedSnapshot + }.flatten() val fakeMutationData: MobileSegment.MobileIncrementalData.MobileMutationData = forge.getForgery() - whenever(mockNodeFlattener.flattenNode(fakeSnapshot1)).thenReturn(fakeFlattenedSnapshot1) - whenever(mockNodeFlattener.flattenNode(fakeSnapshot2)).thenReturn(fakeFlattenedSnapshot2) whenever( mockMutationResolver.resolveMutations( fakeFlattenedSnapshot1, fakeFlattenedSnapshot2 ) ).thenReturn(fakeMutationData) - testedProcessor.processScreenSnapshot(fakeSnapshot1) + testedProcessor.processScreenSnapshots(fakeSnapshot1) // When - testedProcessor.processScreenSnapshot(fakeSnapshot2) + testedProcessor.processScreenSnapshots(fakeSnapshot2) // Then val captor = argumentCaptor() @@ -474,24 +499,32 @@ internal class RecordedDataProcessorTest { @Test fun `M do nothing W process { no mutation was detected }`(forge: Forge) { // Given - val fakeSnapshot1 = forge.aSingleLevelSnapshot() - val fakeSnapshot2 = fakeSnapshot1.copy() - val fakeFlattenedSnapshot1 = forge.aList { - getForgery(MobileSegment.Wireframe::class.java) - } - val fakeFlattenedSnapshot2 = ArrayList(fakeFlattenedSnapshot1) - whenever(mockNodeFlattener.flattenNode(fakeSnapshot1)).thenReturn(fakeFlattenedSnapshot1) - whenever(mockNodeFlattener.flattenNode(fakeSnapshot2)).thenReturn(fakeFlattenedSnapshot2) + val fakeSnapshot1 = forge.aList { aSingleLevelSnapshot() } + val fakeSnapshot2 = forge.aList { aSingleLevelSnapshot() } + val fakeFlattenedSnapshot1 = fakeSnapshot1.map { + val fakeFlattenedSnapshot = forge.aList { + getForgery(MobileSegment.Wireframe::class.java) + } + whenever(mockNodeFlattener.flattenNode(it)).thenReturn(fakeFlattenedSnapshot) + fakeFlattenedSnapshot + }.flatten() + val fakeFlattenedSnapshot2 = fakeSnapshot2.map { + val fakeFlattenedSnapshot = forge.aList { + getForgery(MobileSegment.Wireframe::class.java) + } + whenever(mockNodeFlattener.flattenNode(it)).thenReturn(fakeFlattenedSnapshot) + fakeFlattenedSnapshot + }.flatten() whenever( mockMutationResolver.resolveMutations( fakeFlattenedSnapshot1, fakeFlattenedSnapshot2 ) ).thenReturn(null) - testedProcessor.processScreenSnapshot(fakeSnapshot1) + testedProcessor.processScreenSnapshots(fakeSnapshot1) // When - testedProcessor.processScreenSnapshot(fakeSnapshot2) + testedProcessor.processScreenSnapshots(fakeSnapshot2) // Then // We should only send the FullSnapshotRecord. The IncrementalSnapshotRecord will not be @@ -534,11 +567,11 @@ internal class RecordedDataProcessorTest { @Test fun `M send it to the writer as EnrichedRecord W process { OrientationChanged }`(forge: Forge) { // Given - val fakeSnapshot = forge.aSingleLevelSnapshot() + val fakeSnapshot = forge.aList { forge.aSingleLevelSnapshot() } val fakeOrientationChanged = OrientationChanged(forge.anInt(), forge.anInt()) // When - testedProcessor.processScreenSnapshot(fakeSnapshot, fakeOrientationChanged) + testedProcessor.processScreenSnapshots(fakeSnapshot, fakeOrientationChanged) // Then @@ -559,14 +592,14 @@ internal class RecordedDataProcessorTest { @Test fun `M always send a FullSnapshot W process {OrientationChanged}`(forge: Forge) { // Given - val fakeSnapshot1 = forge.aSingleLevelSnapshot() - val fakeSnapshot2 = forge.aSingleLevelSnapshot() + val fakeSnapshot1 = forge.aList { aSingleLevelSnapshot() } + val fakeSnapshot2 = forge.aList { aSingleLevelSnapshot() } val fakeOrientationChanged1 = OrientationChanged(forge.anInt(), forge.anInt()) val fakeOrientationChanged2 = OrientationChanged(forge.anInt(), forge.anInt()) // When - testedProcessor.processScreenSnapshot(fakeSnapshot1, fakeOrientationChanged1) - testedProcessor.processScreenSnapshot(fakeSnapshot2, fakeOrientationChanged2) + testedProcessor.processScreenSnapshots(fakeSnapshot1, fakeOrientationChanged1) + testedProcessor.processScreenSnapshots(fakeSnapshot2, fakeOrientationChanged2) // Then @@ -596,14 +629,14 @@ internal class RecordedDataProcessorTest { whenever(mockRumContextProvider.getRumContext()) .thenReturn(fakeRumContext) .thenReturn(fakeRumContext2) - val fakeSnapshot1 = forge.aSingleLevelSnapshot() - val fakeSnapshot2 = forge.aSingleLevelSnapshot() + val fakeSnapshot1 = forge.aList { aSingleLevelSnapshot() } + val fakeSnapshot2 = forge.aList { aSingleLevelSnapshot() } val fakeOrientationChanged1 = OrientationChanged(forge.anInt(), forge.anInt()) val fakeOrientationChanged2 = OrientationChanged(forge.anInt(), forge.anInt()) // When - testedProcessor.processScreenSnapshot(fakeSnapshot1, fakeOrientationChanged1) - testedProcessor.processScreenSnapshot(fakeSnapshot2, fakeOrientationChanged2) + testedProcessor.processScreenSnapshots(fakeSnapshot1, fakeOrientationChanged1) + testedProcessor.processScreenSnapshots(fakeSnapshot2, fakeOrientationChanged2) // Then @@ -697,17 +730,15 @@ internal class RecordedDataProcessorTest { private fun processArgument(argument: Any) { when (argument) { - is Node -> { - testedProcessor.processScreenSnapshot(argument) - } is List<*> -> { val records = argument.filterIsInstance() testedProcessor.processTouchEventsRecords(records) } is Pair<*, *> -> { - testedProcessor.processScreenSnapshot( - argument.first as Node, - argument.second as OrientationChanged + @Suppress("UNCHECKED_CAST", "CastToNullableType") + testedProcessor.processScreenSnapshots( + argument.first as List, + argument.second as OrientationChanged? ) } else -> fail( @@ -749,20 +780,15 @@ internal class RecordedDataProcessorTest { @JvmStatic fun processorArguments(): List { - val fakeSnapshot = Node(wireframe = FORGE.getForgery()) + val fakeSnapshots = FORGE.aList { Node(wireframe = FORGE.getForgery()) } val fakeTouchRecords = FORGE.aList { FORGE.getForgery() } - val fakeSnapshotWithOrientationChanged = - Pair( - fakeSnapshot, - OrientationChanged( - FORGE.aPositiveInt(), - FORGE.aPositiveInt() - ) - ) - - return listOf(fakeSnapshot, fakeTouchRecords, fakeSnapshotWithOrientationChanged) + return listOf( + fakeSnapshots to null, + fakeTouchRecords, + fakeSnapshots to OrientationChanged(FORGE.aPositiveInt(), FORGE.aPositiveInt()) + ) } } } diff --git a/library/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/processor/WireframeUtilsTest.kt b/library/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/processor/WireframeUtilsTest.kt index 56fb2b5d53..8ea8cbd44b 100644 --- a/library/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/processor/WireframeUtilsTest.kt +++ b/library/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/processor/WireframeUtilsTest.kt @@ -737,9 +737,7 @@ internal class WireframeUtilsTest { private fun Forge.forgeNonTransparentShapeStyle(): MobileSegment.ShapeStyle { return MobileSegment.ShapeStyle( - backgroundColor = aNullable { - aStringMatching("#[0-9A-F]{6}FF") - }, + backgroundColor = aStringMatching("#[0-9A-F]{6}FF"), opacity = 1f, cornerRadius = aPositiveLong() ) diff --git a/library/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/recorder/ScreenRecorderTest.kt b/library/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/recorder/ScreenRecorderTest.kt index 056ba744da..2dc98185ca 100644 --- a/library/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/recorder/ScreenRecorderTest.kt +++ b/library/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/recorder/ScreenRecorderTest.kt @@ -12,16 +12,24 @@ import android.util.DisplayMetrics import android.view.View import android.view.ViewTreeObserver import android.view.Window +import com.datadog.android.sessionreplay.RecordCallback import com.datadog.android.sessionreplay.processor.Processor +import com.datadog.android.sessionreplay.recorder.callback.NoOpWindowCallback +import com.datadog.android.sessionreplay.recorder.callback.RecorderWindowCallback +import com.datadog.android.sessionreplay.recorder.listener.WindowsOnDrawListener +import com.datadog.android.sessionreplay.utils.ForgeConfigurator import com.datadog.android.sessionreplay.utils.TimeProvider import com.nhaarman.mockitokotlin2.any import com.nhaarman.mockitokotlin2.argumentCaptor +import com.nhaarman.mockitokotlin2.inOrder import com.nhaarman.mockitokotlin2.mock import com.nhaarman.mockitokotlin2.never import com.nhaarman.mockitokotlin2.times import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.verifyNoMoreInteractions import com.nhaarman.mockitokotlin2.whenever 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 @@ -32,12 +40,14 @@ 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 @Extensions( ExtendWith(MockitoExtension::class), ExtendWith(ForgeExtension::class) ) @MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(ForgeConfigurator::class) internal class ScreenRecorderTest { lateinit var testedRecorder: ScreenRecorder @@ -51,234 +61,367 @@ internal class ScreenRecorderTest { @Mock lateinit var mockSnapshotProducer: SnapshotProducer + lateinit var fakeWindowsList: List + + lateinit var mockActivity: Activity + + @Mock + lateinit var mockRecordCallback: RecordCallback + @BeforeEach - fun `set up`() { - testedRecorder = ScreenRecorder(mockProcessor, mockSnapshotProducer, mockTimeProvider) + fun `set up`(forge: Forge) { + mockActivity = forge.aMockedActivity() + fakeWindowsList = forge.aMockedWindowsList() + testedRecorder = ScreenRecorder( + mockProcessor, + mockSnapshotProducer, + mockTimeProvider, + mockRecordCallback + ) } - // region Tests + // region OnDrawListener @Test - fun `M register the RecorderOnDrawListener W startRecording()`(forge: Forge) { - // Given - val mockViewTreeObserver: ViewTreeObserver = mock() - val mockActivity = mockActivity(forge, mockViewTreeObserver) + fun `M register the OnDrawListener W startRecording()`() { + // When + testedRecorder.startRecording(fakeWindowsList, mockActivity) + // Then + val captor = argumentCaptor() + fakeWindowsList.forEach { + verify(it.decorView.viewTreeObserver).addOnDrawListener(captor.capture()) + } + captor.allValues.forEach { assertThat(it).isInstanceOf(WindowsOnDrawListener::class.java) } + } + + @Test + fun `M register one single listener instance W startRecording()`() { // When - testedRecorder.startRecording(mockActivity) + testedRecorder.startRecording(fakeWindowsList, mockActivity) // Then val captor = argumentCaptor() - verify(mockViewTreeObserver).addOnDrawListener(captor.capture()) - assertThat(captor.firstValue).isInstanceOf(RecorderOnDrawListener::class.java) + fakeWindowsList.forEach { + verify(it.decorView.viewTreeObserver).addOnDrawListener(captor.capture()) + } + captor.allValues.reduce { acc, next -> + assertThat(acc).isSameAs(next) + next + } } @Test - fun `M register the RecorderWindowCallback W startRecording()`(forge: Forge) { + fun `M unregister and clean the listeners W stopRecording(windows)`() { // Given - val mockWindow: Window = mock() - val mockDefaultCallback: Window.Callback = mock() - val mockActivity = mockActivity( - forge, - window = mockWindow, - defaultWindowCallback = mockDefaultCallback - ) + testedRecorder.startRecording(fakeWindowsList, mockActivity) // When - testedRecorder.startRecording(mockActivity) + testedRecorder.stopRecording(fakeWindowsList) // Then - val captor = argumentCaptor() - verify(mockWindow).callback = captor.capture() - assertThat(captor.firstValue).isInstanceOf(RecorderWindowCallback::class.java) - assertThat((captor.firstValue as RecorderWindowCallback).wrappedCallback) - .isEqualTo(mockDefaultCallback) + fakeWindowsList.forEach { + val captor = argumentCaptor() + verify(it.decorView.viewTreeObserver).addOnDrawListener(captor.capture()) + verify(it.decorView.viewTreeObserver).removeOnDrawListener(captor.firstValue) + } + assertThat(testedRecorder.windowsListeners).isEmpty() } @Test - fun `M register the RecorderWindowCallback W startRecording{default callback is null}`( - forge: Forge - ) { + fun `M unregister and clean the listeners W stopRecording()`() { // Given - val mockWindow: Window = mock() - val mockActivity = mockActivity( - forge, - window = mockWindow, - defaultWindowCallback = null - ) + testedRecorder.startRecording(fakeWindowsList, mockActivity) // When - testedRecorder.startRecording(mockActivity) + testedRecorder.stopRecording() // Then - val captor = argumentCaptor() - verify(mockWindow).callback = captor.capture() - assertThat(captor.firstValue).isInstanceOf(RecorderWindowCallback::class.java) - assertThat((captor.firstValue as RecorderWindowCallback).wrappedCallback) - .isInstanceOf(NoOpWindowCallback::class.java) + fakeWindowsList.forEach { + val captor = argumentCaptor() + verify(it.decorView.viewTreeObserver).addOnDrawListener(captor.capture()) + verify(it.decorView.viewTreeObserver).removeOnDrawListener(captor.firstValue) + } + assertThat(testedRecorder.windowsListeners).isEmpty() } @Test - fun `M do nothing W startRecording() { activity window is null }`() { + fun `M unregister first and clean the listeners W startRecording()`() { // Given - val mockActivity: Activity = mock() + testedRecorder.startRecording(fakeWindowsList, mockActivity) // When - testedRecorder.startRecording(mockActivity) + testedRecorder.startRecording(fakeWindowsList, mockActivity) // Then - assertThat(testedRecorder.drawListeners).isEmpty() + fakeWindowsList.forEach { + val captor = argumentCaptor() + it.decorView.viewTreeObserver.inOrder { + verify().addOnDrawListener(captor.capture()) + verify().removeOnDrawListener(captor.firstValue) + verify().addOnDrawListener(captor.capture()) + } + } } @Test - fun `M unregister the RecorderOnDrawListener W stopRecording()`(forge: Forge) { + fun `M register listener startRecording() { more activities }`(forge: Forge) { // Given - val mockViewTreeObserver: ViewTreeObserver = mock() - val mockActivity = mockActivity(forge, mockViewTreeObserver) - testedRecorder.startRecording(mockActivity) + val fakeWindowsActivityPairs = forge.aList { + aMockedWindowsList() to aMockedActivity() + } // When - testedRecorder.stopRecording(mockActivity) + fakeWindowsActivityPairs.forEach { + testedRecorder.startRecording(it.first, it.second) + } // Then - val captor = argumentCaptor() - verify(mockViewTreeObserver).addOnDrawListener(captor.capture()) - verify(mockViewTreeObserver).removeOnDrawListener(captor.firstValue) + fakeWindowsActivityPairs.map { it.first }.flatten().forEach { + val captor = argumentCaptor() + it.decorView.viewTreeObserver.inOrder { + verify().addOnDrawListener(captor.capture()) + } + } + } + + // endregion + + // region WindowCallback + + @Test + fun `M register the RecorderWindowCallback W startRecording()`() { + // When + testedRecorder.startRecording(fakeWindowsList, mockActivity) + + // Then + fakeWindowsList.forEach { + val captor = argumentCaptor() + verify(it).callback = captor.capture() + assertThat(captor.firstValue).isInstanceOf(RecorderWindowCallback::class.java) + assertThat((captor.firstValue as RecorderWindowCallback).wrappedCallback) + .isEqualTo(it.callback) + } } @Test - fun `M remove the RecorderWindowCallback W stopRecording(){default callback is not null}`( - forge: Forge - ) { + fun `M register the RecorderWindowCallback W startRecording{default callback is null}`() { // Given - val mockWindow: Window = mock() - val mockDefaultCallback: Window.Callback = mock() - val mockActivity = mockActivity( - forge, - window = mockWindow, - defaultWindowCallback = mockDefaultCallback - ) - testedRecorder.startRecording(mockActivity) - val startRecordingCapture = argumentCaptor() - verify(mockWindow).callback = startRecordingCapture.capture() - assertThat(startRecordingCapture.firstValue) - .isInstanceOf(RecorderWindowCallback::class.java) - whenever(mockWindow.callback).thenReturn(startRecordingCapture.firstValue) + fakeWindowsList.forEach { + whenever(it.callback).thenReturn(null) + } // When - testedRecorder.stopRecording(mockActivity) + testedRecorder.startRecording(fakeWindowsList, mockActivity) // Then - val stopRecordingCaptureTarget = argumentCaptor() - verify(mockWindow, times(2)).callback = stopRecordingCaptureTarget.capture() - assertThat(stopRecordingCaptureTarget.secondValue).isSameAs(mockDefaultCallback) + fakeWindowsList.forEach { + val captor = argumentCaptor() + verify(it).callback = captor.capture() + assertThat(captor.firstValue).isInstanceOf(RecorderWindowCallback::class.java) + assertThat((captor.firstValue as RecorderWindowCallback).wrappedCallback) + .isInstanceOf(NoOpWindowCallback::class.java) + } } @Test - fun `M remove the RecorderWindowCallback W stopRecording(){default callback was null}`( - forge: Forge - ) { + fun `M remove the RecorderWindowCallback W stopRecording(windows){default is not null}`() { // Given - val mockWindow: Window = mock() - val mockActivity = mockActivity( - forge, - window = mockWindow, - defaultWindowCallback = null - ) - testedRecorder.startRecording(mockActivity) - val startRecordingCapture = argumentCaptor() - verify(mockWindow).callback = startRecordingCapture.capture() - assertThat(startRecordingCapture.firstValue) - .isInstanceOf(RecorderWindowCallback::class.java) - whenever(mockWindow.callback).thenReturn(startRecordingCapture.firstValue) + val defaultCallbacks = LinkedList() + fakeWindowsList.forEach { + defaultCallbacks.add(it.callback) + } + testedRecorder.startRecording(fakeWindowsList, mockActivity) + fakeWindowsList.forEach { + defaultCallbacks.add(it.callback) + val startRecordingCapture = argumentCaptor() + verify(it).callback = startRecordingCapture.capture() + assertThat(startRecordingCapture.firstValue) + .isInstanceOf(RecorderWindowCallback::class.java) + whenever(it.callback).thenReturn(startRecordingCapture.firstValue) + } // When - testedRecorder.stopRecording(mockActivity) + testedRecorder.stopRecording(fakeWindowsList) // Then - val stopRecordingCaptureTarget = argumentCaptor() - verify(mockWindow, times(2)).callback = stopRecordingCaptureTarget.capture() - assertThat(stopRecordingCaptureTarget.secondValue).isNull() + fakeWindowsList.forEach { + val stopRecordingCaptureTarget = argumentCaptor() + verify(it, times(2)).callback = stopRecordingCaptureTarget.capture() + assertThat(stopRecordingCaptureTarget.secondValue) + .isSameAs(defaultCallbacks.removeFirst()) + } } @Test - fun `M do nothing W stopRecording(){window callback was not wrapped}`( - forge: Forge - ) { + fun `M remove the RecorderWindowCallback W stopRecording(){default is not null}`() { // Given - val mockWindow: Window = mock() - val mockDefaultCallback: Window.Callback = mock() - val mockActivity = mockActivity( - forge, - window = mockWindow, - defaultWindowCallback = mockDefaultCallback - ) + val defaultCallbacks = LinkedList() + fakeWindowsList.forEach { + defaultCallbacks.add(it.callback) + } + testedRecorder.startRecording(fakeWindowsList, mockActivity) + fakeWindowsList.forEach { + val startRecordingCapture = argumentCaptor() + verify(it).callback = startRecordingCapture.capture() + assertThat(startRecordingCapture.firstValue) + .isInstanceOf(RecorderWindowCallback::class.java) + whenever(it.callback).thenReturn(startRecordingCapture.firstValue) + } // When - testedRecorder.stopRecording(mockActivity) + testedRecorder.stopRecording() // Then - verify(mockWindow, never()).callback = any() + fakeWindowsList.forEach { + val stopRecordingCaptureTarget = argumentCaptor() + verify(it, times(2)).callback = stopRecordingCaptureTarget.capture() + assertThat(stopRecordingCaptureTarget.secondValue) + .isSameAs(defaultCallbacks.removeFirst()) + } } @Test - fun `M do nothing W stopRecording(){window callback was not wrapped and was null}`( - forge: Forge - ) { + fun `M remove the RecorderWindowCallback W stopRecording(windows){default was null}`() { // Given - val mockWindow: Window = mock() - val mockActivity = mockActivity( - forge, - window = mockWindow, - defaultWindowCallback = null - ) + fakeWindowsList.forEach { + whenever(it.callback).thenReturn(null) + } + testedRecorder.startRecording(fakeWindowsList, mockActivity) + fakeWindowsList.forEach { + val startRecordingCapture = argumentCaptor() + verify(it).callback = startRecordingCapture.capture() + assertThat(startRecordingCapture.firstValue) + .isInstanceOf(RecorderWindowCallback::class.java) + whenever(it.callback).thenReturn(startRecordingCapture.firstValue) + } // When - testedRecorder.stopRecording(mockActivity) + testedRecorder.stopRecording(fakeWindowsList) // Then - verify(mockWindow, never()).callback = any() + fakeWindowsList.forEach { + val stopRecordingCaptureTarget = argumentCaptor() + verify(it, times(2)).callback = stopRecordingCaptureTarget.capture() + assertThat(stopRecordingCaptureTarget.secondValue).isNull() + } + } + + @Test + fun `M remove the RecorderWindowCallback W stopRecording(){default was null}`() { + // Given + fakeWindowsList.forEach { + whenever(it.callback).thenReturn(null) + } + testedRecorder.startRecording(fakeWindowsList, mockActivity) + fakeWindowsList.forEach { + val startRecordingCapture = argumentCaptor() + verify(it).callback = startRecordingCapture.capture() + assertThat(startRecordingCapture.firstValue) + .isInstanceOf(RecorderWindowCallback::class.java) + whenever(it.callback).thenReturn(startRecordingCapture.firstValue) + } + + // When + testedRecorder.stopRecording() + + // Then + fakeWindowsList.forEach { + val stopRecordingCaptureTarget = argumentCaptor() + verify(it, times(2)).callback = stopRecordingCaptureTarget.capture() + assertThat(stopRecordingCaptureTarget.secondValue).isNull() + } } @Test - fun `M clean the listeners the RecorderOnDrawListener W stopRecording()`(forge: Forge) { + fun `M do nothing W stopRecording(windows){window callback was not wrapped}`() { + // When + testedRecorder.stopRecording(fakeWindowsList) + + // Then + fakeWindowsList.forEach { + verify(it, never()).callback = any() + } + } + + @Test + fun `M do nothing W stopRecording(){window callback was not wrapped and was null}`() { + // When + testedRecorder.stopRecording() + + // Then + fakeWindowsList.forEach { + verify(it, never()).callback = any() + } + } + + // endregion + + // region Record Callback + + fun `M notify the callback W startRecording()`(forge: Forge) { + // Given + val mockWindows = forge.aMockedWindowsList() + + // When + testedRecorder.startRecording(mockWindows, mockActivity) + + // Then + verify(mockRecordCallback).onStopRecording() + verifyNoMoreInteractions(mockRecordCallback) + } + + fun `M notify the callback W stopRecording(windows)`(forge: Forge) { + // Given + val mockWindows = forge.aMockedWindowsList() + testedRecorder.startRecording(mockWindows, mockActivity) + + // When + testedRecorder.stopRecording(mockWindows) + + // Then + verify(mockRecordCallback).onStopRecording() + verifyNoMoreInteractions(mockRecordCallback) + } + + fun `M notify the callback W stopRecording`(forge: Forge) { // Given - val mockViewTreeObserver: ViewTreeObserver = mock() - val mockActivity = mockActivity(forge, mockViewTreeObserver) - testedRecorder.startRecording(mockActivity) + val mockWindows = forge.aMockedWindowsList() + testedRecorder.startRecording(mockWindows, mockActivity) // When - testedRecorder.stopRecording(mockActivity) + testedRecorder.stopRecording() // Then - assertThat(testedRecorder.drawListeners).isEmpty() + verify(mockRecordCallback).onStopRecording() + verifyNoMoreInteractions(mockRecordCallback) } // endregion // region Internal - private fun mockActivity( - forge: Forge, - viewTreeObserver: ViewTreeObserver = mock(), - window: Window = mock(), - defaultWindowCallback: Window.Callback? = null - ): Activity { - val fakeDensity = forge.aFloat() + private fun Forge.aMockedActivity(): Activity { + val mockActivity: Activity = mock() + val fakeDensity = aPositiveFloat() val displayMetrics = DisplayMetrics().apply { density = fakeDensity } val mockResources: Resources = mock { whenever(it.displayMetrics).thenReturn(displayMetrics) } - val mockDecorView: View = mock { - whenever(it.viewTreeObserver).thenReturn(viewTreeObserver) - } - whenever(window.decorView).thenReturn(mockDecorView) - val mockActivity: Activity = mock() whenever(mockActivity.resources).thenReturn(mockResources) - whenever(mockActivity.window).thenReturn(window) - whenever(mockActivity.window.callback).thenReturn(defaultWindowCallback) return mockActivity } + private fun Forge.aMockedWindowsList(): List { + return aList { + mock { + val mockDecorView: View = mock() + whenever(mockDecorView.viewTreeObserver).thenReturn(mock()) + whenever(it.decorView).thenReturn(mockDecorView) + whenever(it.callback).thenReturn(mock()) + } + } + } + // endregion } diff --git a/library/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/recorder/callback/MotionEventUtilsTest.kt b/library/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/recorder/callback/MotionEventUtilsTest.kt new file mode 100644 index 0000000000..8dfecda445 --- /dev/null +++ b/library/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/recorder/callback/MotionEventUtilsTest.kt @@ -0,0 +1,132 @@ +/* + * 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.recorder.callback + +import android.os.Build +import android.view.MotionEvent +import com.datadog.android.sessionreplay.utils.ForgeConfigurator +import com.datadog.tools.unit.annotations.TestTargetApi +import com.datadog.tools.unit.extensions.ApiLevelExtension +import com.nhaarman.mockitokotlin2.whenever +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.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 + +@Extensions( + ExtendWith(MockitoExtension::class), + ExtendWith(ApiLevelExtension::class), + ExtendWith(ForgeExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +@ForgeConfiguration(ForgeConfigurator::class) +internal class MotionEventUtilsTest { + + lateinit var testeMotionEventUtils: MotionEventUtils + + @Mock + lateinit var mockMotionEvent: MotionEvent + + var pointerXExpectedValues = mutableListOf() + var pointerYExpectedValues = mutableListOf() + + @BeforeEach + fun `set up`(forge: Forge) { + whenever(mockMotionEvent.pointerCount).thenReturn(forge.anInt(min = 10, max = 20)) + for (pointerIndex in 0 until mockMotionEvent.pointerCount) { + val x = forge.aFloat() + val y = forge.aFloat() + pointerXExpectedValues.add(x) + pointerYExpectedValues.add(y) + if (pointerIndex == 0) { + whenever(mockMotionEvent.rawX).thenReturn(x) + whenever(mockMotionEvent.rawY).thenReturn(y) + } + whenever(mockMotionEvent.getRawX(pointerIndex)).thenReturn(x) + whenever(mockMotionEvent.getRawY(pointerIndex)).thenReturn(y) + } + testeMotionEventUtils = MotionEventUtils() + } + + @Test + @TestTargetApi(Build.VERSION_CODES.Q) + fun `M return the absoluteX pointer position W getPointerAbsoluteX{ from Q above }`( + forge: Forge + ) { + // Given + val randomPointerIndex = forge.anInt(min = 0, max = mockMotionEvent.pointerCount) + + // When + val absolutePointerX = testeMotionEventUtils.getPointerAbsoluteX( + mockMotionEvent, + randomPointerIndex + ) + + // Then + assertThat(absolutePointerX).isEqualTo(pointerXExpectedValues[randomPointerIndex]) + } + + @Test + @TestTargetApi(Build.VERSION_CODES.Q) + fun `M return the absoluteY pointer position W getPointerAbsoluteY{ from Q above }`( + forge: Forge + ) { + // Given + val randomPointerIndex = forge.anInt(min = 0, max = mockMotionEvent.pointerCount) + + // When + val absolutePointerY = testeMotionEventUtils.getPointerAbsoluteY( + mockMotionEvent, + randomPointerIndex + ) + + // Then + assertThat(absolutePointerY).isEqualTo(pointerYExpectedValues[randomPointerIndex]) + } + + @Test + fun `M return the absoluteX pointer 0 position W getPointerAbsoluteX{ from Q below }`( + forge: Forge + ) { + // Given + val randomPointerIndex = forge.anInt(min = 0, max = mockMotionEvent.pointerCount) + + // When + val absolutePointerX = testeMotionEventUtils.getPointerAbsoluteX( + mockMotionEvent, + randomPointerIndex + ) + + // Then + assertThat(absolutePointerX).isEqualTo(pointerXExpectedValues[0]) + } + + @Test + fun `M return the absoluteY pointer 0 position W getPointerAbsoluteY{ from Q below }`( + forge: Forge + ) { + // Given + val randomPointerIndex = forge.anInt(min = 0, max = mockMotionEvent.pointerCount) + + // When + val absolutePointerY = testeMotionEventUtils.getPointerAbsoluteY( + mockMotionEvent, + randomPointerIndex + ) + + // Then + assertThat(absolutePointerY).isEqualTo(pointerYExpectedValues[0]) + } +} diff --git a/library/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/recorder/callback/RecorderFragmentLifecycleCallbackTest.kt b/library/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/recorder/callback/RecorderFragmentLifecycleCallbackTest.kt new file mode 100644 index 0000000000..64b42a4049 --- /dev/null +++ b/library/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/recorder/callback/RecorderFragmentLifecycleCallbackTest.kt @@ -0,0 +1,245 @@ +/* + * 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.recorder.callback + +import android.app.Activity +import android.app.Dialog +import android.view.Window +import androidx.fragment.app.DialogFragment +import com.datadog.android.sessionreplay.recorder.Recorder +import com.nhaarman.mockitokotlin2.argumentCaptor +import com.nhaarman.mockitokotlin2.eq +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.verifyZeroInteractions +import com.nhaarman.mockitokotlin2.whenever +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 + +@Extensions( + ExtendWith(MockitoExtension::class) +) +@MockitoSettings(strictness = Strictness.LENIENT) +internal class RecorderFragmentLifecycleCallbackTest { + + lateinit var testedCallback: RecorderFragmentLifecycleCallback + + @Mock + lateinit var mockRecorder: Recorder + + @Mock + lateinit var mockDialogFragment: DialogFragment + + @Mock + lateinit var mockActivityWindow: Window + + @Mock + lateinit var mockDialogWindow: Window + + @Mock + lateinit var mockOwnerActivity: Activity + + @Mock + lateinit var mockDialog: Dialog + + @BeforeEach + fun `set up`() { + whenever(mockDialogFragment.context).thenReturn(mock()) + whenever(mockDialogFragment.dialog).thenReturn(mockDialog) + whenever(mockDialog.ownerActivity).thenReturn(mockOwnerActivity) + whenever(mockOwnerActivity.window).thenReturn(mockActivityWindow) + whenever(mockDialog.window).thenReturn(mockDialogWindow) + testedCallback = RecorderFragmentLifecycleCallback(mockRecorder) + } + + // region Different Window from Activity + + @Test + fun `M start recording the dialog fragment W onFragmentResumed{different windows}`() { + // When + testedCallback.onFragmentResumed(mock(), mockDialogFragment) + + // Then + val captor = argumentCaptor>() + verify(mockRecorder).startRecording(captor.capture(), eq(mockOwnerActivity)) + assertThat(captor.firstValue).containsExactlyElementsOf( + listOf(mockActivityWindow, mockDialogWindow) + ) + } + + @Test + fun `M stop recording the dialog fragment W onFragmentPaused{different windows}`() { + // When + testedCallback.onFragmentPaused(mock(), mockDialogFragment) + + // Then + val captor = argumentCaptor>() + verify(mockRecorder).stopRecording(captor.capture()) + assertThat(captor.firstValue).containsExactlyElementsOf( + listOf(mockActivityWindow, mockDialogWindow) + ) + } + + // endregion + + // region Same Window with Activity + + @Test + fun `M start recording the dialog fragment W onFragmentResumed{same windows}`() { + // When + whenever(mockDialog.window).thenReturn(mockActivityWindow) + testedCallback.onFragmentResumed(mock(), mockDialogFragment) + + // Then + val captor = argumentCaptor>() + verify(mockRecorder).startRecording(captor.capture(), eq(mockOwnerActivity)) + assertThat(captor.firstValue).containsExactlyElementsOf( + listOf(mockActivityWindow) + ) + } + + @Test + fun `M stop recording the dialog fragment W onFragmentPaused{same windows}`() { + // When + whenever(mockDialog.window).thenReturn(mockActivityWindow) + testedCallback.onFragmentPaused(mock(), mockDialogFragment) + + // Then + val captor = argumentCaptor>() + verify(mockRecorder).stopRecording(captor.capture()) + assertThat(captor.firstValue).containsExactlyElementsOf( + listOf(mockActivityWindow) + ) + } + + // endregion + + // region Misc + + @Test + fun `M do nothing W onFragmentResumed{no dialog fragment}`() { + // When + testedCallback.onFragmentResumed(mock(), mock()) + + // Then + verifyZeroInteractions(mockRecorder) + } + + @Test + fun `M do nothing W onFragmentResumed{no context for dialog fragment}`() { + // Given + whenever(mockDialogFragment.context).thenReturn(null) + + // When + testedCallback.onFragmentResumed(mock(), mockDialogFragment) + + // Then + verifyZeroInteractions(mockRecorder) + } + + @Test + fun `M do nothing W onFragmentResumed{no owner activity for dialog fragment}`() { + // Given + whenever(mockDialog.ownerActivity).thenReturn(null) + + // When + testedCallback.onFragmentResumed(mock(), mockDialogFragment) + + // Then + verifyZeroInteractions(mockRecorder) + } + + @Test + fun `M do nothing W onFragmentResumed{no dialog for dialog fragment}`() { + // Given + whenever(mockDialogFragment.dialog).thenReturn(null) + + // When + testedCallback.onFragmentResumed(mock(), mockDialogFragment) + + // Then + verifyZeroInteractions(mockRecorder) + } + + @Test + fun `M do nothing W onFragmentResumed{no window for ownerActivity}`() { + // Given + whenever(mockOwnerActivity.window).thenReturn(null) + + // When + testedCallback.onFragmentResumed(mock(), mockDialogFragment) + + // Then + verifyZeroInteractions(mockRecorder) + } + + @Test + fun `M do nothing W onFragmentPaused{no dialog fragment}`() { + // When + testedCallback.onFragmentPaused(mock(), mock()) + + // Then + verifyZeroInteractions(mockRecorder) + } + + @Test + fun `M do nothing W onFragmentPaused{no context for dialog fragment}`() { + // Given + whenever(mockDialogFragment.context).thenReturn(null) + + // When + testedCallback.onFragmentPaused(mock(), mockDialogFragment) + + // Then + verifyZeroInteractions(mockRecorder) + } + + @Test + fun `M do nothing W onFragmentPaused{no owner activity for dialog fragment}`() { + // Given + whenever(mockDialog.ownerActivity).thenReturn(null) + + // When + testedCallback.onFragmentPaused(mock(), mockDialogFragment) + + // Then + verifyZeroInteractions(mockRecorder) + } + + @Test + fun `M do nothing W onFragmentPaused{no dialog for dialog fragment}`() { + // Given + whenever(mockDialogFragment.dialog).thenReturn(null) + + // When + testedCallback.onFragmentPaused(mock(), mockDialogFragment) + + // Then + verifyZeroInteractions(mockRecorder) + } + + @Test + fun `M do nothing W onFragmentPaused{no window for ownerActivity}`() { + // Given + whenever(mockOwnerActivity.window).thenReturn(null) + + // When + testedCallback.onFragmentPaused(mock(), mockDialogFragment) + + // Then + verifyZeroInteractions(mockRecorder) + } + + // endregion +} diff --git a/library/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/recorder/RecorderWindowCallbackTest.kt b/library/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/recorder/callback/RecorderWindowCallbackTest.kt similarity index 92% rename from library/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/recorder/RecorderWindowCallbackTest.kt rename to library/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/recorder/callback/RecorderWindowCallbackTest.kt index ee4ccfc136..c5a4512c39 100644 --- a/library/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/recorder/RecorderWindowCallbackTest.kt +++ b/library/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/recorder/callback/RecorderWindowCallbackTest.kt @@ -1,10 +1,10 @@ /* - * 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.recorder +package com.datadog.android.sessionreplay.recorder.callback import android.view.MotionEvent import android.view.Window @@ -16,9 +16,7 @@ import com.datadog.android.sessionreplay.utils.ForgeConfigurator import com.datadog.android.sessionreplay.utils.TimeProvider import com.datadog.tools.unit.forge.aThrowable import com.nhaarman.mockitokotlin2.argumentCaptor -import com.nhaarman.mockitokotlin2.doAnswer import com.nhaarman.mockitokotlin2.doThrow -import com.nhaarman.mockitokotlin2.eq import com.nhaarman.mockitokotlin2.mock import com.nhaarman.mockitokotlin2.times import com.nhaarman.mockitokotlin2.verify @@ -68,6 +66,9 @@ internal class RecorderWindowCallbackTest { @IntForgery(min = 1, max = 10) var fakeDensity: Int = 1 + @Mock + lateinit var mockEventUtils: MotionEventUtils + @BeforeEach fun `set up`() { whenever(mockTimeProvider.getDeviceTimestamp()).thenReturn(fakeTimestamp) @@ -77,6 +78,7 @@ internal class RecorderWindowCallbackTest { mockWrappedCallback, mockTimeProvider, copyEvent = { it }, + mockEventUtils, TEST_MOTION_UPDATE_DELAY_THRESHOLD_NS, TEST_FLUSH_BUFFER_THRESHOLD_NS ) @@ -338,17 +340,21 @@ internal class RecorderWindowCallbackTest { List { val pointerIds = aList { anInt(min = 1) } val positionMaxValue = (FLOAT_MAX_INT_VALUE / fakeDensity).toLong() - return pointerIds.map { - MobileSegment.MobileIncrementalData.PointerInteractionData( - eventType, - MobileSegment.PointerType.TOUCH, - it.toLong(), - aLong(min = 0, max = positionMaxValue), - aLong(min = 0, max = positionMaxValue) - ) - } + return pointerIds + .map { + MobileIncrementalData.PointerInteractionData( + eventType, + MobileSegment.PointerType.TOUCH, + it.toLong(), + aLong(min = 0, max = positionMaxValue), + aLong(min = 0, max = positionMaxValue) + ) + } .map { - MobileRecord.MobileIncrementalSnapshotRecord(timestamp = fakeTimestamp, data = it) + MobileRecord.MobileIncrementalSnapshotRecord( + timestamp = fakeTimestamp, + data = it + ) } } @@ -364,15 +370,12 @@ internal class RecorderWindowCallbackTest { whenever(mockMotionEvent.getPointerId(index)).thenReturn(pointerId) val motionEventAction = pointerInteractionData.pointerEventType.asMotionEventAction() whenever(mockMotionEvent.action).thenReturn(motionEventAction) - doAnswer { - val coords = it.arguments[1] as MotionEvent.PointerCoords - coords.x = (pointerInteractionData.x.toInt() * fakeDensity).toFloat() - coords.y = (pointerInteractionData.y.toInt() * fakeDensity).toFloat() - null - }.whenever(mockMotionEvent).getPointerCoords( - eq(index), - com.nhaarman.mockitokotlin2.any() - ) + val expectedXPos = (pointerInteractionData.x.toInt() * fakeDensity).toFloat() + val expectedYPos = (pointerInteractionData.y.toInt() * fakeDensity).toFloat() + whenever(mockEventUtils.getPointerAbsoluteX(mockMotionEvent, index)) + .thenReturn(expectedXPos) + whenever(mockEventUtils.getPointerAbsoluteY(mockMotionEvent, index)) + .thenReturn(expectedYPos) } return mockMotionEvent diff --git a/library/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/recorder/RecorderOnDrawListenerTest.kt b/library/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/recorder/listener/WindowsOnDrawListenerTest.kt similarity index 66% rename from library/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/recorder/RecorderOnDrawListenerTest.kt rename to library/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/recorder/listener/WindowsOnDrawListenerTest.kt index 87e60e51c9..452c8780b1 100644 --- a/library/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/recorder/RecorderOnDrawListenerTest.kt +++ b/library/dd-sdk-android-session-replay/src/test/kotlin/com/datadog/android/sessionreplay/recorder/listener/WindowsOnDrawListenerTest.kt @@ -1,10 +1,10 @@ /* - * 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.recorder +package com.datadog.android.sessionreplay.recorder.listener import android.app.Activity import android.content.res.Configuration @@ -12,6 +12,11 @@ import android.content.res.Resources import android.view.View import android.view.Window import com.datadog.android.sessionreplay.processor.Processor +import com.datadog.android.sessionreplay.recorder.Debouncer +import com.datadog.android.sessionreplay.recorder.Node +import com.datadog.android.sessionreplay.recorder.OrientationChanged +import com.datadog.android.sessionreplay.recorder.SnapshotProducer +import com.datadog.android.sessionreplay.recorder.densityNormalized import com.datadog.android.sessionreplay.utils.ForgeConfigurator import com.nhaarman.mockitokotlin2.any import com.nhaarman.mockitokotlin2.argumentCaptor @@ -23,7 +28,6 @@ import com.nhaarman.mockitokotlin2.verifyZeroInteractions import com.nhaarman.mockitokotlin2.whenever import fr.xgouchet.elmyr.Forge import fr.xgouchet.elmyr.annotation.FloatForgery -import fr.xgouchet.elmyr.annotation.Forgery import fr.xgouchet.elmyr.annotation.IntForgery import fr.xgouchet.elmyr.junit5.ForgeConfiguration import fr.xgouchet.elmyr.junit5.ForgeExtension @@ -43,9 +47,9 @@ import org.mockito.quality.Strictness ) @MockitoSettings(strictness = Strictness.LENIENT) @ForgeConfiguration(ForgeConfigurator::class) -internal class RecorderOnDrawListenerTest { +internal class WindowsOnDrawListenerTest { - lateinit var testedListener: RecorderOnDrawListener + lateinit var testedListener: WindowsOnDrawListener @Mock lateinit var mockActivity: Activity @@ -75,31 +79,37 @@ internal class RecorderOnDrawListenerTest { var fakeDecorHeight: Int = 0 var fakeOrientation: Int = Configuration.ORIENTATION_UNDEFINED - @Forgery - lateinit var fakeNode: Node + lateinit var fakeMockedWindows: List + lateinit var fakeWindowsSnapshots: List @BeforeEach fun `set up`(forge: Forge) { - whenever(mockSnapshotProducer.produce(mockDecorView, fakeDensity)).thenReturn(fakeNode) + fakeMockedWindows = forge.aMockedWindowsList() + fakeWindowsSnapshots = fakeMockedWindows.map { forge.getForgery() } + fakeMockedWindows.forEachIndexed { index, window -> + whenever(mockSnapshotProducer.produce(window.decorView, fakeDensity)) + .thenReturn(fakeWindowsSnapshots[index]) + } whenever(mockDecorView.width).thenReturn(fakeDecorWidth) whenever(mockDecorView.height).thenReturn(fakeDecorHeight) - mockWindow = mock { - whenever(it.decorView).thenReturn(mockDecorView) - } configuration = Configuration() fakeOrientation = forge.anElementFrom( Configuration .ORIENTATION_LANDSCAPE, Configuration.ORIENTATION_PORTRAIT ) + mockWindow = mock { + whenever(it.decorView).thenReturn(mockDecorView) + } + whenever(mockActivity.window).thenReturn(mockWindow) configuration.orientation = fakeOrientation mockResources = mock { whenever(it.configuration).thenReturn(configuration) } - whenever(mockActivity.window).thenReturn(mockWindow) whenever(mockActivity.resources).thenReturn(mockResources) - testedListener = RecorderOnDrawListener( + testedListener = WindowsOnDrawListener( mockActivity, + fakeMockedWindows, fakeDensity, mockProcessor, mockSnapshotProducer, @@ -117,8 +127,8 @@ internal class RecorderOnDrawListenerTest { // Then val argumentCaptor = argumentCaptor() - verify(mockProcessor).processScreenSnapshot( - eq(fakeNode), + verify(mockProcessor).processScreenSnapshots( + eq(fakeWindowsSnapshots), argumentCaptor.capture() ) assertThat(argumentCaptor.firstValue) @@ -153,7 +163,10 @@ internal class RecorderOnDrawListenerTest { // Then val argumentCaptor = argumentCaptor() - verify(mockProcessor).processScreenSnapshot(eq(fakeNode), argumentCaptor.capture()) + verify(mockProcessor).processScreenSnapshots( + eq(fakeWindowsSnapshots), + argumentCaptor.capture() + ) assertThat(argumentCaptor.firstValue) .isEqualTo( OrientationChanged( @@ -174,8 +187,8 @@ internal class RecorderOnDrawListenerTest { // Then val argumentCaptor = argumentCaptor() - verify(mockProcessor, times(2)).processScreenSnapshot( - eq(fakeNode), + verify(mockProcessor, times(2)).processScreenSnapshots( + eq(fakeWindowsSnapshots), argumentCaptor.capture() ) assertThat(argumentCaptor.firstValue) @@ -205,8 +218,8 @@ internal class RecorderOnDrawListenerTest { // Then val argumentCaptor = argumentCaptor() - verify(mockProcessor, times(2)).processScreenSnapshot( - eq(fakeNode), + verify(mockProcessor, times(2)).processScreenSnapshots( + eq(fakeWindowsSnapshots), argumentCaptor.capture() ) assertThat(argumentCaptor.firstValue) @@ -225,7 +238,71 @@ internal class RecorderOnDrawListenerTest { ) } + @Test + fun `M do nothing W onDraw(){ windows are empty }`() { + // Given + stubDebouncer() + testedListener = WindowsOnDrawListener( + mockActivity, + emptyList(), + fakeDensity, + mockProcessor, + mockSnapshotProducer, + mockDebouncer + ) + + // When + testedListener.onDraw() + + // Then + verifyZeroInteractions(mockProcessor) + verifyZeroInteractions(mockSnapshotProducer) + } + + @Test + fun `M do nothing W onDraw(){ windows lost the strong reference }`() { + // Given + testedListener.weakReferencedWindows.forEach { it.clear() } + stubDebouncer() + + // When + testedListener.onDraw() + + // Then + verifyZeroInteractions(mockProcessor) + verifyZeroInteractions(mockSnapshotProducer) + } + + @Test + fun `M do nothing W onDraw(){ owner activity lost the strong reference }`() { + // Given + testedListener.ownerActivityReference.clear() + stubDebouncer() + + // When + testedListener.onDraw() + + // Then + verifyZeroInteractions(mockProcessor) + verifyZeroInteractions(mockSnapshotProducer) + } + + // region Internal + private fun stubDebouncer() { whenever(mockDebouncer.debounce(any())).then { (it.arguments[0] as Runnable).run() } } + + private fun Forge.aMockedWindowsList(): List { + return aList { + mock { + val mockDecorView: View = mock() + whenever(mockDecorView.viewTreeObserver).thenReturn(mock()) + whenever(it.decorView).thenReturn(mockDecorView) + whenever(it.callback).thenReturn(mock()) + } + } + } + + // endregion }