Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/datadog_session_replay/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -77,6 +79,8 @@ android {
}

testOptions {
unitTests.returnDefaultValues = true

unitTests.all {
useJUnitPlatform()

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/*
* 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
import io.flutter.plugin.common.BinaryMessenger

/**
* 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 {
private var attachedMessenger: BinaryMessenger? = null

override fun onFlutterEngineAttachedToFlutterView(engine: FlutterEngine) {
attachedMessenger = engine.messenger
FlutterSessionReplayManager.shared.registerSlot(this@enableSessionReplay, engine.messenger)
}

override fun onFlutterEngineDetachedFromFlutterView() {
// 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
}
}
)
}

/** 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)
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand All @@ -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)
}
}
Loading