diff --git a/android/native.gradle b/android/native.gradle index 0e86a4edff..93ff054427 100644 --- a/android/native.gradle +++ b/android/native.gradle @@ -1,5 +1,5 @@ project.ext.instabug = [ - version: '13.3.0' + version: '13.3.0.6212131-SNAPSHOT', ] dependencies { diff --git a/android/src/main/java/com/instabug/reactlibrary/ArgsRegistry.java b/android/src/main/java/com/instabug/reactlibrary/ArgsRegistry.java index 37f730cbe9..f1a4c1fa94 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,19 @@ 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("hot",SessionMetadata.LaunchType.HOT ); + put("warm",SessionMetadata.LaunchType.WARM ); + }}; + +// 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.HOT,"hot" ); + 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 f78d3a732d..fcab683326 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 5024c61804..d4973894a8 100644 --- a/android/src/main/java/com/instabug/reactlibrary/RNInstabugSessionReplayModule.java +++ b/android/src/main/java/com/instabug/reactlibrary/RNInstabugSessionReplayModule.java @@ -1,24 +1,44 @@ 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.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 +99,7 @@ public void run() { e.printStackTrace(); } } - }); + }); } @ReactMethod @@ -97,6 +117,85 @@ 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.putString("launchType",ArgsRegistry.launchTypeReversed.get(sessionMetadata.getLaunchType()) ); + params.putDouble("launchDuration", sessionMetadata.getLaunchDuration()); + params.putArray("networkLogs",getNetworkLogsArray(sessionMetadata.getNetworkLogs())); + +// TODO:Add rest of sessionMetadata +// params.putDouble("bugsCount", ??); +// params.putDouble("fatalCrashCount",??); +// params.putDouble("oomCrashCount",??); + return params; + } + + public ReadableArray getNetworkLogsArray(List networkLogList ){ + WritableArray networkLogs = Arguments.createArray(); + + 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/test/java/com/instabug/reactlibrary/RNInstabugSessionReplayModuleTest.java b/android/src/test/java/com/instabug/reactlibrary/RNInstabugSessionReplayModuleTest.java index ecf339c72b..1af5e6da08 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 08757b7884..e4ba1e2f9e 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/src/App.tsx b/examples/default/src/App.tsx index 33a3c34f94..abdab11111 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/src/index.ts b/src/index.ts index a6c425fcd0..6e7de02846 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 0000000000..f95c1cba52 --- /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 + */ + bugsCount?: number; + /** + * number of fetal crashes in the session + */ + fatalCrashCount?: number; + /** + * number of out of memory crashes in the session + */ + oomCrashCount?: number; +} diff --git a/src/modules/SessionReplay.ts b/src/modules/SessionReplay.ts index edfef456f3..a0abcd3d76 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 5317e963ef..271f179b8d 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 { + hot: any; + cold: any; + warm: any; +} diff --git a/src/native/NativeSessionReplay.ts b/src/native/NativeSessionReplay.ts index 9c3090fb19..3139ef44a5 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 57cb54ca15..39577bac89 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 { + hot = constants.hot, + cold = constants.cold, + /** + * android only + */ + warm = constants.warm, +} diff --git a/test/mocks/mockSessionReplay.ts b/test/mocks/mockSessionReplay.ts index ea61a8356f..9106a205a0 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 052a63891e..66e672ec59 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); + }); });