diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ac1504bb..5fd73c1b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [14.0.0](https://github.com/Instabug/Instabug-React-Native/compare/v13.4.0...14.0.0) (November 11, 2024) +### Added + +- Add support for opting into session syncing ([#1292](https://github.com/Instabug/Instabug-React-Native/pull/1292)). + ### Changed - Bump Instabug iOS SDK to v14.0.0 ([#1312](https://github.com/Instabug/Instabug-React-Native/pull/1312)). [See release notes](https://github.com/Instabug/Instabug-iOS/releases/tag/14.0.0). diff --git a/android/src/main/java/com/instabug/reactlibrary/ArgsRegistry.java b/android/src/main/java/com/instabug/reactlibrary/ArgsRegistry.java index 37f730cbe..b56db804d 100644 --- a/android/src/main/java/com/instabug/reactlibrary/ArgsRegistry.java +++ b/android/src/main/java/com/instabug/reactlibrary/ArgsRegistry.java @@ -15,6 +15,7 @@ import com.instabug.library.invocation.InstabugInvocationEvent; import com.instabug.library.invocation.util.InstabugFloatingButtonEdge; import com.instabug.library.invocation.util.InstabugVideoRecordingButtonPosition; +import com.instabug.library.sessionreplay.model.SessionMetadata; import com.instabug.library.ui.onboarding.WelcomeMessage; import java.util.ArrayList; @@ -58,6 +59,7 @@ static Map getAll() { putAll(nonFatalExceptionLevel); putAll(locales); putAll(placeholders); + putAll(launchType); }}; } @@ -238,4 +240,18 @@ static Map getAll() { put("team", Key.CHATS_TEAM_STRING_NAME); put("insufficientContentMessage", Key.COMMENT_FIELD_INSUFFICIENT_CONTENT); }}; + + public static ArgsMap launchType = new ArgsMap() {{ + put("cold", SessionMetadata.LaunchType.COLD); + put("warm",SessionMetadata.LaunchType.WARM ); + put("unknown","unknown"); + }}; + +// Temporary workaround to be removed in future release +// This is used for mapping native `LaunchType` values into React Native enum values. + public static HashMap launchTypeReversed = new HashMap() {{ + put(SessionMetadata.LaunchType.COLD,"cold"); + put(SessionMetadata.LaunchType.WARM,"warm" ); + }}; + } diff --git a/android/src/main/java/com/instabug/reactlibrary/Constants.java b/android/src/main/java/com/instabug/reactlibrary/Constants.java index f78d3a732..fcab68332 100644 --- a/android/src/main/java/com/instabug/reactlibrary/Constants.java +++ b/android/src/main/java/com/instabug/reactlibrary/Constants.java @@ -9,4 +9,6 @@ final class Constants { final static String IBG_ON_NEW_MESSAGE_HANDLER = "IBGonNewMessageHandler"; final static String IBG_ON_NEW_REPLY_RECEIVED_CALLBACK = "IBGOnNewReplyReceivedCallback"; + final static String IBG_SESSION_REPLAY_ON_SYNC_CALLBACK_INVOCATION = "IBGSessionReplayOnSyncCallback"; + } diff --git a/android/src/main/java/com/instabug/reactlibrary/RNInstabugSessionReplayModule.java b/android/src/main/java/com/instabug/reactlibrary/RNInstabugSessionReplayModule.java index 5024c6180..20b0a89ad 100644 --- a/android/src/main/java/com/instabug/reactlibrary/RNInstabugSessionReplayModule.java +++ b/android/src/main/java/com/instabug/reactlibrary/RNInstabugSessionReplayModule.java @@ -1,24 +1,45 @@ package com.instabug.reactlibrary; + +import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import com.facebook.react.bridge.Arguments; import com.facebook.react.bridge.Promise; import com.facebook.react.bridge.ReactApplicationContext; -import com.facebook.react.bridge.ReactContextBaseJavaModule; import com.facebook.react.bridge.ReactMethod; -import com.instabug.chat.Replies; +import com.facebook.react.bridge.ReadableArray; +import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.bridge.WritableArray; +import com.facebook.react.bridge.WritableMap; import com.instabug.library.OnSessionReplayLinkReady; +import com.instabug.library.SessionSyncListener; import com.instabug.library.sessionreplay.SessionReplay; +import com.instabug.library.sessionreplay.model.SessionMetadata; +import com.instabug.reactlibrary.utils.EventEmitterModule; import com.instabug.reactlibrary.utils.MainThreadHandler; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; import javax.annotation.Nonnull; -public class RNInstabugSessionReplayModule extends ReactContextBaseJavaModule { +public class RNInstabugSessionReplayModule extends EventEmitterModule { public RNInstabugSessionReplayModule(ReactApplicationContext reactApplicationContext) { super(reactApplicationContext); } + @ReactMethod + public void addListener(String event) { + super.addListener(event); + } + + @ReactMethod + public void removeListeners(Integer count) { + super.removeListeners(count); + } + @Nonnull @Override public String getName() { @@ -79,7 +100,7 @@ public void run() { e.printStackTrace(); } } - }); + }); } @ReactMethod @@ -97,6 +118,96 @@ public void onSessionReplayLinkReady(@Nullable String link) { } }); + } + + public ReadableMap getSessionMetadataMap(SessionMetadata sessionMetadata){ + WritableMap params = Arguments.createMap(); + params.putString("appVersion",sessionMetadata.getAppVersion()); + params.putString("OS",sessionMetadata.getOs()); + params.putString("device",sessionMetadata.getDevice()); + params.putDouble("sessionDurationInSeconds",(double)sessionMetadata.getSessionDurationInSeconds()); + params.putBoolean("hasLinkToAppReview",sessionMetadata.getLinkedToReview()); + params.putArray("networkLogs",getNetworkLogsArray(sessionMetadata.getNetworkLogs())); + + String launchType = sessionMetadata.getLaunchType(); + Long launchDuration = sessionMetadata.getLaunchDuration(); + if (launchType != null) { + params.putString("launchType",ArgsRegistry.launchTypeReversed.get(sessionMetadata.getLaunchType()) ); + } else { + params.putString("launchType",ArgsRegistry.launchType.get("unknown")); + } + + if (launchDuration != null) { + params.putDouble("launchDuration", (double)launchDuration); + } else { + params.putDouble("launchDuration", 0.0); + } + + return params; + } + + public ReadableArray getNetworkLogsArray(List networkLogList ) { + WritableArray networkLogs = Arguments.createArray(); + + if (networkLogList != null) { + for (SessionMetadata.NetworkLog log : networkLogList) { + WritableMap networkLog = Arguments.createMap(); + networkLog.putString("url", log.getUrl()); + networkLog.putDouble("duration", log.getDuration()); + networkLog.putInt("statusCode", log.getStatusCode()); + + networkLogs.pushMap(networkLog); + } + } + + return networkLogs; } + + private boolean shouldSync = true; + private CountDownLatch latch; + @ReactMethod + public void setSyncCallback() { + MainThreadHandler.runOnMainThread(new Runnable() { + @Override + public void run() { + try { + SessionReplay.setSyncCallback(new SessionSyncListener() { + @Override + public boolean onSessionReadyToSync(@NonNull SessionMetadata sessionMetadata) { + + sendEvent(Constants.IBG_SESSION_REPLAY_ON_SYNC_CALLBACK_INVOCATION,getSessionMetadataMap(sessionMetadata)); + + latch = new CountDownLatch(1); + + try { + latch.await(); + } catch (InterruptedException e) { + e.printStackTrace(); + return true; + } + + return shouldSync; + } + }); + } + catch(Exception e){ + e.printStackTrace(); + } + + } + }); + } + + @ReactMethod + public void evaluateSync(boolean result) { + shouldSync = result; + + if (latch != null) { + latch.countDown(); + } + } + + + } diff --git a/android/src/main/java/com/instabug/reactlibrary/utils/EventEmitterModule.java b/android/src/main/java/com/instabug/reactlibrary/utils/EventEmitterModule.java index d2226cc49..b7d96eb01 100644 --- a/android/src/main/java/com/instabug/reactlibrary/utils/EventEmitterModule.java +++ b/android/src/main/java/com/instabug/reactlibrary/utils/EventEmitterModule.java @@ -5,6 +5,7 @@ import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReadableMap; import com.facebook.react.bridge.WritableMap; import com.facebook.react.modules.core.DeviceEventManagerModule; @@ -16,7 +17,7 @@ public EventEmitterModule(ReactApplicationContext context) { } @VisibleForTesting - public void sendEvent(String event, @Nullable WritableMap params) { + public void sendEvent(String event, @Nullable ReadableMap params) { if (listenerCount > 0) { getReactApplicationContext() .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class) diff --git a/android/src/test/java/com/instabug/reactlibrary/RNInstabugSessionReplayModuleTest.java b/android/src/test/java/com/instabug/reactlibrary/RNInstabugSessionReplayModuleTest.java index ecf339c72..1af5e6da0 100644 --- a/android/src/test/java/com/instabug/reactlibrary/RNInstabugSessionReplayModuleTest.java +++ b/android/src/test/java/com/instabug/reactlibrary/RNInstabugSessionReplayModuleTest.java @@ -1,40 +1,44 @@ package com.instabug.reactlibrary; +import static junit.framework.TestCase.assertEquals; +import static junit.framework.TestCase.assertTrue; + +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Matchers.any; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockConstruction; import static org.mockito.Mockito.mockStatic; -import static org.mockito.Mockito.timeout; -import static org.mockito.Mockito.times; +import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import android.os.Handler; import android.os.Looper; import com.facebook.react.bridge.Arguments; -import com.facebook.react.bridge.JavaOnlyArray; +import com.facebook.react.bridge.JavaOnlyMap; import com.facebook.react.bridge.Promise; -import com.facebook.react.bridge.ReadableArray; -import com.facebook.react.bridge.WritableArray; -import com.instabug.chat.Replies; -import com.instabug.featuresrequest.ActionType; -import com.instabug.featuresrequest.FeatureRequests; -import com.instabug.library.Feature; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.WritableMap; import com.instabug.library.OnSessionReplayLinkReady; +import com.instabug.library.SessionSyncListener; import com.instabug.library.sessionreplay.SessionReplay; +import com.instabug.library.sessionreplay.model.SessionMetadata; import com.instabug.reactlibrary.utils.MainThreadHandler; import org.junit.After; import org.junit.Before; import org.junit.Test; +import org.mockito.MockedConstruction; import org.mockito.MockedStatic; import org.mockito.Mockito; import org.mockito.invocation.InvocationOnMock; import org.mockito.stubbing.Answer; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.atomic.AtomicBoolean; public class RNInstabugSessionReplayModuleTest { @@ -44,8 +48,8 @@ public class RNInstabugSessionReplayModuleTest { // Mock Objects private MockedStatic mockLooper; - private MockedStatic mockMainThreadHandler; - private MockedStatic mockSessionReplay; + private MockedStatic mockMainThreadHandler; + private MockedStatic mockSessionReplay; @Before public void mockMainThreadHandler() throws Exception { @@ -107,7 +111,7 @@ public void testSetInstabugLogsEnabled() { @Test public void testGetSessionReplayLink() { Promise promise = mock(Promise.class); - String link="instabug link"; + String link = "instabug link"; mockSessionReplay.when(() -> SessionReplay.getSessionReplayLink(any())).thenAnswer( invocation -> { @@ -136,5 +140,40 @@ public void testSetUserStepsEnabled() { mockSessionReplay.verifyNoMoreInteractions(); } + @Test + public void testSetSyncCallback() throws Exception { + MockedStatic mockArguments = mockStatic(Arguments.class); + MockedConstruction mockCountDownLatch = mockConstruction(CountDownLatch.class); + RNInstabugSessionReplayModule SRModule = spy(new RNInstabugSessionReplayModule(mock(ReactApplicationContext.class))); + + final boolean shouldSync = true; + final AtomicBoolean actual = new AtomicBoolean(); + + mockArguments.when(Arguments::createMap).thenReturn(new JavaOnlyMap()); + + mockSessionReplay.when(() -> SessionReplay.setSyncCallback(any(SessionSyncListener.class))) + .thenAnswer((invocation) -> { + SessionSyncListener listener = (SessionSyncListener) invocation.getArguments()[0]; + SessionMetadata metadata = mock(SessionMetadata.class); + actual.set(listener.onSessionReadyToSync(metadata)); + return null; + }); + + doAnswer((invocation) -> { + SRModule.evaluateSync(shouldSync); + return null; + }).when(SRModule).sendEvent(eq(Constants.IBG_SESSION_REPLAY_ON_SYNC_CALLBACK_INVOCATION), any()); + + WritableMap params = Arguments.createMap(); + + SRModule.setSyncCallback(); + + assertEquals(shouldSync, actual.get()); + verify(SRModule).sendEvent(Constants.IBG_SESSION_REPLAY_ON_SYNC_CALLBACK_INVOCATION, params); + mockSessionReplay.verify(() -> SessionReplay.setSyncCallback(any(SessionSyncListener.class))); + + mockArguments.close(); + mockCountDownLatch.close(); + } } diff --git a/examples/default/e2e/reportBug.e2e.ts b/examples/default/e2e/reportBug.e2e.ts index 08757b788..e4ba1e2f9 100644 --- a/examples/default/e2e/reportBug.e2e.ts +++ b/examples/default/e2e/reportBug.e2e.ts @@ -14,7 +14,9 @@ it('reports a bug', async () => { await waitFor(floatingButton).toBeVisible().withTimeout(30000); await floatingButton.tap(); - await getElement('reportBugMenuItem').tap(); + const reportBugMenuItemButton = getElement('reportBugMenuItem'); + await waitFor(reportBugMenuItemButton).toBeVisible().withTimeout(30000); + await reportBugMenuItemButton.tap(); await getElement('emailField').typeText(mockData.email); await getElement('commentField').typeText(mockData.bugComment); diff --git a/examples/default/ios/InstabugTests/InstabugSessionReplayTests.m b/examples/default/ios/InstabugTests/InstabugSessionReplayTests.m index a2030df9a..c3f037e60 100644 --- a/examples/default/ios/InstabugTests/InstabugSessionReplayTests.m +++ b/examples/default/ios/InstabugTests/InstabugSessionReplayTests.m @@ -16,8 +16,8 @@ @implementation InstabugSessionReplayTests - (void)setUp { - self.mSessionReplay = OCMClassMock([IBGSessionReplay class]); - self.bridge = [[InstabugSessionReplayBridge alloc] init]; + self.mSessionReplay = OCMClassMock([IBGSessionReplay class]); + self.bridge = [[InstabugSessionReplayBridge alloc] init]; } - (void)testSetEnabled { @@ -67,7 +67,60 @@ - (void)testGetSessionReplayLink { [self.bridge getSessionReplayLink:resolve :reject]; OCMVerify([self.mSessionReplay sessionReplayLink]); [self waitForExpectations:@[expectation] timeout:5.0]; - } +- (void)testSetSyncCallback { + id mockMetadata = OCMClassMock([IBGSessionMetadata class]); + id mockNetworkLog = OCMClassMock([IBGSessionMetadataNetworkLogs class]); + id partialMock = OCMPartialMock(self.bridge); + + XCTestExpectation *completionExpectation = [self expectationWithDescription:@"Completion block should be called with the expected value"]; + + BOOL expectedValue = YES; + __block BOOL actualValue = NO; + + OCMStub([mockNetworkLog url]).andReturn(@"http://example.com"); + OCMStub([mockNetworkLog statusCode]).andReturn(200); + + OCMStub([mockMetadata device]).andReturn(@"ipohne"); + OCMStub([mockMetadata os]).andReturn(@"ios"); + OCMStub([mockMetadata appVersion]).andReturn(@"13.4.1"); + OCMStub([mockMetadata sessionDuration]).andReturn(20); + OCMStub([mockMetadata hasLinkToAppReview]).andReturn(NO); + OCMStub([mockMetadata launchType]).andReturn(LaunchTypeCold); + OCMStub([mockMetadata launchDuration]).andReturn(20); + OCMStub([mockMetadata bugsCount]).andReturn(10); + OCMStub([mockMetadata fatalCrashCount]).andReturn(10); + OCMStub([mockMetadata oomCrashCount]).andReturn(10); + OCMStub([mockMetadata networkLogs]).andReturn(@[mockNetworkLog]); + + SessionEvaluationCompletion sessionEvaluationCompletion = ^(BOOL shouldSync) { + actualValue = shouldSync; + [completionExpectation fulfill]; + }; + + OCMStub([self.mSessionReplay setSyncCallbackWithHandler:[OCMArg checkWithBlock: ^BOOL(void(^handler)(IBGSessionMetadata *metadataObject, SessionEvaluationCompletion completion)) { + handler(mockMetadata, sessionEvaluationCompletion); + return YES; + }]]); + + OCMStub([partialMock sendEventWithName:@"IBGSessionReplayOnSyncCallback" body:OCMArg.any]).andDo(^(NSInvocation *invocation) { + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [self.bridge evaluateSync:expectedValue]; + + }); + }); + + + + + [self.bridge setSyncCallback]; + [self waitForExpectationsWithTimeout:2 handler:nil]; + + OCMVerify([partialMock sendEventWithName:@"IBGSessionReplayOnSyncCallback" body:OCMArg.any]); + OCMVerifyAll(self.mSessionReplay); + XCTAssertEqual(actualValue, expectedValue); + } + + @end diff --git a/examples/default/ios/Podfile b/examples/default/ios/Podfile index f77699b78..3526171cd 100644 --- a/examples/default/ios/Podfile +++ b/examples/default/ios/Podfile @@ -1,9 +1,6 @@ -# Resolve react_native_pods.rb with node to allow for hoisting -require Pod::Executable.execute_command('node', ['-p', - 'require.resolve( - "react-native/scripts/react_native_pods.rb", - {paths: [process.argv[1]]}, - )', __dir__]).strip +require_relative '../node_modules/react-native/scripts/react_native_pods' + +require_relative '../node_modules/@react-native-community/cli-platform-ios/native_modules' platform :ios, '13.4' prepare_react_native_project! @@ -18,9 +15,16 @@ target 'InstabugExample' do config = use_native_modules! rn_maps_path = '../node_modules/react-native-maps' pod 'react-native-google-maps', :path => rn_maps_path + # Flags change depending on the env values. + flags = get_default_flags() use_react_native!( :path => config[:reactNativePath], + # Hermes is now enabled by default. Disable by setting this flag to false. + # Upcoming versions of React Native may rely on get_default_flags(), but + # we make it explicit here to aid in the React Native upgrade process. + :hermes_enabled => flags[:hermes_enabled], + :fabric_enabled => flags[:fabric_enabled], # An absolute path to your application root. :app_path => "#{Pod::Config.instance.installation_root}/.." ) @@ -28,15 +32,14 @@ target 'InstabugExample' do target 'InstabugTests' do inherit! :complete pod 'OCMock' - end + end post_install do |installer| - # https://github.com/facebook/react-native/blob/main/packages/react-native/scripts/react_native_pods.rb#L197-L202 react_native_post_install( installer, - config[:reactNativePath], - :mac_catalyst_enabled => false, - # :ccache_enabled => true + # Set `mac_catalyst_enabled` to `true` in order to apply patches + # necessary for Mac Catalyst builds + :mac_catalyst_enabled => false ) end end diff --git a/examples/default/ios/Podfile.lock b/examples/default/ios/Podfile.lock index b05dcb473..81ba100a5 100644 --- a/examples/default/ios/Podfile.lock +++ b/examples/default/ios/Podfile.lock @@ -2067,6 +2067,6 @@ SPEC CHECKSUMS: SocketRocket: abac6f5de4d4d62d24e11868d7a2f427e0ef940d Yoga: 055f92ad73f8c8600a93f0e25ac0b2344c3b07e6 -PODFILE CHECKSUM: 9116afa418638f45a5fba99099befb7da0049828 +PODFILE CHECKSUM: 63bf073bef3872df95ea45e7c9c023a331ebb3c3 COCOAPODS: 1.14.0 diff --git a/examples/default/src/App.tsx b/examples/default/src/App.tsx index 33a3c34f9..abdab1111 100644 --- a/examples/default/src/App.tsx +++ b/examples/default/src/App.tsx @@ -8,7 +8,10 @@ import Instabug, { InvocationEvent, LogLevel, ReproStepsMode, + SessionReplay, + LaunchType, } from 'instabug-reactnative'; +import type { SessionMetadata } from 'instabug-reactnative'; import { NativeBaseProvider } from 'native-base'; import { RootTabNavigator } from './navigation/RootTab'; @@ -20,8 +23,24 @@ import { QueryClient, QueryClientProvider } from 'react-query'; const queryClient = new QueryClient(); export const App: React.FC = () => { + const shouldSyncSession = (data: SessionMetadata) => { + if (data.launchType === LaunchType.cold) { + return true; + } + if (data.sessionDurationInSeconds > 20) { + return true; + } + if (data.OS === 'OS Level 34') { + return true; + } + return false; + }; + const navigationRef = useNavigationContainerRef(); + useEffect(() => { + SessionReplay.setSyncCallback((data) => shouldSyncSession(data)); + Instabug.init({ token: 'deb1910a7342814af4e4c9210c786f35', invocationEvents: [InvocationEvent.floatingButton], diff --git a/ios/RNInstabug/ArgsRegistry.h b/ios/RNInstabug/ArgsRegistry.h index 09c8ac91c..c7720e38f 100644 --- a/ios/RNInstabug/ArgsRegistry.h +++ b/ios/RNInstabug/ArgsRegistry.h @@ -22,6 +22,7 @@ typedef NSDictionary ArgsDictionary; + (ArgsDictionary *) reproStates; + (ArgsDictionary *) locales; + (ArgsDictionary *)nonFatalExceptionLevel; ++ (ArgsDictionary *) launchType; + (NSDictionary *) placeholders; diff --git a/ios/RNInstabug/ArgsRegistry.m b/ios/RNInstabug/ArgsRegistry.m index 7099f4976..bc14302ac 100644 --- a/ios/RNInstabug/ArgsRegistry.m +++ b/ios/RNInstabug/ArgsRegistry.m @@ -20,6 +20,7 @@ + (NSMutableDictionary *) getAll { [all addEntriesFromDictionary:ArgsRegistry.locales]; [all addEntriesFromDictionary:ArgsRegistry.nonFatalExceptionLevel]; [all addEntriesFromDictionary:ArgsRegistry.placeholders]; + [all addEntriesFromDictionary:ArgsRegistry.launchType]; return all; } @@ -241,4 +242,11 @@ + (ArgsDictionary *)nonFatalExceptionLevel { }; } ++ (ArgsDictionary *) launchType { + return @{ + @"cold": @(LaunchTypeCold), + @"unknown":@(LaunchTypeUnknown) + }; +} + @end diff --git a/ios/RNInstabug/InstabugSessionReplayBridge.h b/ios/RNInstabug/InstabugSessionReplayBridge.h index 1f247ab26..259ea1c14 100644 --- a/ios/RNInstabug/InstabugSessionReplayBridge.h +++ b/ios/RNInstabug/InstabugSessionReplayBridge.h @@ -2,6 +2,7 @@ #import #import #import +#import @interface InstabugSessionReplayBridge : RCTEventEmitter /* @@ -20,6 +21,12 @@ - (void)getSessionReplayLink:(RCTPromiseResolveBlock)resolve :(RCTPromiseRejectBlock)reject; +- (void)setSyncCallback; + +- (void)evaluateSync:(BOOL)result; + +@property (atomic, copy) SessionEvaluationCompletion sessionEvaluationCompletion; + @end diff --git a/ios/RNInstabug/InstabugSessionReplayBridge.m b/ios/RNInstabug/InstabugSessionReplayBridge.m index bbaf7ffd6..2c865ecb5 100644 --- a/ios/RNInstabug/InstabugSessionReplayBridge.m +++ b/ios/RNInstabug/InstabugSessionReplayBridge.m @@ -18,7 +18,9 @@ + (BOOL)requiresMainQueueSetup } - (NSArray *)supportedEvents { - return @[]; + return @[ + @"IBGSessionReplayOnSyncCallback", + ]; } RCT_EXPORT_MODULE(IBGSessionReplay) @@ -45,6 +47,55 @@ + (BOOL)requiresMainQueueSetup resolve(link); } +- (NSArray *)getNetworkLogsArray: + (NSArray*) networkLogs { + NSMutableArray *networkLogsArray = [NSMutableArray array]; + + for (IBGSessionMetadataNetworkLogs* log in networkLogs) { + NSDictionary *nLog = @{@"url": log.url, @"statusCode": @(log.statusCode), @"duration": @(log.duration)}; + [networkLogsArray addObject:nLog]; + } + return networkLogsArray; +} + +- (NSDictionary *)getMetadataObjectMap:(IBGSessionMetadata *)metadataObject { + return @{ + @"appVersion": metadataObject.appVersion, + @"OS": metadataObject.os, + @"device": metadataObject.device, + @"sessionDurationInSeconds": @(metadataObject.sessionDuration), + @"hasLinkToAppReview": @(metadataObject.hasLinkToAppReview), + @"launchType": @(metadataObject.launchType), + @"launchDuration": @(metadataObject.launchDuration), + @"bugsCount": @(metadataObject.bugsCount), + @"fatalCrashCount": @(metadataObject.fatalCrashCount), + @"oomCrashCount": @(metadataObject.oomCrashCount), + @"networkLogs":[self getNetworkLogsArray:metadataObject.networkLogs] + }; +} + +RCT_EXPORT_METHOD(setSyncCallback) { + [IBGSessionReplay setSyncCallbackWithHandler:^(IBGSessionMetadata * _Nonnull metadataObject, SessionEvaluationCompletion _Nonnull completion) { + + [self sendEventWithName:@"IBGSessionReplayOnSyncCallback" + body:[self getMetadataObjectMap:metadataObject]]; + + self.sessionEvaluationCompletion = completion; + }]; +} + +RCT_EXPORT_METHOD(evaluateSync:(BOOL)result) { + + if (self.sessionEvaluationCompletion) { + + self.sessionEvaluationCompletion(result); + + self.sessionEvaluationCompletion = nil; + + } +} + + @synthesize description; @synthesize hash; diff --git a/src/index.ts b/src/index.ts index a6c425fcd..6e7de0284 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ import * as Replies from './modules/Replies'; import type { Survey } from './modules/Surveys'; import * as Surveys from './modules/Surveys'; import * as SessionReplay from './modules/SessionReplay'; +import type { SessionMetadata } from './models/SessionMetadata'; export * from './utils/Enums'; export { @@ -28,6 +29,6 @@ export { Replies, Surveys, }; -export type { InstabugConfig, Survey, NetworkData, NetworkDataObfuscationHandler }; +export type { InstabugConfig, Survey, NetworkData, NetworkDataObfuscationHandler, SessionMetadata }; export default Instabug; diff --git a/src/models/SessionMetadata.ts b/src/models/SessionMetadata.ts new file mode 100644 index 000000000..485a3d470 --- /dev/null +++ b/src/models/SessionMetadata.ts @@ -0,0 +1,57 @@ +import type { LaunchType } from '../utils/Enums'; + +/** + * network log item + */ +export interface NetworkLog { + url: string; + duration: number; + statusCode: number; +} + +export interface SessionMetadata { + /** + * app version of the session + */ + appVersion: string; + /** + * operating system of the session + */ + OS: string; + /** + * mobile device model of the session + */ + device: string; + /** + * session duration in seconds + */ + sessionDurationInSeconds: number; + /** + * list of netwrok requests occurred during the session + */ + networkLogs: NetworkLog[]; + /** + * launch type of the session + */ + launchType: LaunchType; + /** + * is an in-app review occurred in the previous session. + */ + hasLinkToAppReview: boolean; + /** + * app launch duration + */ + launchDuration: number; + /** + * number of bugs in the session (iOS only) + */ + bugsCount?: number; + /** + * number of fetal crashes in the session (iOS only) + */ + fatalCrashCount?: number; + /** + * number of out of memory crashes in the session (iOS only) + */ + oomCrashCount?: number; +} diff --git a/src/modules/SessionReplay.ts b/src/modules/SessionReplay.ts index edfef456f..a0abcd3d7 100644 --- a/src/modules/SessionReplay.ts +++ b/src/modules/SessionReplay.ts @@ -1,5 +1,5 @@ -import { NativeSessionReplay } from '../native/NativeSessionReplay'; - +import { NativeSessionReplay, NativeEvents, emitter } from '../native/NativeSessionReplay'; +import type { SessionMetadata } from '../models/SessionMetadata'; /** * Enables or disables Session Replay for your Instabug integration. * @@ -75,3 +75,37 @@ export const setUserStepsEnabled = (isEnabled: boolean) => { export const getSessionReplayLink = async (): Promise => { return NativeSessionReplay.getSessionReplayLink(); }; + +/** + * Set a callback for whether this session should sync + * + * @param handler + + * @example + * ```ts + * SessionReplay.setSyncCallback((metadata) => { + * return metadata.device == "Xiaomi M2007J3SY" && + * metadata.os == "OS Level 33" && + * metadata.appVersion == "3.1.4 (4)" || + * metadata.sessionDurationInSeconds > 20; + * }); + * ``` + */ +export const setSyncCallback = async ( + handler: (payload: SessionMetadata) => boolean, +): Promise => { + emitter.addListener(NativeEvents.SESSION_REPLAY_ON_SYNC_CALLBACK_INVOCATION, (payload) => { + const result = handler(payload); + const shouldSync = Boolean(result); + + if (typeof result !== 'boolean') { + console.warn( + `IBG-RN: The callback passed to SessionReplay.setSyncCallback was expected to return a boolean but returned "${result}". The value has been cast to boolean, proceeding with ${shouldSync}.`, + ); + } + + NativeSessionReplay.evaluateSync(shouldSync); + }); + + return NativeSessionReplay.setSyncCallback(); +}; diff --git a/src/native/NativeConstants.ts b/src/native/NativeConstants.ts index 5317e963e..a4e98e2c8 100644 --- a/src/native/NativeConstants.ts +++ b/src/native/NativeConstants.ts @@ -12,7 +12,8 @@ export type NativeConstants = NativeSdkDebugLogsLevel & NativeReproStepsMode & NativeLocale & NativeNonFatalErrorLevel & - NativeStringKey; + NativeStringKey & + NativeLaunchType; interface NativeSdkDebugLogsLevel { sdkDebugLogsLevelVerbose: any; @@ -188,3 +189,9 @@ interface NativeStringKey { welcomeMessageLiveWelcomeStepContent: any; welcomeMessageLiveWelcomeStepTitle: any; } + +interface NativeLaunchType { + cold: any; + warm: any; + unknown: any; +} diff --git a/src/native/NativeSessionReplay.ts b/src/native/NativeSessionReplay.ts index 9c3090fb1..3139ef44a 100644 --- a/src/native/NativeSessionReplay.ts +++ b/src/native/NativeSessionReplay.ts @@ -1,3 +1,4 @@ +import { NativeEventEmitter } from 'react-native'; import type { NativeModule } from 'react-native'; import { NativeModules } from './NativePackage'; @@ -8,6 +9,13 @@ export interface SessionReplayNativeModule extends NativeModule { setInstabugLogsEnabled(isEnabled: boolean): void; setUserStepsEnabled(isEnabled: boolean): void; getSessionReplayLink(): Promise; + setSyncCallback(): Promise; + evaluateSync(shouldSync: boolean): void; } export const NativeSessionReplay = NativeModules.IBGSessionReplay; +export enum NativeEvents { + SESSION_REPLAY_ON_SYNC_CALLBACK_INVOCATION = 'IBGSessionReplayOnSyncCallback', +} + +export const emitter = new NativeEventEmitter(NativeSessionReplay); diff --git a/src/utils/Enums.ts b/src/utils/Enums.ts index 57cb54ca1..37ffa33ce 100644 --- a/src/utils/Enums.ts +++ b/src/utils/Enums.ts @@ -232,3 +232,12 @@ export enum StringKey { welcomeMessageLiveWelcomeStepContent = constants.welcomeMessageLiveWelcomeStepContent, welcomeMessageLiveWelcomeStepTitle = constants.welcomeMessageLiveWelcomeStepTitle, } + +export enum LaunchType { + cold = constants.cold, + unknown = constants.unknown, + /** + * Android only + */ + warm = constants.warm, +} diff --git a/test/mocks/mockSessionReplay.ts b/test/mocks/mockSessionReplay.ts index ea61a8356..9106a205a 100644 --- a/test/mocks/mockSessionReplay.ts +++ b/test/mocks/mockSessionReplay.ts @@ -8,6 +8,8 @@ const mockSessionReplay: SessionReplayNativeModule = { setInstabugLogsEnabled: jest.fn(), setUserStepsEnabled: jest.fn(), getSessionReplayLink: jest.fn().mockReturnValue('link'), + setSyncCallback: jest.fn(), + evaluateSync: jest.fn(), }; export default mockSessionReplay; diff --git a/test/modules/SessionReplay.spec.ts b/test/modules/SessionReplay.spec.ts index 052a63891..66e672ec5 100644 --- a/test/modules/SessionReplay.spec.ts +++ b/test/modules/SessionReplay.spec.ts @@ -1,5 +1,5 @@ import * as SessionReplay from '../../src/modules/SessionReplay'; -import { NativeSessionReplay } from '../../src/native/NativeSessionReplay'; +import { NativeSessionReplay, emitter, NativeEvents } from '../../src/native/NativeSessionReplay'; describe('Session Replay Module', () => { it('should call the native method setEnabled', () => { @@ -36,4 +36,17 @@ describe('Session Replay Module', () => { expect(NativeSessionReplay.getSessionReplayLink).toBeCalledTimes(1); expect(NativeSessionReplay.getSessionReplayLink).toReturnWith('link'); }); + + it('should call the native method setSyncCallback', () => { + const shouldSync = true; + const callback = jest.fn().mockReturnValue(shouldSync); + + SessionReplay.setSyncCallback(callback); + emitter.emit(NativeEvents.SESSION_REPLAY_ON_SYNC_CALLBACK_INVOCATION); + + expect(NativeSessionReplay.setSyncCallback).toBeCalledTimes(1); + expect(emitter.listenerCount(NativeEvents.SESSION_REPLAY_ON_SYNC_CALLBACK_INVOCATION)).toBe(1); + expect(NativeSessionReplay.evaluateSync).toBeCalledTimes(1); + expect(NativeSessionReplay.evaluateSync).toBeCalledWith(shouldSync); + }); });