diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bd2f398f..9f468c9c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased](https://github.com/Instabug/Instabug-React-Native/compare/v11.14.0...dev) +### Added + +- Add support for Session Replay, which includes capturing session details, visual reproduction of sessions as well as support for user steps, network and Instabug logs. ([#1034](https://github.com/Instabug/Instabug-React-Native/pull/1034)). + ### Changed - **BREAKING** Remove deprecated APIs ([#1027](https://github.com/Instabug/Instabug-React-Native/pull/1027)). See migration guide for more details. diff --git a/android/src/main/java/com/instabug/reactlibrary/RNInstabugReactnativePackage.java b/android/src/main/java/com/instabug/reactlibrary/RNInstabugReactnativePackage.java index 39ea227d7..1d3828725 100644 --- a/android/src/main/java/com/instabug/reactlibrary/RNInstabugReactnativePackage.java +++ b/android/src/main/java/com/instabug/reactlibrary/RNInstabugReactnativePackage.java @@ -28,6 +28,7 @@ public List createNativeModules(@NonNull ReactApplicationContext r modules.add(new RNInstabugFeatureRequestsModule(reactContext)); modules.add(new RNInstabugRepliesModule(reactContext)); modules.add(new RNInstabugAPMModule(reactContext)); + modules.add(new RNInstabugSessionReplayModule(reactContext)); return modules; } diff --git a/android/src/main/java/com/instabug/reactlibrary/RNInstabugSessionReplayModule.java b/android/src/main/java/com/instabug/reactlibrary/RNInstabugSessionReplayModule.java new file mode 100644 index 000000000..9ec982fc2 --- /dev/null +++ b/android/src/main/java/com/instabug/reactlibrary/RNInstabugSessionReplayModule.java @@ -0,0 +1,79 @@ +package com.instabug.reactlibrary; + +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; +import com.instabug.library.sessionreplay.SessionReplay; +import com.instabug.reactlibrary.utils.MainThreadHandler; + +import javax.annotation.Nonnull; + +public class RNInstabugSessionReplayModule extends ReactContextBaseJavaModule { + + public RNInstabugSessionReplayModule(ReactApplicationContext reactApplicationContext) { + super(reactApplicationContext); + } + + @Nonnull + @Override + public String getName() { + return "IBGSessionReplay"; + } + + @ReactMethod + public void setEnabled(final boolean isEnabled) { + MainThreadHandler.runOnMainThread(new Runnable() { + @Override + public void run() { + try { + SessionReplay.setEnabled(isEnabled); + } catch (Exception e) { + e.printStackTrace(); + } + } + }); + } + + @ReactMethod + public void setNetworkLogsEnabled(final boolean isEnabled) { + MainThreadHandler.runOnMainThread(new Runnable() { + @Override + public void run() { + try { + SessionReplay.setNetworkLogsEnabled(isEnabled); + } catch (Exception e) { + e.printStackTrace(); + } + } + }); + } + + + @ReactMethod + public void setInstabugLogsEnabled(final boolean isEnabled) { + MainThreadHandler.runOnMainThread(new Runnable() { + @Override + public void run() { + try { + SessionReplay.setIBGLogsEnabled(isEnabled); + } catch (Exception e) { + e.printStackTrace(); + } + } + }); + } + + @ReactMethod + public void setUserStepsEnabled(final boolean isEnabled) { + MainThreadHandler.runOnMainThread(new Runnable() { + @Override + public void run() { + try { + SessionReplay.setUserStepsEnabled(isEnabled); + } catch (Exception e) { + e.printStackTrace(); + } + } + }); + } +} diff --git a/android/src/test/java/com/instabug/reactlibrary/RNInstabugSessionReplayModuleTest.java b/android/src/test/java/com/instabug/reactlibrary/RNInstabugSessionReplayModuleTest.java new file mode 100644 index 000000000..577304629 --- /dev/null +++ b/android/src/test/java/com/instabug/reactlibrary/RNInstabugSessionReplayModuleTest.java @@ -0,0 +1,109 @@ +package com.instabug.reactlibrary; + +import static org.mockito.Matchers.any; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import android.os.Looper; + +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.JavaOnlyArray; +import com.facebook.react.bridge.ReadableArray; +import com.facebook.react.bridge.WritableArray; +import com.instabug.featuresrequest.ActionType; +import com.instabug.featuresrequest.FeatureRequests; +import com.instabug.library.Feature; +import com.instabug.library.sessionreplay.SessionReplay; +import com.instabug.reactlibrary.utils.MainThreadHandler; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.invocation.InvocationOnMock; +import org.mockito.stubbing.Answer; + +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; + + +public class RNInstabugSessionReplayModuleTest { + private RNInstabugSessionReplayModule sessionReplayModule = new RNInstabugSessionReplayModule(null); + + private final static ScheduledExecutorService mainThread = Executors.newSingleThreadScheduledExecutor(); + + // Mock Objects + private MockedStatic mockLooper; + private MockedStatic mockMainThreadHandler; + private MockedStatic mockSessionReplay; + + @Before + public void mockMainThreadHandler() throws Exception { + // Mock static functions + mockSessionReplay = mockStatic(SessionReplay.class); + mockLooper = mockStatic(Looper.class); + mockMainThreadHandler = mockStatic(MainThreadHandler.class); + + // Mock Looper class + Looper mockMainThreadLooper = Mockito.mock(Looper.class); + Mockito.when(Looper.getMainLooper()).thenReturn(mockMainThreadLooper); + + // Override runOnMainThread + Answer handlerPostAnswer = new Answer() { + @Override + public Boolean answer(InvocationOnMock invocation) throws Throwable { + invocation.getArgument(0, Runnable.class).run(); + return true; + } + }; + Mockito.doAnswer(handlerPostAnswer).when(MainThreadHandler.class); + MainThreadHandler.runOnMainThread(any(Runnable.class)); + } + @After + public void tearDown() { + // Remove static mocks + mockLooper.close(); + mockMainThreadHandler.close(); + mockSessionReplay.close(); + } + + @Test + public void testSetEnabled() { + + sessionReplayModule.setEnabled(true); + + mockSessionReplay.verify(() -> SessionReplay.setEnabled(true)); + mockSessionReplay.verifyNoMoreInteractions(); + } + + @Test + public void testSetNetworkLogsEnabled() { + + sessionReplayModule.setNetworkLogsEnabled(true); + + mockSessionReplay.verify(() -> SessionReplay.setNetworkLogsEnabled(true)); + mockSessionReplay.verifyNoMoreInteractions(); + } + + @Test + public void testSetInstabugLogsEnabled() { + + sessionReplayModule.setInstabugLogsEnabled(true); + + mockSessionReplay.verify(() -> SessionReplay.setIBGLogsEnabled(true)); + mockSessionReplay.verifyNoMoreInteractions(); + } + + @Test + public void testSetUserStepsEnabled() { + + sessionReplayModule.setUserStepsEnabled(true); + + mockSessionReplay.verify(() -> SessionReplay.setUserStepsEnabled(true)); + mockSessionReplay.verifyNoMoreInteractions(); + } + + +} diff --git a/examples/default/ios/InstabugExample.xcodeproj/project.pbxproj b/examples/default/ios/InstabugExample.xcodeproj/project.pbxproj index 108e9e9f1..68d57d960 100644 --- a/examples/default/ios/InstabugExample.xcodeproj/project.pbxproj +++ b/examples/default/ios/InstabugExample.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.mm */; }; 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; 13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; }; + 20E556262AC55766007416B1 /* InstabugSessionReplayTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 20E556252AC55766007416B1 /* InstabugSessionReplayTests.m */; }; 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; }; CC3DF88E2A1DFC9A003E9914 /* InstabugCrashReportingTests.m in Sources */ = {isa = PBXBuildFile; fileRef = CC3DF8852A1DFC99003E9914 /* InstabugCrashReportingTests.m */; }; CC3DF88F2A1DFC9A003E9914 /* InstabugBugReportingTests.m in Sources */ = {isa = PBXBuildFile; fileRef = CC3DF8862A1DFC99003E9914 /* InstabugBugReportingTests.m */; }; @@ -42,6 +43,7 @@ 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = InstabugExample/Images.xcassets; sourceTree = ""; }; 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = InstabugExample/Info.plist; sourceTree = ""; }; 13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = InstabugExample/main.m; sourceTree = ""; }; + 20E556252AC55766007416B1 /* InstabugSessionReplayTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = InstabugSessionReplayTests.m; sourceTree = ""; }; 3AF7A6E02D40E0CEEA833CC4 /* libPods-InstabugExample-InstabugTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-InstabugExample-InstabugTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 4FC6A9E6B294FF9929A1423D /* Pods-InstabugExample-InstabugTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-InstabugExample-InstabugTests.debug.xcconfig"; path = "Target Support Files/Pods-InstabugExample-InstabugTests/Pods-InstabugExample-InstabugTests.debug.xcconfig"; sourceTree = ""; }; 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = InstabugExample/LaunchScreen.storyboard; sourceTree = ""; }; @@ -91,6 +93,7 @@ CC3DF8852A1DFC99003E9914 /* InstabugCrashReportingTests.m */, CC3DF8882A1DFC99003E9914 /* InstabugFeatureRequestsTests.m */, CC3DF88A2A1DFC99003E9914 /* InstabugRepliesTests.m */, + 20E556252AC55766007416B1 /* InstabugSessionReplayTests.m */, CC3DF8872A1DFC99003E9914 /* InstabugSampleTests.m */, CC3DF88B2A1DFC99003E9914 /* InstabugSurveysTests.m */, 00E356F01AD99517003FC87E /* Supporting Files */, @@ -442,6 +445,7 @@ CC3DF8902A1DFC9A003E9914 /* InstabugSampleTests.m in Sources */, CC3DF8912A1DFC9A003E9914 /* InstabugFeatureRequestsTests.m in Sources */, CC3DF8932A1DFC9A003E9914 /* InstabugSurveysTests.m in Sources */, + 20E556262AC55766007416B1 /* InstabugSessionReplayTests.m in Sources */, CC3DF88F2A1DFC9A003E9914 /* InstabugBugReportingTests.m in Sources */, CC3DF88E2A1DFC9A003E9914 /* InstabugCrashReportingTests.m in Sources */, CC3DF8942A1DFC9A003E9914 /* InstabugAPMTests.m in Sources */, diff --git a/examples/default/ios/InstabugTests/InstabugSessionReplayTests.m b/examples/default/ios/InstabugTests/InstabugSessionReplayTests.m new file mode 100644 index 000000000..f65d85288 --- /dev/null +++ b/examples/default/ios/InstabugTests/InstabugSessionReplayTests.m @@ -0,0 +1,56 @@ +#import +#import "OCMock/OCMock.h" +#import "InstabugSessionReplayBridge.h" +#import +#import "Instabug/Instabug.h" +#import "IBGConstants.h" + +@interface InstabugSessionReplayTests : XCTestCase + +@property (nonatomic, strong) id mSessionReplay; +@property (nonatomic, strong) InstabugSessionReplayBridge *bridge; + +@end + +@implementation InstabugSessionReplayTests + + +- (void)setUp { + self.mSessionReplay = OCMClassMock([IBGSessionReplay class]); + self.bridge = [[InstabugSessionReplayBridge alloc] init]; +} + +- (void)testSetEnabled { + BOOL enabled = NO; + + [self.bridge setEnabled:enabled]; + + OCMVerify([self.mSessionReplay setEnabled:enabled]); +} + +- (void)testSetInstabugLogsEnabled { + BOOL enabled = NO; + + [self.bridge setInstabugLogsEnabled:enabled]; + + OCMVerify([self.mSessionReplay setIBGLogsEnabled:enabled]); +} + +- (void)testSetNetworkLogsEnabled { + BOOL enabled = NO; + + [self.bridge setNetworkLogsEnabled:enabled]; + + OCMVerify([self.mSessionReplay setNetworkLogsEnabled:enabled]); +} + +- (void)testSetUserStepsEnabled { + BOOL enabled = NO; + + [self.bridge setUserStepsEnabled:enabled]; + + OCMVerify([self.mSessionReplay setUserStepsEnabled:enabled]); +} + + +@end diff --git a/ios/RNInstabug/InstabugSessionReplayBridge.h b/ios/RNInstabug/InstabugSessionReplayBridge.h new file mode 100644 index 000000000..7e13db716 --- /dev/null +++ b/ios/RNInstabug/InstabugSessionReplayBridge.h @@ -0,0 +1,24 @@ +#import +#import +#import +#import + +@interface InstabugSessionReplayBridge : RCTEventEmitter +/* + +------------------------------------------------------------------------+ + | Session Replay Module | + +------------------------------------------------------------------------+ + */ + +- (void)setEnabled:(BOOL)isEnabled; + +- (void)setInstabugLogsEnabled:(BOOL)isEnabled; + +- (void)setNetworkLogsEnabled:(BOOL)isEnabled; + +- (void)setUserStepsEnabled:(BOOL)isEnabled; + + +@end + + diff --git a/ios/RNInstabug/InstabugSessionReplayBridge.m b/ios/RNInstabug/InstabugSessionReplayBridge.m new file mode 100644 index 000000000..5b3eb5ae7 --- /dev/null +++ b/ios/RNInstabug/InstabugSessionReplayBridge.m @@ -0,0 +1,50 @@ +#import +#import +#import +#import +#import +#import +#import "InstabugSessionReplayBridge.h" + +@implementation InstabugSessionReplayBridge + +- (dispatch_queue_t)methodQueue { + return dispatch_get_main_queue(); +} + ++ (BOOL)requiresMainQueueSetup +{ + return NO; +} + +- (NSArray *)supportedEvents { + return @[]; +} + +RCT_EXPORT_MODULE(IBGSessionReplay) + +RCT_EXPORT_METHOD(setEnabled:(BOOL)isEnabled) { + IBGSessionReplay.enabled = isEnabled; +} + +RCT_EXPORT_METHOD(setNetworkLogsEnabled:(BOOL)isEnabled) { + IBGSessionReplay.networkLogsEnabled = isEnabled; +} + +RCT_EXPORT_METHOD(setInstabugLogsEnabled:(BOOL)isEnabled) { + IBGSessionReplay.IBGLogsEnabled = isEnabled; +} + +RCT_EXPORT_METHOD(setUserStepsEnabled:(BOOL)isEnabled) { + IBGSessionReplay.userStepsEnabled = isEnabled; +} + +@synthesize description; + +@synthesize hash; + +@synthesize superclass; + +@end + + diff --git a/src/index.ts b/src/index.ts index 75d434101..a6c425fcd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ import type { NetworkData, NetworkDataObfuscationHandler } from './modules/Netwo import * as Replies from './modules/Replies'; import type { Survey } from './modules/Surveys'; import * as Surveys from './modules/Surveys'; +import * as SessionReplay from './modules/SessionReplay'; export * from './utils/Enums'; export { @@ -23,6 +24,7 @@ export { CrashReporting, FeatureRequests, NetworkLogger, + SessionReplay, Replies, Surveys, }; diff --git a/src/modules/SessionReplay.ts b/src/modules/SessionReplay.ts new file mode 100644 index 000000000..aa92eea47 --- /dev/null +++ b/src/modules/SessionReplay.ts @@ -0,0 +1,65 @@ +import { NativeSessionReplay } from '../native/NativeSessionReplay'; + +/** + * Enables or disables Session Replay for your Instabug integration. + * + * By default, Session Replay is enabled if it is available in your current plan + * + * @param isEnabled + * + * @example + * ```ts + * SessionReplay.setEnabled(true); + * ``` + */ +export const setEnabled = (isEnabled: boolean) => { + NativeSessionReplay.setEnabled(isEnabled); +}; + +/** + * Enables or disables network logs for Session Replay. + * + * By default, network logs are enabled. + * + * @param isEnabled + * + * @example + * ```ts + * SessionReplay.setNetworkLogsEnabled(true); + * ``` + */ +export const setNetworkLogsEnabled = (isEnabled: boolean) => { + NativeSessionReplay.setNetworkLogsEnabled(isEnabled); +}; + +/** + * Enables or disables Instabug logs for Session Replay. + * + * By default, Instabug logs are enabled. + * + * @param isEnabled + * + * @example + * ```ts + * SessionReplay.setInstabugLogsEnabled(true); + * ``` + */ +export const setInstabugLogsEnabled = (isEnabled: boolean) => { + NativeSessionReplay.setInstabugLogsEnabled(isEnabled); +}; + +/** + * Enables or disables capturing of user steps for Session Replay. + * + * By default, user steps are enabled. + * + * @param isEnabled + * + * @example + * ```ts + * SessionReplay.setUserStepsEnabled(true); + * ``` + */ +export const setUserStepsEnabled = (isEnabled: boolean) => { + NativeSessionReplay.setUserStepsEnabled(isEnabled); +}; diff --git a/src/native/NativePackage.ts b/src/native/NativePackage.ts index 2f74572f0..9c31789bd 100644 --- a/src/native/NativePackage.ts +++ b/src/native/NativePackage.ts @@ -7,6 +7,7 @@ import type { FeatureRequestsNativeModule } from './NativeFeatureRequests'; import type { InstabugNativeModule } from './NativeInstabug'; import type { RepliesNativeModule } from './NativeReplies'; import type { SurveysNativeModule } from './NativeSurveys'; +import type { SessionReplayNativeModule } from './NativeSessionReplay'; export interface InstabugNativePackage { IBGAPM: ApmNativeModule; @@ -16,6 +17,7 @@ export interface InstabugNativePackage { Instabug: InstabugNativeModule; IBGReplies: RepliesNativeModule; IBGSurveys: SurveysNativeModule; + IBGSessionReplay: SessionReplayNativeModule; } export const NativeModules = ReactNativeModules as InstabugNativePackage; diff --git a/src/native/NativeSessionReplay.ts b/src/native/NativeSessionReplay.ts new file mode 100644 index 000000000..0f754d968 --- /dev/null +++ b/src/native/NativeSessionReplay.ts @@ -0,0 +1,12 @@ +import type { NativeModule } from 'react-native'; + +import { NativeModules } from './NativePackage'; + +export interface SessionReplayNativeModule extends NativeModule { + setEnabled(isEnabled: boolean): void; + setNetworkLogsEnabled(isEnabled: boolean): void; + setInstabugLogsEnabled(isEnabled: boolean): void; + setUserStepsEnabled(isEnabled: boolean): void; +} + +export const NativeSessionReplay = NativeModules.IBGSessionReplay; diff --git a/test/mocks/mockNativeModules.ts b/test/mocks/mockNativeModules.ts index b53fa2351..5618930e8 100644 --- a/test/mocks/mockNativeModules.ts +++ b/test/mocks/mockNativeModules.ts @@ -3,6 +3,7 @@ import mockAPM from './mockAPM'; import mockBugReporting from './mockBugReporting'; import mockCrashReporting from './mockCrashReporting'; import mockFeatureRequests from './mockFeatureRequests'; +import mockSessionReplay from './mockSessionReplay'; import mockInstabug from './mockInstabug'; import mockReplies from './mockReplies'; import mockSurveys from './mockSurveys'; @@ -14,6 +15,7 @@ jest.mock('react-native', () => { IBGBugReporting: mockBugReporting, IBGCrashReporting: mockCrashReporting, IBGFeatureRequests: mockFeatureRequests, + IBGSessionReplay: mockSessionReplay, Instabug: mockInstabug, IBGReplies: mockReplies, IBGSurveys: mockSurveys, diff --git a/test/mocks/mockSessionReplay.ts b/test/mocks/mockSessionReplay.ts new file mode 100644 index 000000000..72b509b1c --- /dev/null +++ b/test/mocks/mockSessionReplay.ts @@ -0,0 +1,12 @@ +import type { SessionReplayNativeModule } from '../../src/native/NativeSessionReplay'; + +const mockSessionReplay: SessionReplayNativeModule = { + addListener: jest.fn(), + removeListeners: jest.fn(), + setEnabled: jest.fn(), + setNetworkLogsEnabled: jest.fn(), + setInstabugLogsEnabled: jest.fn(), + setUserStepsEnabled: jest.fn(), +}; + +export default mockSessionReplay; diff --git a/test/modules/SessionReplay.spec.ts b/test/modules/SessionReplay.spec.ts new file mode 100644 index 000000000..46163378b --- /dev/null +++ b/test/modules/SessionReplay.spec.ts @@ -0,0 +1,32 @@ +import * as SessionReplay from '../../src/modules/SessionReplay'; +import { NativeSessionReplay } from '../../src/native/NativeSessionReplay'; + +describe('Session Replay Module', () => { + it('should call the native method setEnabled', () => { + SessionReplay.setEnabled(true); + + expect(NativeSessionReplay.setEnabled).toBeCalledTimes(1); + expect(NativeSessionReplay.setEnabled).toBeCalledWith(true); + }); + + it('should call the native method setNetworkLogsEnabled', () => { + SessionReplay.setNetworkLogsEnabled(true); + + expect(NativeSessionReplay.setNetworkLogsEnabled).toBeCalledTimes(1); + expect(NativeSessionReplay.setNetworkLogsEnabled).toBeCalledWith(true); + }); + + it('should call the native method setInstabugLogsEnabled', () => { + SessionReplay.setInstabugLogsEnabled(true); + + expect(NativeSessionReplay.setInstabugLogsEnabled).toBeCalledTimes(1); + expect(NativeSessionReplay.setInstabugLogsEnabled).toBeCalledWith(true); + }); + + it('should call the native method setUserStepsEnabled', () => { + SessionReplay.setUserStepsEnabled(true); + + expect(NativeSessionReplay.setUserStepsEnabled).toBeCalledTimes(1); + expect(NativeSessionReplay.setUserStepsEnabled).toBeCalledWith(true); + }); +});