diff --git a/packages/session-replay-react-native/android/build.gradle b/packages/session-replay-react-native/android/build.gradle index 02b727dbe..a57a73f87 100644 --- a/packages/session-replay-react-native/android/build.gradle +++ b/packages/session-replay-react-native/android/build.gradle @@ -96,7 +96,8 @@ repositories { def kotlin_version = getExtOrDefault("kotlinVersion") dependencies { - implementation("com.amplitude:session-replay-android:[0.24.0,0.25.0)") + // TODO(SDKRN-68): 0.29.0 is blocked on session-replay-android#471 merging and publishing to Maven Central. + implementation("com.amplitude:session-replay-android:[0.29.0,0.30.0)") implementation("com.amplitude:analytics-android:[1.25.0,1.26.0)") // For < 0.71, this will be from the local maven repo diff --git a/packages/session-replay-react-native/android/src/main/java/com/amplitude/sessionreplayreactnative/SessionReplayReactNativeModule.kt b/packages/session-replay-react-native/android/src/main/java/com/amplitude/sessionreplayreactnative/SessionReplayReactNativeModule.kt index 4172c50e7..93b22b44e 100644 --- a/packages/session-replay-react-native/android/src/main/java/com/amplitude/sessionreplayreactnative/SessionReplayReactNativeModule.kt +++ b/packages/session-replay-react-native/android/src/main/java/com/amplitude/sessionreplayreactnative/SessionReplayReactNativeModule.kt @@ -108,7 +108,7 @@ class SessionReplayReactNativeModule(private val reactContext: ReactApplicationC override fun setDeviceId(deviceId: String?, promise: Promise) { try { nativeConfig = requireNotNull(nativeConfig).copy(deviceId = deviceId) - sessionReplay?.setDeviceId(deviceId ?: "") + sessionReplay?.setDeviceId(deviceId) promise.resolve(null) } catch (e: Exception) { promise.reject("SET_DEVICE_ID_ERROR", e.message, e) @@ -218,7 +218,7 @@ class SessionReplayReactNativeModule(private val reactContext: ReactApplicationC return SessionReplay( apiKey = config.apiKey, context = reactContext.applicationContext, - deviceId = config.deviceId ?: "", + deviceId = config.deviceId, sessionId = config.sessionId, optOut = config.optOut, sampleRate = config.sampleRate, diff --git a/packages/session-replay-react-native/ios/AMPNativeSessionReplay.mm b/packages/session-replay-react-native/ios/AMPNativeSessionReplay.mm index 2aec876b8..36406d56d 100644 --- a/packages/session-replay-react-native/ios/AMPNativeSessionReplay.mm +++ b/packages/session-replay-react-native/ios/AMPNativeSessionReplay.mm @@ -8,7 +8,7 @@ @interface RCT_EXTERN_MODULE(AMPNativeSessionReplay, NSObject) RCT_EXTERN_METHOD(setSessionId:(nonnull NSNumber)sessionId resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) -RCT_EXTERN_METHOD(setDeviceId:(NSString)deviceId resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD(setDeviceId:(nullable NSString *)deviceId resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) RCT_EXTERN_METHOD(setOptOut:(BOOL)optOut resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject) diff --git a/packages/session-replay-react-native/ios/NativeSessionReplay.swift b/packages/session-replay-react-native/ios/NativeSessionReplay.swift index 2d924f757..a4fe06275 100644 --- a/packages/session-replay-react-native/ios/NativeSessionReplay.swift +++ b/packages/session-replay-react-native/ios/NativeSessionReplay.swift @@ -71,7 +71,7 @@ class NativeSessionReplay: NSObject, RCTBridgeModule { } @objc(setDeviceId:resolve:reject:) - func setDeviceId(_ deviceId: NSString, resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) -> Void { + func setDeviceId(_ deviceId: NSString?, resolve: RCTPromiseResolveBlock, reject: RCTPromiseRejectBlock) -> Void { logger?.debug(message: "setDeviceId: \(deviceId)") sessionReplay?.deviceId = deviceId as String? resolve(nil) diff --git a/packages/session-replay-react-native/test/session-replay.test.ts b/packages/session-replay-react-native/test/session-replay.test.ts index 0cc9b3528..a69fa3df2 100644 --- a/packages/session-replay-react-native/test/session-replay.test.ts +++ b/packages/session-replay-react-native/test/session-replay.test.ts @@ -13,6 +13,8 @@ jest.mock('../src/logger', () => require('./utils/logger')); import { init, start, stop, getSessionId, teardown, setOptOut, type SessionReplayConfig } from '../src/index'; import { NativeModules } from 'react-native'; import { LogLevel } from '@amplitude/analytics-types'; +import { readFileSync } from 'fs'; +import { join } from 'path'; const mockNativeModules = NativeModules as jest.Mocked; @@ -110,6 +112,65 @@ describe('Session Replay Integration Tests', () => { expect(nativeModule.teardown).not.toHaveBeenCalled(); }); + describe('nullable device ID contract', () => { + it('forwards a null device ID during initialization', async () => { + let pending!: Promise; + let setupMock!: jest.Mock; + jest.isolateModules(() => { + const { init: freshInit } = require('../src/index') as typeof import('../src/index'); + const { NativeModules: freshNativeModules } = require('react-native') as typeof import('react-native'); + setupMock = (freshNativeModules as jest.Mocked).AMPNativeSessionReplay.setup; + pending = freshInit({ apiKey: 'test-api-key', deviceId: null }); + }); + + await pending; + expect(setupMock).toHaveBeenCalledWith(expect.objectContaining({ deviceId: null })); + expect(setupMock).not.toHaveBeenCalledWith(expect.objectContaining({ deviceId: '' })); + }); + + it('forwards null when clearing the device ID', async () => { + let pending!: Promise; + let setDeviceIdMock!: jest.Mock; + jest.isolateModules(() => { + const { init: freshInit, setDeviceId: freshSetDeviceId } = + require('../src/index') as typeof import('../src/index'); + const { NativeModules: freshNativeModules } = require('react-native') as typeof import('react-native'); + setDeviceIdMock = (freshNativeModules as jest.Mocked).AMPNativeSessionReplay.setDeviceId; + pending = (async () => { + await freshInit({ apiKey: 'test-api-key' }); + await freshSetDeviceId(null); + })(); + }); + + await pending; + expect(setDeviceIdMock).toHaveBeenCalledWith(null); + expect(setDeviceIdMock).not.toHaveBeenCalledWith(''); + }); + + it('keeps the Android bridge nullable without empty-string coercion', () => { + const source = readFileSync( + join( + __dirname, + '../android/src/main/java/com/amplitude/sessionreplayreactnative/SessionReplayReactNativeModule.kt', + ), + 'utf8', + ); + + expect(source).toContain('sessionReplay?.setDeviceId(deviceId)'); + expect(source).toContain('deviceId = config.deviceId,'); + expect(source).not.toContain('deviceId ?: ""'); + expect(source).not.toContain('config.deviceId ?: ""'); + }); + + it('declares the iOS setDeviceId bridge parameter nullable', () => { + const swiftSource = readFileSync(join(__dirname, '../ios/NativeSessionReplay.swift'), 'utf8'); + const objcSource = readFileSync(join(__dirname, '../ios/AMPNativeSessionReplay.mm'), 'utf8'); + + expect(swiftSource).toContain('func setDeviceId(_ deviceId: NSString?'); + expect(objcSource).toContain('setDeviceId:(nullable NSString *)deviceId'); + }); + }); + // These tests cover the resolution chain in `nativeConfig()` for the // deprecated top-level `maskLevel` field alongside `privacyConfig.maskLevel`. // `init()` keeps `isInitialized` in module scope, so each test uses