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
3 changes: 2 additions & 1 deletion packages/session-replay-react-native/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof NativeModules>;

Expand Down Expand Up @@ -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<void>;
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<typeof NativeModules>).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<void>;
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<typeof NativeModules>).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
Expand Down
Loading