Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,15 @@ android {
targetCompatibility JavaVersion.VERSION_1_8
}

testOptions {
unitTests {
returnDefaultValues = true
all {
useJUnitPlatform()
}
}
}

sourceSets {
main {
java.srcDirs += [
Expand All @@ -74,4 +83,14 @@ def kotlin_version = getExtOrDefault("kotlinVersion")
dependencies {
implementation "com.facebook.react:react-android"
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
implementation "com.launchdarkly:launchdarkly-observability-android:0.34.1"
implementation "com.launchdarkly:launchdarkly-android-client-sdk:5.11.0"
// compileOnly: OTel Attributes appears in ObservabilityOptions parameter types; provided at
// runtime transitively through launchdarkly-observability-android.
compileOnly "io.opentelemetry:opentelemetry-api:1.51.0"

testImplementation platform("org.junit:junit-bom:5.13.4")
testImplementation "org.junit.jupiter:junit-jupiter"
testRuntimeOnly "org.junit.platform:junit-platform-launcher"
testImplementation "io.mockk:mockk:1.14.5"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
package com.sessionreplayreactnative

import android.app.Application
import android.os.Handler
import android.os.Looper
import com.facebook.react.bridge.ReadableMap
import com.launchdarkly.logging.LDLogger
import com.launchdarkly.observability.api.ObservabilityOptions
import com.launchdarkly.observability.plugin.Observability
import com.launchdarkly.observability.replay.PrivacyProfile
import com.launchdarkly.observability.replay.ReplayOptions
import com.launchdarkly.observability.replay.plugin.SessionReplay
import com.launchdarkly.observability.sdk.LDReplay
import com.launchdarkly.sdk.ContextKind
import com.launchdarkly.sdk.LDContext
import com.launchdarkly.sdk.android.Components
import com.launchdarkly.sdk.android.LDAndroidLogging
import com.launchdarkly.sdk.android.LDClient
import com.launchdarkly.sdk.android.LDConfig

internal class SessionReplayClientAdapter private constructor() {

private val lock = Any()
private var mobileKey: String? = null
private var serviceName: String = DEFAULT_SERVICE_NAME
private var replayOptions: ReplayOptions? = null
// Only accessed from the main thread (all reads/writes are inside Handler(mainLooper).post blocks).
private var initialized = false
private val logger = LDLogger.withAdapter(LDAndroidLogging.adapter(), TAG)

fun setMobileKey(mobileKey: String, options: ReadableMap?) {
synchronized(lock) {
this.mobileKey = mobileKey
this.serviceName = options?.takeIf { it.hasKey("serviceName") }
?.getString("serviceName")
?.takeIf { it.isNotBlank() }
?: DEFAULT_SERVICE_NAME
this.replayOptions = replayOptionsFrom(options)
}
}

fun start(application: Application, completion: (Boolean, String?) -> Unit) {
val localMobileKey: String?
val localServiceName: String
val localReplayOptions: ReplayOptions?

// Capture configuration under the lock, then release it before posting to the main thread.
synchronized(lock) {
localMobileKey = mobileKey
localReplayOptions = replayOptions
localServiceName = serviceName
}
if (localMobileKey == null || localReplayOptions == null) {
val msg = "start: configure() was not called — mobile key or options are missing"
logger.error(msg)
completion(false, msg)
return
}

// All work runs on the main thread so that:
// 1. initLDClient() satisfies the main-thread requirement of OpenTelemetryRum.build().
// 2. Consecutive start()/stop() calls are naturally serialized without locks.
Handler(Looper.getMainLooper()).post {
if (!initialized) {
try {
initLDClient(application, localMobileKey, localServiceName, localReplayOptions)
} catch (e: Exception) {
logger.error("start: LDClient.init() threw {0}: {1}", e::class.simpleName, e.message)
completion(false, "Session replay failed to initialize.")
return@post
}
initialized = true
} else {
logger.debug("start: already initialized, re-applying isEnabled={0}", localReplayOptions.enabled)
}
try {
applyEnabled(localReplayOptions.enabled)
} catch (e: Exception) {
logger.error("start: applyEnabled threw {0}: {1}", e::class.simpleName, e.message)
completion(false, "Session replay failed to start.")
return@post
}
completion(true, null)
Comment thread
cursor[bot] marked this conversation as resolved.
}
}

fun stop(completion: () -> Unit) {
logger.debug("stop")
// Post to the main thread so that stop() queues behind any in-progress start().
Handler(Looper.getMainLooper()).post {
try {
LDReplay.stop()
} catch (e: Exception) {
logger.error("stop: threw {0}: {1}", e::class.simpleName, e.message)
}
completion()
}
}

private fun initLDClient(application: Application, mobileKey: String, serviceName: String, replayOptions: ReplayOptions) {
logger.debug("initLDClient: calling LDClient.init()")
val config = LDConfig.Builder(LDConfig.Builder.AutoEnvAttributes.Enabled)
.mobileKey(mobileKey)
.offline(true)
.plugins(
Components.plugins().setPlugins(
listOf(
// TODO: Pass JS ObservabilityOptions such as backendUrl,
// resourceAttributes, and sessionBackgroundTimeout through to here.
Observability(
application = application,
mobileKey = mobileKey,
options = ObservabilityOptions(
serviceName = serviceName,
logAdapter = LDAndroidLogging.adapter(),
)
),
SessionReplay(options = replayOptions),
)
)
)
.build()

// The context key is a placeholder. The LDClient is offline and never sends it to
// LaunchDarkly servers, but SessionReplay does use it locally to attribute sessions.
//
// TODO: Pass the actual initial context here once the LaunchDarkly React Native SDK
// supports providing a context at initialization time. Currently, context is only
// available after an explicit client.identify() call — getContext() always returns
// undefined when register() runs during the LDClient constructor.
val placeholderContext = LDContext.builder(ContextKind.DEFAULT, "placeholder").build()
// timeout=0: return immediately without blocking the main thread waiting for flags.
// onPluginsReady() fires synchronously during init() before it returns.
LDClient.init(application, config, placeholderContext, 0)
}

private fun applyEnabled(enabled: Boolean) {
if (enabled) {
LDReplay.start()
} else {
LDReplay.stop()
}
}

internal fun replayOptionsFrom(map: ReadableMap?): ReplayOptions {
if (map == null) {
return ReplayOptions(
enabled = true,
privacyProfile = PrivacyProfile(maskTextInputs = true)
)
}

val isEnabled = if (map.hasKey("isEnabled")) map.getBoolean("isEnabled") else true
val maskTextInputs = if (map.hasKey("maskTextInputs")) map.getBoolean("maskTextInputs") else true
val maskWebViews = if (map.hasKey("maskWebViews")) map.getBoolean("maskWebViews") else false
val maskText = if (map.hasKey("maskLabels")) map.getBoolean("maskLabels") else false
val maskImages = if (map.hasKey("maskImages")) map.getBoolean("maskImages") else false

return ReplayOptions(
enabled = isEnabled,
privacyProfile = PrivacyProfile(
maskTextInputs = maskTextInputs,
maskWebViews = maskWebViews,
maskText = maskText,
maskImageViews = maskImages,
)
)
}

companion object {
val shared = SessionReplayClientAdapter()
private const val TAG = "LDSessionReplay"
private const val DEFAULT_SERVICE_NAME = "sessionreplay-react-native"
}
}
Original file line number Diff line number Diff line change
@@ -1,43 +1,58 @@
package com.sessionreplayreactnative

import android.app.Application
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReadableMap
import com.facebook.react.module.annotations.ReactModule
import com.facebook.react.bridge.Promise

@ReactModule(name = SessionReplayReactNativeModule.NAME)
class SessionReplayReactNativeModule(reactContext: ReactApplicationContext) :
NativeSessionReplayReactNativeSpec(reactContext) {

override fun getName(): String {
return NAME
}
override fun getName(): String = NAME

override fun configure(
mobileKey: String,
options: com.facebook.react.bridge.ReadableMap?,
promise: Promise
) {
promise.reject(
"NOT_SUPPORTED",
"Session replay is not yet supported on Android. iOS support is available.",
null
)
override fun configure(mobileKey: String, options: ReadableMap?, promise: Promise) {
val key = mobileKey.trim()
if (key.isEmpty()) {
promise.reject("invalid_mobile_key", "Session replay requires a non-empty mobile key.", null)
return
}
try {
SessionReplayClientAdapter.shared.setMobileKey(key, options)
promise.resolve(null)
} catch (e: Exception) {
promise.reject("configure_failed", e.message, e)
}
}

override fun startSessionReplay(promise: Promise) {
promise.reject(
"NOT_SUPPORTED",
"Session replay is not yet supported on Android. iOS support is available.",
null
)
val application = reactApplicationContext.applicationContext as? Application
if (application == null) {
promise.reject("start_failed", "Could not obtain application context.", null)
return
}
try {
SessionReplayClientAdapter.shared.start(application) { success, errorMessage ->
if (success) {
promise.resolve(null)
} else {
promise.reject("start_failed", errorMessage ?: "Session replay failed to start.", null)
}
}
} catch (e: Exception) {
promise.reject("start_failed", e.message, e)
}
}

override fun stopSessionReplay(promise: Promise) {
promise.reject(
"NOT_SUPPORTED",
"Session replay is not yet supported on Android. iOS support is available.",
null
)
try {
SessionReplayClientAdapter.shared.stop {
promise.resolve(null)
}
} catch (e: Exception) {
promise.reject("stop_failed", e.message, e)
}
}

companion object {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package com.sessionreplayreactnative

import android.app.Application
import com.facebook.react.bridge.ReadableMap
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test

class SessionReplayClientAdapterTest {

private fun newAdapter(): SessionReplayClientAdapter {
val constructor = SessionReplayClientAdapter::class.java.getDeclaredConstructor()
constructor.isAccessible = true
return constructor.newInstance()
}

@Test
fun `replayOptionsFrom null map returns defaults`() {
val adapter = newAdapter()
val options = adapter.replayOptionsFrom(null)

assertTrue(options.enabled)
assertTrue(options.privacyProfile.maskTextInputs)
assertFalse(options.privacyProfile.maskWebViews)
assertFalse(options.privacyProfile.maskText)
assertFalse(options.privacyProfile.maskImageViews)
}

@Test
fun `replayOptionsFrom maps maskLabels key to maskText field`() {
val adapter = newAdapter()
val map = mockk<ReadableMap> {
every { hasKey("maskLabels") } returns true
every { getBoolean("maskLabels") } returns true
every { hasKey("isEnabled") } returns false
every { hasKey("maskTextInputs") } returns false
every { hasKey("maskWebViews") } returns false
every { hasKey("maskImages") } returns false
}

val options = adapter.replayOptionsFrom(map)

assertTrue(options.privacyProfile.maskText)
}

@Test
fun `start before setMobileKey calls completion with failure`() {
val adapter = newAdapter()
var success: Boolean? = null
var errorMessage: String? = null

adapter.start(mockk<Application>(relaxed = true)) { s, e ->
success = s
errorMessage = e
}

assertEquals(false, success)
assertTrue(errorMessage!!.contains("mobile key"))
}
}
Original file line number Diff line number Diff line change
@@ -1 +1,38 @@
it.todo('write a test');
import NativeSessionReplayReactNative from '../NativeSessionReplayReactNative';
import { configureSessionReplay, createSessionReplayPlugin } from '../index';

jest.mock('../NativeSessionReplayReactNative', () => ({
configure: jest.fn().mockResolvedValue(undefined),
startSessionReplay: jest.fn().mockResolvedValue(undefined),
stopSessionReplay: jest.fn().mockResolvedValue(undefined),
}));

describe('configureSessionReplay', () => {
it('rejects if key is empty', async () => {
await expect(configureSessionReplay('')).rejects.toThrow();
});

it('rejects if key is whitespace', async () => {
await expect(configureSessionReplay(' ')).rejects.toThrow();
});
});

describe('SessionReplayPluginAdapter', () => {
it('calls configure and startSessionReplay on register', async () => {
const plugin = createSessionReplayPlugin();
plugin.register(
{},
{ sdk: { name: 'test', version: '0.0.0' }, mobileKey: 'mob-key-123' }
);

await new Promise(process.nextTick);

expect(NativeSessionReplayReactNative.configure).toHaveBeenCalledWith(
'mob-key-123',
{}
);
expect(
NativeSessionReplayReactNative.startSessionReplay
).toHaveBeenCalled();
});
});
Loading