From 7bcd24ac2a11342907671f4d013a7212b187f86d Mon Sep 17 00:00:00 2001 From: Juan Naranjo Date: Mon, 24 Aug 2026 17:13:45 +0200 Subject: [PATCH 1/3] feat(sr): add hybrid Session Replay support on Android Record a Flutter view embedded in a native Android host into the host's replay instead of a standalone session, mirroring the iOS support. Add FlutterSessionReplayManager, a process-wide singleton owning the feature, core and engine registry, and make FlutterSessionReplayBridge per-engine so multiple engines share one feature. Route embedded segments and resources through _SessionReplayInternalProxy, reached via compileOnly and a guarded Class.forName check so pure-Flutter apps degrade instead of crashing. Expose enableSessionReplay() on FlutterFragment/FlutterActivity/FlutterView. --- .../android/build.gradle | 4 + .../DatadogSessionReplayExtensions.kt | 135 +++++ .../DatadogSessionReplayPlugin.kt | 52 +- .../FlutterSessionReplayBridge.kt | 272 ++++++++-- .../FlutterSessionReplayManager.kt | 336 ++++++++++++ .../embedded/EmbeddedSessionReplay.kt | 116 +++++ .../sessionreplay/embedded/SegmentParser.kt | 87 ++++ .../feature/FlutterSessionReplayFeature.kt | 45 +- .../sessionreplay/resource/ResourceFeature.kt | 8 +- .../resource/RoutedResourceWriter.kt | 54 ++ .../DatadogSessionReplayPluginTest.kt | 252 ++++++--- .../sessionreplay/EmbeddedSessionReplaySpy.kt | 82 +++ .../FlutterSessionReplayBridgeTest.kt | 432 ++++++++++----- .../FlutterSessionReplayManagerTest.kt | 492 ++++++++++++++++++ .../embedded/SegmentParserTest.kt | 124 +++++ .../DefaultFlutterSessionReplayFeatureTest.kt | 28 +- .../resource/RoutedResourceWriterTest.kt | 63 +++ .../lib/datadog_session_replay.dart | 15 +- ...datadog_session_replay_bridge_android.dart | 449 +++++++++++++--- ...tadog_session_replay_platform_android.dart | 25 +- 20 files changed, 2749 insertions(+), 322 deletions(-) create mode 100644 packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/DatadogSessionReplayExtensions.kt create mode 100644 packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/FlutterSessionReplayManager.kt create mode 100644 packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/embedded/EmbeddedSessionReplay.kt create mode 100644 packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/embedded/SegmentParser.kt create mode 100644 packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/resource/RoutedResourceWriter.kt create mode 100644 packages/datadog_session_replay/android/src/test/kotlin/com/datadoghq/flutter/sessionreplay/EmbeddedSessionReplaySpy.kt create mode 100644 packages/datadog_session_replay/android/src/test/kotlin/com/datadoghq/flutter/sessionreplay/FlutterSessionReplayManagerTest.kt create mode 100644 packages/datadog_session_replay/android/src/test/kotlin/com/datadoghq/flutter/sessionreplay/embedded/SegmentParserTest.kt create mode 100644 packages/datadog_session_replay/android/src/test/kotlin/com/datadoghq/flutter/sessionreplay/resource/RoutedResourceWriterTest.kt diff --git a/packages/datadog_session_replay/android/build.gradle b/packages/datadog_session_replay/android/build.gradle index aff9c1695..b42f6ef73 100644 --- a/packages/datadog_session_replay/android/build.gradle +++ b/packages/datadog_session_replay/android/build.gradle @@ -66,6 +66,8 @@ android { implementation("com.squareup.okhttp3:okhttp:5.3.2") implementation("com.google.code.gson:gson:2.12.1") + compileOnly("com.datadoghq:dd-sdk-android-session-replay:$datadog_version") + testImplementation("com.willowtreeapps.assertk:assertk:0.28.1") testImplementation("org.jetbrains.kotlin:kotlin-test") testRuntimeOnly("org.junit.platform:junit-platform-launcher") @@ -77,6 +79,8 @@ android { } testOptions { + unitTests.returnDefaultValues = true + unitTests.all { useJUnitPlatform() diff --git a/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/DatadogSessionReplayExtensions.kt b/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/DatadogSessionReplayExtensions.kt new file mode 100644 index 000000000..b7f61d73d --- /dev/null +++ b/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/DatadogSessionReplayExtensions.kt @@ -0,0 +1,135 @@ +/* + * 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 2025-Present Datadog, Inc. + */ + +@file:JvmName("DatadogSessionReplay") + +package com.datadoghq.flutter.sessionreplay + +import androidx.annotation.UiThread +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.android.FlutterFragment +import io.flutter.embedding.android.FlutterView +import io.flutter.embedding.engine.FlutterEngine + +/** + * Records this Flutter content as part of the native app's Session Replay. + * + * Call this from a native host that embeds Flutter, on the object that hosts it, and configure + * Session Replay on the Dart side with `isEmbedded: true`. Nothing else is needed — the Flutter + * records are composited into this host's replay wherever this view sits on screen. + * + * Call it as early as you like: if the Flutter view or its engine does not exist yet, recording + * starts as soon as they do. Calling it more than once for the same host is harmless, and the host + * keeps the same slot across configuration changes, so the replay is continuous. + * + * Each host gets its own slot, so an app embedding several engines records each one in the right + * place. + * + * This does nothing unless the native Session Replay is enabled in the host app — in a pure-Flutter + * app, configure Session Replay from Dart instead and leave `isEmbedded` at its default. + */ +@UiThread +fun FlutterFragment.enableSessionReplay() { + // The fragment's own lifecycle, not the view's: the view lifecycle owner is replaced every time + // the view is recreated, so observing it would stop at the first configuration change. + observeHost(lifecycle) { findFlutterView() } +} + +/** See [FlutterFragment.enableSessionReplay]. */ +@UiThread +fun FlutterActivity.enableSessionReplay() { + observeHost(lifecycle) { findFlutterView() } +} + +/** + * See [FlutterFragment.enableSessionReplay]. + * + * Use this overload when the host manages the [FlutterView] itself rather than through + * [FlutterFragment] or [FlutterActivity]. There is no lifecycle to follow here, so this tracks the + * view's engine for as long as the view lives; a host that replaces the view must call this again on + * the new one. + */ +@UiThread +fun FlutterView.enableSessionReplay() { + registerSlot() + // Registration needs an engine, which a view is not required to have yet. Following attachment + // is also what keeps a cached engine moving between views — a common add-to-app pattern — + // pointing at the view currently showing it. + addFlutterEngineAttachmentListener( + object : FlutterView.FlutterEngineAttachmentListener { + override fun onFlutterEngineAttachedToFlutterView(engine: FlutterEngine) { + FlutterSessionReplayManager.shared.registerSlot(this@enableSessionReplay, engine.messenger) + } + + override fun onFlutterEngineDetachedFromFlutterView() { + // The engine is already gone by the time this fires, so the slot cannot be + // unregistered by messenger here. It is dropped when the plugin detaches, and until + // then the weakly held view lets a re-attach reuse the same slot. + } + } + ) +} + +/** Registers this view as its engine's slot, if it currently has an engine. */ +@UiThread +private fun FlutterView.registerSlot() { + val engine = attachedFlutterEngine ?: return + FlutterSessionReplayManager.shared.registerSlot(this, engine.messenger) +} + +/** + * The messenger identifying an engine across this plugin. + * + * A plugin binding's `binaryMessenger` is this same object, which is what lets the host side and the + * plugin side agree on which engine they are talking about. + */ +private val FlutterEngine.messenger get() = dartExecutor + +/** + * Registers the host's Flutter view as a slot, now if [findView] can find one and every time the + * host starts thereafter. + * + * Re-resolving on each start is what makes a single call at any point in the host's setup enough: + * the view may not exist yet when the host calls, and — for a fragment or an activity recreated on a + * configuration change — the view it eventually gets is not the one it would have had. Registration + * is idempotent and preserves the slot ID, so repeating it costs nothing and keeps the replay + * unbroken. + */ +@UiThread +private fun observeHost(lifecycle: Lifecycle, findView: () -> FlutterView?) { + findView()?.registerSlot() + + // Observed even when that succeeded: a fragment's view is torn down and rebuilt around a stop, + // so the view registered just now is not necessarily the one the host ends up displaying. + lifecycle.addObserver( + object : LifecycleEventObserver { + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + when (event) { + // ON_START rather than ON_CREATE: for both hosts the Flutter view exists and has + // its engine by the time the host is started. + Lifecycle.Event.ON_START -> findView()?.registerSlot() + Lifecycle.Event.ON_DESTROY -> source.lifecycle.removeObserver(this) + else -> Unit + } + } + } + ) +} + +/** + * Finds the [FlutterView] a [FlutterFragment] or [FlutterActivity] hosts. + * + * Both give their Flutter view the same well-known ID, and neither exposes it directly — + * `getFlutterEngine()` is protected on the activity, and would not give us the view in any case. + */ +private fun FlutterFragment.findFlutterView(): FlutterView? = + view?.findViewById(FlutterFragment.FLUTTER_VIEW_ID) + +private fun FlutterActivity.findFlutterView(): FlutterView? = + findViewById(FlutterActivity.FLUTTER_VIEW_ID) diff --git a/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/DatadogSessionReplayPlugin.kt b/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/DatadogSessionReplayPlugin.kt index 6be23dd54..67d09fe1e 100644 --- a/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/DatadogSessionReplayPlugin.kt +++ b/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/DatadogSessionReplayPlugin.kt @@ -9,22 +9,35 @@ package com.datadoghq.flutter.sessionreplay import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.plugin.common.MethodChannel -class DatadogSessionReplayPlugin : FlutterPlugin { +class DatadogSessionReplayPlugin private constructor( + private val manager: FlutterSessionReplayManager +) : FlutterPlugin { + // The constructor Flutter's plugin registrant calls. + constructor() : this(FlutterSessionReplayManager.shared) + private var channel: MethodChannel? = null override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { - // FFI plugins do not receive engine lifecycle events, so we cannot determine - // which engine called enable() from within the FFI call itself. Instead, after - // calling enable() via FFI, Dart fires a non-awaited 'claimOwnership' message - // through this method channel. Because method channels route to the plugin - // instance for their specific engine, we can reliably associate the enable() - // call with this engine's messenger and set listenerOwner correctly. + // FFI plugins do not receive engine lifecycle events, so we cannot determine which engine + // called enable() from within the FFI call itself. Instead, after calling enable() via FFI, + // Dart fires a non-awaited 'registerEngine' message through this method channel, carrying + // the token of the bridge it just created. Because method channels route to the plugin + // instance for their specific engine, this pairs that bridge with this engine's messenger. // See: https://github.com/flutter/flutter/issues/184124 - channel = MethodChannel(binding.binaryMessenger, "datadog_session_replay/engine") + channel = MethodChannel(binding.binaryMessenger, ENGINE_CHANNEL_NAME) channel?.setMethodCallHandler { call, result -> - if (call.method == "claimOwnership") { - FlutterSessionReplayBridge.claimOwnership(binding.binaryMessenger) - result.success(null) + if (call.method == REGISTER_ENGINE_METHOD) { + val engineToken = call.arguments as? String + if (engineToken == null) { + result.error( + "DatadogSdk:InvalidOperation", + "$REGISTER_ENGINE_METHOD requires the engine token as its argument.", + null + ) + } else { + manager.bind(engineToken, binding.binaryMessenger) + result.success(null) + } } else { result.notImplemented() } @@ -34,10 +47,17 @@ class DatadogSessionReplayPlugin : FlutterPlugin { override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { channel?.setMethodCallHandler(null) channel = null - // Null out the context listener so context updates don't attempt to invoke a - // callback into the now-destroyed Dart isolate, which would cause a SIGABRT. - // The ownership check ensures a secondary engine detaching doesn't clear the - // listener registered by a still-live engine. - FlutterSessionReplayBridge.detachFromEngine(binding.binaryMessenger) + // Release this engine's bridge, so context updates don't attempt to invoke a callback into + // the now-destroyed Dart isolate, which would cause a SIGABRT. Keyed by messenger, so a + // secondary engine detaching doesn't disturb a still-live one. + manager.detach(binding.binaryMessenger) + } + + internal companion object { + internal const val ENGINE_CHANNEL_NAME = "datadog_session_replay/engine" + internal const val REGISTER_ENGINE_METHOD = "registerEngine" + + internal fun create(manager: FlutterSessionReplayManager) = + DatadogSessionReplayPlugin(manager) } } diff --git a/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/FlutterSessionReplayBridge.kt b/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/FlutterSessionReplayBridge.kt index 61f24b701..d9af9502f 100644 --- a/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/FlutterSessionReplayBridge.kt +++ b/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/FlutterSessionReplayBridge.kt @@ -10,10 +10,38 @@ import com.datadog.android.Datadog import com.datadog.android.api.feature.FeatureSdkCore import com.datadoghq.flutter.sessionreplay.feature.DefaultFlutterSessionReplayFeature import io.flutter.plugin.common.BinaryMessenger +import java.lang.ref.WeakReference import java.nio.ByteBuffer +import java.util.UUID +/** + * The Session Replay bridge for a single Flutter engine. + * + * One instance exists per engine. It owns only per-engine state — this engine's Dart RUM-context + * callback and the routing of this engine's records — and delegates everything shared (the feature, + * the core, the engine registry, host slot IDs) to [FlutterSessionReplayManager]. + */ @Suppress("TooManyFunctions") -internal object FlutterSessionReplayBridge { +internal class FlutterSessionReplayBridge private constructor( + /** The coordinator shared with every other engine's bridge. */ + private val manager: FlutterSessionReplayManager +) { + /** Creates a bridge backed by the process-wide coordinator. This is what Dart constructs. */ + constructor() : this(FlutterSessionReplayManager.shared) + + companion object { + /** + * Cap on [pendingSegments], so an engine that never becomes resolvable — a host that + * configured `isEmbedded: true` but never called `dd.enableSessionReplay()` — drops the + * oldest segments rather than growing without bound. Two seconds of capture at the default + * 100ms cadence. + */ + internal const val MAX_PENDING_SEGMENTS = 20 + + /** Creates a bridge backed by [manager]. Used in tests, to substitute the coordinator. */ + internal fun create(manager: FlutterSessionReplayManager) = FlutterSessionReplayBridge(manager) + } + data class RumContext( val applicationId: String?, val sessionId: String?, @@ -37,74 +65,218 @@ internal object FlutterSessionReplayBridge { val onContextChanged: ContextListener ) + /** + * Identifies this bridge to its own engine. + * + * The FFI `enable()` call cannot tell which engine invoked it, and this bridge never sees a + * messenger. Dart reads this token after construction and passes it to the plugin instance for + * its engine over the engine method channel, which is the one place the messenger *is* known — + * letting the manager pair the two. See [FlutterSessionReplayManager.bind]. + */ + val engineToken: String = UUID.randomUUID().toString() + + /** + * This engine's Dart RUM-context callback, set in [enable] and invoked by the manager's context + * fan-out. + */ @Volatile - var contextListener: ContextListener? = null + private var contextListener: ContextListener? = null + + /** + * The messenger of the engine this bridge serves, set by [FlutterSessionReplayManager.bind] once + * `registerEngine` has paired the two. Needed to resolve this engine's slot ID at write time. + * Weak — the engine owns it. + */ + private var boundMessenger: WeakReference? = null - var feature: DefaultFlutterSessionReplayFeature? = null - internal var listenerOwner: BinaryMessenger? = null + /** + * Which recording path this engine's segments belong to, as declared by Dart in [setEmbedded]. + * Deliberately does *not* carry the slot ID: that is resolved per segment from the engine's + * current view, so a re-registered host view is picked up without anything on the Dart side + * having to notice it changed. + */ + private enum class EmbeddingState { + /** [setEmbedded] not yet called. */ + UNKNOWN, - fun claimOwnership(messenger: BinaryMessenger) { - listenerOwner = messenger + /** Flutter is embedded in a native host. */ + EMBEDDED, + + /** Flutter is the host app. */ + STANDALONE } + private var embeddingState = EmbeddingState.UNKNOWN + + /** + * Segments with nowhere to go yet — either Dart has not declared the embedding state, or the + * embedded slot cannot be resolved because the host has not registered this engine's view yet + * (a pre-warmed engine). Drained by [flushPendingSegments]. + */ + private val pendingSegments = ArrayDeque() + + /** + * Guards everything the segment path touches. Segments arrive from the Dart processor isolate + * over JNI, while binding and embedding state are set from the platform thread. + */ + private val lock = Any() + + // region Engine lifecycle + + /** Delivers a RUM context update to this engine's Dart callback. */ + fun receive(context: RumContext?) { + if (context == null) { + return + } + contextListener?.onContextChanged(context) + } + + /** + * Records the messenger of the engine this bridge belongs to, and drains anything that was + * waiting on it. See [boundMessenger]. + */ + fun bind(messenger: BinaryMessenger) { + synchronized(lock) { + boundMessenger = WeakReference(messenger) + } + flushPendingSegments() + } + + /** Tears down everything tied to this engine's Dart isolate, called when the engine detaches. */ + fun detach() { + contextListener = null + synchronized(lock) { + boundMessenger = null + embeddingState = EmbeddingState.UNKNOWN + pendingSegments.clear() + } + } + + // endregion + fun enable( configuration: Configuration, core: FeatureSdkCore? = null ): DefaultFlutterSessionReplayFeature { - // Always replace the context listener. This is to prevent a crash in the case of a - // Hot Restart, where the previously created context listener has been destroyed. + // Register this engine for live RUM context fan-out before anything else, so it receives + // updates even if the feature was already registered by another engine. Always replaces the + // context listener, which also covers a Hot Restart, where the previously created listener + // has been destroyed. contextListener = configuration.onContextChanged - // Clear any stale ownership. claimOwnership() will re-establish it for the correct - // engine once the Dart-side 'claimOwnership' method channel message is delivered. - // There is a brief gap between enable() and claimOwnership() during which - // listenerOwner is null; this is intentional and acceptable — see the comment in - // DatadogSessionReplayPlugin.onAttachedToEngine for the full explanation. - listenerOwner = null - // If this is already initialized, just return the existing feature (don't recreate and - // and replace it on the core). - feature?.let { - return it - } + manager.register(this) - val featureSdkCore = core ?: Datadog.getInstance() as FeatureSdkCore - val newFeature = DefaultFlutterSessionReplayFeature( - featureSdkCore, - { context -> contextListener?.onContextChanged(RumContext(context)) }, - configuration.customEndpointUrl - ) - featureSdkCore.registerFeature(newFeature) - feature = newFeature - return newFeature - } + val feature = manager.enableFeature(core, configuration.customEndpointUrl) - fun detachFromEngine(messenger: BinaryMessenger) { - // Only null the listener if the detaching engine is the one that registered it. - // This prevents a detaching secondary engine from clearing a live engine's callback. - if (listenerOwner === messenger) { - contextListener = null - listenerOwner = null - } - } + // The feature only reports context *changes*, and in hybrid apps the native RUM view is + // usually already active by now — so prime this engine with the current context instead of + // waiting for the next change. + manager.primeContext(this) - // Only used in testing - internal fun shutdown() { - feature = null - contextListener = null - listenerOwner = null + return feature } + // region Replay state + + /** + * Whether this engine should publish replay state (`has_replay`, record counts) to the core. + * + * Only the standalone path may: when embedded, the native Session Replay publishes both — its + * embedded-content receiver counts our records — and publishing from here too would have the + * two fight over the same core-context keys, making the value RUM reads depend on which wrote + * last. + */ + private val publishesReplayState: Boolean + get() = synchronized(lock) { embeddingState == EmbeddingState.STANDALONE } + fun setHasReplay(viewId: String, hasReplay: Boolean) { - feature?.setHasReplay(viewId, hasReplay) + if (!publishesReplayState) { + return + } + manager.feature?.setHasReplay(viewId, hasReplay) } fun setRecordCount(viewId: String, recordCount: Int) { - feature?.setRecordCount(viewId, recordCount) + if (!publishesReplayState) { + return + } + manager.feature?.setRecordCount(viewId, recordCount) + } + + // endregion + + // region Segments + + /** + * Declares which recording path this engine writes to. Called once by Dart, straight after + * [enable], from the `isEmbedded` it was configured with. + */ + fun setEmbedded(isEmbedded: Boolean) { + synchronized(lock) { + embeddingState = if (isEmbedded) EmbeddingState.EMBEDDED else EmbeddingState.STANDALONE + } + flushPendingSegments() } fun writeSegment(segment: String) { - feature?.writeSegment(segment) + synchronized(lock) { + pendingSegments.addLast(segment) + while (pendingSegments.size > MAX_PENDING_SEGMENTS) { + pendingSegments.removeFirst() + } + } + flushPendingSegments() } + /** + * Writes every buffered segment, if a destination can be resolved right now. + * + * The embedded slot is resolved here — per flush, from the engine's current view — rather than + * cached when the engine enables. That is what removes the need for Dart to observe its view: + * each segment simply picks up whatever slot ID the host's registered view carries now. + * + * Segments are drained under [lock] but written outside it, so a write never holds the lock + * against the Dart thread appending the next segment. + */ + private fun flushPendingSegments() { + var slotId: String? = null + + val drained = synchronized(lock) { + if (pendingSegments.isEmpty()) { + return + } + + when (embeddingState) { + // Dart has not declared the embedding state yet. + EmbeddingState.UNKNOWN -> return + + // Flutter is the host app — write directly to the Flutter SR feature scope. + EmbeddingState.STANDALONE -> Unit + + // Flutter is embedded — hand the records to the native recording so the player can + // composite them into the host's embedded-content placeholder. + EmbeddingState.EMBEDDED -> { + val messenger = boundMessenger?.get() + // Either `registerEngine` has not landed yet, or the host has not registered + // this engine's view. Keep buffering and retry on the next segment. + slotId = messenger?.let { manager.slotId(it) } ?: return + } + } + + pendingSegments.toList().also { pendingSegments.clear() } + } + + val resolvedSlotId = slotId + if (resolvedSlotId == null) { + drained.forEach { manager.feature?.writeSegment(it) } + } else { + drained.forEach { manager.sendToNative(it, resolvedSlotId) } + } + } + + // endregion + + // region Telemetry + fun telemetryDebug(message: String) { Datadog._internalProxy()._telemetry.debug(message) } @@ -113,13 +285,17 @@ internal object FlutterSessionReplayBridge { Datadog._internalProxy()._telemetry.error(message, stack, kind) } + // endregion + + // region Resources + fun saveImageForProcessing( resourceId: Int, imageData: ByteBuffer, width: Int, height: Int ) { - feature?.resourceResolver?.addResource( + manager.feature?.resourceResolver?.addResource( resourceKey = resourceId, width = width, height = height, @@ -128,6 +304,8 @@ internal object FlutterSessionReplayBridge { } fun resourceIdForKey(resourceId: Int): String? { - return feature?.resourceResolver?.resolveResource(resourceId) + return manager.feature?.resourceResolver?.resolveResource(resourceId) } + + // endregion } diff --git a/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/FlutterSessionReplayManager.kt b/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/FlutterSessionReplayManager.kt new file mode 100644 index 000000000..9536ddd0a --- /dev/null +++ b/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/FlutterSessionReplayManager.kt @@ -0,0 +1,336 @@ +/* + * 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 2025-Present Datadog, Inc. + */ + +package com.datadoghq.flutter.sessionreplay + +import android.view.View +import com.datadog.android.Datadog +import com.datadog.android.api.feature.FeatureSdkCore +import com.datadoghq.flutter.sessionreplay.embedded.DefaultEmbeddedSessionReplay +import com.datadoghq.flutter.sessionreplay.embedded.EmbeddedSessionReplay +import com.datadoghq.flutter.sessionreplay.embedded.SegmentParser +import com.datadoghq.flutter.sessionreplay.feature.DefaultFlutterSessionReplayFeature +import io.flutter.plugin.common.BinaryMessenger +import java.lang.ref.WeakReference +import java.util.Collections +import java.util.UUID +import java.util.WeakHashMap + +/** + * Process-wide coordinator for Flutter Session Replay. + * + * A [FlutterSessionReplayBridge] exists once per Flutter engine, but everything that must be shared + * across engines lives here: the single registered [DefaultFlutterSessionReplayFeature], the core it + * is registered in, the registry of live engines used to fan RUM context out to all of them, and the + * slot IDs minted for the native host's embedded Flutter views. + * + * This state also has to outlive engine detach/re-attach cycles, which is why it is held by + * [shared] rather than by the bridges themselves. + * + * Every registry is guarded by [lock]. Unlike the iOS counterpart, segments arrive here from the + * Dart processor isolate over JNI while slots are registered on the UI thread, so the two genuinely + * race. + * + * Its constructor is not private so tests can build an isolated instance and inject it into + * `FlutterSessionReplayBridge(manager = …)` instead of sharing process-wide state between cases. + */ +@Suppress("TooManyFunctions") +internal class FlutterSessionReplayManager( + feature: DefaultFlutterSessionReplayFeature? = null, + private val embeddedSessionReplay: EmbeddedSessionReplay = DefaultEmbeddedSessionReplay +) { + companion object { + val shared = FlutterSessionReplayManager() + } + + private val lock = Any() + + /** The shared feature, registered by the first engine to enable. */ + @Volatile + var feature: DefaultFlutterSessionReplayFeature? = feature + private set + + /** Retained so embedded engines can hand records to the native Session Replay. */ + @Volatile + private var core: FeatureSdkCore? = null + + /** + * Whether Flutter is embedded in a native host, and therefore whether resources belong to the + * native Session Replay rather than the Flutter resources feature. + */ + @Volatile + private var isEmbedded = false + + /** + * Every live engine bridge, weakly held. The single context listener fans out to all of them + * (see [broadcastContext]), so every engine — not just the last to enable — receives live RUM + * context updates. Entries clear automatically when an engine's bridge is released. + */ + private val engines: MutableSet = + Collections.newSetFromMap(WeakHashMap()) + + /** + * Maps each engine's messenger to that engine's bridge, so a detaching engine can be torn down + * (see [detach]). Populated by [bind], because neither side knows both halves on its own: the + * bridge is created over FFI without a messenger, and the plugin instance that has the + * messenger never sees the bridge. + */ + private val bridgesByMessenger = + WeakHashMap>() + + /** + * Maps each engine's messenger to the slot registered for its embedded Flutter view. + * + * Unlike iOS — which reads the slot back off the view because the native SDK owns the + * associated object — the ID is kept here. The native module is a `compileOnly` dependency, so + * its resources are not merged into a pure-Flutter app and `R.id.datadog_session_replay_slot_id` + * cannot be resolved to read the tag back. Since this class is what mints the ID in the first + * place, holding it costs nothing. + */ + private val slotsByMessenger = WeakHashMap() + + /** + * A slot minted for one engine's Flutter view. The view is weak so a released host view + * controller does not keep its view tree alive; a cleared reference means the slot is no longer + * resolvable and callers go back to buffering. + */ + private class SlotRegistration( + val slotId: String, + view: View + ) { + private val viewRef = WeakReference(view) + val view: View? get() = viewRef.get() + } + + // region Engines + + /** + * Registers an engine's bridge for RUM context fan-out. + * + * Synchronous — it does not depend on any method-channel round trip, which is unreliable for + * pre-warmed secondary engines. + */ + fun register(engine: FlutterSessionReplayBridge) { + synchronized(lock) { + engines.add(engine) + } + } + + /** + * Delivers a context update to every live engine. Snapshots the engines under [lock] first, so + * one detaching mid-iteration cannot mutate the set underneath us, and so a Dart callback never + * runs while the lock is held. Engines that already detached are simply absent, so this never + * calls into a destroyed Dart isolate. + */ + fun broadcastContext(context: FlutterSessionReplayBridge.RumContext?) { + val snapshot = synchronized(lock) { engines.toList() } + snapshot.forEach { it.receive(context) } + } + + /** + * Pairs the bridge holding [engineToken] with the engine [messenger] belongs to. + * + * Called from the engine method channel, so it runs once per engine, after that engine's bridge + * has registered. + */ + fun bind(engineToken: String, messenger: BinaryMessenger) { + val bridge = synchronized(lock) { + val match = engines.firstOrNull { it.engineToken == engineToken } + ?: return@synchronized null + bridgesByMessenger[messenger] = WeakReference(match) + match + } ?: return + + // The bridge needs the messenger too — it resolves this engine's slot ID through it on + // every segment write, so records always carry the slot the host registered. + bridge.bind(messenger) + } + + /** + * Tears down the engine [messenger] belongs to, called when its plugin detaches. + * + * Drops the engine's bridge from the fan-out registry and releases its Dart context callback, so + * a context update arriving after the isolate is gone cannot call into it. The weak maps would + * clear these entries eventually; doing it here closes the window where the bridge outlives its + * isolate. + * + * Only ever affects the detaching engine, so a secondary engine closing cannot disturb a live one. + */ + fun detach(messenger: BinaryMessenger) { + val bridge = synchronized(lock) { + val existing = bridgesByMessenger.remove(messenger)?.get() + if (existing != null) { + engines.remove(existing) + } + slotsByMessenger.remove(messenger) + existing + } + bridge?.detach() + } + + /** + * Reads the current RUM context and delivers it to [engine] alone. + * + * In hybrid apps the native RUM view is already active before an engine enables, and the feature + * only reports context *changes* — so without this the engine would wait for the next change + * before it could stamp records with a view ID. Priming lets it start recording immediately. + */ + fun primeContext(engine: FlutterSessionReplayBridge) { + val context = feature?.readCurrentContext() ?: return + engine.receive(FlutterSessionReplayBridge.RumContext(context)) + } + + // endregion + + // region Feature + + /** + * Registers the shared Session Replay feature in [sdkCore]. Subsequent calls reuse the + * already-registered feature: every engine calls this, but only one feature exists. + */ + fun enableFeature( + sdkCore: FeatureSdkCore?, + customEndpointUrl: String? + ): DefaultFlutterSessionReplayFeature { + val featureSdkCore = sdkCore ?: Datadog.getInstance() as FeatureSdkCore + core = featureSdkCore + + feature?.let { return it } + + val newFeature = DefaultFlutterSessionReplayFeature( + sdkCore = featureSdkCore, + onContextChanged = { context -> + broadcastContext(FlutterSessionReplayBridge.RumContext(context)) + }, + customEndpointUrl = customEndpointUrl, + embeddedResourceSink = { identifier, data, mimeType -> + sendToNative(identifier, data, mimeType) + } + ) + featureSdkCore.registerFeature(newFeature) + feature = newFeature + return newFeature + } + + // endregion + + // region Slots + + /** + * Registers [view] as the host slot for [messenger]'s embedded Flutter content and assigns it a + * slot ID. + * + * This is the only place a slot ID is minted. Reading one — which happens on every segment + * write — deliberately does not assign, so a write can never be what brings a slot into + * existence: the native recorder emits the embedded-content wireframe only for views that + * already carry an ID when a snapshot is taken, and minting on write would let records reach the + * player ahead of the placeholder they belong to. + * + * Re-registering the same view for the same engine keeps the existing ID, so the player sees one + * continuous slot across a host view that is torn down and rebuilt. Must be called on the UI + * thread, as [EmbeddedSessionReplay.setSlotId] tags the view. + */ + fun registerSlot(view: View, messenger: BinaryMessenger) { + isEmbedded = true + + val registration = synchronized(lock) { + val existing = slotsByMessenger[messenger] + if (existing != null && existing.view === view) { + return@synchronized null + } + // A previous view for this engine is being replaced — clear its tag so the native + // registry stops tracking a slot nothing renders into any more. + existing?.view?.let { embeddedSessionReplay.setSlotId(it, null) } + + val slotId = existing?.slotId ?: UUID.randomUUID().toString() + SlotRegistration(slotId, view).also { slotsByMessenger[messenger] = it } + } ?: return + + embeddedSessionReplay.setSlotId(view, registration.slotId) + } + + /** + * Detaches the slot registered for [messenger], if its view is still the one registered. + * + * Called when a host view detaches from its engine. The registration is dropped rather than + * kept, so records go back to buffering instead of naming a slot the native recorder no longer + * emits a placeholder for. + */ + fun unregisterSlot(messenger: BinaryMessenger) { + val view = synchronized(lock) { slotsByMessenger.remove(messenger)?.view } ?: return + embeddedSessionReplay.setSlotId(view, null) + } + + /** + * Returns the slot ID of the view hosting [messenger]'s embedded Flutter content, or `null` if + * the host has not registered one — Flutter is not embedded, or the registered view has been + * released. Callers keep their segments buffered while this is `null`. + */ + fun slotId(messenger: BinaryMessenger): String? { + return synchronized(lock) { + val registration = slotsByMessenger[messenger] ?: return@synchronized null + if (registration.view == null) { + slotsByMessenger.remove(messenger) + null + } else { + registration.slotId + } + } + } + + // endregion + + // region Records + + /** + * Parses [segment] and hands its records to the native Session Replay, stamped with [slotId], so + * the player can composite them into the host's embedded-content wireframe. + * + * The view ID carried by the segment is the *native* RUM view ID: the native receiver pairs it + * with the native application and session IDs and counts records against it, which RUM reads + * back per view. It is native because the Dart side stamps records with the RUM context this + * class fans out, which originates natively. + */ + fun sendToNative(segment: String, slotId: String) { + val sdkCore = core ?: return + val parsed = SegmentParser.parse(segment) ?: return + embeddedSessionReplay.addRecords(parsed.records, slotId, parsed.viewId, sdkCore) + } + + // endregion + + // region Resources + + /** + * Hands a resource to the native Session Replay, so it is deduplicated against the host's own + * resources and that deduplication survives app launches. + * + * Returns `false` when Flutter is not embedded — or the native module is absent — so the caller + * writes to the Flutter resources feature instead. + */ + fun sendToNative(identifier: String, data: ByteArray, mimeType: String): Boolean { + val sdkCore = core + if (!isEmbedded || sdkCore == null || !embeddedSessionReplay.isAvailable) { + return false + } + embeddedSessionReplay.addResource(identifier, data, mimeType, sdkCore) + return true + } + + // endregion + + /** Only used in testing. */ + fun shutdown() { + synchronized(lock) { + feature = null + core = null + isEmbedded = false + engines.clear() + bridgesByMessenger.clear() + slotsByMessenger.clear() + } + } +} diff --git a/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/embedded/EmbeddedSessionReplay.kt b/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/embedded/EmbeddedSessionReplay.kt new file mode 100644 index 000000000..bf172cfc1 --- /dev/null +++ b/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/embedded/EmbeddedSessionReplay.kt @@ -0,0 +1,116 @@ +/* + * 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 2025-Present Datadog, Inc. + */ + +package com.datadoghq.flutter.sessionreplay.embedded + +import android.view.View +import com.datadog.android.api.SdkCore +import com.datadog.android.sessionreplay._SessionReplayInternalProxy + +/** + * The slice of the native Session Replay module this plugin depends on in hybrid apps. + * + * Behind an interface so the manager can be tested without the native module on the classpath, + * and so the availability guard below has a single place to live. + */ +internal interface EmbeddedSessionReplay { + /** + * Whether the native Session Replay module is present in this app. + * + * `false` in a pure-Flutter app, where nothing enables native Session Replay and the module is + * therefore not packaged — see the `compileOnly` dependency in `build.gradle`. + */ + val isAvailable: Boolean + + /** + * Marks [view] as the host slot for this engine's Flutter content, or clears it when [slotId] + * is `null`. Must be called on the UI thread. + */ + fun setSlotId(view: View, slotId: String?) + + /** Hands a batch of Flutter records to the native recording, stamped with [slotId]. */ + fun addRecords( + records: List>, + slotId: String, + viewId: String, + sdkCore: SdkCore + ) + + /** Hands a Flutter resource to the native recording. */ + fun addResource( + identifier: String, + data: ByteArray, + mimeType: String, + sdkCore: SdkCore + ) +} + +/** + * Calls the native Session Replay module, tolerating its absence. + * + * `dd-sdk-android-session-replay` is a `compileOnly` dependency, so in a pure-Flutter app these + * symbols are missing at runtime and touching them raises [LinkageError] rather than an exception. + * [isAvailable] resolves the class once up front so the common path is a boolean check, and each + * call is still guarded — the class resolving does not by itself prove every member links. + */ +internal object DefaultEmbeddedSessionReplay : EmbeddedSessionReplay { + override val isAvailable: Boolean by lazy { + try { + Class.forName(PROXY_CLASS_NAME) + true + } catch (@Suppress("SwallowedException") e: ClassNotFoundException) { + false + } catch (@Suppress("SwallowedException") e: LinkageError) { + false + } + } + + override fun setSlotId(view: View, slotId: String?) { + guarded { + _SessionReplayInternalProxy.setEmbeddedContentSlotId(view, slotId) + } + } + + override fun addRecords( + records: List>, + slotId: String, + viewId: String, + sdkCore: SdkCore + ) { + guarded { + _SessionReplayInternalProxy.addEmbeddedContentRecords(records, slotId, viewId, sdkCore) + } + } + + override fun addResource( + identifier: String, + data: ByteArray, + mimeType: String, + sdkCore: SdkCore + ) { + guarded { + _SessionReplayInternalProxy.addEmbeddedContentResource(identifier, data, mimeType, sdkCore) + } + } + + /** + * Runs [block] only when the native module is present, and absorbs the [LinkageError] it would + * raise if a member turned out to be missing anyway — a version skew between this plugin and + * the native SDK should degrade to "no embedded replay", never crash the host app. + */ + private inline fun guarded(block: () -> Unit) { + if (!isAvailable) { + return + } + try { + block() + } catch (@Suppress("SwallowedException") e: LinkageError) { + // Native Session Replay is present but does not expose the embedded-content API. + } + } + + private const val PROXY_CLASS_NAME = "com.datadog.android.sessionreplay._SessionReplayInternalProxy" +} \ No newline at end of file diff --git a/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/embedded/SegmentParser.kt b/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/embedded/SegmentParser.kt new file mode 100644 index 000000000..bf1ffb296 --- /dev/null +++ b/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/embedded/SegmentParser.kt @@ -0,0 +1,87 @@ +/* + * 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 2025-Present Datadog, Inc. + */ + +package com.datadoghq.flutter.sessionreplay.embedded + +import com.datadoghq.flutter.sessionreplay.models.EnrichedRecord +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import com.google.gson.JsonPrimitive + +/** + * A segment produced by the Dart processor, unpacked into what the native embedded-content API + * takes: the records as plain maps, plus the RUM view they belong to. + */ +internal data class ParsedSegment( + val records: List>, + val viewId: String +) + +/** + * Unpacks the JSON the Dart processor writes — an `EnrichedRecord` — for + * `_SessionReplayInternalProxy.addEmbeddedContentRecords`, which takes records as maps rather than + * as JSON. + * + * Returns `null` for anything that would produce an empty or unattributable batch: malformed JSON, + * a missing `viewID`, or no records. The native receiver would drop those anyway, and stopping here + * keeps the caller's buffering logic from treating a dud segment as delivered. + */ +internal object SegmentParser { + fun parse(segmentJson: String): ParsedSegment? { + val root = runCatching { JsonParser.parseString(segmentJson) } + .getOrNull() as? JsonObject + ?: return null + + val viewId = (root.get(EnrichedRecord.VIEW_ID_KEY) as? JsonPrimitive) + ?.takeIf { it.isString } + ?.asString + ?: return null + + val records = (root.get(EnrichedRecord.RECORDS_KEY) as? JsonArray) + ?.mapNotNull { element -> (element as? JsonObject)?.let { toMap(it) } } + ?.takeIf { it.isNotEmpty() } + ?: return null + + return ParsedSegment(records, viewId) + } + + private fun toMap(source: JsonObject): Map { + return source.entrySet().associate { (key, value) -> key to toValue(value) } + } + + private fun toValue(element: JsonElement): Any? { + return when { + element.isJsonObject -> toMap(element.asJsonObject) + element.isJsonArray -> element.asJsonArray.map { toValue(it) } + element.isJsonPrimitive -> toPrimitive(element.asJsonPrimitive) + else -> null + } + } + + /** + * Gson models every JSON number as a single `Number` type, so the integral ones have to be + * recovered by inspecting the literal. Reading them all as `Double` would re-serialize record + * timestamps in exponent form and lose precision past 2^53, and the player reads those + * timestamps as integers. + */ + private fun toPrimitive(primitive: JsonPrimitive): Any? { + return when { + primitive.isBoolean -> primitive.asBoolean + primitive.isString -> primitive.asString + primitive.isNumber -> { + val literal = primitive.asString + if (literal.any { it == '.' || it == 'e' || it == 'E' }) { + primitive.asDouble + } else { + literal.toLongOrNull() ?: primitive.asDouble + } + } + else -> null + } + } +} diff --git a/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/feature/FlutterSessionReplayFeature.kt b/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/feature/FlutterSessionReplayFeature.kt index 65604e87b..84b55ac0e 100644 --- a/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/feature/FlutterSessionReplayFeature.kt +++ b/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/feature/FlutterSessionReplayFeature.kt @@ -20,14 +20,17 @@ import com.datadog.android.api.storage.FeatureStorageConfiguration import com.datadog.android.api.storage.RawBatchEvent import com.datadoghq.flutter.sessionreplay.resource.DefaultResourceResolver import com.datadoghq.flutter.sessionreplay.resource.DefaultResourceWriter +import com.datadoghq.flutter.sessionreplay.resource.EmbeddedResourceSink import com.datadoghq.flutter.sessionreplay.resource.ResourceDataStoreManager import com.datadoghq.flutter.sessionreplay.resource.ResourceFeature import com.datadoghq.flutter.sessionreplay.resource.ResourceResolver +import com.datadoghq.flutter.sessionreplay.resource.RoutedResourceWriter internal interface FlutterSessionReplayFeature : StorageBackedFeature { fun setHasReplay(viewId: String, hasReplay: Boolean) fun setRecordCount(viewId: String, recordCount: Int) fun writeSegment(segment: String) + fun readCurrentContext(): DefaultFlutterSessionReplayFeature.RumContext? val resourceResolver: ResourceResolver } @@ -36,6 +39,7 @@ internal class DefaultFlutterSessionReplayFeature( private val sdkCore: FeatureSdkCore, private val onContextChanged: (RumContext) -> Unit, private val customEndpointUrl: String?, + private val embeddedResourceSink: EmbeddedResourceSink = { _, _, _ -> false }, private val mainThreadHandler: Handler = Handler(Looper.getMainLooper()) ) : FlutterSessionReplayFeature, StorageBackedFeature, @@ -57,7 +61,7 @@ internal class DefaultFlutterSessionReplayFeature( override lateinit var resourceResolver: ResourceResolver - override val name = Feature.SESSION_REPLAY_FEATURE_NAME + override val name = FLUTTER_SESSION_REPLAY_FEATURE_NAME override val storageConfiguration = STORAGE_CONFIGURATION override val requestFactory: RequestFactory by lazy { @@ -72,7 +76,7 @@ internal class DefaultFlutterSessionReplayFeature( this ) sdkCore.setEventReceiver( - Feature.SESSION_REPLAY_FEATURE_NAME, + FLUTTER_SESSION_REPLAY_FEATURE_NAME, this ) @@ -86,10 +90,28 @@ internal class DefaultFlutterSessionReplayFeature( // so it must be created after the resources feature above is registered. resourceResolver = DefaultResourceResolver( sdkCore.internalLogger, - DefaultResourceWriter(sdkCore, ResourceDataStoreManager(sdkCore)) + RoutedResourceWriter( + DefaultResourceWriter(sdkCore, ResourceDataStoreManager(sdkCore)), + embeddedResourceSink + ) ) } + /** + * The RUM context as it stands right now, rather than at the next change. + * + * Used to prime an engine that enables while a RUM view is already active — see + * `FlutterSessionReplayManager.primeContext`. Returns `null` when RUM has published no context + * yet, in which case [onContextUpdate] delivers the first one soon enough. + */ + override fun readCurrentContext(): RumContext? { + val context = sdkCore.getFeatureContext(Feature.RUM_FEATURE_NAME) + if (context.isEmpty()) { + return null + } + return RumContext(context) + } + override fun onStop() { } @@ -105,6 +127,10 @@ internal class DefaultFlutterSessionReplayFeature( } } + // Both of these stay on [Feature.SESSION_REPLAY_FEATURE_NAME], unlike the registration above: + // that context is where RUM reads `has_replay` and the record counts from, regardless of which + // feature wrote them. Only the standalone path reaches here, so there is no native Session + // Replay to contend with — see `FlutterSessionReplayBridge.publishesReplayState`. override fun setHasReplay(viewId: String, hasReplay: Boolean) { sdkCore.updateFeatureContext(Feature.SESSION_REPLAY_FEATURE_NAME) { @Suppress("UNCHECKED_CAST") @@ -127,7 +153,7 @@ internal class DefaultFlutterSessionReplayFeature( } override fun writeSegment(segment: String) { - sdkCore.getFeature(Feature.SESSION_REPLAY_FEATURE_NAME) + sdkCore.getFeature(FLUTTER_SESSION_REPLAY_FEATURE_NAME) ?.withWriteContext { _, writeScope -> synchronized(this) { val serializedSegment = segment.toByteArray(Charsets.UTF_8) @@ -144,6 +170,17 @@ internal class DefaultFlutterSessionReplayFeature( } companion object { + /** + * The name this feature registers under, deliberately not [Feature.SESSION_REPLAY_FEATURE_NAME]. + * + * The core keys features by name and a later registration replaces an earlier one, so + * claiming the native module's name in a hybrid app would evict the native Session Replay + * from the core — breaking its uploads, and with them the embedded-content path this plugin + * hands Flutter records to. Matches the iOS plugin, which registers `flutter-session-replay` + * for the same reason. + */ + internal const val FLUTTER_SESSION_REPLAY_FEATURE_NAME = "flutter-session-replay" + /** * Session Replay storage configuration with the following parameters: * max item size = 10 MB, diff --git a/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/resource/ResourceFeature.kt b/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/resource/ResourceFeature.kt index 825cf5645..9adb538e8 100644 --- a/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/resource/ResourceFeature.kt +++ b/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/resource/ResourceFeature.kt @@ -45,6 +45,12 @@ class ResourceFeature( maxBatchSize = 10 * 1024 * 1024 ) - internal const val SESSION_REPLAY_RESOURCES_FEATURE_NAME = "session-replay-resources" + /** + * Deliberately not the native module's `session-replay-resources`: features are keyed by + * name in the core and the last registration wins, so sharing the name would evict the + * native resources feature in a hybrid app. See + * `DefaultFlutterSessionReplayFeature.FLUTTER_SESSION_REPLAY_FEATURE_NAME`. + */ + internal const val SESSION_REPLAY_RESOURCES_FEATURE_NAME = "flutter-session-replay-resources" } } diff --git a/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/resource/RoutedResourceWriter.kt b/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/resource/RoutedResourceWriter.kt new file mode 100644 index 000000000..bec9cc1a6 --- /dev/null +++ b/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/resource/RoutedResourceWriter.kt @@ -0,0 +1,54 @@ +/* + * 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 2025-Present Datadog, Inc. + */ + +package com.datadoghq.flutter.sessionreplay.resource + +/** + * Hands a resource to the native Session Replay, returning `false` when it cannot take it. + * + * Mirrors `_SessionReplayInternalProxy.addEmbeddedContentResource`, minus the core, which the + * manager supplies. + */ +internal typealias EmbeddedResourceSink = ( + identifier: String, + resourceData: ByteArray, + mimeType: String +) -> Boolean + +/** + * Sends resources to the native Session Replay when Flutter is embedded, and to the Flutter + * resources feature otherwise. + * + * Embedded resources have to go through the native recording rather than be uploaded from here: + * both sides hash resources by content, so routing them together is what lets a resource shared + * between native and Flutter content be uploaded once, and it puts Flutter resources behind the + * native module's persistent known-resources store, which survives app launches. + * + * [sendToNative] answers whether it took the resource — rather than being asked up front — because + * the embedding state can change under us: an engine writes resources before the host has + * registered its view, and a pure-Flutter app has no native module at all. Anything it declines + * falls through to [standaloneWriter], so a resource is never dropped. + */ +internal class RoutedResourceWriter( + private val standaloneWriter: ResourceWriter, + private val sendToNative: EmbeddedResourceSink +) : ResourceWriter { + override fun write(identifier: String, resourceData: ByteArray) { + if (sendToNative(identifier, resourceData, MIME_TYPE)) { + return + } + standaloneWriter.write(identifier, resourceData) + } + + companion object { + /** + * The images are actually WEBP — see `BitmapHandler.getImageCompressionFormat` — but the + * intake is told `image/png`, matching what [ResourceRequestBodyFactory] declares on the + * standalone path and what the native SDK sends for its own resources. + */ + internal const val MIME_TYPE = "image/png" + } +} diff --git a/packages/datadog_session_replay/android/src/test/kotlin/com/datadoghq/flutter/sessionreplay/DatadogSessionReplayPluginTest.kt b/packages/datadog_session_replay/android/src/test/kotlin/com/datadoghq/flutter/sessionreplay/DatadogSessionReplayPluginTest.kt index e6e3a61e3..5b479bb49 100644 --- a/packages/datadog_session_replay/android/src/test/kotlin/com/datadoghq/flutter/sessionreplay/DatadogSessionReplayPluginTest.kt +++ b/packages/datadog_session_replay/android/src/test/kotlin/com/datadoghq/flutter/sessionreplay/DatadogSessionReplayPluginTest.kt @@ -6,92 +6,224 @@ package com.datadoghq.flutter.sessionreplay -import android.os.Looper +import android.view.View import assertk.assertThat -import assertk.assertions.isNotNull +import assertk.assertions.containsExactly +import assertk.assertions.hasSize +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse import assertk.assertions.isNull +import assertk.assertions.isTrue import com.datadog.android.api.feature.FeatureSdkCore +import com.datadoghq.flutter.sessionreplay.feature.DefaultFlutterSessionReplayFeature +import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.junit5.ForgeExtension import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.StandardMethodCodec +import io.mockk.CapturingSlot import io.mockk.every import io.mockk.mockk -import io.mockk.mockkStatic -import io.mockk.unmockkStatic -import kotlin.test.AfterTest +import java.nio.ByteBuffer import kotlin.test.Test -import org.junit.jupiter.api.AfterAll -import org.junit.jupiter.api.BeforeAll -import org.junit.jupiter.api.TestInstance - -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -class DatadogSessionReplayPluginTest { - @BeforeAll - fun beforeAll() { - val mockLooper = mockk() - mockkStatic(Looper::class) - every { Looper.getMainLooper() }.returns(mockLooper) - } +import org.junit.jupiter.api.extension.ExtendWith + +/** + * Tests the plugin's only job: pairing an engine's messenger with the bridge that engine created + * over FFI, and releasing it again on detach. + * + * Each case builds its own manager and injects it, so nothing touches + * [FlutterSessionReplayManager.shared]. + */ +@ExtendWith(ForgeExtension::class) +internal class DatadogSessionReplayPluginTest { + private val mockCore: FeatureSdkCore = mockk(relaxed = true) + private val mockFeature: DefaultFlutterSessionReplayFeature = mockk(relaxed = true) + private val embedded = EmbeddedSessionReplaySpy() + private val manager = FlutterSessionReplayManager(mockFeature, embedded) + + /** + * One engine: a messenger, the plugin attached to it, and the handler that plugin installed on + * the engine channel. + * + * The handler is captured off the messenger rather than the channel being driven directly, + * because sending an encoded call through it is exactly what the real engine does when Dart + * invokes `registerEngine`. + */ + private inner class Engine { + val messenger = mockk(relaxed = true) + val plugin = DatadogSessionReplayPlugin.create(manager) + private var handler: BinaryMessenger.BinaryMessageHandler? = null - @AfterAll - fun afterAll() { - unmockkStatic(Looper::class) + init { + // Nullable, because `setMethodCallHandler(null)` on detach comes through here too. + val handlerSlot = CapturingSlot() + every { + messenger.setMessageHandler( + DatadogSessionReplayPlugin.ENGINE_CHANNEL_NAME, + captureNullable(handlerSlot) + ) + } answers { handler = handlerSlot.captured } + plugin.onAttachedToEngine(binding()) + } + + fun binding(): FlutterPlugin.FlutterPluginBinding { + val binding = mockk(relaxed = true) + every { binding.binaryMessenger } returns messenger + return binding + } + + /** Delivers a call over the engine channel, as Dart invoking it would. */ + fun invoke(method: String, arguments: Any?): ByteBuffer? { + val message = StandardMethodCodec.INSTANCE + .encodeMethodCall(MethodCall(method, arguments)) + .also { it.flip() } + var reply: ByteBuffer? = null + val channelHandler = checkNotNull(handler) { + "The plugin did not install a handler on the engine channel." + } + channelHandler.onMessage(message) { reply = it } + return reply + } + + /** A bridge that has enabled against the shared manager, as Dart's `enable()` does. */ + fun enableBridge(): FlutterSessionReplayBridge { + val bridge = FlutterSessionReplayBridge.create(manager) + bridge.enable( + FlutterSessionReplayBridge.Configuration(onContextChanged = mockk(relaxed = true)), + core = mockCore + ) + bridge.setEmbedded(true) + return bridge + } } - @AfterTest - fun afterEach() { - FlutterSessionReplayBridge.shutdown() + /** Whether the encoded [reply] is an error envelope rather than a success one. */ + private fun isError(reply: ByteBuffer?): Boolean { + val buffer = checkNotNull(reply) { "The plugin did not reply." } + buffer.position(0) + // StandardMethodCodec tags a success envelope with 0 and an error envelope with 1. + return buffer.get() != 0.toByte() } - private fun makeBinding(messenger: BinaryMessenger): FlutterPlugin.FlutterPluginBinding { - val binding = mockk(relaxed = true) - every { binding.binaryMessenger } returns messenger - return binding + private fun segment(viewId: String, type: Int) = + """{"records":[{"type":$type}],"viewID":"$viewId"}""" + + @Test + fun `M route the engine's segments to its slot W registerEngine`( + @StringForgery viewId: String + ) { + // Given - an engine whose host has registered its Flutter view + val engine = Engine() + val bridge = engine.enableBridge() + val hostView = mockk() + manager.registerSlot(hostView, engine.messenger) + + // When - Dart hands over the token of the bridge it just created + val reply = engine.invoke( + DatadogSessionReplayPlugin.REGISTER_ENGINE_METHOD, + bridge.engineToken + ) + bridge.writeSegment(segment(viewId, type = 1)) + + // Then - the bridge can resolve this engine's slot, which is the whole point of the pairing + assertThat(isError(reply)).isFalse() + assertThat(embedded.recordBatches).hasSize(1) + assertThat(embedded.recordBatches[0].slotId).isEqualTo(embedded.slotIdOf(hostView)) + assertThat(embedded.recordBatches[0].viewId).isEqualTo(viewId) } @Test - fun `M null contextListener W onDetachedFromEngine with owning engine`() { + fun `M error W registerEngine without a token`() { // Given - val mockMessenger = mockk(relaxed = true) - val mockCore: FeatureSdkCore = mockk(relaxed = true) - val configuration = FlutterSessionReplayBridge.Configuration( - customEndpointUrl = null, - onContextChanged = mockk(relaxed = true) - ) - val plugin = DatadogSessionReplayPlugin() - plugin.onAttachedToEngine(makeBinding(mockMessenger)) - FlutterSessionReplayBridge.enable(configuration, core = mockCore) - // Simulate claimOwnership arriving from the Dart method channel - FlutterSessionReplayBridge.claimOwnership(mockMessenger) + val engine = Engine() // When - plugin.onDetachedFromEngine(makeBinding(mockMessenger)) + val reply = engine.invoke(DatadogSessionReplayPlugin.REGISTER_ENGINE_METHOD, null) - // Then - assertThat(FlutterSessionReplayBridge.contextListener).isNull() + // Then - failing loudly beats silently leaving the engine unpaired and its records buffering + assertThat(isError(reply)).isTrue() } @Test - fun `M not null contextListener W onDetachedFromEngine with non-owning engine`() { + fun `M reply notImplemented W an unknown method`( + @StringForgery method: String + ) { // Given - val owningMessenger = mockk(relaxed = true) - val otherMessenger = mockk(relaxed = true) - val mockCore: FeatureSdkCore = mockk(relaxed = true) - val configuration = FlutterSessionReplayBridge.Configuration( - customEndpointUrl = null, - onContextChanged = mockk(relaxed = true) - ) - val owningPlugin = DatadogSessionReplayPlugin() - owningPlugin.onAttachedToEngine(makeBinding(owningMessenger)) - FlutterSessionReplayBridge.enable(configuration, core = mockCore) - FlutterSessionReplayBridge.claimOwnership(owningMessenger) + val engine = Engine() - val otherPlugin = DatadogSessionReplayPlugin() - otherPlugin.onAttachedToEngine(makeBinding(otherMessenger)) + // When + val reply = engine.invoke(method, null) - // When — other engine detaches before the owning engine - otherPlugin.onDetachedFromEngine(makeBinding(otherMessenger)) + // Then - notImplemented is encoded as a null reply + assertThat(reply).isNull() + } + + @Test + fun `M stop routing this engine's segments W onDetachedFromEngine`( + @StringForgery viewId: String + ) { + // Given - a paired engine that has been recording into its slot + val engine = Engine() + val bridge = engine.enableBridge() + manager.registerSlot(mockk(), engine.messenger) + engine.invoke(DatadogSessionReplayPlugin.REGISTER_ENGINE_METHOD, bridge.engineToken) + bridge.writeSegment(segment(viewId, type = 1)) + val batchesBeforeDetach = embedded.recordBatches.size + + // When - the engine goes away and a segment already in flight lands + engine.plugin.onDetachedFromEngine(engine.binding()) + bridge.writeSegment(segment(viewId, type = 2)) + + // Then - nothing more reaches the native recording. This is the same teardown that releases + // the Dart context callback, so a later context update cannot call into a destroyed isolate. + assertThat(embedded.recordBatches).hasSize(batchesBeforeDetach) + } + + @Test + fun `M leave other engines recording W onDetachedFromEngine`( + @StringForgery viewId: String + ) { + // Given - two engines, as in a host with a pre-warmed secondary engine + val first = Engine() + val firstBridge = first.enableBridge() + manager.registerSlot(mockk(), first.messenger) + first.invoke(DatadogSessionReplayPlugin.REGISTER_ENGINE_METHOD, firstBridge.engineToken) + + val second = Engine() + val secondBridge = second.enableBridge() + manager.registerSlot(mockk(), second.messenger) + second.invoke(DatadogSessionReplayPlugin.REGISTER_ENGINE_METHOD, secondBridge.engineToken) + + // When - only the second engine detaches + second.plugin.onDetachedFromEngine(second.binding()) + firstBridge.writeSegment(segment(viewId, type = 1)) + secondBridge.writeSegment(segment(viewId, type = 2)) + + // Then - the still-live engine is undisturbed + assertThat(embedded.recordBatches).hasSize(1) + assertThat(embedded.recordBatches.map { it.slotId }) + .containsExactly(manager.slotId(first.messenger)) + } + + @Test + fun `M ignore an unknown token W registerEngine`( + @StringForgery unknownToken: String, + @StringForgery viewId: String + ) { + // Given - a stale token, e.g. from a bridge replaced by a Hot Restart + val engine = Engine() + val bridge = engine.enableBridge() + manager.registerSlot(mockk(), engine.messenger) + + // When + engine.invoke(DatadogSessionReplayPlugin.REGISTER_ENGINE_METHOD, unknownToken) + bridge.writeSegment(segment(viewId, type = 1)) - // Then — owning engine's listener is preserved - assertThat(FlutterSessionReplayBridge.contextListener).isNotNull() + // Then - the bridge stays unpaired and keeps buffering, rather than being handed a messenger + // that belongs to some other engine + assertThat(embedded.recordBatches).isEmpty() } } diff --git a/packages/datadog_session_replay/android/src/test/kotlin/com/datadoghq/flutter/sessionreplay/EmbeddedSessionReplaySpy.kt b/packages/datadog_session_replay/android/src/test/kotlin/com/datadoghq/flutter/sessionreplay/EmbeddedSessionReplaySpy.kt new file mode 100644 index 000000000..098ac63cd --- /dev/null +++ b/packages/datadog_session_replay/android/src/test/kotlin/com/datadoghq/flutter/sessionreplay/EmbeddedSessionReplaySpy.kt @@ -0,0 +1,82 @@ +/* + * 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 2025-Present Datadog, Inc. + */ + +package com.datadoghq.flutter.sessionreplay + +import android.view.View +import com.datadog.android.api.SdkCore +import com.datadoghq.flutter.sessionreplay.embedded.EmbeddedSessionReplay + +/** + * Stands in for the native Session Replay module, recording what was handed to it. + * + * The real implementation calls into `dd-sdk-android-session-replay`, which is a `compileOnly` + * dependency: it is not on the runtime classpath of these tests, and its behaviour is the native + * SDK's to verify, not ours. What matters here is *what* we send it and *when*. + */ +internal class EmbeddedSessionReplaySpy( + override var isAvailable: Boolean = true +) : EmbeddedSessionReplay { + data class RecordBatch( + val records: List>, + val slotId: String, + val viewId: String + ) + + data class Resource( + val identifier: String, + val data: ByteArray, + val mimeType: String + ) { + // ByteArray identity would make every comparison false, so compare the bytes. + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Resource) return false + return identifier == other.identifier && + data.contentEquals(other.data) && + mimeType == other.mimeType + } + + override fun hashCode(): Int { + var result = identifier.hashCode() + result = 31 * result + data.contentHashCode() + result = 31 * result + mimeType.hashCode() + return result + } + } + + /** Every `setSlotId` call in order, including the `null`s that clear a slot. */ + val slotIdAssignments = mutableListOf>() + + val recordBatches = mutableListOf() + val resources = mutableListOf() + + /** The slot ID currently tagged on [view], as the native recorder would read it. */ + fun slotIdOf(view: View): String? = + slotIdAssignments.lastOrNull { it.first === view }?.second + + override fun setSlotId(view: View, slotId: String?) { + slotIdAssignments.add(view to slotId) + } + + override fun addRecords( + records: List>, + slotId: String, + viewId: String, + sdkCore: SdkCore + ) { + recordBatches.add(RecordBatch(records, slotId, viewId)) + } + + override fun addResource( + identifier: String, + data: ByteArray, + mimeType: String, + sdkCore: SdkCore + ) { + resources.add(Resource(identifier, data, mimeType)) + } +} diff --git a/packages/datadog_session_replay/android/src/test/kotlin/com/datadoghq/flutter/sessionreplay/FlutterSessionReplayBridgeTest.kt b/packages/datadog_session_replay/android/src/test/kotlin/com/datadoghq/flutter/sessionreplay/FlutterSessionReplayBridgeTest.kt index a97203b37..dbd7266a8 100644 --- a/packages/datadog_session_replay/android/src/test/kotlin/com/datadoghq/flutter/sessionreplay/FlutterSessionReplayBridgeTest.kt +++ b/packages/datadog_session_replay/android/src/test/kotlin/com/datadoghq/flutter/sessionreplay/FlutterSessionReplayBridgeTest.kt @@ -6,8 +6,11 @@ package com.datadoghq.flutter.sessionreplay -import android.os.Looper +import android.view.View import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.hasSize +import assertk.assertions.isEmpty import assertk.assertions.isEqualTo import assertk.assertions.isNotNull import assertk.assertions.isNull @@ -22,218 +25,415 @@ import fr.xgouchet.elmyr.junit5.ForgeExtension import io.flutter.plugin.common.BinaryMessenger import io.mockk.every import io.mockk.mockk -import io.mockk.mockkStatic -import io.mockk.unmockkStatic import io.mockk.verify import java.nio.ByteBuffer -import kotlin.test.AfterTest import kotlin.test.Test -import org.junit.jupiter.api.AfterAll -import org.junit.jupiter.api.BeforeAll -import org.junit.jupiter.api.TestInstance import org.junit.jupiter.api.extension.ExtendWith -internal fun FlutterSessionReplayBridge.enableWithMock( - mockFeature: DefaultFlutterSessionReplayFeature -) { - this.feature = mockFeature -} - +/** + * Tests the per-engine bridge. + * + * Every test builds its own manager, so no state leaks between tests and nothing touches + * [FlutterSessionReplayManager.shared]. + */ @ExtendWith(ForgeExtension::class) -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -class FlutterSessionReplayBridgeTest { - @BeforeAll - fun beforeAll() { - val mockLooper = mockk() - mockkStatic(Looper::class) - every { Looper.getMainLooper() }.returns(mockLooper) +internal class FlutterSessionReplayBridgeTest { + private val mockCore: FeatureSdkCore = mockk(relaxed = true) + private val mockFeature: DefaultFlutterSessionReplayFeature = mockk(relaxed = true) + private val embedded = EmbeddedSessionReplaySpy() + private val manager = FlutterSessionReplayManager(mockFeature, embedded) + private val bridge = FlutterSessionReplayBridge.create(manager) + + private val messenger = mockk() + private val hostView = mockk() + + /** Every segment the standalone path wrote to the feature, in order. */ + private val writtenSegments = mutableListOf() + + /** + * A record batch the manager will accept: `sendToNative(segment, slotId)` requires non-empty + * `records` and a `viewID`. + */ + private fun segment(viewId: String = "view-id", recordCount: Int = 1): String { + val records = (0 until recordCount).joinToString(",") { """{"type":$it}""" } + return """{"records":[$records],"viewID":"$viewId"}""" } - @AfterAll - fun afterAll() { - unmockkStatic(Looper::class) + /** + * Enables the bridge against the mock core. The manager keeps the injected mock feature — + * `enableFeature` returns early when one already exists — but still retains the core, which is + * what the embedded path passes records to. + */ + private fun enable( + onContextChanged: (FlutterSessionReplayBridge.RumContext) -> Unit = {} + ) { + every { mockFeature.writeSegment(any()) } answers { writtenSegments.add(firstArg()) } + bridge.enable( + FlutterSessionReplayBridge.Configuration( + customEndpointUrl = null, + onContextChanged = object : FlutterSessionReplayBridge.ContextListener { + override fun onContextChanged(context: FlutterSessionReplayBridge.RumContext) { + onContextChanged(context) + } + } + ), + core = mockCore + ) } - @AfterTest - fun afterEach() { - FlutterSessionReplayBridge.shutdown() + /** + * Puts the bridge on the embedded path with a resolvable slot: the host registering its view, + * the `registerEngine` handshake, and Dart declaring `isEmbedded`. Returns the slot ID the + * records are expected to carry. Must be called after [enable], which is what puts the bridge in + * the registry `bind` looks it up in. + */ + private fun embed(): String { + manager.registerSlot(hostView, messenger) + manager.bind(bridge.engineToken, messenger) + bridge.setEmbedded(true) + return checkNotNull(manager.slotId(messenger)) } + private fun rumContext(viewId: String) = DefaultFlutterSessionReplayFeature.RumContext( + applicationId = "application-id", + sessionId = "session-id", + viewId = viewId, + viewServerTimeOffset = 0L + ) + + // region Enabling + @Test - fun `M register the feature W enable`() { - // Given - var mockCore: FeatureSdkCore = mockk(relaxed = true) - val configuration = FlutterSessionReplayBridge.Configuration( - customEndpointUrl = null, - onContextChanged = mockk(relaxed = true) + fun `M register the feature in core W enable`() { + // Given - a manager with no feature yet, unlike the injected-mock setup + val manager = FlutterSessionReplayManager(embeddedSessionReplay = embedded) + val bridge = FlutterSessionReplayBridge.create(manager) + + // When + val feature = bridge.enable( + FlutterSessionReplayBridge.Configuration(onContextChanged = mockk(relaxed = true)), + core = mockCore ) + // Then + assertThat(manager.feature).isNotNull() + verify { mockCore.registerFeature(feature) } + } + + @Test + fun `M prime the engine with the current context W enable`( + @StringForgery viewId: String + ) { + // Given - the native RUM view is already active, as in a hybrid app + every { mockFeature.readCurrentContext() } returns rumContext(viewId) + // When - FlutterSessionReplayBridge.enable(configuration, core = mockCore) + var receivedContext: FlutterSessionReplayBridge.RumContext? = null + enable { receivedContext = it } + + // Then - the engine starts recording immediately instead of waiting for a context change + assertThat(receivedContext?.applicationId).isEqualTo("application-id") + assertThat(receivedContext?.sessionId).isEqualTo("session-id") + assertThat(receivedContext?.viewId).isEqualTo(viewId) + } + + @Test + fun `M register the engine for context fan-out W enable`( + @StringForgery viewId: String + ) { + // Given + var receivedContext: FlutterSessionReplayBridge.RumContext? = null + enable { receivedContext = it } + + // When - a later context change reaches the manager + manager.broadcastContext(FlutterSessionReplayBridge.RumContext(rumContext(viewId))) // Then - assertThat(FlutterSessionReplayBridge.feature).isNotNull() - verify { mockCore.registerFeature(FlutterSessionReplayBridge.feature!!) } + assertThat(receivedContext?.viewId).isEqualTo(viewId) } + // endregion + + // region Segment routing + @Test - fun `M clear listenerOwner W enable`() { - // Given — pre-seed a stale owner - val staleMessenger = mockk() - FlutterSessionReplayBridge.claimOwnership(staleMessenger) - val mockCore: FeatureSdkCore = mockk(relaxed = true) - val configuration = FlutterSessionReplayBridge.Configuration( - customEndpointUrl = null, - onContextChanged = mockk(relaxed = true) - ) + fun `M buffer instead of guessing W writeSegment before the embedding is known`() { + // Given + enable() + + // When - Dart has not called setEmbedded yet + bridge.writeSegment(segment()) + + // Then - the records go nowhere rather than down the wrong path + assertThat(writtenSegments).isEmpty() + assertThat(embedded.recordBatches).isEmpty() + } + + @Test + fun `M flush buffered segments to the feature W setEmbedded false`() { + // Given + enable() + val first = segment(viewId = "view-1") + val second = segment(viewId = "view-2") + bridge.writeSegment(first) + bridge.writeSegment(second) + + // When - Flutter is the host app + bridge.setEmbedded(false) + + // Then - buffered segments replay in order + assertThat(writtenSegments).containsExactly(first, second) + assertThat(embedded.recordBatches).isEmpty() + } + + @Test + fun `M flush buffered segments to the native recording W setEmbedded true`() { + // Given + enable() + bridge.writeSegment(segment(viewId = "view-1")) + bridge.writeSegment(segment(viewId = "view-2")) // When - FlutterSessionReplayBridge.enable(configuration, core = mockCore) + val expectedSlotId = embed() - // Then — listenerOwner is cleared; claimOwnership() will re-establish it - assertThat(FlutterSessionReplayBridge.listenerOwner).isNull() + // Then + assertThat(writtenSegments).isEmpty() + assertThat(embedded.recordBatches.map { it.viewId }).containsExactly("view-1", "view-2") + assertThat(embedded.recordBatches.map { it.slotId }.distinct()) + .containsExactly(expectedSlotId) } @Test - fun `M set listenerOwner W claimOwnership`() { + fun `M write to the feature W writeSegment when standalone`() { // Given - val mockMessenger = mockk() + enable() + bridge.setEmbedded(false) // When - FlutterSessionReplayBridge.claimOwnership(mockMessenger) + val segment = segment() + bridge.writeSegment(segment) // Then - assertThat(FlutterSessionReplayBridge.listenerOwner).isEqualTo(mockMessenger) + assertThat(writtenSegments).containsExactly(segment) + assertThat(embedded.recordBatches).isEmpty() + } + + @Test + fun `M stamp the records with the slot id W writeSegment when embedded`() { + // Given + enable() + val expectedSlotId = embed() + + // When + bridge.writeSegment(segment(viewId = "view-id", recordCount = 3)) + + // Then - the player needs the slot to composite these into the host's placeholder, and RUM + // keys record counts off the *native* view ID the records were stamped with + assertThat(embedded.recordBatches).hasSize(1) + assertThat(embedded.recordBatches[0].slotId).isEqualTo(expectedSlotId) + assertThat(embedded.recordBatches[0].viewId).isEqualTo("view-id") + assertThat(embedded.recordBatches[0].records).hasSize(3) + assertThat(writtenSegments).isEmpty() + } + + @Test + fun `M buffer until the host registers its view W writeSegment when embedded`() { + // Given - a pre-warmed engine: Dart declared isEmbedded and started recording before the + // host called enableSessionReplay(), so there is no slot to stamp records with + enable() + bridge.setEmbedded(true) + bridge.writeSegment(segment(viewId = "view-1")) + bridge.writeSegment(segment(viewId = "view-2")) + assertThat(embedded.recordBatches).isEmpty() + + // When - the host presents the engine's view + val expectedSlotId = embed() + bridge.writeSegment(segment(viewId = "view-3")) + + // Then - nothing was written to a slot the player has no placeholder for, and the buffered + // segments replay in order once one exists + assertThat(embedded.recordBatches.map { it.viewId }) + .containsExactly("view-1", "view-2", "view-3") + assertThat(embedded.recordBatches.map { it.slotId }.distinct()) + .containsExactly(expectedSlotId) + assertThat(writtenSegments).isEmpty() + } + + @Test + fun `M drop the oldest segments W writeSegment and the slot never resolves`() { + // Given - a host that configured isEmbedded but never registered a view + enable() + bridge.setEmbedded(true) + val overflow = FlutterSessionReplayBridge.MAX_PENDING_SEGMENTS + 5 + repeat(overflow) { bridge.writeSegment(segment(viewId = "view-$it")) } + + // When - a slot finally appears + embed() + + // Then - the buffer is capped, so an unresolvable engine cannot grow it without bound; what + // survives is the most recent capture rather than a stale prefix + val expectedCount = FlutterSessionReplayBridge.MAX_PENDING_SEGMENTS + assertThat(embedded.recordBatches).hasSize(expectedCount) + assertThat(embedded.recordBatches.first().viewId) + .isEqualTo("view-${overflow - expectedCount}") + assertThat(embedded.recordBatches.last().viewId).isEqualTo("view-${overflow - 1}") + } + + @Test + fun `M stop routing segments W detach`() { + // Given - an embedded engine that has been recording + enable() + embed() + bridge.writeSegment(segment()) + val batchesBeforeDetach = embedded.recordBatches.size + + // When - the engine detaches, then a segment already in flight lands + manager.detach(messenger) + bridge.writeSegment(segment()) + + // Then - the embedding state was reset, so the segment buffers rather than naming a slot + // that no longer belongs to this engine + assertThat(embedded.recordBatches).hasSize(batchesBeforeDetach) + assertThat(writtenSegments).isEmpty() } + // endregion + + // region Replay state publishing + @Test - fun `M call setHasReplay on the feature W setHasReplay`( + fun `M publish to the feature W setHasReplay when standalone`( @StringForgery viewId: String, @BoolForgery hasReplay: Boolean ) { // Given - val mockFeature = mockk(relaxed = true) - FlutterSessionReplayBridge.enableWithMock(mockFeature) + enable() + bridge.setEmbedded(false) // When - FlutterSessionReplayBridge.setHasReplay(viewId, hasReplay) + bridge.setHasReplay(viewId, hasReplay) // Then verify { mockFeature.setHasReplay(viewId, hasReplay) } } @Test - fun `M call setRecordCount on the feature W setRecordCount`( + fun `M stay quiet W setHasReplay when embedded`( + @StringForgery viewId: String + ) { + // Given + enable() + bridge.setEmbedded(true) + + // When + bridge.setHasReplay(viewId, true) + + // Then - the native Session Replay owns this core-context key when embedded; publishing + // from here too would make the value depend on which side wrote last + verify(exactly = 0) { mockFeature.setHasReplay(any(), any()) } + } + + @Test + fun `M stay quiet W setHasReplay before the embedding is known`( + @StringForgery viewId: String + ) { + // Given + enable() + + // When + bridge.setHasReplay(viewId, true) + + // Then - publishing would be a guess, and guessing wrong corrupts the native value + verify(exactly = 0) { mockFeature.setHasReplay(any(), any()) } + } + + @Test + fun `M publish to the feature W setRecordCount when standalone`( @StringForgery viewId: String, - @IntForgery recordCount: Int + @IntForgery(min = 0, max = 1000) recordCount: Int ) { // Given - val mockFeature = mockk(relaxed = true) - FlutterSessionReplayBridge.enableWithMock(mockFeature) + enable() + bridge.setEmbedded(false) // When - FlutterSessionReplayBridge.setRecordCount(viewId, recordCount) + bridge.setRecordCount(viewId, recordCount) // Then verify { mockFeature.setRecordCount(viewId, recordCount) } } @Test - fun `M call writeSegment on the feature W writeSegment`( - @StringForgery segment: String + fun `M stay quiet W setRecordCount when embedded`( + @StringForgery viewId: String, + @IntForgery(min = 0, max = 1000) recordCount: Int ) { // Given - val mockFeature = mockk(relaxed = true) - FlutterSessionReplayBridge.enableWithMock(mockFeature) + enable() + bridge.setEmbedded(true) // When - FlutterSessionReplayBridge.writeSegment(segment) + bridge.setRecordCount(viewId, recordCount) - // Then - verify { mockFeature.writeSegment(segment) } + // Then - the native embedded-content receiver counts our records instead + verify(exactly = 0) { mockFeature.setRecordCount(any(), any()) } } + // endregion + + // region Resources + @Test - fun `M addResource W saveImageForProcessing`( + fun `M forward to the resource resolver W saveImageForProcessing`( forge: Forge, @IntForgery key: Int, @IntForgery width: Int, @IntForgery height: Int ) { // Given - val mockFeature = mockk(relaxed = true) val mockResourceResolver = mockk(relaxed = true) every { mockFeature.resourceResolver } returns mockResourceResolver - - FlutterSessionReplayBridge.enableWithMock(mockFeature) + enable() // When val data = ByteBuffer.allocate(forge.anInt(1, 100)) - FlutterSessionReplayBridge.saveImageForProcessing(key, data, width, height) + bridge.saveImageForProcessing(key, data, width, height) // Then verify { mockResourceResolver.addResource(key, width, height, data) } } @Test - fun `M resolveResource W resourceIdForKey`( + fun `M return the identifier the resolver minted W resourceIdForKey`( @IntForgery key: Int, - @StringForgery resolvedKey: String + @StringForgery resolvedId: String ) { // Given - val mockFeature = mockk(relaxed = true) val mockResourceResolver = mockk(relaxed = true) every { mockFeature.resourceResolver } returns mockResourceResolver - every { mockResourceResolver.resolveResource(key) } returns resolvedKey - - FlutterSessionReplayBridge.enableWithMock(mockFeature) + every { mockResourceResolver.resolveResource(key) } returns resolvedId + enable() // When - val result = FlutterSessionReplayBridge.resourceIdForKey(key) + val result = bridge.resourceIdForKey(key) - // Then - verify { mockResourceResolver.resolveResource(key) } - assertThat(result).isEqualTo(resolvedKey) + // Then - this is the value that goes into the image wireframe + assertThat(result).isEqualTo(resolvedId) } @Test - fun `M null contextListener only W detachFromEngine with owning messenger`() { + fun `M be null W resourceIdForKey for an untracked key`( + @IntForgery key: Int + ) { // Given - val mockMessenger = mockk() - val mockCore: FeatureSdkCore = mockk(relaxed = true) - val configuration = FlutterSessionReplayBridge.Configuration( - customEndpointUrl = null, - onContextChanged = mockk(relaxed = true) - ) - FlutterSessionReplayBridge.enable(configuration, core = mockCore) - FlutterSessionReplayBridge.claimOwnership(mockMessenger) - - // When - FlutterSessionReplayBridge.detachFromEngine(mockMessenger) + val mockResourceResolver = mockk(relaxed = true) + every { mockFeature.resourceResolver } returns mockResourceResolver + every { mockResourceResolver.resolveResource(key) } returns null + enable() // Then - assertThat(FlutterSessionReplayBridge.contextListener).isNull() - assertThat(FlutterSessionReplayBridge.feature).isNotNull() + assertThat(bridge.resourceIdForKey(key)).isNull() } - @Test - fun `M not null contextListener W detachFromEngine with non-owning messenger`() { - // Given - val owningMessenger = mockk() - val otherMessenger = mockk() - val mockCore: FeatureSdkCore = mockk(relaxed = true) - val configuration = FlutterSessionReplayBridge.Configuration( - customEndpointUrl = null, - onContextChanged = mockk(relaxed = true) - ) - FlutterSessionReplayBridge.enable(configuration, core = mockCore) - FlutterSessionReplayBridge.claimOwnership(owningMessenger) - - // When — a different engine detaches - FlutterSessionReplayBridge.detachFromEngine(otherMessenger) - - // Then — listener is preserved - assertThat(FlutterSessionReplayBridge.contextListener).isNotNull() - assertThat(FlutterSessionReplayBridge.feature).isNotNull() - } + // endregion } diff --git a/packages/datadog_session_replay/android/src/test/kotlin/com/datadoghq/flutter/sessionreplay/FlutterSessionReplayManagerTest.kt b/packages/datadog_session_replay/android/src/test/kotlin/com/datadoghq/flutter/sessionreplay/FlutterSessionReplayManagerTest.kt new file mode 100644 index 000000000..5e3087bc9 --- /dev/null +++ b/packages/datadog_session_replay/android/src/test/kotlin/com/datadoghq/flutter/sessionreplay/FlutterSessionReplayManagerTest.kt @@ -0,0 +1,492 @@ +/* + * 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 2025-Present Datadog, Inc. + */ + +package com.datadoghq.flutter.sessionreplay + +import android.view.View +import assertk.assertThat +import assertk.assertions.hasSize +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isNotEqualTo +import assertk.assertions.isNotNull +import assertk.assertions.isNull +import assertk.assertions.isSameInstanceAs +import assertk.assertions.isTrue +import com.datadog.android.api.feature.Feature +import com.datadog.android.api.feature.FeatureSdkCore +import com.datadoghq.flutter.sessionreplay.feature.DefaultFlutterSessionReplayFeature +import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.junit5.ForgeExtension +import io.flutter.plugin.common.BinaryMessenger +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlin.test.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource + +/** + * Tests the process-wide coordinator: the shared feature, context fan-out across engines, the host + * slot-ID registry, and the two native paths (records and resources). + */ +@ExtendWith(ForgeExtension::class) +internal class FlutterSessionReplayManagerTest { + private val mockCore: FeatureSdkCore = mockk(relaxed = true) + private val mockFeature: DefaultFlutterSessionReplayFeature = mockk(relaxed = true) + private val embedded = EmbeddedSessionReplaySpy() + private val manager = FlutterSessionReplayManager(mockFeature, embedded) + + /** Puts the manager in the embedded state and returns the messenger it was registered under. */ + private fun embed(): BinaryMessenger { + val messenger = mockk() + manager.registerSlot(mockk(), messenger) + return messenger + } + + private fun rumContext(viewId: String) = DefaultFlutterSessionReplayFeature.RumContext( + applicationId = "application-id", + sessionId = "session-id", + viewId = viewId, + viewServerTimeOffset = 0L + ) + + /** An engine that has enabled against this manager, with its context callback recorded. */ + private fun enableEngine( + onContextChanged: (FlutterSessionReplayBridge.RumContext?) -> Unit = {} + ): FlutterSessionReplayBridge { + val bridge = FlutterSessionReplayBridge.create(manager) + bridge.enable( + FlutterSessionReplayBridge.Configuration( + customEndpointUrl = null, + onContextChanged = object : FlutterSessionReplayBridge.ContextListener { + override fun onContextChanged(context: FlutterSessionReplayBridge.RumContext) { + onContextChanged(context) + } + } + ), + core = mockCore + ) + return bridge + } + + // region Feature registration + + @Test + fun `M register one feature for every engine W enableFeature`() { + // Given - a manager with no feature yet + val manager = FlutterSessionReplayManager(embeddedSessionReplay = embedded) + + // When - two engines enable, as in a hybrid app with an embedded panel and a full-screen route + val firstFeature = manager.enableFeature(mockCore, null) + val secondFeature = manager.enableFeature(mockCore, null) + + // Then - the second engine reuses the feature instead of registering a duplicate + assertThat(secondFeature).isSameInstanceAs(firstFeature) + verify(exactly = 1) { mockCore.registerFeature(firstFeature) } + } + + // endregion + + // region Context fan-out + + @Test + fun `M reach every registered engine W broadcastContext`( + @StringForgery viewId: String + ) { + // Given - two engines, both enabled against the same manager + var contextA: FlutterSessionReplayBridge.RumContext? = null + var contextB: FlutterSessionReplayBridge.RumContext? = null + enableEngine { contextA = it } + enableEngine { contextB = it } + + // When + manager.broadcastContext(FlutterSessionReplayBridge.RumContext(rumContext(viewId))) + + // Then - not just the engine that enabled last + assertThat(contextA?.viewId).isEqualTo(viewId) + assertThat(contextB?.viewId).isEqualTo(viewId) + } + + @Test + fun `M deliver to that engine only W primeContext`( + @StringForgery viewId: String + ) { + // Given - engine A is already recording; engine B enables later + var callsToA = 0 + enableEngine { callsToA++ } + val callsToAAfterEnable = callsToA + + // When + var contextB: FlutterSessionReplayBridge.RumContext? = null + every { mockFeature.readCurrentContext() } returns rumContext(viewId) + enableEngine { contextB = it } + + // Then - priming is not a broadcast: A is not re-notified + assertThat(contextB?.viewId).isEqualTo(viewId) + assertThat(callsToA).isEqualTo(callsToAAfterEnable) + } + + @Test + fun `M not prime W enable and RUM has published no context`() { + // Given + every { mockFeature.readCurrentContext() } returns null + + // When + var callCount = 0 + enableEngine { callCount++ } + + // Then - the engine waits for onContextUpdate rather than being handed an empty context + assertThat(callCount).isEqualTo(0) + } + + // endregion + + // region Detach + + @Test + fun `M stop delivering context to that engine W detach after bind`( + @StringForgery viewId: String + ) { + // Given - one engine, bound to its messenger the way `registerEngine` does + var callsToEngine = 0 + val messenger = mockk() + val engine = enableEngine { callsToEngine++ } + manager.bind(engine.engineToken, messenger) + val callsBeforeDetach = callsToEngine + + // When - the engine detaches while the bridge is still alive, as on force close + manager.detach(messenger) + manager.broadcastContext(FlutterSessionReplayBridge.RumContext(rumContext(viewId))) + + // Then - the Dart callback is gone, so this cannot call into a destroyed isolate + assertThat(callsToEngine).isEqualTo(callsBeforeDetach) + } + + @Test + fun `M leave other engines recording W detach`( + @StringForgery viewId: String + ) { + // Given - two engines, as in a hybrid app with an embedded panel and a full-screen route + var callsToA = 0 + var contextB: FlutterSessionReplayBridge.RumContext? = null + val messengerA = mockk() + val messengerB = mockk() + val engineA = enableEngine { callsToA++ } + val engineB = enableEngine { contextB = it } + manager.bind(engineA.engineToken, messengerA) + manager.bind(engineB.engineToken, messengerB) + val callsToABeforeDetach = callsToA + + // When - only the secondary engine detaches + manager.detach(messengerA) + manager.broadcastContext(FlutterSessionReplayBridge.RumContext(rumContext(viewId))) + + // Then - B keeps receiving; a closing engine cannot clear a live one's callback + assertThat(callsToA).isEqualTo(callsToABeforeDetach) + assertThat(contextB?.viewId).isEqualTo(viewId) + } + + @Test + fun `M drop the engine's slot W detach`() { + // Given - an embedded engine with a registered slot + val messenger = embed() + assertThat(manager.slotId(messenger)).isNotNull() + + // When + manager.detach(messenger) + + // Then - the host must re-register on re-attach rather than reuse a dead view's slot + assertThat(manager.slotId(messenger)).isNull() + } + + @Test + fun `M do nothing W detach with an unbound messenger`( + @StringForgery viewId: String + ) { + // Given - an engine that never completed the `registerEngine` handshake + var contextForEngine: FlutterSessionReplayBridge.RumContext? = null + enableEngine { contextForEngine = it } + + // When - an unrelated messenger detaches + manager.detach(mockk()) + manager.broadcastContext(FlutterSessionReplayBridge.RumContext(rumContext(viewId))) + + // Then + assertThat(contextForEngine?.viewId).isEqualTo(viewId) + } + + @Test + fun `M ignore W bind with an unknown token`( + @StringForgery viewId: String, + @StringForgery unknownToken: String + ) { + // Given + var contextForEngine: FlutterSessionReplayBridge.RumContext? = null + val messenger = mockk() + enableEngine { contextForEngine = it } + + // When - a token no bridge claims, then that messenger detaches + manager.bind(unknownToken, messenger) + manager.detach(messenger) + manager.broadcastContext(FlutterSessionReplayBridge.RumContext(rumContext(viewId))) + + // Then - no bridge was associated, so nothing was torn down + assertThat(contextForEngine?.viewId).isEqualTo(viewId) + } + + // endregion + + // region Slot IDs + + @Test + fun `M assign a slot id to the host view W registerSlot`() { + // Given + val view = mockk() + + // When + manager.registerSlot(view, mockk()) + + // Then - the native recorder only emits the embedded-content placeholder for views that + // already carry an ID, so it must be assigned before the host is first snapshotted + assertThat(embedded.slotIdOf(view)).isNotNull() + } + + @Test + fun `M return the id assigned to the engine's host view W slotId`() { + // Given + val messenger = mockk() + val view = mockk() + manager.registerSlot(view, messenger) + + // When + val slotId = manager.slotId(messenger) + + // Then + assertThat(slotId).isNotNull() + assertThat(slotId).isEqualTo(embedded.slotIdOf(view)) + } + + @Test + fun `M be stable across repeated queries W slotId`() { + // Given - the bridge resolves the slot on every segment write + val messenger = embed() + + // When + val first = manager.slotId(messenger) + val second = manager.slotId(messenger) + + // Then - a new ID per query would orphan the records already stamped with the old one + assertThat(first).isNotNull() + assertThat(second).isEqualTo(first) + } + + @Test + fun `M keep the existing slot id W registerSlot called twice`() { + // Given + val messenger = mockk() + val view = mockk() + manager.registerSlot(view, messenger) + val firstSlotId = manager.slotId(messenger) + + // When - the host calls enableSessionReplay() again, or the host restarts + manager.registerSlot(view, messenger) + + // Then - and the view is not re-tagged, since nothing about it changed + assertThat(manager.slotId(messenger)).isEqualTo(firstSlotId) + assertThat(embedded.slotIdAssignments).hasSize(1) + } + + @Test + fun `M keep the slot id and clear the old view W registerSlot with a recreated view`() { + // Given - a fragment recreated on a configuration change gets a new FlutterView + val messenger = mockk() + val firstView = mockk() + manager.registerSlot(firstView, messenger) + val firstSlotId = manager.slotId(messenger) + + // When + val secondView = mockk() + manager.registerSlot(secondView, messenger) + + // Then - the same slot moves to the new view, so the player sees one continuous slot... + assertThat(manager.slotId(messenger)).isEqualTo(firstSlotId) + assertThat(embedded.slotIdOf(secondView)).isEqualTo(firstSlotId) + // ...and the old view stops being tracked, so nothing renders into a slot twice + assertThat(embedded.slotIdOf(firstView)).isNull() + } + + @Test + fun `M be null W slotId for an unregistered messenger`() { + assertThat(manager.slotId(mockk())).isNull() + } + + @Test + fun `M clear the view's slot W unregisterSlot`() { + // Given + val messenger = mockk() + val view = mockk() + manager.registerSlot(view, messenger) + + // When - the host view detaches from its engine + manager.unregisterSlot(messenger) + + // Then - records go back to buffering rather than naming a slot with no placeholder + assertThat(manager.slotId(messenger)).isNull() + assertThat(embedded.slotIdOf(view)).isNull() + } + + @Test + fun `M keep slots per engine W registerSlot for several engines`() { + // Given + val messengerA = mockk() + val messengerB = mockk() + + // When + manager.registerSlot(mockk(), messengerA) + manager.registerSlot(mockk(), messengerB) + + // Then - sharing a slot would composite both engines into the same placeholder + assertThat(manager.slotId(messengerA)).isNotNull() + assertThat(manager.slotId(messengerA)).isNotEqualTo(manager.slotId(messengerB)) + } + + // endregion + + // region Records + + @Test + fun `M pass the records to the native recording W sendToNative`() { + // Given + manager.enableFeature(mockCore, null) + + // When + val segment = """{"records":[{"type":1},{"type":2}],"viewID":"view-id"}""" + manager.sendToNative(segment, "slot-id") + + // Then + assertThat(embedded.recordBatches).hasSize(1) + assertThat(embedded.recordBatches[0].records).hasSize(2) + assertThat(embedded.recordBatches[0].slotId).isEqualTo("slot-id") + assertThat(embedded.recordBatches[0].viewId).isEqualTo("view-id") + } + + @ParameterizedTest + @ValueSource( + strings = [ + "not json at all", + """{"records":[{"type":1}]}""", // no viewID + """{"viewID":"view-id"}""", // no records + """{"records":[],"viewID":"view-id"}""", // empty records + """{"records":"not-an-array","viewID":"v"}""" + ] + ) + fun `M pass nothing W sendToNative with an unusable segment`(segment: String) { + // Given + manager.enableFeature(mockCore, null) + + // When + manager.sendToNative(segment, "slot-id") + + // Then - a malformed batch would be unplayable, so it is dropped rather than forwarded + assertThat(embedded.recordBatches).isEmpty() + } + + @Test + fun `M pass nothing W sendToNative before any engine enabled`() { + // Given - no core retained yet + + // When + manager.sendToNative("""{"records":[{"type":1}],"viewID":"view-id"}""", "slot-id") + + // Then + assertThat(embedded.recordBatches).isEmpty() + } + + // endregion + + // region Resources + + @Test + fun `M pass the resource to the native recording and claim it W sendToNative when embedded`() { + // Given + manager.enableFeature(mockCore, null) + embed() + + // When + val data = byteArrayOf(1, 2, 3) + val claimed = manager.sendToNative("identifier", data, "image/png") + + // Then - routed to the native writer, whose dedup is shared with the host and persisted + assertThat(claimed).isTrue() + assertThat(embedded.resources).hasSize(1) + assertThat(embedded.resources[0]) + .isEqualTo(EmbeddedSessionReplaySpy.Resource("identifier", data, "image/png")) + } + + @Test + fun `M decline the resource W sendToNative when standalone`() { + // Given - no host ever registered a slot, so Flutter is not embedded + manager.enableFeature(mockCore, null) + + // When + val claimed = manager.sendToNative("identifier", byteArrayOf(1, 2, 3), "image/png") + + // Then - declining sends it to the Flutter resources feature instead + assertThat(claimed).isFalse() + assertThat(embedded.resources).isEmpty() + } + + @Test + fun `M decline the resource W sendToNative before any engine enabled`() { + // Given - embedded, but no core retained yet + embed() + + // When + val claimed = manager.sendToNative("identifier", byteArrayOf(1, 2, 3), "image/png") + + // Then + assertThat(claimed).isFalse() + } + + @Test + fun `M decline the resource W sendToNative and the native module is absent`() { + // Given - a pure-Flutter app, where dd-sdk-android-session-replay is not packaged + embedded.isAvailable = false + manager.enableFeature(mockCore, null) + embed() + + // When + val claimed = manager.sendToNative("identifier", byteArrayOf(1, 2, 3), "image/png") + + // Then - the resource must still reach the Flutter resources feature + assertThat(claimed).isFalse() + } + + // endregion + + @Test + fun `M read the RUM feature context W enableFeature and prime`() { + // Given - a real feature, to check which core context priming reads + val manager = FlutterSessionReplayManager(embeddedSessionReplay = embedded) + every { mockCore.getFeatureContext(Feature.RUM_FEATURE_NAME, any()) } returns emptyMap() + + // When + val engine = FlutterSessionReplayBridge.create(manager) + engine.enable( + FlutterSessionReplayBridge.Configuration( + customEndpointUrl = null, + onContextChanged = mockk(relaxed = true) + ), + core = mockCore + ) + + // Then + verify { mockCore.getFeatureContext(Feature.RUM_FEATURE_NAME, any()) } + } +} diff --git a/packages/datadog_session_replay/android/src/test/kotlin/com/datadoghq/flutter/sessionreplay/embedded/SegmentParserTest.kt b/packages/datadog_session_replay/android/src/test/kotlin/com/datadoghq/flutter/sessionreplay/embedded/SegmentParserTest.kt new file mode 100644 index 000000000..5aec7a99c --- /dev/null +++ b/packages/datadog_session_replay/android/src/test/kotlin/com/datadoghq/flutter/sessionreplay/embedded/SegmentParserTest.kt @@ -0,0 +1,124 @@ +/* + * 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 2025-Present Datadog, Inc. + */ + +package com.datadoghq.flutter.sessionreplay.embedded + +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.hasSize +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import assertk.assertions.isNotNull +import assertk.assertions.isNull +import kotlin.test.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.ValueSource + +/** + * Tests unpacking a Dart-produced segment for the native embedded-content API. + * + * This has no iOS counterpart: iOS hands the records over already decoded, while Gson collapses + * every JSON number into one `Number` type and so needs the integral ones recovered by hand. + */ +internal class SegmentParserTest { + @Test + fun `M return the records and view id W parse`() { + // Given + val segment = """ + { + "records": [{"type": 1}, {"type": 2}], + "viewID": "view-id" + } + """.trimIndent() + + // When + val parsed = SegmentParser.parse(segment) + + // Then + assertThat(parsed).isNotNull() + assertThat(parsed!!.viewId).isEqualTo("view-id") + assertThat(parsed.records.map { it["type"] }).containsExactly(1L, 2L) + } + + @Test + fun `M keep integral numbers as Long W parse`() { + // Given - a record timestamp, which is milliseconds since the epoch + val timestamp = 1_757_000_000_123L + val segment = """{"records":[{"timestamp":$timestamp}],"viewID":"view-id"}""" + + // When + val parsed = SegmentParser.parse(segment) + + // Then - read as a Double this would re-serialize in exponent form and lose precision past + // 2^53; the player reads record timestamps as integers + val value = parsed?.records?.first()?.get("timestamp") + assertThat(value).isNotNull().isInstanceOf() + assertThat(value).isEqualTo(timestamp) + } + + @Test + fun `M keep fractional numbers as Double W parse`() { + // Given - wireframe geometry, which is not integral + val segment = """{"records":[{"x":12.5,"y":1e3}],"viewID":"view-id"}""" + + // When + val record = SegmentParser.parse(segment)?.records?.first() + + // Then + assertThat(record?.get("x")).isEqualTo(12.5) + assertThat(record?.get("y")).isEqualTo(1000.0) + } + + @Test + fun `M preserve nested structure W parse`() { + // Given - records are deeply nested: a snapshot wraps a wireframe list + val segment = """ + { + "records": [{ + "data": {"wireframes": [{"id": 7, "text": "hi", "visible": true}]} + }], + "viewID": "view-id" + } + """.trimIndent() + + // When + val record = SegmentParser.parse(segment)?.records?.first() + + // Then + @Suppress("UNCHECKED_CAST") + val wireframes = (record?.get("data") as? Map) + ?.get("wireframes") as? List> + assertThat(wireframes).isNotNull() + assertThat(wireframes!!).hasSize(1) + assertThat(wireframes[0]["id"]).isEqualTo(7L) + assertThat(wireframes[0]["text"]).isEqualTo("hi") + assertThat(wireframes[0]["visible"]).isEqualTo(true) + } + + @ParameterizedTest + @ValueSource( + strings = [ + // Not JSON at all. + "not json", + // Valid JSON, but not an object. + "[]", + // No viewID to attribute the records to. + """{"records":[{"type":1}]}""", + // viewID of the wrong type. + """{"records":[{"type":1}],"viewID":42}""", + // Nothing to send. + """{"records":[],"viewID":"view-id"}""", + """{"viewID":"view-id"}""", + // records of the wrong type. + """{"records":{"type":1},"viewID":"view-id"}""" + ] + ) + fun `M return null W parse a segment that could not be delivered`(segment: String) { + // Then - the native receiver would drop these anyway, and returning null keeps the caller + // from treating a dud segment as delivered + assertThat(SegmentParser.parse(segment)).isNull() + } +} diff --git a/packages/datadog_session_replay/android/src/test/kotlin/com/datadoghq/flutter/sessionreplay/feature/DefaultFlutterSessionReplayFeatureTest.kt b/packages/datadog_session_replay/android/src/test/kotlin/com/datadoghq/flutter/sessionreplay/feature/DefaultFlutterSessionReplayFeatureTest.kt index 8649cf0a6..97138242a 100644 --- a/packages/datadog_session_replay/android/src/test/kotlin/com/datadoghq/flutter/sessionreplay/feature/DefaultFlutterSessionReplayFeatureTest.kt +++ b/packages/datadog_session_replay/android/src/test/kotlin/com/datadoghq/flutter/sessionreplay/feature/DefaultFlutterSessionReplayFeatureTest.kt @@ -9,6 +9,7 @@ package com.datadoghq.flutter.sessionreplay.feature import android.os.Handler import assertk.assertThat import assertk.assertions.isEqualTo +import assertk.assertions.isNotEqualTo import assertk.assertions.isNotNull import com.datadog.android.api.feature.Feature import com.datadog.android.api.feature.FeatureSdkCore @@ -57,7 +58,7 @@ internal class DefaultFlutterSessionReplayFeatureTest { mockCore, onContextChanged, customEndpoint, - mockHandler + mainThreadHandler = mockHandler ) val contextValue = mapOf( "application_id" to applicationId, @@ -93,7 +94,7 @@ internal class DefaultFlutterSessionReplayFeatureTest { mockCore, onContextChanged, customEndpoint, - mockHandler + mainThreadHandler = mockHandler ) var context = mutableMapOf() every { mockCore.updateFeatureContext(any(), any(), captureLambda()) } answers { @@ -130,7 +131,7 @@ internal class DefaultFlutterSessionReplayFeatureTest { mockCore, onContextChanged, customEndpoint, - mockHandler + mainThreadHandler = mockHandler ) var context = mutableMapOf() every { mockCore.updateFeatureContext(any(), any(), captureLambda()) } answers { @@ -153,4 +154,25 @@ internal class DefaultFlutterSessionReplayFeatureTest { assertThat(viewMap?.get("has_replay")).isEqualTo(true) assertThat(viewMap?.get("records_count")).isEqualTo(recordCount) } + + @Test + fun `M not claim the native feature name W registered {hybrid apps}`( + @StringForgery customEndpoint: String + ) { + // Given + val feature = DefaultFlutterSessionReplayFeature( + mockCore, + mockk(relaxed = true), + customEndpoint, + mainThreadHandler = mockHandler + ) + + // Then + // The core keys features by name and the last registration wins, so sharing the native + // module's name would evict the native Session Replay in a hybrid app — taking its uploads + // and the embedded-content path with it. + assertThat(feature.name).isNotEqualTo(Feature.SESSION_REPLAY_FEATURE_NAME) + assertThat(feature.name) + .isEqualTo(DefaultFlutterSessionReplayFeature.FLUTTER_SESSION_REPLAY_FEATURE_NAME) + } } diff --git a/packages/datadog_session_replay/android/src/test/kotlin/com/datadoghq/flutter/sessionreplay/resource/RoutedResourceWriterTest.kt b/packages/datadog_session_replay/android/src/test/kotlin/com/datadoghq/flutter/sessionreplay/resource/RoutedResourceWriterTest.kt new file mode 100644 index 000000000..8229ebaca --- /dev/null +++ b/packages/datadog_session_replay/android/src/test/kotlin/com/datadoghq/flutter/sessionreplay/resource/RoutedResourceWriterTest.kt @@ -0,0 +1,63 @@ +/* + * Unless explicitly stated otherwise all files in this repository are licensed under the Apache License Version 2.0. + * This product includes software developed at Datadog (https://www.datadoghq.com/). + * Copyright 2025-Present Datadog, Inc. + */ + +package com.datadoghq.flutter.sessionreplay.resource + +import assertk.assertThat +import assertk.assertions.isEqualTo +import fr.xgouchet.elmyr.annotation.StringForgery +import fr.xgouchet.elmyr.junit5.ForgeExtension +import io.mockk.mockk +import io.mockk.verify +import kotlin.test.Test +import org.junit.jupiter.api.extension.ExtendWith + +/** + * Tests the fork in the resource path: an embedded app's images belong to the native Session Replay, + * a standalone app's to the Flutter resources feature. + */ +@ExtendWith(ForgeExtension::class) +internal class RoutedResourceWriterTest { + private val standaloneWriter = mockk(relaxed = true) + + @Test + fun `M write only to the native Session Replay W embedded`( + @StringForgery identifier: String + ) { + // Given - the sink accepts, which is what the manager does once Flutter is embedded + var received: Triple? = null + val writer = RoutedResourceWriter(standaloneWriter) { id, data, mimeType -> + received = Triple(id, data, mimeType) + true + } + val data = byteArrayOf(1, 2, 3) + + // When + writer.write(identifier, data) + + // Then - writing to both would upload the same image twice under two different features + assertThat(received?.first).isEqualTo(identifier) + assertThat(received?.second).isEqualTo(data) + assertThat(received?.third).isEqualTo(RoutedResourceWriter.MIME_TYPE) + verify(exactly = 0) { standaloneWriter.write(any(), any()) } + } + + @Test + fun `M fall back to the Flutter resources feature W the sink declines`( + @StringForgery identifier: String + ) { + // Given - the sink declines when Flutter is the host app, or when the native Session Replay + // module is not on the runtime classpath at all + val writer = RoutedResourceWriter(standaloneWriter) { _, _, _ -> false } + val data = byteArrayOf(1, 2, 3) + + // When + writer.write(identifier, data) + + // Then + verify { standaloneWriter.write(identifier, data) } + } +} diff --git a/packages/datadog_session_replay/lib/datadog_session_replay.dart b/packages/datadog_session_replay/lib/datadog_session_replay.dart index 992d06c0f..629e70c92 100644 --- a/packages/datadog_session_replay/lib/datadog_session_replay.dart +++ b/packages/datadog_session_replay/lib/datadog_session_replay.dart @@ -193,11 +193,16 @@ class DatadogSessionReplayConfiguration { /// Defaults to approximately 800×800 decoded pixels. int maxImagePixelBudget; - /// Whether this Flutter module is embedded inside a native iOS host app - /// (Flutter add-to-app). When `true`, Session Replay resolves the - /// `FlutterView` slot ID after the first frame and stamps it on every - /// outgoing record so the player can composite the Flutter content into - /// the native host's `embedded_view` placeholder. + /// Whether this Flutter module is embedded inside a native host app + /// (Flutter add-to-app). When `true`, records are handed to the native + /// Session Replay instead of being uploaded from here, stamped with the slot + /// ID of the host view showing this engine, so the player composites the + /// Flutter content into the native host's embedded-content placeholder. + /// + /// The host must opt each Flutter view in as well — call + /// `flutterViewController.dd.enableSessionReplay()` on iOS, or + /// `flutterFragment.enableSessionReplay()` on Android. Records captured + /// before it does are buffered natively, up to a couple of seconds' worth. /// /// Set to `false` (the default) when Flutter is the host application. bool isEmbedded; diff --git a/packages/datadog_session_replay/lib/src/android/datadog_session_replay_bridge_android.dart b/packages/datadog_session_replay/lib/src/android/datadog_session_replay_bridge_android.dart index 49d7fb214..c5d0fbe0d 100644 --- a/packages/datadog_session_replay/lib/src/android/datadog_session_replay_bridge_android.dart +++ b/packages/datadog_session_replay/lib/src/android/datadog_session_replay_bridge_android.dart @@ -40,6 +40,139 @@ import 'dart:core' as core$_; import 'package:jni/_internal.dart' as jni$_; import 'package:jni/jni.dart' as jni$_; +/// from: `com.datadoghq.flutter.sessionreplay.FlutterSessionReplayBridge$Companion` +class FlutterSessionReplayBridge$Companion extends jni$_.JObject { + @jni$_.internal + @core$_.override + final jni$_.JObjType $type; + + @jni$_.internal + FlutterSessionReplayBridge$Companion.fromReference( + jni$_.JReference reference, + ) : $type = type, + super.fromReference(reference); + + static final _class = jni$_.JClass.forName( + r'com/datadoghq/flutter/sessionreplay/FlutterSessionReplayBridge$Companion'); + + /// The type which includes information such as the signature of this class. + static const nullableType = + $FlutterSessionReplayBridge$Companion$NullableType(); + static const type = $FlutterSessionReplayBridge$Companion$Type(); + static final _id_new$ = _class.constructorId( + r'(Lkotlin/jvm/internal/DefaultConstructorMarker;)V', + ); + + static final _new$ = jni$_.ProtectedJniExtensions.lookup< + jni$_.NativeFunction< + jni$_.JniResult Function( + jni$_.Pointer, + jni$_.JMethodIDPtr, + jni$_.VarArgs<(jni$_.Pointer,)>)>>( + 'globalEnv_NewObject') + .asFunction< + jni$_.JniResult Function(jni$_.Pointer, + jni$_.JMethodIDPtr, jni$_.Pointer)>(); + + /// from: `synthetic public void (kotlin.jvm.internal.DefaultConstructorMarker defaultConstructorMarker)` + /// The returned object must be released after use, by calling the [release] method. + factory FlutterSessionReplayBridge$Companion( + jni$_.JObject? defaultConstructorMarker, + ) { + final _$defaultConstructorMarker = + defaultConstructorMarker?.reference ?? jni$_.jNullReference; + return FlutterSessionReplayBridge$Companion.fromReference(_new$( + _class.reference.pointer, + _id_new$ as jni$_.JMethodIDPtr, + _$defaultConstructorMarker.pointer) + .reference); + } +} + +final class $FlutterSessionReplayBridge$Companion$NullableType + extends jni$_.JObjType { + @jni$_.internal + const $FlutterSessionReplayBridge$Companion$NullableType(); + + @jni$_.internal + @core$_.override + String get signature => + r'Lcom/datadoghq/flutter/sessionreplay/FlutterSessionReplayBridge$Companion;'; + + @jni$_.internal + @core$_.override + FlutterSessionReplayBridge$Companion? fromReference( + jni$_.JReference reference) => + reference.isNull + ? null + : FlutterSessionReplayBridge$Companion.fromReference( + reference, + ); + @jni$_.internal + @core$_.override + jni$_.JObjType get superType => const jni$_.JObjectType(); + + @jni$_.internal + @core$_.override + jni$_.JObjType get nullableType => + this; + + @jni$_.internal + @core$_.override + final superCount = 1; + + @core$_.override + int get hashCode => + ($FlutterSessionReplayBridge$Companion$NullableType).hashCode; + + @core$_.override + bool operator ==(Object other) { + return other.runtimeType == + ($FlutterSessionReplayBridge$Companion$NullableType) && + other is $FlutterSessionReplayBridge$Companion$NullableType; + } +} + +final class $FlutterSessionReplayBridge$Companion$Type + extends jni$_.JObjType { + @jni$_.internal + const $FlutterSessionReplayBridge$Companion$Type(); + + @jni$_.internal + @core$_.override + String get signature => + r'Lcom/datadoghq/flutter/sessionreplay/FlutterSessionReplayBridge$Companion;'; + + @jni$_.internal + @core$_.override + FlutterSessionReplayBridge$Companion fromReference( + jni$_.JReference reference) => + FlutterSessionReplayBridge$Companion.fromReference( + reference, + ); + @jni$_.internal + @core$_.override + jni$_.JObjType get superType => const jni$_.JObjectType(); + + @jni$_.internal + @core$_.override + jni$_.JObjType get nullableType => + const $FlutterSessionReplayBridge$Companion$NullableType(); + + @jni$_.internal + @core$_.override + final superCount = 1; + + @core$_.override + int get hashCode => ($FlutterSessionReplayBridge$Companion$Type).hashCode; + + @core$_.override + bool operator ==(Object other) { + return other.runtimeType == ($FlutterSessionReplayBridge$Companion$Type) && + other is $FlutterSessionReplayBridge$Companion$Type; + } +} + /// from: `com.datadoghq.flutter.sessionreplay.FlutterSessionReplayBridge$Configuration` class FlutterSessionReplayBridge$Configuration extends jni$_.JObject { @jni$_.internal @@ -1179,6 +1312,112 @@ final class $FlutterSessionReplayBridge$RumContext$Type } } +/// from: `com.datadoghq.flutter.sessionreplay.FlutterSessionReplayBridge$WhenMappings` +class FlutterSessionReplayBridge$WhenMappings extends jni$_.JObject { + @jni$_.internal + @core$_.override + final jni$_.JObjType $type; + + @jni$_.internal + FlutterSessionReplayBridge$WhenMappings.fromReference( + jni$_.JReference reference, + ) : $type = type, + super.fromReference(reference); + + static final _class = jni$_.JClass.forName( + r'com/datadoghq/flutter/sessionreplay/FlutterSessionReplayBridge$WhenMappings'); + + /// The type which includes information such as the signature of this class. + static const nullableType = + $FlutterSessionReplayBridge$WhenMappings$NullableType(); + static const type = $FlutterSessionReplayBridge$WhenMappings$Type(); +} + +final class $FlutterSessionReplayBridge$WhenMappings$NullableType + extends jni$_.JObjType { + @jni$_.internal + const $FlutterSessionReplayBridge$WhenMappings$NullableType(); + + @jni$_.internal + @core$_.override + String get signature => + r'Lcom/datadoghq/flutter/sessionreplay/FlutterSessionReplayBridge$WhenMappings;'; + + @jni$_.internal + @core$_.override + FlutterSessionReplayBridge$WhenMappings? fromReference( + jni$_.JReference reference) => + reference.isNull + ? null + : FlutterSessionReplayBridge$WhenMappings.fromReference( + reference, + ); + @jni$_.internal + @core$_.override + jni$_.JObjType get superType => const jni$_.JObjectNullableType(); + + @jni$_.internal + @core$_.override + jni$_.JObjType get nullableType => + this; + + @jni$_.internal + @core$_.override + final superCount = 1; + + @core$_.override + int get hashCode => + ($FlutterSessionReplayBridge$WhenMappings$NullableType).hashCode; + + @core$_.override + bool operator ==(Object other) { + return other.runtimeType == + ($FlutterSessionReplayBridge$WhenMappings$NullableType) && + other is $FlutterSessionReplayBridge$WhenMappings$NullableType; + } +} + +final class $FlutterSessionReplayBridge$WhenMappings$Type + extends jni$_.JObjType { + @jni$_.internal + const $FlutterSessionReplayBridge$WhenMappings$Type(); + + @jni$_.internal + @core$_.override + String get signature => + r'Lcom/datadoghq/flutter/sessionreplay/FlutterSessionReplayBridge$WhenMappings;'; + + @jni$_.internal + @core$_.override + FlutterSessionReplayBridge$WhenMappings fromReference( + jni$_.JReference reference) => + FlutterSessionReplayBridge$WhenMappings.fromReference( + reference, + ); + @jni$_.internal + @core$_.override + jni$_.JObjType get superType => const jni$_.JObjectNullableType(); + + @jni$_.internal + @core$_.override + jni$_.JObjType get nullableType => + const $FlutterSessionReplayBridge$WhenMappings$NullableType(); + + @jni$_.internal + @core$_.override + final superCount = 1; + + @core$_.override + int get hashCode => ($FlutterSessionReplayBridge$WhenMappings$Type).hashCode; + + @core$_.override + bool operator ==(Object other) { + return other.runtimeType == + ($FlutterSessionReplayBridge$WhenMappings$Type) && + other is $FlutterSessionReplayBridge$WhenMappings$Type; + } +} + /// from: `com.datadoghq.flutter.sessionreplay.FlutterSessionReplayBridge` class FlutterSessionReplayBridge extends jni$_.JObject { @jni$_.internal @@ -1197,22 +1436,48 @@ class FlutterSessionReplayBridge extends jni$_.JObject { /// The type which includes information such as the signature of this class. static const nullableType = $FlutterSessionReplayBridge$NullableType(); static const type = $FlutterSessionReplayBridge$Type(); - static final _id_INSTANCE = _class.staticFieldId( - r'INSTANCE', - r'Lcom/datadoghq/flutter/sessionreplay/FlutterSessionReplayBridge;', + static final _id_Companion = _class.staticFieldId( + r'Companion', + r'Lcom/datadoghq/flutter/sessionreplay/FlutterSessionReplayBridge$Companion;', ); - /// from: `static public final com.datadoghq.flutter.sessionreplay.FlutterSessionReplayBridge INSTANCE` + /// from: `static public final com.datadoghq.flutter.sessionreplay.FlutterSessionReplayBridge$Companion Companion` /// The returned object must be released after use, by calling the [release] method. - static FlutterSessionReplayBridge get INSTANCE => - _id_INSTANCE.get(_class, const $FlutterSessionReplayBridge$Type()); + static FlutterSessionReplayBridge$Companion get Companion => _id_Companion + .get(_class, const $FlutterSessionReplayBridge$Companion$Type()); - static final _id_getContextListener = _class.instanceMethodId( - r'getContextListener', - r'()Lcom/datadoghq/flutter/sessionreplay/FlutterSessionReplayBridge$ContextListener;', + /// from: `static public final int MAX_PENDING_SEGMENTS` + static const MAX_PENDING_SEGMENTS = 20; + static final _id_new$ = _class.constructorId( + r'()V', ); - static final _getContextListener = jni$_.ProtectedJniExtensions.lookup< + static final _new$ = jni$_.ProtectedJniExtensions.lookup< + jni$_.NativeFunction< + jni$_.JniResult Function( + jni$_.Pointer, + jni$_.JMethodIDPtr, + )>>('globalEnv_NewObject') + .asFunction< + jni$_.JniResult Function( + jni$_.Pointer, + jni$_.JMethodIDPtr, + )>(); + + /// from: `public void ()` + /// The returned object must be released after use, by calling the [release] method. + factory FlutterSessionReplayBridge() { + return FlutterSessionReplayBridge.fromReference( + _new$(_class.reference.pointer, _id_new$ as jni$_.JMethodIDPtr) + .reference); + } + + static final _id_getEngineToken = _class.instanceMethodId( + r'getEngineToken', + r'()Ljava/lang/String;', + ); + + static final _getEngineToken = jni$_.ProtectedJniExtensions.lookup< jni$_.NativeFunction< jni$_.JniResult Function( jni$_.Pointer, @@ -1224,21 +1489,20 @@ class FlutterSessionReplayBridge extends jni$_.JObject { jni$_.JMethodIDPtr, )>(); - /// from: `public final com.datadoghq.flutter.sessionreplay.FlutterSessionReplayBridge$ContextListener getContextListener()` + /// from: `public final java.lang.String getEngineToken()` /// The returned object must be released after use, by calling the [release] method. - FlutterSessionReplayBridge$ContextListener? getContextListener() { - return _getContextListener( - reference.pointer, _id_getContextListener as jni$_.JMethodIDPtr) - .object( - const $FlutterSessionReplayBridge$ContextListener$NullableType()); + jni$_.JString getEngineToken() { + return _getEngineToken( + reference.pointer, _id_getEngineToken as jni$_.JMethodIDPtr) + .object(const jni$_.JStringType()); } - static final _id_setContextListener = _class.instanceMethodId( - r'setContextListener', - r'(Lcom/datadoghq/flutter/sessionreplay/FlutterSessionReplayBridge$ContextListener;)V', + static final _id_receive = _class.instanceMethodId( + r'receive', + r'(Lcom/datadoghq/flutter/sessionreplay/FlutterSessionReplayBridge$RumContext;)V', ); - static final _setContextListener = jni$_.ProtectedJniExtensions.lookup< + static final _receive = jni$_.ProtectedJniExtensions.lookup< jni$_.NativeFunction< jni$_.JThrowablePtr Function( jni$_.Pointer, @@ -1249,49 +1513,22 @@ class FlutterSessionReplayBridge extends jni$_.JObject { jni$_.JThrowablePtr Function(jni$_.Pointer, jni$_.JMethodIDPtr, jni$_.Pointer)>(); - /// from: `public final void setContextListener(com.datadoghq.flutter.sessionreplay.FlutterSessionReplayBridge$ContextListener contextListener)` - void setContextListener( - FlutterSessionReplayBridge$ContextListener? contextListener, + /// from: `public final void receive(com.datadoghq.flutter.sessionreplay.FlutterSessionReplayBridge$RumContext rumContext)` + void receive( + FlutterSessionReplayBridge$RumContext? rumContext, ) { - final _$contextListener = - contextListener?.reference ?? jni$_.jNullReference; - _setContextListener( - reference.pointer, - _id_setContextListener as jni$_.JMethodIDPtr, - _$contextListener.pointer) + final _$rumContext = rumContext?.reference ?? jni$_.jNullReference; + _receive(reference.pointer, _id_receive as jni$_.JMethodIDPtr, + _$rumContext.pointer) .check(); } - static final _id_getFeature = _class.instanceMethodId( - r'getFeature', - r'()Lcom/datadoghq/flutter/sessionreplay/feature/DefaultFlutterSessionReplayFeature;', - ); - - static final _getFeature = jni$_.ProtectedJniExtensions.lookup< - jni$_.NativeFunction< - jni$_.JniResult Function( - jni$_.Pointer, - jni$_.JMethodIDPtr, - )>>('globalEnv_CallObjectMethod') - .asFunction< - jni$_.JniResult Function( - jni$_.Pointer, - jni$_.JMethodIDPtr, - )>(); - - /// from: `public final com.datadoghq.flutter.sessionreplay.feature.DefaultFlutterSessionReplayFeature getFeature()` - /// The returned object must be released after use, by calling the [release] method. - jni$_.JObject? getFeature() { - return _getFeature(reference.pointer, _id_getFeature as jni$_.JMethodIDPtr) - .object(const jni$_.JObjectNullableType()); - } - - static final _id_setFeature = _class.instanceMethodId( - r'setFeature', - r'(Lcom/datadoghq/flutter/sessionreplay/feature/DefaultFlutterSessionReplayFeature;)V', + static final _id_bind = _class.instanceMethodId( + r'bind', + r'(Lio/flutter/plugin/common/BinaryMessenger;)V', ); - static final _setFeature = jni$_.ProtectedJniExtensions.lookup< + static final _bind = jni$_.ProtectedJniExtensions.lookup< jni$_.NativeFunction< jni$_.JThrowablePtr Function( jni$_.Pointer, @@ -1302,17 +1539,38 @@ class FlutterSessionReplayBridge extends jni$_.JObject { jni$_.JThrowablePtr Function(jni$_.Pointer, jni$_.JMethodIDPtr, jni$_.Pointer)>(); - /// from: `public final void setFeature(com.datadoghq.flutter.sessionreplay.feature.DefaultFlutterSessionReplayFeature defaultFlutterSessionReplayFeature)` - void setFeature( - jni$_.JObject? defaultFlutterSessionReplayFeature, + /// from: `public final void bind(io.flutter.plugin.common.BinaryMessenger binaryMessenger)` + void bind( + jni$_.JObject binaryMessenger, ) { - final _$defaultFlutterSessionReplayFeature = - defaultFlutterSessionReplayFeature?.reference ?? jni$_.jNullReference; - _setFeature(reference.pointer, _id_setFeature as jni$_.JMethodIDPtr, - _$defaultFlutterSessionReplayFeature.pointer) + final _$binaryMessenger = binaryMessenger.reference; + _bind(reference.pointer, _id_bind as jni$_.JMethodIDPtr, + _$binaryMessenger.pointer) .check(); } + static final _id_detach = _class.instanceMethodId( + r'detach', + r'()V', + ); + + static final _detach = jni$_.ProtectedJniExtensions.lookup< + jni$_.NativeFunction< + jni$_.JThrowablePtr Function( + jni$_.Pointer, + jni$_.JMethodIDPtr, + )>>('globalEnv_CallVoidMethod') + .asFunction< + jni$_.JThrowablePtr Function( + jni$_.Pointer, + jni$_.JMethodIDPtr, + )>(); + + /// from: `public final void detach()` + void detach() { + _detach(reference.pointer, _id_detach as jni$_.JMethodIDPtr).check(); + } + static final _id_enable = _class.instanceMethodId( r'enable', r'(Lcom/datadoghq/flutter/sessionreplay/FlutterSessionReplayBridge$Configuration;Lcom/datadog/android/api/feature/FeatureSdkCore;)Lcom/datadoghq/flutter/sessionreplay/feature/DefaultFlutterSessionReplayFeature;', @@ -1404,6 +1662,30 @@ class FlutterSessionReplayBridge extends jni$_.JObject { .check(); } + static final _id_setEmbedded = _class.instanceMethodId( + r'setEmbedded', + r'(Z)V', + ); + + static final _setEmbedded = jni$_.ProtectedJniExtensions.lookup< + jni$_.NativeFunction< + jni$_.JThrowablePtr Function( + jni$_.Pointer, + jni$_.JMethodIDPtr, + jni$_.VarArgs<(jni$_.Int32,)>)>>('globalEnv_CallVoidMethod') + .asFunction< + jni$_.JThrowablePtr Function( + jni$_.Pointer, jni$_.JMethodIDPtr, int)>(); + + /// from: `public final void setEmbedded(boolean z)` + void setEmbedded( + bool z, + ) { + _setEmbedded( + reference.pointer, _id_setEmbedded as jni$_.JMethodIDPtr, z ? 1 : 0) + .check(); + } + static final _id_writeSegment = _class.instanceMethodId( r'writeSegment', r'(Ljava/lang/String;)V', @@ -1557,6 +1839,45 @@ class FlutterSessionReplayBridge extends jni$_.JObject { reference.pointer, _id_resourceIdForKey as jni$_.JMethodIDPtr, i) .object(const jni$_.JStringNullableType()); } + + static final _id_new$1 = _class.constructorId( + r'(Lcom/datadoghq/flutter/sessionreplay/FlutterSessionReplayManager;Lkotlin/jvm/internal/DefaultConstructorMarker;)V', + ); + + static final _new$1 = jni$_.ProtectedJniExtensions.lookup< + jni$_.NativeFunction< + jni$_.JniResult Function( + jni$_.Pointer, + jni$_.JMethodIDPtr, + jni$_.VarArgs< + ( + jni$_.Pointer, + jni$_.Pointer + )>)>>('globalEnv_NewObject') + .asFunction< + jni$_.JniResult Function( + jni$_.Pointer, + jni$_.JMethodIDPtr, + jni$_.Pointer, + jni$_.Pointer)>(); + + /// from: `synthetic public void (com.datadoghq.flutter.sessionreplay.FlutterSessionReplayManager flutterSessionReplayManager, kotlin.jvm.internal.DefaultConstructorMarker defaultConstructorMarker)` + /// The returned object must be released after use, by calling the [release] method. + factory FlutterSessionReplayBridge.new$1( + jni$_.JObject? flutterSessionReplayManager, + jni$_.JObject? defaultConstructorMarker, + ) { + final _$flutterSessionReplayManager = + flutterSessionReplayManager?.reference ?? jni$_.jNullReference; + final _$defaultConstructorMarker = + defaultConstructorMarker?.reference ?? jni$_.jNullReference; + return FlutterSessionReplayBridge.fromReference(_new$1( + _class.reference.pointer, + _id_new$1 as jni$_.JMethodIDPtr, + _$flutterSessionReplayManager.pointer, + _$defaultConstructorMarker.pointer) + .reference); + } } final class $FlutterSessionReplayBridge$NullableType diff --git a/packages/datadog_session_replay/lib/src/android/datadog_session_replay_platform_android.dart b/packages/datadog_session_replay/lib/src/android/datadog_session_replay_platform_android.dart index 7f564d73c..0957e7adb 100644 --- a/packages/datadog_session_replay/lib/src/android/datadog_session_replay_platform_android.dart +++ b/packages/datadog_session_replay/lib/src/android/datadog_session_replay_platform_android.dart @@ -13,8 +13,10 @@ import '../datadog_session_replay_platform_interface.dart'; import '../rum_context.dart'; import 'datadog_session_replay_bridge_android.dart'; -// See comment in DatadogSessionReplayPlugin.onAttachedToEngine for why we use a -// method channel to claim engine ownership after the FFI enable() call. +// Per-engine method channel used to pair this engine's bridge with its messenger +// (`registerEngine`). The FFI `enable()` call can't tell which engine invoked it, so +// this channel — which routes to the plugin instance for a specific engine — provides +// that engine's messenger natively. See DatadogSessionReplayPlugin.onAttachedToEngine. // Flutter issue: https://github.com/flutter/flutter/issues/184124 const _engineChannel = MethodChannel('datadog_session_replay/engine'); @@ -22,7 +24,7 @@ class DatadogSessionReplayPlatformAndroid extends DatadogSessionReplayPlatform { late FlutterSessionReplayBridge _bridge; DatadogSessionReplayPlatformAndroid() { - _bridge = FlutterSessionReplayBridge.INSTANCE; + _bridge = FlutterSessionReplayBridge(); } DatadogSessionReplayPlatformAndroid.fromJObject(JObject ref) @@ -58,10 +60,21 @@ class DatadogSessionReplayPlatformAndroid extends DatadogSessionReplayPlatform { ); _bridge.enable(mappedConfig, null); - // Non-awaited: routes through the method channel to the correct engine's plugin - // instance, which calls claimOwnership() with that engine's BinaryMessenger. + + // Tell the bridge which path its segments take. Embedded records go to the native + // recording, standalone records to the Flutter feature. The slotId is deliberately not + // part of this: the bridge resolves it natively per segment, from the view the host + // registered, so Dart never has to observe its own view to keep up. + _bridge.setEmbedded(configuration.isEmbedded); + + // Hand this bridge's token to the plugin instance for this engine. The bridge is + // created over JNI and never sees a messenger, while the plugin has the messenger but + // never sees the bridge — this call is what pairs them, which is both how the engine's + // Dart context callback gets released on detach and how the bridge reaches the messenger + // it resolves slotIds through. Segments captured before it lands are buffered natively. // ignore: unawaited_futures - _engineChannel.invokeMethod('claimOwnership'); + _engineChannel.invokeMethod( + 'registerEngine', _bridge.getEngineToken().toDartString()); return true; } From d4a60a5cf2f4a5ba29eae13d7882b8101fcc940d Mon Sep 17 00:00:00 2001 From: Juan Naranjo Date: Tue, 25 Aug 2026 11:44:49 +0200 Subject: [PATCH 2/3] fix(sr): feedback addressed --- .../DatadogSessionReplayExtensions.kt | 16 +- .../FlutterSessionReplayBridge.kt | 14 +- .../FlutterSessionReplayManager.kt | 25 ++- .../resource/ResourceResolver.kt | 90 +++++++--- .../FlutterSessionReplayBridgeTest.kt | 21 ++- .../FlutterSessionReplayManagerTest.kt | 58 +++++- .../resource/ResourceResolverTest.kt | 168 +++++++++++++++--- 7 files changed, 331 insertions(+), 61 deletions(-) diff --git a/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/DatadogSessionReplayExtensions.kt b/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/DatadogSessionReplayExtensions.kt index b7f61d73d..3a13f0f4a 100644 --- a/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/DatadogSessionReplayExtensions.kt +++ b/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/DatadogSessionReplayExtensions.kt @@ -16,6 +16,7 @@ import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterFragment import io.flutter.embedding.android.FlutterView import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.BinaryMessenger /** * Records this Flutter content as part of the native app's Session Replay. @@ -63,14 +64,23 @@ fun FlutterView.enableSessionReplay() { // pointing at the view currently showing it. addFlutterEngineAttachmentListener( object : FlutterView.FlutterEngineAttachmentListener { + private var attachedMessenger: BinaryMessenger? = null + override fun onFlutterEngineAttachedToFlutterView(engine: FlutterEngine) { + attachedMessenger = engine.messenger FlutterSessionReplayManager.shared.registerSlot(this@enableSessionReplay, engine.messenger) } override fun onFlutterEngineDetachedFromFlutterView() { - // The engine is already gone by the time this fires, so the slot cannot be - // unregistered by messenger here. It is dropped when the plugin detaches, and until - // then the weakly held view lets a re-attach reuse the same slot. + // A cached engine moving between views — the common add-to-app pattern — keeps its + // plugin attached, so `onDetachedFromEngine` never runs and nothing else would drop + // this slot. Left registered, the engine would go on naming a view it no longer + // renders into, and the native recorder would keep emitting a placeholder for it. + // Dropping it sends records back to buffering until the engine registers somewhere. + attachedMessenger?.let { + FlutterSessionReplayManager.shared.unregisterSlot(it, this@enableSessionReplay) + } + attachedMessenger = null } } ) diff --git a/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/FlutterSessionReplayBridge.kt b/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/FlutterSessionReplayBridge.kt index d9af9502f..92800bdf1 100644 --- a/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/FlutterSessionReplayBridge.kt +++ b/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/FlutterSessionReplayBridge.kt @@ -142,9 +142,20 @@ internal class FlutterSessionReplayBridge private constructor( flushPendingSegments() } + /** + * Retries delivery of this engine's buffered segments, called once the host has registered the + * view that hosts its content. + */ + fun onSlotRegistered() { + flushPendingSegments() + } + /** Tears down everything tied to this engine's Dart isolate, called when the engine detaches. */ fun detach() { contextListener = null + // The resolver is shared and keeps entries indefinitely, so this engine's resources have to + // be dropped explicitly or they outlive the isolate that issued their keys. + manager.feature?.resourceResolver?.releaseEngine(engineToken) synchronized(lock) { boundMessenger = null embeddingState = EmbeddingState.UNKNOWN @@ -296,6 +307,7 @@ internal class FlutterSessionReplayBridge private constructor( height: Int ) { manager.feature?.resourceResolver?.addResource( + engineToken = engineToken, resourceKey = resourceId, width = width, height = height, @@ -304,7 +316,7 @@ internal class FlutterSessionReplayBridge private constructor( } fun resourceIdForKey(resourceId: Int): String? { - return manager.feature?.resourceResolver?.resolveResource(resourceId) + return manager.feature?.resourceResolver?.resolveResource(engineToken, resourceId) } // endregion diff --git a/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/FlutterSessionReplayManager.kt b/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/FlutterSessionReplayManager.kt index 9536ddd0a..c4592d05d 100644 --- a/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/FlutterSessionReplayManager.kt +++ b/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/FlutterSessionReplayManager.kt @@ -160,14 +160,17 @@ internal class FlutterSessionReplayManager( * Only ever affects the detaching engine, so a secondary engine closing cannot disturb a live one. */ fun detach(messenger: BinaryMessenger) { + var slotView: View? = null val bridge = synchronized(lock) { val existing = bridgesByMessenger.remove(messenger)?.get() if (existing != null) { engines.remove(existing) } - slotsByMessenger.remove(messenger) + slotView = slotsByMessenger.remove(messenger)?.view existing } + + slotView?.let { embeddedSessionReplay.setSlotId(it, null) } bridge?.detach() } @@ -236,6 +239,7 @@ internal class FlutterSessionReplayManager( fun registerSlot(view: View, messenger: BinaryMessenger) { isEmbedded = true + var bridge: FlutterSessionReplayBridge? = null val registration = synchronized(lock) { val existing = slotsByMessenger[messenger] if (existing != null && existing.view === view) { @@ -245,22 +249,35 @@ internal class FlutterSessionReplayManager( // registry stops tracking a slot nothing renders into any more. existing?.view?.let { embeddedSessionReplay.setSlotId(it, null) } + bridge = bridgesByMessenger[messenger]?.get() val slotId = existing?.slotId ?: UUID.randomUUID().toString() SlotRegistration(slotId, view).also { slotsByMessenger[messenger] = it } } ?: return embeddedSessionReplay.setSlotId(view, registration.slotId) + + bridge?.onSlotRegistered() } /** - * Detaches the slot registered for [messenger], if its view is still the one registered. + * Detaches the slot registered for [messenger], if [view] is still the one registered. * * Called when a host view detaches from its engine. The registration is dropped rather than * kept, so records go back to buffering instead of naming a slot the native recorder no longer * emits a placeholder for. + * + * Scoped to [view] rather than dropping whatever [messenger] currently points at, because a + * cached engine can be handed from one host view to the next: if the new view registers before + * the old one reports its detach, dropping by messenger alone would tear down the registration + * that just replaced this one. */ - fun unregisterSlot(messenger: BinaryMessenger) { - val view = synchronized(lock) { slotsByMessenger.remove(messenger)?.view } ?: return + fun unregisterSlot(messenger: BinaryMessenger, view: View) { + synchronized(lock) { + if (slotsByMessenger[messenger]?.view !== view) { + return + } + slotsByMessenger.remove(messenger) + } embeddedSessionReplay.setSlotId(view, null) } diff --git a/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/resource/ResourceResolver.kt b/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/resource/ResourceResolver.kt index 8108aa2c0..c6a3c2b61 100644 --- a/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/resource/ResourceResolver.kt +++ b/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/resource/ResourceResolver.kt @@ -10,6 +10,8 @@ import com.datadog.android.api.InternalLogger import java.nio.ByteBuffer import java.security.MessageDigest import java.security.NoSuchAlgorithmException +import java.util.Collections +import java.util.concurrent.ConcurrentHashMap internal interface ResourceResolver { class ResourceEntry( @@ -19,17 +21,25 @@ internal interface ResourceResolver { val width: Int, // The height of the resource val height: Int, - // The resource Id which is the MD5 hash of the actual resource - var resourceId: String? = null, + // The resource Id which is the MD5 hash of the actual resource. + // Volatile because [DefaultResourceResolver.resolveResource] reads it on a fast path + // outside the entry's lock, while the thread that resolves it writes it under that lock. + @Volatile var resourceId: String? = null, // The actual byte array of the resource, which is valid only until - // the resource's hash is generated, at which point it is set to null + // the resource's hash is generated, at which point it is set to null. + // Only ever touched while holding the entry's lock. var resourceBytes: ByteBuffer? ) /** * Adds a resource with the given Flutter Key to be resolved later. + * + * @param engineToken Identifies the engine that issued [resourceKey]. Resource keys are only + * unique within one engine, so this is what keeps two engines' resources apart — see the note + * on engine scoping in [DefaultResourceResolver]. */ fun addResource( + engineToken: String, resourceKey: Int, width: Int, height: Int, @@ -41,10 +51,16 @@ internal interface ResourceResolver { * This will process the resource if it has not been processed yet, and therefore * should only be called on a background thread. * + * @param engineToken The engine that issued [resourceKey], as passed to [addResource]. * @param resourceKey The Flutter resource key to resolve. * @return The resource ID (MD5 hash) or null if the resource key is unknown or processing failed. */ - fun resolveResource(resourceKey: Int): String? + fun resolveResource(engineToken: String, resourceKey: Int): String? + + /** + * Forgets every resource belonging to [engineToken], called when that engine detaches. + */ + fun releaseEngine(engineToken: String) } /** @@ -61,10 +77,21 @@ internal class DefaultResourceResolver( val resourceWriter: ResourceWriter, val bitmapHandler: BitmapHandler = DefaultBitmapHandler(internalLogger) ) : ResourceResolver { - private val resourceKeyMap: MutableMap = mutableMapOf() - private val knownResources: MutableSet = mutableSetOf() + /** + * Engine token -> that engine's resources by key. Nested rather than keyed on a composite so a + * lookup allocates nothing — [resolveResource] runs for every resource in every segment — and + * so [releaseEngine] is a single removal. + */ + @Suppress("UnsafeThirdPartyFunctionCall") // map is initialized empty + private val resourcesByEngine: + MutableMap> = ConcurrentHashMap() + + @Suppress("UnsafeThirdPartyFunctionCall") // map is initialized empty + private val knownResources: MutableSet = + Collections.newSetFromMap(ConcurrentHashMap()) override fun addResource( + engineToken: String, resourceKey: Int, width: Int, height: Int, @@ -76,38 +103,51 @@ internal class DefaultResourceResolver( height, resourceBytes = resourceBytes ) - resourceKeyMap[resourceKey] = entry + // computeIfAbsent rather than getOrPut: two engines enabling at once would otherwise each + // build a map and one would be dropped, taking whatever the loser had already added. + @Suppress("UnsafeThirdPartyFunctionCall") // map is initialized empty + val engineResources = resourcesByEngine.computeIfAbsent(engineToken) { ConcurrentHashMap() } + engineResources[resourceKey] = entry return entry } - override fun resolveResource(resourceKey: Int): String? { + override fun releaseEngine(engineToken: String) { + resourcesByEngine.remove(engineToken) + } + + override fun resolveResource(engineToken: String, resourceKey: Int): String? { // TODO(RUM-0): Telemetry, unknown resource key - val resourceEntry = resourceKeyMap[resourceKey] ?: return null + val resourceEntry = resourcesByEngine[engineToken]?.get(resourceKey) ?: return null - if (resourceEntry.resourceId != null) { - return resourceEntry.resourceId - } + resourceEntry.resourceId?.let { return it } + + return synchronized(resourceEntry) { + resourceEntry.resourceId?.let { return@synchronized it } - val resourceId = resourceEntry.resourceBytes?.let { - val bitmap = bitmapHandler.createBitmap(resourceEntry.width, resourceEntry.height, it) + val resourceBytes = resourceEntry.resourceBytes ?: return@synchronized null + val bitmap = bitmapHandler.createBitmap( + resourceEntry.width, + resourceEntry.height, + resourceBytes + ) // Discard the original bytes as fast as possible as they are no longer needed resourceEntry.resourceBytes = null - return bitmapHandler.compressBitmap(bitmap, IMAGE_QUALITY)?.let { compressedData -> - // Generate the resource ID (MD5 hash) from the bytes - val resourceId = generateResourceId(compressedData) - resourceEntry.resourceId = resourceId + val compressedData = bitmapHandler.compressBitmap(bitmap, IMAGE_QUALITY) + ?: return@synchronized null - if (resourceId != null && !knownResources.contains(resourceEntry.resourceId)) { - knownResources.add(resourceId) - resourceWriter.write(identifier = resourceId, resourceData = compressedData) - } + // Generate the resource ID (MD5 hash) from the bytes + val resourceId = generateResourceId(compressedData) ?: return@synchronized null + resourceEntry.resourceId = resourceId - return resourceId + // `add` reports whether the ID was new, which makes the check-and-claim atomic; + // `contains` followed by `add` would let two threads both write the same resource. + if (knownResources.add(resourceId)) { + resourceWriter.write(identifier = resourceId, resourceData = compressedData) } - } - return resourceId + resourceId + } } private fun generateResourceId(input: ByteArray): String? { diff --git a/packages/datadog_session_replay/android/src/test/kotlin/com/datadoghq/flutter/sessionreplay/FlutterSessionReplayBridgeTest.kt b/packages/datadog_session_replay/android/src/test/kotlin/com/datadoghq/flutter/sessionreplay/FlutterSessionReplayBridgeTest.kt index dbd7266a8..d48184ca1 100644 --- a/packages/datadog_session_replay/android/src/test/kotlin/com/datadoghq/flutter/sessionreplay/FlutterSessionReplayBridgeTest.kt +++ b/packages/datadog_session_replay/android/src/test/kotlin/com/datadoghq/flutter/sessionreplay/FlutterSessionReplayBridgeTest.kt @@ -400,7 +400,7 @@ internal class FlutterSessionReplayBridgeTest { bridge.saveImageForProcessing(key, data, width, height) // Then - verify { mockResourceResolver.addResource(key, width, height, data) } + verify { mockResourceResolver.addResource(bridge.engineToken, key, width, height, data) } } @Test @@ -411,7 +411,7 @@ internal class FlutterSessionReplayBridgeTest { // Given val mockResourceResolver = mockk(relaxed = true) every { mockFeature.resourceResolver } returns mockResourceResolver - every { mockResourceResolver.resolveResource(key) } returns resolvedId + every { mockResourceResolver.resolveResource(bridge.engineToken, key) } returns resolvedId enable() // When @@ -428,12 +428,27 @@ internal class FlutterSessionReplayBridgeTest { // Given val mockResourceResolver = mockk(relaxed = true) every { mockFeature.resourceResolver } returns mockResourceResolver - every { mockResourceResolver.resolveResource(key) } returns null + every { mockResourceResolver.resolveResource(bridge.engineToken, key) } returns null enable() // Then assertThat(bridge.resourceIdForKey(key)).isNull() } + @Test + fun `M release the engine's resources W detach`() { + // Given + val mockResourceResolver = mockk(relaxed = true) + every { mockFeature.resourceResolver } returns mockResourceResolver + enable() + + // When + bridge.detach() + + // Then - the resolver is shared and never evicts on its own, so the entries this engine's + // keys point at would otherwise outlive its isolate + verify { mockResourceResolver.releaseEngine(bridge.engineToken) } + } + // endregion } diff --git a/packages/datadog_session_replay/android/src/test/kotlin/com/datadoghq/flutter/sessionreplay/FlutterSessionReplayManagerTest.kt b/packages/datadog_session_replay/android/src/test/kotlin/com/datadoghq/flutter/sessionreplay/FlutterSessionReplayManagerTest.kt index 5e3087bc9..3f359250d 100644 --- a/packages/datadog_session_replay/android/src/test/kotlin/com/datadoghq/flutter/sessionreplay/FlutterSessionReplayManagerTest.kt +++ b/packages/datadog_session_replay/android/src/test/kotlin/com/datadoghq/flutter/sessionreplay/FlutterSessionReplayManagerTest.kt @@ -205,6 +205,22 @@ internal class FlutterSessionReplayManagerTest { assertThat(manager.slotId(messenger)).isNull() } + @Test + fun `M clear the host view's slot W detach`() { + // Given - an embedded engine whose host view is still on screen, as when a cached engine is + // destroyed out from under the view showing it + val messenger = mockk() + val view = mockk() + manager.registerSlot(view, messenger) + + // When + manager.detach(messenger) + + // Then - the tag has to go too: the native recorder draws the placeholder from the view, so + // leaving it set would keep a slot on screen that nothing can fill + assertThat(embedded.slotIdOf(view)).isNull() + } + @Test fun `M do nothing W detach with an unbound messenger`( @StringForgery viewId: String @@ -334,13 +350,31 @@ internal class FlutterSessionReplayManagerTest { manager.registerSlot(view, messenger) // When - the host view detaches from its engine - manager.unregisterSlot(messenger) + manager.unregisterSlot(messenger, view) // Then - records go back to buffering rather than naming a slot with no placeholder assertThat(manager.slotId(messenger)).isNull() assertThat(embedded.slotIdOf(view)).isNull() } + @Test + fun `M keep the new registration W unregisterSlot for a view already replaced`() { + // Given - a cached engine handed from one host view to the next, the new view registering + // before the old one reports its detach + val messenger = mockk() + val oldView = mockk() + val newView = mockk() + manager.registerSlot(oldView, messenger) + manager.registerSlot(newView, messenger) + + // When - the old view's detach arrives late + manager.unregisterSlot(messenger, oldView) + + // Then - it must not tear down the registration that replaced it + assertThat(manager.slotId(messenger)).isNotNull() + assertThat(embedded.slotIdOf(newView)).isEqualTo(manager.slotId(messenger)) + } + @Test fun `M keep slots per engine W registerSlot for several engines`() { // Given @@ -356,6 +390,28 @@ internal class FlutterSessionReplayManagerTest { assertThat(manager.slotId(messengerA)).isNotEqualTo(manager.slotId(messengerB)) } + @Test + fun `M deliver what the engine buffered W registerSlot`() { + // Given - a pre-warmed engine: it enables, binds and records before the host has a view to + // host it, so its segments have nowhere to go yet + manager.enableFeature(mockCore, null) + val bridge = enableEngine() + val messenger = mockk() + manager.bind(bridge.engineToken, messenger) + bridge.setEmbedded(true) + bridge.writeSegment("""{"records":[{"type":1}],"viewID":"view-id"}""") + assertThat(embedded.recordBatches).isEmpty() + + // When - the host finally registers its view + val view = mockk() + manager.registerSlot(view, messenger) + + // Then - the buffer is drained rather than left waiting on a segment that a static UI may + // never produce, and that detach would discard + assertThat(embedded.recordBatches).hasSize(1) + assertThat(embedded.recordBatches[0].slotId).isEqualTo(embedded.slotIdOf(view)) + } + // endregion // region Records diff --git a/packages/datadog_session_replay/android/src/test/kotlin/com/datadoghq/flutter/sessionreplay/resource/ResourceResolverTest.kt b/packages/datadog_session_replay/android/src/test/kotlin/com/datadoghq/flutter/sessionreplay/resource/ResourceResolverTest.kt index 6f8f9dd9b..2828d8b10 100644 --- a/packages/datadog_session_replay/android/src/test/kotlin/com/datadoghq/flutter/sessionreplay/resource/ResourceResolverTest.kt +++ b/packages/datadog_session_replay/android/src/test/kotlin/com/datadoghq/flutter/sessionreplay/resource/ResourceResolverTest.kt @@ -27,6 +27,9 @@ internal class ResourceResolverTest { val mockResourcesWriter = mockk() val mockBitmapHandler = mockk() + /** Resource keys are only unique within one engine, so every call is scoped by a token. */ + val engineToken = "test-engine" + fun createFakeImage(width: Int, height: Int, filledWith: Byte = 0): ByteBuffer { val byteArray = ByteArray(width * height) { filledWith } return ByteBuffer.wrap(byteArray) @@ -44,7 +47,7 @@ internal class ResourceResolverTest { ) // When - val result = resolver.resolveResource(key) + val result = resolver.resolveResource(engineToken, key) // Then assertThat(result).isNull() @@ -70,8 +73,8 @@ internal class ResourceResolverTest { // When val fakeImage = createFakeImage(width = imageWidth, height = imageHeight) - resolver.addResource(key, imageWidth, imageHeight, fakeImage) - val result = resolver.resolveResource(key) + resolver.addResource(engineToken, key, imageWidth, imageHeight, fakeImage) + val result = resolver.resolveResource(engineToken, key) // Then - this is the MD5 hash of an array of 25 zero bytes assertThat(result).isEqualTo("d28c293e10139d5d8f6e4592aeaffc1b") @@ -98,10 +101,10 @@ internal class ResourceResolverTest { // When val fakeImage = createFakeImage(imageWidth, imageHeight) - resolver.addResource(keyA, imageWidth, imageHeight, fakeImage) - resolver.addResource(keyB, imageWidth, imageHeight, fakeImage) - val resultA = resolver.resolveResource(keyA) - val resultB = resolver.resolveResource(keyB) + resolver.addResource(engineToken, keyA, imageWidth, imageHeight, fakeImage) + resolver.addResource(engineToken, keyB, imageWidth, imageHeight, fakeImage) + val resultA = resolver.resolveResource(engineToken, keyA) + val resultB = resolver.resolveResource(engineToken, keyB) // Then assertThat(resultA).isEqualTo(resultB) @@ -138,10 +141,10 @@ internal class ResourceResolverTest { every { mockResourcesWriter.write(any(), any()) } answers {} // When - resolver.addResource(key, imageWidthA, imageHeightA, fakeImageA) - resolver.addResource(key + 1, imageWidthB, imageHeightB, fakeImageB) - val resultA = resolver.resolveResource(key) - val resultB = resolver.resolveResource(key + 1) + resolver.addResource(engineToken, key, imageWidthA, imageHeightA, fakeImageA) + resolver.addResource(engineToken, key + 1, imageWidthB, imageHeightB, fakeImageB) + val resultA = resolver.resolveResource(engineToken, key) + val resultB = resolver.resolveResource(engineToken, key + 1) // Then assertThat(resultA).isNotEqualTo(resultB) @@ -167,9 +170,9 @@ internal class ResourceResolverTest { every { mockResourcesWriter.write(any(), any()) } answers {} // When - resolver.addResource(key, imageWidth, imageHeight, fakeImage) - val resultA = resolver.resolveResource(key) - val resultB = resolver.resolveResource(key) + resolver.addResource(engineToken, key, imageWidth, imageHeight, fakeImage) + val resultA = resolver.resolveResource(engineToken, key) + val resultB = resolver.resolveResource(engineToken, key) // Then assertThat(resultA).isEqualTo(resultB) @@ -198,8 +201,8 @@ internal class ResourceResolverTest { every { mockResourcesWriter.write(any(), any()) } answers {} // When - resolver.addResource(key, imageWidth, imageHeight, fakeImage) - val resultA = resolver.resolveResource(key) + resolver.addResource(engineToken, key, imageWidth, imageHeight, fakeImage) + val resultA = resolver.resolveResource(engineToken, key) // Then verify { mockResourcesWriter.write(resultA!!, refEq(fakeCompressedImage)) } @@ -236,10 +239,10 @@ internal class ResourceResolverTest { every { mockResourcesWriter.write(any(), any()) } answers {} // When - resolver.addResource(key, imageWidthA, imageHeightA, fakeImageA) - resolver.addResource(key + 1, imageWidthB, imageHeightB, fakeImageB) - val resultA = resolver.resolveResource(key) - val resultB = resolver.resolveResource(key + 1) + resolver.addResource(engineToken, key, imageWidthA, imageHeightA, fakeImageA) + resolver.addResource(engineToken, key + 1, imageWidthB, imageHeightB, fakeImageB) + val resultA = resolver.resolveResource(engineToken, key) + val resultB = resolver.resolveResource(engineToken, key + 1) // Then assertThat(resultA).isNotEqualTo(resultB) @@ -267,13 +270,130 @@ internal class ResourceResolverTest { every { mockResourcesWriter.write(any(), any()) } answers {} // When - resolver.addResource(key, imageWidth, imageHeight, fakeImage) - resolver.addResource(key + 1, imageWidth, imageHeight, fakeImage) - val resultA = resolver.resolveResource(key) - val resultB = resolver.resolveResource(key) + resolver.addResource(engineToken, key, imageWidth, imageHeight, fakeImage) + resolver.addResource(engineToken, key + 1, imageWidth, imageHeight, fakeImage) + val resultA = resolver.resolveResource(engineToken, key) + val resultB = resolver.resolveResource(engineToken, key) // Then assertThat(resultA).isEqualTo(resultB) verify(exactly = 1) { mockResourcesWriter.write(resultA!!, refEq(fakeCompressedImage)) } } + + // region Engine scoping + + @Test + fun `M keep resources apart W resolveResource {same key, two engines}`( + @IntForgery key: Int, + @IntForgery(5, 50) imageWidth: Int, + @IntForgery(5, 50) imageHeight: Int + ) { + // Given - both engines issue the same key, because each Dart isolate counts from the same + // startingResourceKey + val resolver = DefaultResourceResolver( + mockInternalLogger, + mockResourcesWriter, + mockBitmapHandler + ) + val engineA = "engine-a" + val engineB = "engine-b" + val fakeImageA = createFakeImage(imageWidth, imageHeight, filledWith = 0) + val fakeImageB = createFakeImage(imageWidth, imageHeight, filledWith = 7) + val mockBitmapA = mockk() + val mockBitmapB = mockk() + val fakeCompressedImageA = ByteArray(25) { 0 } + val fakeCompressedImageB = ByteArray(25) { 124 } + every { + mockBitmapHandler.createBitmap(any(), any(), refEq(fakeImageA)) + } returns mockBitmapA + every { + mockBitmapHandler.createBitmap(any(), any(), refEq(fakeImageB)) + } returns mockBitmapB + every { mockBitmapHandler.compressBitmap(mockBitmapA, any()) } returns fakeCompressedImageA + every { mockBitmapHandler.compressBitmap(mockBitmapB, any()) } returns fakeCompressedImageB + every { mockResourcesWriter.write(any(), any()) } answers {} + + // When + resolver.addResource(engineA, key, imageWidth, imageHeight, fakeImageA) + resolver.addResource(engineB, key, imageWidth, imageHeight, fakeImageB) + + // Then - each engine gets its own image back, not whichever was added last + assertThat(resolver.resolveResource(engineA, key)) + .isEqualTo("d28c293e10139d5d8f6e4592aeaffc1b") + assertThat(resolver.resolveResource(engineB, key)) + .isNotEqualTo("d28c293e10139d5d8f6e4592aeaffc1b") + } + + @Test + fun `M return null W resolveResource {key belongs to another engine}`( + @IntForgery key: Int, + @IntForgery(5, 50) imageWidth: Int, + @IntForgery(5, 50) imageHeight: Int + ) { + // Given + val resolver = DefaultResourceResolver( + mockInternalLogger, + mockResourcesWriter, + mockBitmapHandler + ) + val fakeImage = createFakeImage(imageWidth, imageHeight) + + // When + resolver.addResource("engine-a", key, imageWidth, imageHeight, fakeImage) + + // Then + assertThat(resolver.resolveResource("engine-b", key)).isNull() + } + + @Test + fun `M forget the engine's resources W releaseEngine`( + @IntForgery key: Int, + @IntForgery(5, 50) imageWidth: Int, + @IntForgery(5, 50) imageHeight: Int + ) { + // Given + val resolver = DefaultResourceResolver( + mockInternalLogger, + mockResourcesWriter, + mockBitmapHandler + ) + val fakeImage = createFakeImage(imageWidth, imageHeight) + resolver.addResource(engineToken, key, imageWidth, imageHeight, fakeImage) + + // When + resolver.releaseEngine(engineToken) + + // Then + assertThat(resolver.resolveResource(engineToken, key)).isNull() + } + + @Test + fun `M leave other engines untouched W releaseEngine`( + @IntForgery key: Int, + @IntForgery(5, 50) imageWidth: Int, + @IntForgery(5, 50) imageHeight: Int + ) { + // Given + val resolver = DefaultResourceResolver( + mockInternalLogger, + mockResourcesWriter, + mockBitmapHandler + ) + val fakeImage = createFakeImage(imageWidth, imageHeight) + val mockBitmap = mockk() + every { mockBitmapHandler.createBitmap(any(), any(), refEq(fakeImage)) } returns mockBitmap + every { mockBitmapHandler.compressBitmap(mockBitmap, any()) } returns ByteArray(25) { 0 } + every { mockResourcesWriter.write(any(), any()) } answers {} + resolver.addResource("engine-a", key, imageWidth, imageHeight, fakeImage) + resolver.addResource("engine-b", key, imageWidth, imageHeight, fakeImage) + + // When + resolver.releaseEngine("engine-a") + + // Then - a detaching engine cannot disturb one that is still recording + assertThat(resolver.resolveResource("engine-b", key)) + .isEqualTo("d28c293e10139d5d8f6e4592aeaffc1b") + } + + // endregion } From 5973287afbc54bca003177b843493af3667e77b7 Mon Sep 17 00:00:00 2001 From: Juan Naranjo Date: Wed, 26 Aug 2026 11:12:39 +0200 Subject: [PATCH 3/3] refactor(sr): let Gson type segment numbers instead of walking the tree --- .../sessionreplay/embedded/SegmentParser.kt | 66 +++++-------------- 1 file changed, 18 insertions(+), 48 deletions(-) diff --git a/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/embedded/SegmentParser.kt b/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/embedded/SegmentParser.kt index bf1ffb296..243e72bb4 100644 --- a/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/embedded/SegmentParser.kt +++ b/packages/datadog_session_replay/android/src/main/kotlin/com/datadoghq/flutter/sessionreplay/embedded/SegmentParser.kt @@ -7,11 +7,9 @@ package com.datadoghq.flutter.sessionreplay.embedded import com.datadoghq.flutter.sessionreplay.models.EnrichedRecord -import com.google.gson.JsonArray -import com.google.gson.JsonElement -import com.google.gson.JsonObject -import com.google.gson.JsonParser -import com.google.gson.JsonPrimitive +import com.google.gson.GsonBuilder +import com.google.gson.ToNumberPolicy +import com.google.gson.reflect.TypeToken /** * A segment produced by the Dart processor, unpacked into what the native embedded-content API @@ -32,56 +30,28 @@ internal data class ParsedSegment( * keeps the caller's buffering logic from treating a dud segment as delivered. */ internal object SegmentParser { + + private val gson = GsonBuilder() + .setObjectToNumberStrategy(ToNumberPolicy.LONG_OR_DOUBLE) + .create() + + private val segmentType = object : TypeToken>() {}.type + fun parse(segmentJson: String): ParsedSegment? { - val root = runCatching { JsonParser.parseString(segmentJson) } - .getOrNull() as? JsonObject - ?: return null + val root: Map = runCatching { + gson.fromJson>(segmentJson, segmentType) + }.getOrNull() ?: return null - val viewId = (root.get(EnrichedRecord.VIEW_ID_KEY) as? JsonPrimitive) - ?.takeIf { it.isString } - ?.asString - ?: return null + val viewId = root[EnrichedRecord.VIEW_ID_KEY] as? String ?: return null - val records = (root.get(EnrichedRecord.RECORDS_KEY) as? JsonArray) - ?.mapNotNull { element -> (element as? JsonObject)?.let { toMap(it) } } + val records = (root[EnrichedRecord.RECORDS_KEY] as? List<*>) + ?.mapNotNull { it.asRecord() } ?.takeIf { it.isNotEmpty() } ?: return null return ParsedSegment(records, viewId) } - private fun toMap(source: JsonObject): Map { - return source.entrySet().associate { (key, value) -> key to toValue(value) } - } - - private fun toValue(element: JsonElement): Any? { - return when { - element.isJsonObject -> toMap(element.asJsonObject) - element.isJsonArray -> element.asJsonArray.map { toValue(it) } - element.isJsonPrimitive -> toPrimitive(element.asJsonPrimitive) - else -> null - } - } - - /** - * Gson models every JSON number as a single `Number` type, so the integral ones have to be - * recovered by inspecting the literal. Reading them all as `Double` would re-serialize record - * timestamps in exponent form and lose precision past 2^53, and the player reads those - * timestamps as integers. - */ - private fun toPrimitive(primitive: JsonPrimitive): Any? { - return when { - primitive.isBoolean -> primitive.asBoolean - primitive.isString -> primitive.asString - primitive.isNumber -> { - val literal = primitive.asString - if (literal.any { it == '.' || it == 'e' || it == 'E' }) { - primitive.asDouble - } else { - literal.toLongOrNull() ?: primitive.asDouble - } - } - else -> null - } - } + @Suppress("UNCHECKED_CAST") + private fun Any?.asRecord(): Map? = this as? Map }