diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fd6730ab5..77f97581f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ - Add support for xCode 16. ([#1370](https://github.com/Instabug/Instabug-React-Native/pull/1370)) +- Add support for network spans. ([#1394](https://github.com/Instabug/Instabug-React-Native/pull/1394)) + ### Fixed - Not sending the inComplete xhrRequest. ([#1365](https://github.com/Instabug/Instabug-React-Native/pull/1365)) diff --git a/RNInstabug.podspec b/RNInstabug.podspec index af69112cc1..b571a6c68f 100644 --- a/RNInstabug.podspec +++ b/RNInstabug.podspec @@ -16,6 +16,7 @@ Pod::Spec.new do |s| s.source_files = "ios/**/*.{h,m,mm}" s.dependency 'React-Core' - use_instabug!(s) + # use_instabug!(s) + s.dependency 'Instabug' end diff --git a/android/src/main/java/com/instabug/reactlibrary/Constants.java b/android/src/main/java/com/instabug/reactlibrary/Constants.java index f6986200d3..9f1a0cf355 100644 --- a/android/src/main/java/com/instabug/reactlibrary/Constants.java +++ b/android/src/main/java/com/instabug/reactlibrary/Constants.java @@ -10,6 +10,9 @@ 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_ON_FEATURES_UPDATED_CALLBACK = "IBGOnFeatureUpdatedCallback"; + final static String IBG_NETWORK_LOGGER_HANDLER = "IBGNetworkLoggerHandler"; + final static String IBG_ON_NEW_W3C_FLAGS_UPDATE_RECEIVED_CALLBACK = "IBGOnNewW3CFlagsUpdateReceivedCallback"; final static String IBG_SESSION_REPLAY_ON_SYNC_CALLBACK_INVOCATION = "IBGSessionReplayOnSyncCallback"; diff --git a/android/src/main/java/com/instabug/reactlibrary/RNInstabugNetworkLoggerModule.java b/android/src/main/java/com/instabug/reactlibrary/RNInstabugNetworkLoggerModule.java new file mode 100644 index 0000000000..a47cc7e212 --- /dev/null +++ b/android/src/main/java/com/instabug/reactlibrary/RNInstabugNetworkLoggerModule.java @@ -0,0 +1,195 @@ +package com.instabug.reactlibrary; + + +import static com.instabug.apm.configuration.cp.APMFeature.APM_NETWORK_PLUGIN_INSTALLED; +import static com.instabug.apm.configuration.cp.APMFeature.CP_NATIVE_INTERCEPTION_ENABLED; + +import android.util.Log; + +import androidx.annotation.NonNull; + +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.Promise; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.bridge.ReadableMapKeySetIterator; +import com.facebook.react.bridge.WritableMap; +import com.facebook.react.bridge.WritableNativeMap; +import com.instabug.apm.InternalAPM; +import com.instabug.apm.sanitization.OnCompleteCallback; +import com.instabug.library.logging.listeners.networklogs.NetworkLogSnapshot; +import com.instabug.reactlibrary.utils.EventEmitterModule; +import com.instabug.reactlibrary.utils.MainThreadHandler; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + + +public class RNInstabugNetworkLoggerModule extends EventEmitterModule { + + public final ConcurrentHashMap> callbackMap = new ConcurrentHashMap>(); + + public RNInstabugNetworkLoggerModule(ReactApplicationContext reactContext) { + super(reactContext); + } + + + @NonNull + @Override + public String getName() { + return "IBGNetworkLogger"; + } + + + @ReactMethod + public void addListener(String event) { + super.addListener(event); + } + + @ReactMethod + public void removeListeners(Integer count) { + super.removeListeners(count); + } + + private boolean getFlagValue(String key) { + return InternalAPM._isFeatureEnabledCP(key, ""); + } + + private WritableMap convertFromMapToWritableMap(Map map) { + WritableMap writableMap = new WritableNativeMap(); + for (String key : map.keySet()) { + Object value = map.get(key); + writableMap.putString(key, (String) value); + } + return writableMap; + } + + private Map convertReadableMapToMap(ReadableMap readableMap) { + Map map = new HashMap<>(); + if (readableMap != null) { + ReadableMapKeySetIterator iterator = readableMap.keySetIterator(); + while (iterator.hasNextKey()) { + String key = iterator.nextKey(); + map.put(key, readableMap.getString(key)); + } + } + return map; + } + + /** + * Get first time Value of [cp_native_interception_enabled] flag + */ + @ReactMethod + public void isNativeInterceptionEnabled(Promise promise) { + MainThreadHandler.runOnMainThread(new Runnable() { + @Override + public void run() { + try { + promise.resolve(getFlagValue(CP_NATIVE_INTERCEPTION_ENABLED)); + } catch (Exception e) { + e.printStackTrace(); + promise.resolve(false); // Will rollback to JS interceptor + } + + } + }); + } + + /** + * Indicate if user added APM Network plugin or not + * [true] means user added the APM plugin + * [false] means not + */ + @ReactMethod + public void hasAPMNetworkPlugin(Promise promise) { + MainThreadHandler.runOnMainThread(new Runnable() { + @Override + public void run() { + try { + promise.resolve(getFlagValue(APM_NETWORK_PLUGIN_INSTALLED)); + } catch (Exception e) { + e.printStackTrace(); + promise.resolve(false); // Will rollback to JS interceptor + } + + } + }); + } + + + @ReactMethod + public void registerNetworkLogsListener() { + MainThreadHandler.runOnMainThread(new Runnable() { + @Override + public void run() { + InternalAPM._registerNetworkLogSanitizer((networkLogSnapshot, onCompleteCallback) -> { + final String id = String.valueOf(onCompleteCallback.hashCode()); + callbackMap.put(id, onCompleteCallback); + + WritableMap networkSnapshotParams = Arguments.createMap(); + networkSnapshotParams.putString("id", id); + networkSnapshotParams.putString("url", networkLogSnapshot.getUrl()); + networkSnapshotParams.putInt("responseCode", networkLogSnapshot.getResponseCode()); + networkSnapshotParams.putString("requestBody", networkLogSnapshot.getRequestBody()); + networkSnapshotParams.putString("response", networkLogSnapshot.getResponse()); + final Map requestHeaders = networkLogSnapshot.getRequestHeaders(); + if (requestHeaders != null) { + networkSnapshotParams.putMap("requestHeader", convertFromMapToWritableMap(requestHeaders)); + } + final Map responseHeaders = networkLogSnapshot.getResponseHeaders(); + if (responseHeaders != null) { + networkSnapshotParams.putMap("responseHeader", convertFromMapToWritableMap(responseHeaders)); + } + + sendEvent(Constants.IBG_NETWORK_LOGGER_HANDLER, networkSnapshotParams); + }); + } + }); + } + + @ReactMethod + public void resetNetworkLogsListener() { + MainThreadHandler.runOnMainThread(new Runnable() { + @Override + public void run() { + InternalAPM._registerNetworkLogSanitizer(null); + } + }); + } + + @ReactMethod + public void updateNetworkLogSnapshot( + String url, + String callbackID, + String requestBody, + String responseBody, + int responseCode, + ReadableMap requestHeaders, + ReadableMap responseHeaders + ) { + try { + // Convert ReadableMap to a Java Map for easier handling + Map requestHeadersMap = convertReadableMapToMap(requestHeaders); + Map responseHeadersMap = convertReadableMapToMap(responseHeaders); + + NetworkLogSnapshot modifiedSnapshot = null; + if (!url.isEmpty()) { + modifiedSnapshot = new NetworkLogSnapshot(url, requestHeadersMap, requestBody, responseHeadersMap, responseBody, responseCode); + } + + final OnCompleteCallback callback = callbackMap.get(callbackID); + if (callback != null) { + callback.onComplete(modifiedSnapshot); + callbackMap.remove(callbackID); + } + } catch (Exception e) { + // Reject the promise to indicate an error occurred + Log.e("IB-CP-Bridge", "InstabugNetworkLogger.updateNetworkLogSnapshot failed to parse the network snapshot object."); + } + } +} diff --git a/android/src/main/java/com/instabug/reactlibrary/RNInstabugReactnativeModule.java b/android/src/main/java/com/instabug/reactlibrary/RNInstabugReactnativeModule.java index a0d2d357d2..991dc97259 100644 --- a/android/src/main/java/com/instabug/reactlibrary/RNInstabugReactnativeModule.java +++ b/android/src/main/java/com/instabug/reactlibrary/RNInstabugReactnativeModule.java @@ -1,5 +1,7 @@ package com.instabug.reactlibrary; +import static com.instabug.apm.configuration.cp.APMFeature.APM_NETWORK_PLUGIN_INSTALLED; +import static com.instabug.apm.configuration.cp.APMFeature.CP_NATIVE_INTERCEPTION_ENABLED; import static com.instabug.reactlibrary.utils.InstabugUtil.getMethod; import android.app.Application; @@ -38,7 +40,8 @@ import com.instabug.library.internal.crossplatform.FeaturesStateListener; import com.instabug.library.internal.crossplatform.InternalCore; import com.instabug.library.featuresflags.model.IBGFeatureFlag; -import com.instabug.library.featuresflags.model.IBGFeatureFlag; +import com.instabug.library.internal.crossplatform.InternalCore; +import com.instabug.library.internal.crossplatform.OnFeaturesUpdatedListener; import com.instabug.library.internal.module.InstabugLocale; import com.instabug.library.invocation.InstabugInvocationEvent; import com.instabug.library.logging.InstabugLog; @@ -1270,7 +1273,23 @@ public Map getConstants() { return constants; } - /** + + @ReactMethod + public void setOnFeaturesUpdatedListener() { + InternalCore.INSTANCE._setOnFeaturesUpdatedListener(new OnFeaturesUpdatedListener() { + @Override + public void invoke() { + final boolean cpNativeInterceptionEnabled = InternalAPM._isFeatureEnabledCP(CP_NATIVE_INTERCEPTION_ENABLED, ""); + final boolean hasAPMPlugin = InternalAPM._isFeatureEnabledCP(APM_NETWORK_PLUGIN_INSTALLED, ""); + + WritableMap params = Arguments.createMap(); + params.putBoolean("cpNativeInterceptionEnabled", cpNativeInterceptionEnabled); + params.putBoolean("hasAPMPlugin", hasAPMPlugin); + sendEvent(Constants.IBG_ON_FEATURES_UPDATED_CALLBACK, params); + } + }); + } + /** * Enables or disables capturing network body. * @param isEnabled A boolean to enable/disable capturing network body. */ diff --git a/android/src/main/java/com/instabug/reactlibrary/RNInstabugReactnativePackage.java b/android/src/main/java/com/instabug/reactlibrary/RNInstabugReactnativePackage.java index 1d38287252..0cabd1bcfa 100644 --- a/android/src/main/java/com/instabug/reactlibrary/RNInstabugReactnativePackage.java +++ b/android/src/main/java/com/instabug/reactlibrary/RNInstabugReactnativePackage.java @@ -29,6 +29,7 @@ public List createNativeModules(@NonNull ReactApplicationContext r modules.add(new RNInstabugRepliesModule(reactContext)); modules.add(new RNInstabugAPMModule(reactContext)); modules.add(new RNInstabugSessionReplayModule(reactContext)); + modules.add(new RNInstabugNetworkLoggerModule(reactContext)); return modules; } diff --git a/android/src/test/java/com/instabug/reactlibrary/RNInstabugNetworkLoggerModuleTest.java b/android/src/test/java/com/instabug/reactlibrary/RNInstabugNetworkLoggerModuleTest.java new file mode 100644 index 0000000000..30cec00abe --- /dev/null +++ b/android/src/test/java/com/instabug/reactlibrary/RNInstabugNetworkLoggerModuleTest.java @@ -0,0 +1,207 @@ +package com.instabug.reactlibrary; + +import static com.instabug.apm.configuration.cp.APMFeature.APM_NETWORK_PLUGIN_INSTALLED; +import static com.instabug.apm.configuration.cp.APMFeature.CP_NATIVE_INTERCEPTION_ENABLED; +import static org.mockito.Mockito.*; +import static org.junit.Assert.*; + +import android.os.Looper; + +import com.facebook.react.bridge.Promise; +import com.facebook.react.bridge.ReactApplicationContext; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.stubbing.Answer; + +import com.instabug.apm.InternalAPM; +import com.instabug.reactlibrary.utils.MainThreadHandler; + +public class RNInstabugNetworkLoggerModuleTest { + + // Mock Objects + private MockedStatic mockLooper; + private MockedStatic mockMainThreadHandler; + private RNInstabugNetworkLoggerModule rnInstabugNetworkLoggerModule; + private Promise mockPromise; + + @Before + public void mockMainThreadHandler() { + // Mock Object + ReactApplicationContext mockReactApplicationContext = mock(ReactApplicationContext.class); + mockPromise = mock(Promise.class); + rnInstabugNetworkLoggerModule = new RNInstabugNetworkLoggerModule(mockReactApplicationContext); + + // Mock static functions + mockLooper = mockStatic(Looper.class); + mockMainThreadHandler = mockStatic(MainThreadHandler.class); + // Mock Looper class + Looper mockMainThreadLooper = mock(Looper.class); + when(Looper.getMainLooper()).thenReturn(mockMainThreadLooper); + + // Override runOnMainThread + Answer handlerPostAnswer = invocation -> { + 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(); + } + + + @Test + public void testGetName() { + // Test the getName method + String name = rnInstabugNetworkLoggerModule.getName(); + assertEquals("IBGNetworkLogger", name); + } + + @Test + public void testAddListener() { + // Test addListener method + rnInstabugNetworkLoggerModule.addListener("event_name"); + // Nothing to assert, but check no exceptions are thrown + } + + @Test + public void testRemoveListeners() { + // Test removeListeners method + rnInstabugNetworkLoggerModule.removeListeners(1); + // Nothing to assert, but check no exceptions are thrown + } + + @Test + public void testIsNativeInterceptionEnabled_True() { + + // Mock InternalAPM behavior within the scope of this test + try (MockedStatic internalAPMMock = mockStatic(InternalAPM.class)) { + internalAPMMock.when(() -> InternalAPM._isFeatureEnabledCP(CP_NATIVE_INTERCEPTION_ENABLED, "")).thenReturn(true); + + // Execute the method + rnInstabugNetworkLoggerModule.isNativeInterceptionEnabled(mockPromise); + + // Capture the Promise.resolve() call + ArgumentCaptor captor = ArgumentCaptor.forClass(Boolean.class); + verify(mockPromise).resolve(captor.capture()); + + // Assert that true was passed to resolve + internalAPMMock.verify(() -> InternalAPM._isFeatureEnabledCP(CP_NATIVE_INTERCEPTION_ENABLED, "")); + assertTrue(captor.getValue()); + } + } + + @Test + public void testIsNativeInterceptionEnabled_False() { + + try (MockedStatic internalAPMMock = mockStatic(InternalAPM.class)) { + internalAPMMock.when(() -> InternalAPM._isFeatureEnabledCP(CP_NATIVE_INTERCEPTION_ENABLED, "")).thenReturn(false); + + // Execute the method + rnInstabugNetworkLoggerModule.isNativeInterceptionEnabled(mockPromise); + + // Capture the Promise.resolve() call + ArgumentCaptor captor = ArgumentCaptor.forClass(Boolean.class); + verify(mockPromise).resolve(captor.capture()); + + // Assert that false was passed to resolve + assertFalse(captor.getValue()); + } + } + + @Test + public void testIsNativeInterceptionEnabled_Exception() { + + // Simulate an exception in InternalAPM + try (MockedStatic internalAPMMock = mockStatic(InternalAPM.class)) { + internalAPMMock.when(() -> InternalAPM._isFeatureEnabledCP(anyString(), anyString())).thenThrow(new RuntimeException("Error")); + + // Execute the method + rnInstabugNetworkLoggerModule.isNativeInterceptionEnabled(mockPromise); + + // Capture the Promise.resolve() call in case of an exception + ArgumentCaptor captor = ArgumentCaptor.forClass(Boolean.class); + verify(mockPromise).resolve(captor.capture()); + + // Assert that false was passed to resolve when exception occurs + assertFalse(captor.getValue()); + } + } + + @Test + public void testHasAPMNetworkPlugin_True() { + + try (MockedStatic internalAPMMock = mockStatic(InternalAPM.class)) { + internalAPMMock.when(() -> InternalAPM._isFeatureEnabledCP(APM_NETWORK_PLUGIN_INSTALLED, "")).thenReturn(true); + + // Execute the method + rnInstabugNetworkLoggerModule.hasAPMNetworkPlugin(mockPromise); + + // Capture the Promise.resolve() call + ArgumentCaptor captor = ArgumentCaptor.forClass(Boolean.class); + verify(mockPromise).resolve(captor.capture()); + + // Assert that true was passed to resolve + internalAPMMock.verify(() -> InternalAPM._isFeatureEnabledCP(APM_NETWORK_PLUGIN_INSTALLED, "")); + assertTrue(captor.getValue()); + } + } + + @Test + public void testHasAPMNetworkPlugin_False() { + + try (MockedStatic internalAPMMock = mockStatic(InternalAPM.class)) { + internalAPMMock.when(() -> InternalAPM._isFeatureEnabledCP(APM_NETWORK_PLUGIN_INSTALLED, "")).thenReturn(false); + + // Execute the method + rnInstabugNetworkLoggerModule.hasAPMNetworkPlugin(mockPromise); + + // Capture the Promise.resolve() call + ArgumentCaptor captor = ArgumentCaptor.forClass(Boolean.class); + verify(mockPromise).resolve(captor.capture()); + + // Assert that false was passed to resolve + assertFalse(captor.getValue()); + } + } + + @Test + public void testHasAPMNetworkPlugin_Exception() { + + // Simulate an exception in InternalAPM + try (MockedStatic internalAPMMock = mockStatic(InternalAPM.class)) { + internalAPMMock.when(() -> InternalAPM._isFeatureEnabledCP(anyString(), anyString())).thenThrow(new RuntimeException("Error")); + + // Execute the method + rnInstabugNetworkLoggerModule.hasAPMNetworkPlugin(mockPromise); + + // Capture the Promise.resolve() call in case of an exception + ArgumentCaptor captor = ArgumentCaptor.forClass(Boolean.class); + verify(mockPromise).resolve(captor.capture()); + + // Assert that false was passed to resolve when exception occurs + assertFalse(captor.getValue()); + } + } + + @Test + public void testRegisterNetworkLogsListenerCalled() { + try (MockedStatic internalAPMMock = mockStatic(InternalAPM.class)) { + // Run the method + rnInstabugNetworkLoggerModule.registerNetworkLogsListener(); + + // Verify the sanitizer was registered + internalAPMMock.verify(() -> InternalAPM._registerNetworkLogSanitizer(any())); + } + } +} diff --git a/examples/default/android/app/build.gradle b/examples/default/android/app/build.gradle index b017ddb2f9..b039dd1901 100644 --- a/examples/default/android/app/build.gradle +++ b/examples/default/android/app/build.gradle @@ -2,7 +2,7 @@ apply plugin: "com.android.application" apply plugin: "org.jetbrains.kotlin.android" apply plugin: "com.facebook.react" apply from: project(':react-native-config').projectDir.getPath() + "/dotenv.gradle" - +apply plugin: 'instabug-apm' /** * This is the configuration block to customize your React Native Android app. * By default you don't need to apply any configuration, just uncomment the lines you need. @@ -124,6 +124,12 @@ android { } } +instabug { + apm { + networkEnabled = true + } +} + dependencies { // The version of react-native is set by the React Native Gradle Plugin implementation("com.facebook.react:react-android") diff --git a/examples/default/android/build.gradle b/examples/default/android/build.gradle index 72cc6f93ba..5729e78c9e 100644 --- a/examples/default/android/build.gradle +++ b/examples/default/android/build.gradle @@ -12,11 +12,19 @@ buildscript { repositories { google() mavenCentral() + maven { + url "https://mvn.instabug.com/nexus/repository/instabug-internal/" + credentials { + username "instabug" + password System.getenv("INSTABUG_REPOSITORY_PASSWORD") + } + } } dependencies { classpath("com.android.tools.build:gradle:8.1.0") classpath("com.facebook.react:react-native-gradle-plugin") classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") + classpath("com.instabug.library:instabug-plugin:14.1.0.6273967-SNAPSHOT") } } @@ -26,6 +34,13 @@ allprojects { url("$rootDir/../node_modules/detox/Detox-android") } + maven { + url "https://mvn.instabug.com/nexus/repository/instabug-internal/" + credentials { + username "instabug" + password System.getenv("INSTABUG_REPOSITORY_PASSWORD") + } + } maven { credentials { username System.getenv("DREAM11_MAVEN_USERNAME") diff --git a/examples/default/ios/InstabugTests/InstabugNetworkLoggerTests.m b/examples/default/ios/InstabugTests/InstabugNetworkLoggerTests.m new file mode 100644 index 0000000000..693c2c01cb --- /dev/null +++ b/examples/default/ios/InstabugTests/InstabugNetworkLoggerTests.m @@ -0,0 +1,124 @@ +#import +#import "InstabugNetworkLoggerBridge.h" + +@interface InstabugNetworkLoggerBridgeTests : XCTestCase + +@property (nonatomic, strong) InstabugNetworkLoggerBridge *networkLoggerBridge; + +@end + +@implementation InstabugNetworkLoggerBridgeTests + +- (void)setUp { + [super setUp]; + self.networkLoggerBridge = [[InstabugNetworkLoggerBridge alloc] init]; +} + +- (void)tearDown { + self.networkLoggerBridge = nil; + [super tearDown]; +} + +- (void)testInitialization { + XCTAssertNotNil(self.networkLoggerBridge.requestObfuscationCompletionDictionary); + XCTAssertNotNil(self.networkLoggerBridge.responseObfuscationCompletionDictionary); + XCTAssertNotNil(self.networkLoggerBridge.requestFilteringCompletionDictionary); + XCTAssertNotNil(self.networkLoggerBridge.responseFilteringCompletionDictionary); +} + +- (void)testRequiresMainQueueSetup { + XCTAssertFalse([InstabugNetworkLoggerBridge requiresMainQueueSetup]); +} + +- (void)testSupportedEvents { + NSArray *events = [self.networkLoggerBridge supportedEvents]; + NSArray *expectedEvents = @[@"IBGpreInvocationHandler", @"IBGNetworkLoggerHandler"]; + XCTAssertEqualObjects(events, expectedEvents); +} + +- (void)testMethodQueue { + dispatch_queue_t queue = [self.networkLoggerBridge methodQueue]; + XCTAssertEqual(queue, dispatch_get_main_queue()); +} + +- (void)testStartObserving { + [self.networkLoggerBridge startObserving]; + // Since `hasListeners` is private, we will assume it is true based on no errors or behavior issues + XCTAssertTrue(YES); // Expect no crashes +} + +- (void)testStopObserving { + [self.networkLoggerBridge stopObserving]; + XCTAssertTrue(YES); // Ensure the method doesn't cause issues +} + +- (void)testIsNativeInterceptionEnabled { + XCTestExpectation *expectation = [self expectationWithDescription:@"isNativeInterceptionEnabled"]; + + [self.networkLoggerBridge isNativeInterceptionEnabled:^(id result) { + XCTAssertNotNil(result); + XCTAssertTrue([result isKindOfClass:[NSNumber class]]); + [expectation fulfill]; + } :^(NSString *code, NSString *message, NSError *error) { + XCTFail(@"Promise rejection not expected."); + }]; + + [self waitForExpectationsWithTimeout:1.0 handler:nil]; +} + +- (void)testRegisterNetworkLogsListenerFiltering { + [self.networkLoggerBridge registerNetworkLogsListener:NetworkListenerTypeFiltering]; + // Expect no crashes and check that filtering handler was set + XCTAssertTrue(YES); // Could add additional assertions if more visibility into handler setup is possible +} + +- (void)testRegisterNetworkLogsListenerObfuscation { + [self.networkLoggerBridge registerNetworkLogsListener:NetworkListenerTypeObfuscation]; + XCTAssertTrue(YES); // Expect no crashes, similar reasoning +} + +- (void)testRegisterNetworkLogsListenerBoth { + [self.networkLoggerBridge registerNetworkLogsListener:NetworkListenerTypeBoth]; + XCTAssertTrue(YES); // Same reason, ensuring no crash +} + +- (void)testUpdateNetworkLogSnapshotValidJson { + NSString *jsonString = @"{\"url\":\"https://example.com\",\"requestBody\":\"bodyData\",\"requestHeader\":{\"key\":\"value\"},\"id\":\"12345\"}"; + + [self.networkLoggerBridge updateNetworkLogSnapshot:jsonString]; + + // Expect no errors or logs regarding completion issues + XCTAssertTrue(YES); +} + +- (void)testUpdateNetworkLogSnapshotInvalidJson { + NSString *invalidJsonString = @"invalid json string"; + + // This should fail gracefully and log an error + [self.networkLoggerBridge updateNetworkLogSnapshot:invalidJsonString]; + XCTAssertTrue(YES); // No crash, expect graceful handling +} + +- (void)testSetNetworkLoggingRequestFilterPredicateIOS { + NSString *callbackID = @"12345"; + + // Mock a completion handler + self.networkLoggerBridge.requestFilteringCompletionDictionary[callbackID] = ^(BOOL shouldSave) { + XCTAssertTrue(shouldSave); + }; + + [self.networkLoggerBridge setNetworkLoggingRequestFilterPredicateIOS:callbackID :YES]; + + XCTAssertTrue(YES); // Ensure that the handler is invoked correctly +} + +- (void)testSetNetworkLoggingRequestFilterPredicateIOSInvalidCallback { + NSString *invalidCallbackID = @"invalidID"; + + // This should fail gracefully and log an error + [self.networkLoggerBridge setNetworkLoggingRequestFilterPredicateIOS:invalidCallbackID :YES]; + + XCTAssertTrue(YES); // No crash, expect graceful handling +} + +@end diff --git a/examples/default/ios/Podfile b/examples/default/ios/Podfile index 10b2cc5b7f..ea3be9c257 100644 --- a/examples/default/ios/Podfile +++ b/examples/default/ios/Podfile @@ -16,8 +16,7 @@ target 'InstabugExample' do rn_maps_path = '../node_modules/react-native-maps' pod 'react-native-google-maps', :path => rn_maps_path # add this line - pod 'Instabug', :podspec => 'https://ios-releases.instabug.com/custom/fix-main-thread-warning/15.0.0/Instabug.podspec' - + pod 'Instabug', :podspec => 'https://ios-releases.instabug.com/custom/sanity/15.0.1/Instabug.podspec' # Flags change depending on the env values. flags = get_default_flags() diff --git a/examples/default/ios/Podfile.lock b/examples/default/ios/Podfile.lock index 55b2b299a6..9cde707ae3 100644 --- a/examples/default/ios/Podfile.lock +++ b/examples/default/ios/Podfile.lock @@ -31,7 +31,7 @@ PODS: - hermes-engine (0.75.4): - hermes-engine/Pre-built (= 0.75.4) - hermes-engine/Pre-built (0.75.4) - - Instabug (15.0.0) + - Instabug (15.0.1) - instabug-reactnative-ndk (0.1.0): - DoubleConversion - glog @@ -1626,7 +1626,7 @@ PODS: - ReactCommon/turbomodule/core - Yoga - RNInstabug (14.3.0): - - Instabug (= 15.0.0) + - Instabug - React-Core - RNReanimated (3.16.1): - DoubleConversion @@ -1770,7 +1770,7 @@ DEPENDENCIES: - fmt (from `../node_modules/react-native/third-party-podspecs/fmt.podspec`) - glog (from `../node_modules/react-native/third-party-podspecs/glog.podspec`) - hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) - - Instabug (from `https://ios-releases.instabug.com/custom/fix-main-thread-warning/15.0.0/Instabug.podspec`) + - Instabug (from `https://ios-releases.instabug.com/custom/sanity/15.0.1/Instabug.podspec`) - instabug-reactnative-ndk (from `../node_modules/instabug-reactnative-ndk`) - OCMock - RCT-Folly (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`) @@ -1869,7 +1869,7 @@ EXTERNAL SOURCES: :podspec: "../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec" :tag: hermes-2024-08-15-RNv0.75.1-4b3bf912cc0f705b51b71ce1a5b8bd79b93a451b Instabug: - :podspec: https://ios-releases.instabug.com/custom/fix-main-thread-warning/15.0.0/Instabug.podspec + :podspec: https://ios-releases.instabug.com/custom/sanity/15.0.1/Instabug.podspec instabug-reactnative-ndk: :path: "../node_modules/instabug-reactnative-ndk" RCT-Folly: @@ -2024,7 +2024,7 @@ SPEC CHECKSUMS: Google-Maps-iOS-Utils: f77eab4c4326d7e6a277f8e23a0232402731913a GoogleMaps: 032f676450ba0779bd8ce16840690915f84e57ac hermes-engine: ea92f60f37dba025e293cbe4b4a548fd26b610a0 - Instabug: 3b1db5a683e85ec5a02946aa2b3314036f9022be + Instabug: 9e81b71be68626dafc74759f3458f7c5894dd2e1 instabug-reactnative-ndk: d765ac289d56e8896398d02760d9abf2562fc641 OCMock: 589f2c84dacb1f5aaf6e4cec1f292551fe748e74 RCT-Folly: 4464f4d875961fce86008d45f4ecf6cef6de0740 @@ -2092,7 +2092,7 @@ SPEC CHECKSUMS: ReactCommon: 6a952e50c2a4b694731d7682aaa6c79bc156e4ad RNCClipboard: 2821ac938ef46f736a8de0c8814845dde2dcbdfb RNGestureHandler: 511250b190a284388f9dd0d2e56c1df76f14cfb8 - RNInstabug: fd8d5ad4eab9a25aa85534e3b32f400cb0a4b61c + RNInstabug: a038636a8fb8e078e69d3c51fca38396fa1ffdab RNReanimated: f42a5044d121d68e91680caacb0293f4274228eb RNScreens: c7ceced6a8384cb9be5e7a5e88e9e714401fd958 RNSVG: 8b1a777d54096b8c2a0fd38fc9d5a454332bbb4d @@ -2100,6 +2100,6 @@ SPEC CHECKSUMS: SocketRocket: abac6f5de4d4d62d24e11868d7a2f427e0ef940d Yoga: 055f92ad73f8c8600a93f0e25ac0b2344c3b07e6 -PODFILE CHECKSUM: 62df19179352b0c81c037eac790f1c5fb84b8ed0 +PODFILE CHECKSUM: a1b532d67a1a86843e1f086101751ad55afa52da COCOAPODS: 1.14.0 diff --git a/examples/default/src/App.tsx b/examples/default/src/App.tsx index abdab11111..1220028577 100644 --- a/examples/default/src/App.tsx +++ b/examples/default/src/App.tsx @@ -1,17 +1,19 @@ -import React, { useEffect } from 'react'; -import { StyleSheet } from 'react-native'; +import React, { useEffect, useState } from 'react'; +import { ActivityIndicator, StyleSheet } from 'react-native'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { NavigationContainer, useNavigationContainerRef } from '@react-navigation/native'; +import type { SessionMetadata } from 'instabug-reactnative'; import Instabug, { CrashReporting, InvocationEvent, + LaunchType, LogLevel, + NetworkInterceptionMode, + NetworkLogger, ReproStepsMode, SessionReplay, - LaunchType, } from 'instabug-reactnative'; -import type { SessionMetadata } from 'instabug-reactnative'; import { NativeBaseProvider } from 'native-base'; import { RootTabNavigator } from './navigation/RootTab'; @@ -38,32 +40,55 @@ export const App: React.FC = () => { const navigationRef = useNavigationContainerRef(); - useEffect(() => { - SessionReplay.setSyncCallback((data) => shouldSyncSession(data)); + const [isInstabugInitialized, setIsInstabugInitialized] = useState(false); - Instabug.init({ - token: 'deb1910a7342814af4e4c9210c786f35', - invocationEvents: [InvocationEvent.floatingButton], - debugLogsLevel: LogLevel.verbose, - }); - CrashReporting.setNDKCrashesEnabled(true); + const initializeInstabug = async () => { + try { + SessionReplay.setSyncCallback((data) => shouldSyncSession(data)); + + await Instabug.init({ + token: 'deb1910a7342814af4e4c9210c786f35', + invocationEvents: [InvocationEvent.floatingButton], + debugLogsLevel: LogLevel.verbose, + networkInterceptionMode: NetworkInterceptionMode.native, + }); + + CrashReporting.setNDKCrashesEnabled(true); + Instabug.setReproStepsConfig({ all: ReproStepsMode.enabled }); - Instabug.setReproStepsConfig({ - all: ReproStepsMode.enabled, + setIsInstabugInitialized(true); // Set to true after initialization + } catch (error) { + console.error('Instabug initialization failed:', error); + setIsInstabugInitialized(true); // Proceed even if initialization fails + } + }; + + useEffect(() => { + initializeInstabug().then(() => { + NetworkLogger.setNetworkDataObfuscationHandler(async (networkData) => { + networkData.url = `${networkData.url}/JS/Obfuscated`; + return networkData; + }); + // NetworkLogger.setRequestFilterExpression('false'); }); - }, []); + }); useEffect(() => { + // @ts-ignore const unregisterListener = Instabug.setNavigationListener(navigationRef); return unregisterListener; }, [navigationRef]); + if (!isInstabugInitialized) { + return ; + } + return ( - + @@ -76,4 +101,9 @@ const styles = StyleSheet.create({ root: { flex: 1, }, + loading: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, }); diff --git a/examples/default/src/screens/apm/NetworkScreen.tsx b/examples/default/src/screens/apm/NetworkScreen.tsx index 7df8af7f49..4225e6185d 100644 --- a/examples/default/src/screens/apm/NetworkScreen.tsx +++ b/examples/default/src/screens/apm/NetworkScreen.tsx @@ -5,12 +5,11 @@ import { Screen } from '../../components/Screen'; import { ClipboardTextInput } from '../../components/ClipboardTextInput'; import { useQuery } from 'react-query'; import { HStack, VStack } from 'native-base'; -import { gql, request } from 'graphql-request'; +import { gql, GraphQLClient } from 'graphql-request'; import { CustomButton } from '../../components/CustomButton'; import axios from 'axios'; import type { HomeStackParamList } from '../../navigation/HomeStack'; import type { NativeStackScreenProps } from '@react-navigation/native-stack'; -import { ListTile } from '../../components/ListTile'; import { useNetInfo } from '@react-native-community/netinfo'; export const NetworkScreen: React.FC< @@ -20,15 +19,17 @@ export const NetworkScreen: React.FC< const { width, height } = useWindowDimensions(); const { isConnected } = useNetInfo(); + const defaultRequestBaseUrl = 'https://jsonplaceholder.typicode.com/posts/'; + const shortenLink = 'https://shorturl.at/3Ufj3'; + const defaultRequestUrl = `${defaultRequestBaseUrl}1`; - const defaultRequestUrl = 'https://jsonplaceholder.typicode.com/posts/1'; const imageUrls = [ 'https://fastly.picsum.photos/id/57/200/300.jpg?hmac=l908G1qVr4r7dP947-tak2mY8Vvic_vEYzCXUCKKskY', 'https://fastly.picsum.photos/id/619/200/300.jpg?hmac=WqBGwlGjuY9RCdpzRaG9G-rc9Fi7TGUINX_-klAL2kA', ]; async function sendRequestToUrl() { - let urlToSend = ''; + let urlToSend: string; if (endpointUrl.trim() !== '') { urlToSend = endpointUrl; @@ -56,7 +57,7 @@ export const NetworkScreen: React.FC< } async function sendRequestToUrlUsingAxios() { - let urlToSend = ''; + let urlToSend: string; if (endpointUrl.trim() !== '') { urlToSend = endpointUrl; @@ -81,7 +82,33 @@ export const NetworkScreen: React.FC< } } + async function sendRedirectRequestToUrl() { + try { + console.log('Sending request to: ', shortenLink); + const response = await fetch(shortenLink); + console.log('Received from: ', response.url); + + // Format the JSON response for better logging + const data = await response.json(); + + // Format the JSON response for better logging + const formattedData = JSON.stringify(data, null, 2); + + // Log the formatted response + console.log('Response:', formattedData); + } catch (error) { + // Handle errors appropriately + console.error('Error:', error); + } + } + const fetchGraphQlData = async () => { + const client = new GraphQLClient('https://countries.trevorblades.com/graphql', { + headers: { + 'ibg-graphql-header': 'AndrewQL', // change Query Name here + }, + }); + const document = gql` query { country(code: "EG") { @@ -91,10 +118,7 @@ export const NetworkScreen: React.FC< } `; - return request<{ country: { emoji: string; name: string } }>( - 'https://countries.trevorblades.com/graphql', - document, - ); + return client.request<{ country: { emoji: string; name: string } }>(document); }; const { data, isError, isSuccess, isLoading, refetch } = useQuery('helloQuery', fetchGraphQlData); @@ -107,6 +131,39 @@ export const NetworkScreen: React.FC< axios.get('https://httpbin.org/anything'); }; + function generateUrls(count: number = 10) { + const urls = []; + for (let i = 1; i <= count; i++) { + urls.push(defaultRequestBaseUrl + i); + } + return urls; + } + + async function makeSequentialApiCalls(urls: string[]): Promise { + const results: any[] = []; + + try { + for (let i = 0; i < urls.length; i++) { + await fetch(urls[i]); + results.push(results[i]); + } + return results; + } catch (error) { + console.error('Error making parallel API calls:', error); + throw error; + } + } + async function makeParallelApiCalls(urls: string[]): Promise { + const fetchPromises = urls.map((url) => fetch(url).then((response) => response.json())); + + try { + return await Promise.all(fetchPromises); + } catch (error) { + console.error('Error making parallel API calls:', error); + throw error; + } + } + return ( @@ -119,15 +176,35 @@ export const NetworkScreen: React.FC< value={endpointUrl} /> + - makeParallelApiCalls(generateUrls())} + title="Send Parallel Requests" + /> + makeSequentialApiCalls(generateUrls())} + title="Send Sequantail Requests" + /> + + refetch()} title="Reload GraphQL" /> + navigation.navigate('HttpScreen')} + title="Go HTTP Screen" + /> + + simulateNetworkRequest()} /> - simulateNetworkRequestWithoutHeader()} /> @@ -153,7 +230,6 @@ export const NetworkScreen: React.FC< ))} - navigation.navigate('HttpScreen')} /> ); diff --git a/ios/RNInstabug/InstabugNetworkLoggerBridge.h b/ios/RNInstabug/InstabugNetworkLoggerBridge.h new file mode 100644 index 0000000000..9673f85248 --- /dev/null +++ b/ios/RNInstabug/InstabugNetworkLoggerBridge.h @@ -0,0 +1,44 @@ +#import +#import +#import + +typedef void (^ IBGURLRequestAsyncObfuscationCompletedHandler)(NSURLRequest * _Nonnull request); +typedef void (^IBGURLRequestResponseAsyncFilteringCompletedHandler)(BOOL keep); + +typedef NS_ENUM(NSInteger, NetworkListenerType) { + NetworkListenerTypeFiltering, + NetworkListenerTypeObfuscation, + NetworkListenerTypeBoth +}; + +@interface InstabugNetworkLoggerBridge : RCTEventEmitter + +@property NSMutableDictionary * _Nonnull requestObfuscationCompletionDictionary; +@property NSMutableDictionary * _Nonnull responseObfuscationCompletionDictionary; +@property NSMutableDictionary * _Nonnull requestFilteringCompletionDictionary; +@property NSMutableDictionary * _Nonnull responseFilteringCompletionDictionary; + +/* + +------------------------------------------------------------------------+ + | NetworkLogger Module | + +------------------------------------------------------------------------+ + */ + +- (void)isNativeInterceptionEnabled:(RCTPromiseResolveBlock _Nullable )resolve :(RCTPromiseRejectBlock _Nullable )reject; + +- (void) registerNetworkLogsListener:(NetworkListenerType)listenerType; + +- (void)updateNetworkLogSnapshot:(NSString * _Nonnull)url + callbackID:(NSString * _Nonnull)callbackID + requestBody:(NSString * _Nullable)requestBody + responseBody:(NSString * _Nullable)responseBody + responseCode:(double)responseCode + requestHeaders:(NSDictionary * _Nullable)requestHeaders + responseHeaders:(NSDictionary * _Nullable)responseHeaders; + +- (void) setNetworkLoggingRequestFilterPredicateIOS:(NSString * _Nonnull) callbackID : (BOOL)value; + +- (void)forceStartNetworkLoggingIOS; + +- (void)forceStopNetworkLoggingIOS; +@end diff --git a/ios/RNInstabug/InstabugNetworkLoggerBridge.m b/ios/RNInstabug/InstabugNetworkLoggerBridge.m new file mode 100644 index 0000000000..16d71c8144 --- /dev/null +++ b/ios/RNInstabug/InstabugNetworkLoggerBridge.m @@ -0,0 +1,204 @@ +// +// InstabugNetworkLoggerBridge.m +// RNInstabug +// +// Created by Andrew Amin on 01/10/2024. +// +#import "InstabugNetworkLoggerBridge.h" +#import "Util/IBGNetworkLogger+CP.h" + +#import +#import + +// Extend RCTConvert to handle NetworkListenerType enum conversion +@implementation RCTConvert (NetworkListenerType) + +// The RCT_ENUM_CONVERTER macro handles the conversion between JS values (Int) and Objective-C enum values +RCT_ENUM_CONVERTER(NetworkListenerType, (@{ + @"filtering": @(NetworkListenerTypeFiltering), + @"obfuscation": @(NetworkListenerTypeObfuscation), + @"both": @(NetworkListenerTypeBoth) +}), NetworkListenerTypeFiltering, integerValue) + +@end + +@implementation InstabugNetworkLoggerBridge + + +- (instancetype)init { + self = [super init]; + if (self) { + _requestObfuscationCompletionDictionary = [[NSMutableDictionary alloc] init]; + _responseObfuscationCompletionDictionary = [[NSMutableDictionary alloc] init]; + _requestFilteringCompletionDictionary = [[NSMutableDictionary alloc] init]; + _responseFilteringCompletionDictionary = [[NSMutableDictionary alloc] init]; + } + return self; +} + +- (dispatch_queue_t)methodQueue { + return dispatch_get_main_queue(); +} + ++ (BOOL)requiresMainQueueSetup +{ + return NO; +} + +- (NSArray *)supportedEvents { + return @[ + @"IBGpreInvocationHandler", + @"IBGNetworkLoggerHandler" + ]; +} +RCT_EXPORT_MODULE(IBGNetworkLogger) + +bool hasListeners = NO; + + + +// Will be called when this module's first listener is added. +-(void)startObserving { + hasListeners = YES; + // Set up any upstream listeners or background tasks as necessary +} + +// Will be called when this module's last listener is removed, or on dealloc. +-(void)stopObserving { + hasListeners = NO; + // Remove upstream listeners, stop unnecessary background tasks +} + +RCT_EXPORT_METHOD(isNativeInterceptionEnabled:(RCTPromiseResolveBlock)resolve :(RCTPromiseRejectBlock)reject) { + resolve(@(IBGNetworkLogger.isNativeNetworkInterceptionFeatureEnabled)); +} + +RCT_EXPORT_METHOD(registerNetworkLogsListener: (NetworkListenerType) listenerType) { + switch (listenerType) { + case NetworkListenerTypeFiltering: + [self setupRequestFilteringHandler]; + break; + + case NetworkListenerTypeObfuscation: + [self setupRequestObfuscationHandler]; + break; + + case NetworkListenerTypeBoth: + // The obfuscation handler sends additional data to the JavaScript side. If filtering is applied, the request will be ignored; otherwise, it will be obfuscated and saved in the database. + [self setupRequestObfuscationHandler]; + break; + + default: + NSLog(@"Unknown NetworkListenerType"); + break; + } +} + + +RCT_EXPORT_METHOD(updateNetworkLogSnapshot:(NSString * _Nonnull)url + callbackID:(NSString * _Nonnull)callbackID + requestBody:(NSString * _Nullable)requestBody + responseBody:(NSString * _Nullable)responseBody + responseCode:(double)responseCode + requestHeaders:(NSDictionary * _Nullable)requestHeaders + responseHeaders:(NSDictionary * _Nullable)responseHeaders) +{ + // Validate and construct the URL + NSURL *requestURL = [NSURL URLWithString:url]; + if (!requestURL) { + NSLog(@"Invalid URL: %@", url); + return; + } + + // Initialize the NSMutableURLRequest + NSMutableURLRequest *request = [[NSMutableURLRequest alloc] initWithURL:requestURL]; + + // Set the HTTP body if provided + if (requestBody && [requestBody isKindOfClass:[NSString class]]) { + request.HTTPBody = [requestBody dataUsingEncoding:NSUTF8StringEncoding]; + } + + // Ensure requestHeaders is a valid dictionary before setting it + if (requestHeaders && [requestHeaders isKindOfClass:[NSDictionary class]]) { + request.allHTTPHeaderFields = requestHeaders; + } else { + NSLog(@"Invalid requestHeaders format, expected NSDictionary."); + } + + // Ensure callbackID is valid and the completion handler exists + IBGURLRequestAsyncObfuscationCompletedHandler completionHandler = self.requestObfuscationCompletionDictionary[callbackID]; + if (callbackID && [callbackID isKindOfClass:[NSString class]] && completionHandler) { + // Call the completion handler with the constructed request + completionHandler(request); + } else { + NSLog(@"CallbackID not found or completion handler is unavailable for CallbackID: %@", callbackID); + } +} + +RCT_EXPORT_METHOD(setNetworkLoggingRequestFilterPredicateIOS: (NSString * _Nonnull) callbackID : (BOOL)value ){ + + if (self.requestFilteringCompletionDictionary[callbackID] != nil) { + // ⬇️ YES == Request will be saved, NO == will be ignored + ((IBGURLRequestResponseAsyncFilteringCompletedHandler)self.requestFilteringCompletionDictionary[callbackID])(value); + } else { + NSLog(@"Not Available Completion"); + } +} + + +#pragma mark - Helper Methods + +// Set up the filtering handler +- (void)setupRequestFilteringHandler { + [IBGNetworkLogger setCPRequestFilteringHandler:^(NSURLRequest * _Nonnull request, void (^ _Nonnull completion)(BOOL)) { + NSString *callbackID = [[[NSUUID alloc] init] UUIDString]; + self.requestFilteringCompletionDictionary[callbackID] = completion; + + NSDictionary *dict = [self createNetworkRequestDictForRequest:request callbackID:callbackID]; + if(hasListeners){ + [self sendEventWithName:@"IBGNetworkLoggerHandler" body:dict]; + } + + }]; +} + +// Set up the obfuscation handler +- (void)setupRequestObfuscationHandler { + [IBGNetworkLogger setCPRequestAsyncObfuscationHandler:^(NSURLRequest * _Nonnull request, void (^ _Nonnull completion)(NSURLRequest * _Nonnull)) { + NSString *callbackID = [[[NSUUID alloc] init] UUIDString]; + self.requestObfuscationCompletionDictionary[callbackID] = completion; + + + NSDictionary *dict = [self createNetworkRequestDictForRequest:request callbackID:callbackID]; + if (hasListeners) { + [self sendEventWithName:@"IBGNetworkLoggerHandler" body:dict]; + } + + }]; +} + +// Helper to create a dictionary from the request and callbackID +- (NSDictionary *)createNetworkRequestDictForRequest:(NSURLRequest *)request callbackID:(NSString *)callbackID { + NSString *urlString = request.URL.absoluteString ?: @""; + NSString *bodyString = [[NSString alloc] initWithData:request.HTTPBody encoding:NSUTF8StringEncoding] ?: @""; + NSDictionary *headerDict = request.allHTTPHeaderFields ?: @{}; + + return @{ + @"id": callbackID, + @"url": urlString, + @"requestBody": bodyString, + @"requestHeader": headerDict + }; +} + +RCT_EXPORT_METHOD(forceStartNetworkLoggingIOS) { + [IBGNetworkLogger forceStartNetworkLogging]; +} + +RCT_EXPORT_METHOD(forceStopNetworkLoggingIOS) { + [IBGNetworkLogger forceStopNetworkLogging]; +} + + + +@end diff --git a/ios/RNInstabug/InstabugReactBridge.m b/ios/RNInstabug/InstabugReactBridge.m index 04d1c7edec..a2c75aa551 100644 --- a/ios/RNInstabug/InstabugReactBridge.m +++ b/ios/RNInstabug/InstabugReactBridge.m @@ -23,7 +23,7 @@ + (void)setWillSendReportHandler_private:(void(^)(IBGReport *report, void(^repor @implementation InstabugReactBridge - (NSArray *)supportedEvents { - return @[@"IBGpreSendingHandler"]; + return @[@"IBGpreSendingHandler" , @"IBGNetworkLoggerHandler"]; } RCT_EXPORT_MODULE(Instabug) @@ -440,10 +440,6 @@ + (BOOL)iOSVersionIsLessThan:(NSString *)iOSVersion { return [iOSVersion compare:[UIDevice currentDevice].systemVersion options:NSNumericSearch] == NSOrderedDescending; }; -RCT_EXPORT_METHOD(setNetworkLogBodyEnabled:(BOOL)isEnabled) { - IBGNetworkLogger.logBodyEnabled = isEnabled; -} - RCT_EXPORT_METHOD(enableAutoMasking:(NSArray *)autoMaskingTypes) { IBGAutoMaskScreenshotOption autoMaskingOptions = 0; @@ -454,6 +450,10 @@ + (BOOL)iOSVersionIsLessThan:(NSString *)iOSVersion { } [Instabug setAutoMaskScreenshots: autoMaskingOptions]; - }; + +RCT_EXPORT_METHOD(setNetworkLogBodyEnabled:(BOOL)isEnabled) { + IBGNetworkLogger.logBodyEnabled = isEnabled; +} + @end diff --git a/ios/RNInstabug/Util/IBGNetworkLogger+CP.h b/ios/RNInstabug/Util/IBGNetworkLogger+CP.h index a1e208e88c..d0fd44992b 100644 --- a/ios/RNInstabug/Util/IBGNetworkLogger+CP.h +++ b/ios/RNInstabug/Util/IBGNetworkLogger+CP.h @@ -8,6 +8,7 @@ NS_ASSUME_NONNULL_BEGIN @property (class, atomic, assign) BOOL w3ExternalGeneratedHeaderEnabled; @property (class, atomic, assign) BOOL w3CaughtHeaderEnabled; +@property (class, atomic, assign) BOOL isNativeNetworkInterceptionFeatureEnabled; + (void)disableAutomaticCapturingOfNetworkLogs; + (void)addNetworkLogWithUrl:(NSString *_Nonnull)url @@ -54,6 +55,13 @@ NS_ASSUME_NONNULL_BEGIN generatedW3CTraceparent:(NSString * _Nullable)generatedW3CTraceparent caughtedW3CTraceparent:(NSString * _Nullable)caughtedW3CTraceparent; ++ (void)forceStartNetworkLogging; ++ (void)forceStopNetworkLogging; + ++ (void)setCPRequestAsyncObfuscationHandler:(void (^)(NSURLRequest * requestToBeObfuscated, void (^ completion)(NSURLRequest * obfuscatedRequest)))asyncObfuscationHandler; ++ (void)setCPRequestFilteringHandler:(void (^)(NSURLRequest * request, void (^completion)(BOOL keep)))requestFilteringHandler; ++ (void)setCPResponseFilteringHandler:(void (^)(NSURLResponse * response, void (^comppletion)(BOOL keep)))responseFilteringHandler; + @end NS_ASSUME_NONNULL_END diff --git a/ios/native.rb b/ios/native.rb index 7be4eb4230..b09f2fd5f2 100644 --- a/ios/native.rb +++ b/ios/native.rb @@ -1,4 +1,4 @@ -$instabug = { :version => '15.0.0' } +$instabug = { :version => '15.0.1' } def use_instabug! (spec = nil) version = $instabug[:version] diff --git a/src/modules/Instabug.ts b/src/modules/Instabug.ts index 9d2adb8f8b..1bcd5bf8b5 100644 --- a/src/modules/Instabug.ts +++ b/src/modules/Instabug.ts @@ -1,5 +1,10 @@ -import type React from 'react'; -import { Platform, findNodeHandle, processColor } from 'react-native'; +import { + AppState, + type AppStateStatus, + findNodeHandle, + Platform, + processColor, +} from 'react-native'; import type { NavigationContainerRefWithCurrent, @@ -22,11 +27,18 @@ import { StringKey, WelcomeMessageMode, } from '../utils/Enums'; -import InstabugUtils, { stringifyIfNotString } from '../utils/InstabugUtils'; +import InstabugUtils, { + checkNetworkRequestHandlers, + resetNativeObfuscationListener, + setApmNetworkFlagsIfChanged, + stringifyIfNotString, +} from '../utils/InstabugUtils'; import * as NetworkLogger from './NetworkLogger'; import { captureUnhandledRejections } from '../utils/UnhandledRejectionTracking'; import type { ReproConfig } from '../models/ReproConfig'; import type { FeatureFlag } from '../models/FeatureFlag'; +import { addAppStateListener } from '../utils/AppStatesHandler'; +import { NativeNetworkLogger } from '../native/NativeNetworkLogger'; import InstabugConstants from '../utils/InstabugConstants'; import { InstabugRNConfig } from '../utils/config'; import { Logger } from '../utils/logger'; @@ -35,6 +47,10 @@ let _currentScreen: string | null = null; let _lastScreen: string | null = null; let _isFirstScreen = false; const firstScreen = 'Initial Screen'; +let _currentAppState = AppState.currentState; +let isNativeInterceptionFeatureEnabled = false; // Checks the value of "cp_native_interception_enabled" backend flag. +let hasAPMNetworkPlugin = false; // Android only: checks if the APM plugin is installed. +let shouldEnableNativeInterception = false; // For Android: used to disable APM logging inside reportNetworkLog() -> NativeAPM.networkLogAndroid(), For iOS: used to control native interception (true == enabled , false == disabled) /** * Enables or disables Instabug functionality. @@ -68,44 +84,235 @@ function reportCurrentViewForAndroid(screenName: string | null) { * Should be called in constructor of the AppRegistry component * @param config SDK configurations. See {@link InstabugConfig} for more info. */ -export const init = (config: InstabugConfig) => { +export const init = async (config: InstabugConfig) => { + if (Platform.OS === 'android') { + // Add android feature flags listener for android + registerW3CFlagsListener(); + addOnFeatureUpdatedListener(config); + } else { + isNativeInterceptionFeatureEnabled = await NativeNetworkLogger.isNativeInterceptionEnabled(); + + // Add app state listener to handle background/foreground transitions + addAppStateListener(async (nextAppState) => handleAppStateChange(nextAppState, config)); + + handleNetworkInterceptionMode(config); + + //Set APM networking flags for the first time + setApmNetworkFlagsIfChanged({ + isNativeInterceptionFeatureEnabled: isNativeInterceptionFeatureEnabled, + hasAPMNetworkPlugin: hasAPMNetworkPlugin, + shouldEnableNativeInterception: shouldEnableNativeInterception, + }); + } + + // call Instabug native init method + initializeNativeInstabug(config); + + // Set up error capturing and rejection handling InstabugUtils.captureJsErrors(); captureUnhandledRejections(); + _isFirstScreen = true; + _currentScreen = firstScreen; + + InstabugRNConfig.debugLogsLevel = config.debugLogsLevel ?? LogLevel.error; + + reportCurrentViewForAndroid(firstScreen); + setTimeout(() => { + if (_currentScreen === firstScreen) { + NativeInstabug.reportScreenChange(firstScreen); + _currentScreen = null; + } + }, 1000); +}; + +/** + * Handles app state changes and updates APM network flags if necessary. + */ +const handleAppStateChange = async (nextAppState: AppStateStatus, config: InstabugConfig) => { + // Checks if the app has come to the foreground + if (['inactive', 'background'].includes(_currentAppState) && nextAppState === 'active') { + const isUpdated = await fetchApmNetworkFlags(); + + if (isUpdated) { + refreshAPMNetworkConfigs(config); + } + } + + _currentAppState = nextAppState; +}; + +/** + * Fetches the current APM network flags. + */ +const fetchApmNetworkFlags = async () => { + let isUpdated = false; + const newNativeInterceptionFeatureEnabled = + await NativeNetworkLogger.isNativeInterceptionEnabled(); + if (isNativeInterceptionFeatureEnabled !== newNativeInterceptionFeatureEnabled) { + isNativeInterceptionFeatureEnabled = newNativeInterceptionFeatureEnabled; + isUpdated = true; + } if (Platform.OS === 'android') { - registerW3CFlagsListener(); + const newHasAPMNetworkPlugin = await NativeNetworkLogger.hasAPMNetworkPlugin(); + if (hasAPMNetworkPlugin !== newHasAPMNetworkPlugin) { + hasAPMNetworkPlugin = newHasAPMNetworkPlugin; + isUpdated = true; + } } + return isUpdated; +}; - // Default networkInterceptionMode to JavaScript +/** + * Handles platform-specific checks and updates the network interception mode. + */ +const handleNetworkInterceptionMode = (config: InstabugConfig) => { + // Default networkInterceptionMode to JavaScript if not set if (config.networkInterceptionMode == null) { config.networkInterceptionMode = NetworkInterceptionMode.javascript; } + if (Platform.OS === 'android') { + handleInterceptionModeForAndroid(config); + config.networkInterceptionMode = NetworkInterceptionMode.javascript; // Need to enable JS interceptor in all scenarios for Bugs & Crashes network logs + } else if (Platform.OS === 'ios') { + handleInterceptionModeForIOS(config); + //enable | disable native obfuscation and filtering synchronously + NetworkLogger.setNativeInterceptionEnabled(shouldEnableNativeInterception); + } + if (config.networkInterceptionMode === NetworkInterceptionMode.javascript) { NetworkLogger.setEnabled(true); } +}; + +/** + * Handles the network interception logic for Android if the user set + * network interception mode with [NetworkInterceptionMode.javascript]. + */ +function handleAndroidJSInterception() { + if (isNativeInterceptionFeatureEnabled && hasAPMNetworkPlugin) { + shouldEnableNativeInterception = true; + Logger.warn( + InstabugConstants.IBG_APM_TAG + InstabugConstants.SWITCHED_TO_NATIVE_INTERCEPTION_MESSAGE, + ); + } +} +/** + * Handles the network interception logic for Android if the user set + * network interception mode with [NetworkInterceptionMode.native]. + */ +function handleAndroidNativeInterception() { + if (isNativeInterceptionFeatureEnabled) { + shouldEnableNativeInterception = hasAPMNetworkPlugin; + if (!hasAPMNetworkPlugin) { + Logger.error(InstabugConstants.IBG_APM_TAG + InstabugConstants.PLUGIN_NOT_INSTALLED_MESSAGE); + } + } else { + shouldEnableNativeInterception = false; // rollback to use JS interceptor for APM & Core. + Logger.error( + InstabugConstants.IBG_APM_TAG + InstabugConstants.NATIVE_INTERCEPTION_DISABLED_MESSAGE, + ); + } +} + +/** + * Control either to enable or disable the native interception for iOS after the init method is called. + */ +function handleIOSNativeInterception(config: InstabugConfig) { + if ( + shouldEnableNativeInterception && + config.networkInterceptionMode === NetworkInterceptionMode.native + ) { + NativeNetworkLogger.forceStartNetworkLoggingIOS(); // Enable native iOS automatic network logging. + } else { + NativeNetworkLogger.forceStopNetworkLoggingIOS(); // Disable native iOS automatic network logging. + } +} + +/** + * Handles the network interception mode logic for Android. + * By deciding which interception mode should be enabled (Native or JavaScript). + */ +const handleInterceptionModeForAndroid = (config: InstabugConfig) => { + const { networkInterceptionMode } = config; + + if (networkInterceptionMode === NetworkInterceptionMode.javascript) { + handleAndroidJSInterception(); + } else { + handleAndroidNativeInterception(); + } +}; + +/** + * Handles the interception mode logic for iOS. + * By deciding which interception mode should be enabled (Native or JavaScript). + */ +const handleInterceptionModeForIOS = (config: InstabugConfig) => { + if (config.networkInterceptionMode === NetworkInterceptionMode.native) { + if (isNativeInterceptionFeatureEnabled) { + shouldEnableNativeInterception = true; + NetworkLogger.setEnabled(false); // insure JS interceptor is disabled + } else { + shouldEnableNativeInterception = false; + NetworkLogger.setEnabled(true); // rollback to JS interceptor + Logger.error( + InstabugConstants.IBG_APM_TAG + InstabugConstants.NATIVE_INTERCEPTION_DISABLED_MESSAGE, + ); + } + } +}; + +/** + * Initializes Instabug with the given configuration. + */ +const initializeNativeInstabug = (config: InstabugConfig) => { NativeInstabug.init( config.token, config.invocationEvents, config.debugLogsLevel ?? LogLevel.error, - config.networkInterceptionMode === NetworkInterceptionMode.native, + shouldEnableNativeInterception && + config.networkInterceptionMode === NetworkInterceptionMode.native, config.codePushVersion, ); +}; - _isFirstScreen = true; - _currentScreen = firstScreen; - - InstabugRNConfig.debugLogsLevel = config.debugLogsLevel ?? LogLevel.error; +/** + * Refresh the APM network configurations. + */ +function refreshAPMNetworkConfigs(config: InstabugConfig, forceRefreshIOS: boolean = true) { + handleNetworkInterceptionMode(config); + if (Platform.OS === 'ios' && forceRefreshIOS) { + handleIOSNativeInterception(config); + } + setApmNetworkFlagsIfChanged({ + isNativeInterceptionFeatureEnabled, + hasAPMNetworkPlugin, + shouldEnableNativeInterception, + }); + if (shouldEnableNativeInterception) { + checkNetworkRequestHandlers(); + } else { + // remove any attached [NativeNetworkLogger] Listeners if exists, to avoid memory leaks. + resetNativeObfuscationListener(); + } +} - reportCurrentViewForAndroid(firstScreen); - setTimeout(() => { - if (_currentScreen === firstScreen) { - NativeInstabug.reportScreenChange(firstScreen); - _currentScreen = null; - } - }, 1000); -}; +/** + * Add Android Listener for native feature flags changes. + */ +function addOnFeatureUpdatedListener(config: InstabugConfig) { + emitter.addListener(NativeEvents.IBG_ON_FEATURES_UPDATED_CALLBACK, (flags) => { + const { cpNativeInterceptionEnabled, hasAPMPlugin } = flags; + isNativeInterceptionFeatureEnabled = cpNativeInterceptionEnabled; + hasAPMNetworkPlugin = hasAPMPlugin; + shouldEnableNativeInterception = + config.networkInterceptionMode === NetworkInterceptionMode.native; + refreshAPMNetworkConfigs(config); + }); + NativeInstabug.setOnFeaturesUpdatedListener(); +} /** * Sets the Code Push version to be sent with each report. diff --git a/src/modules/NetworkLogger.ts b/src/modules/NetworkLogger.ts index 4e6b6d3794..8de927c663 100644 --- a/src/modules/NetworkLogger.ts +++ b/src/modules/NetworkLogger.ts @@ -2,16 +2,32 @@ import type { RequestHandler } from '@apollo/client'; import InstabugConstants from '../utils/InstabugConstants'; import xhr, { NetworkData, ProgressCallback } from '../utils/XhrNetworkInterceptor'; -import { isContentTypeNotAllowed, reportNetworkLog } from '../utils/InstabugUtils'; import { InstabugRNConfig } from '../utils/config'; import { Logger } from '../utils/logger'; import { NativeInstabug } from '../native/NativeInstabug'; +import { + isContentTypeNotAllowed, + registerFilteringAndObfuscationListener, + registerFilteringListener, + registerObfuscationListener, + reportNetworkLog, +} from '../utils/InstabugUtils'; +import { + NativeNetworkLogger, + NativeNetworkLoggerEvent, + NetworkListenerType, + NetworkLoggerEmitter, +} from '../native/NativeNetworkLogger'; +import { Platform } from 'react-native'; export type { NetworkData }; export type NetworkDataObfuscationHandler = (data: NetworkData) => Promise; let _networkDataObfuscationHandler: NetworkDataObfuscationHandler | null | undefined; let _requestFilterExpression = 'false'; +let _isNativeInterceptionEnabled = false; +let _networkListener: NetworkListenerType | null = null; +let hasFilterExpression = false; function getPortFromUrl(url: string) { const portMatch = url.match(/:(\d+)(?=\/|$)/); @@ -76,6 +92,22 @@ export const setEnabled = (isEnabled: boolean) => { } }; +/** + * @internal + * Sets whether enabling or disabling native network interception. + * It is disabled by default. + * @param isEnabled + */ +export const setNativeInterceptionEnabled = (isEnabled: boolean) => { + _isNativeInterceptionEnabled = isEnabled; +}; + +export const getNetworkDataObfuscationHandler = () => _networkDataObfuscationHandler; + +export const getRequestFilterExpression = () => _requestFilterExpression; + +export const hasRequestFilterExpression = () => hasFilterExpression; + /** * Obfuscates any response data. * @param handler @@ -84,6 +116,13 @@ export const setNetworkDataObfuscationHandler = ( handler?: NetworkDataObfuscationHandler | null | undefined, ) => { _networkDataObfuscationHandler = handler; + if (_isNativeInterceptionEnabled && Platform.OS === 'ios') { + if (hasFilterExpression) { + registerFilteringAndObfuscationListener(_requestFilterExpression); + } else { + registerObfuscationListener(); + } + } }; /** @@ -92,6 +131,15 @@ export const setNetworkDataObfuscationHandler = ( */ export const setRequestFilterExpression = (expression: string) => { _requestFilterExpression = expression; + hasFilterExpression = true; + + if (_isNativeInterceptionEnabled && Platform.OS === 'ios') { + if (_networkDataObfuscationHandler) { + registerFilteringAndObfuscationListener(_requestFilterExpression); + } else { + registerFilteringListener(_requestFilterExpression); + } + } }; /** @@ -123,3 +171,84 @@ export const apolloLinkRequestHandler: RequestHandler = (operation, forward) => export const setNetworkLogBodyEnabled = (isEnabled: boolean) => { NativeInstabug.setNetworkLogBodyEnabled(isEnabled); }; + +/** + * @internal + * Exported for internal/testing purposes only. + */ +export const resetNetworkListener = () => { + if (process.env.NODE_ENV === 'test') { + _networkListener = null; + NativeNetworkLogger.resetNetworkLogsListener(); + } else { + Logger.error( + `${InstabugConstants.IBG_APM_TAG}: The \`resetNetworkListener()\` method is intended solely for testing purposes.`, + ); + } +}; + +/** + * @internal + * Exported for internal/testing purposes only. + */ +export const registerNetworkLogsListener = ( + type: NetworkListenerType, + handler?: (networkSnapshot: NetworkData) => void, +) => { + if (Platform.OS === 'ios') { + // remove old listeners + if (NetworkLoggerEmitter.listenerCount(NativeNetworkLoggerEvent.NETWORK_LOGGER_HANDLER) > 0) { + NetworkLoggerEmitter.removeAllListeners(NativeNetworkLoggerEvent.NETWORK_LOGGER_HANDLER); + } + + if (_networkListener == null) { + // set new listener. + _networkListener = type; + } else { + // attach a new listener to the existing one. + _networkListener = NetworkListenerType.both; + } + } + + NetworkLoggerEmitter.addListener( + NativeNetworkLoggerEvent.NETWORK_LOGGER_HANDLER, + (networkSnapshot) => { + // Mapping the data [Native -> React-Native]. + const { id, url, requestHeader, requestBody, responseHeader, response, responseCode } = + networkSnapshot; + + const networkSnapshotObj: NetworkData = { + id: id, + url: url, + requestBody: requestBody, + requestHeaders: requestHeader, + method: '', + responseBody: response, + responseCode: responseCode, + responseHeaders: responseHeader, + contentType: '', + duration: 0, + requestBodySize: 0, + responseBodySize: 0, + errorDomain: '', + errorCode: 0, + startTime: 0, + serverErrorMessage: '', + requestContentType: '', + isW3cHeaderFound: true, + networkStartTimeInSeconds: 0, + partialId: 0, + w3cCaughtHeader: '', + w3cGeneratedHeader: '', + }; + if (handler) { + handler(networkSnapshotObj); + } + }, + ); + if (Platform.OS === 'ios') { + NativeNetworkLogger.registerNetworkLogsListener(_networkListener ?? NetworkListenerType.both); + } else { + NativeNetworkLogger.registerNetworkLogsListener(); + } +}; diff --git a/src/native/NativeInstabug.ts b/src/native/NativeInstabug.ts index 6b915e7f95..c9204a4c59 100644 --- a/src/native/NativeInstabug.ts +++ b/src/native/NativeInstabug.ts @@ -154,6 +154,8 @@ export interface InstabugNativeModule extends NativeModule { // W3C Feature Flags Listener for Android registerW3CFlagsChangeListener(): void; + + setOnFeaturesUpdatedListener(handler?: (params: any) => void): void; // android only enableAutoMasking(autoMaskingTypes: AutoMaskingType[]): void; } @@ -161,6 +163,7 @@ export const NativeInstabug = NativeModules.Instabug; export enum NativeEvents { PRESENDING_HANDLER = 'IBGpreSendingHandler', + IBG_ON_FEATURES_UPDATED_CALLBACK = 'IBGOnFeatureUpdatedCallback', ON_W3C_FLAGS_CHANGE = 'IBGOnNewW3CFlagsUpdateReceivedCallback', } diff --git a/src/native/NativeNetworkLogger.ts b/src/native/NativeNetworkLogger.ts new file mode 100644 index 0000000000..4162a0fbff --- /dev/null +++ b/src/native/NativeNetworkLogger.ts @@ -0,0 +1,42 @@ +import { NativeModules } from './NativePackage'; +import { NativeEventEmitter, type NativeModule } from 'react-native'; + +export enum NetworkListenerType { + filtering = 'filtering', + obfuscation = 'obfuscation', + both = 'both', +} + +export interface NetworkLoggerNativeModule extends NativeModule { + isNativeInterceptionEnabled(): Promise; + + registerNetworkLogsListener(type?: NetworkListenerType): void; + + updateNetworkLogSnapshot( + url: string, + callbackID: string, + requestBody: string | null, + responseBody: string | null, + responseCode: number, + requestHeaders: Record, + responseHeaders: Record, + ): void; + + hasAPMNetworkPlugin(): Promise; // Android only + + resetNetworkLogsListener(): void; //Android only + + setNetworkLoggingRequestFilterPredicateIOS(id: string, value: boolean): void; // iOS only + + forceStartNetworkLoggingIOS(): void; // iOS only; + + forceStopNetworkLoggingIOS(): void; // iOS only; +} + +export const NativeNetworkLogger = NativeModules.IBGNetworkLogger; + +export enum NativeNetworkLoggerEvent { + NETWORK_LOGGER_HANDLER = 'IBGNetworkLoggerHandler', +} + +export const NetworkLoggerEmitter = new NativeEventEmitter(NativeNetworkLogger); diff --git a/src/native/NativePackage.ts b/src/native/NativePackage.ts index 9c31789bd4..51ac6fd0c7 100644 --- a/src/native/NativePackage.ts +++ b/src/native/NativePackage.ts @@ -8,6 +8,7 @@ import type { InstabugNativeModule } from './NativeInstabug'; import type { RepliesNativeModule } from './NativeReplies'; import type { SurveysNativeModule } from './NativeSurveys'; import type { SessionReplayNativeModule } from './NativeSessionReplay'; +import type { NetworkLoggerNativeModule } from './NativeNetworkLogger'; export interface InstabugNativePackage { IBGAPM: ApmNativeModule; @@ -18,6 +19,7 @@ export interface InstabugNativePackage { IBGReplies: RepliesNativeModule; IBGSurveys: SurveysNativeModule; IBGSessionReplay: SessionReplayNativeModule; + IBGNetworkLogger: NetworkLoggerNativeModule; } export const NativeModules = ReactNativeModules as InstabugNativePackage; diff --git a/src/utils/AppStatesHandler.ts b/src/utils/AppStatesHandler.ts new file mode 100644 index 0000000000..ac71f50b91 --- /dev/null +++ b/src/utils/AppStatesHandler.ts @@ -0,0 +1,19 @@ +import { AppState, type AppStateStatus } from 'react-native'; + +let subscription: any = null; + +// Register the event listener manually +export const addAppStateListener = (handleAppStateChange: (state: AppStateStatus) => void) => { + if (!subscription) { + subscription = AppState.addEventListener('change', handleAppStateChange); + } +}; + +// Unregister the event listener manually +//todo: find where to Unregister appState listener +export const removeAppStateListener = () => { + if (subscription) { + subscription.remove(); + subscription = null; + } +}; diff --git a/src/utils/InstabugConstants.ts b/src/utils/InstabugConstants.ts index aedc840701..5d576c15b1 100644 --- a/src/utils/InstabugConstants.ts +++ b/src/utils/InstabugConstants.ts @@ -12,6 +12,13 @@ const InstabugConstants = { REMOVE_USER_ATTRIBUTES_ERROR_TYPE_MESSAGE: 'IBG-RN: Expected key and value passed to removeUserAttribute to be of type string', DEFAULT_METRO_PORT: '8081', + IBG_APM_TAG: 'IBG-APM: ', + SWITCHED_TO_NATIVE_INTERCEPTION_MESSAGE: + 'Android Plugin Detected. Switched to Native Interception.', + PLUGIN_NOT_INSTALLED_MESSAGE: + 'Network Spans will not be captured as Android Plugin is not installed. Disabling Native Interception to minimize data loss.', + NATIVE_INTERCEPTION_DISABLED_MESSAGE: + 'Network Spans capture is disabled by Instabug. Disabling native interception to avoid data loss.', }; export default InstabugConstants; diff --git a/src/utils/InstabugUtils.ts b/src/utils/InstabugUtils.ts index df19f0d42a..b70153a7dd 100644 --- a/src/utils/InstabugUtils.ts +++ b/src/utils/InstabugUtils.ts @@ -8,10 +8,41 @@ import type { NavigationState as NavigationStateV5, PartialState } from '@react- import type { NavigationState as NavigationStateV4 } from 'react-navigation'; import type { CrashData } from '../native/NativeCrashReporting'; -import type { NetworkData } from './XhrNetworkInterceptor'; import { NativeCrashReporting } from '../native/NativeCrashReporting'; +import type { NetworkData } from './XhrNetworkInterceptor'; import { NativeInstabug } from '../native/NativeInstabug'; import { NativeAPM } from '../native/NativeAPM'; +import * as NetworkLogger from '../modules/NetworkLogger'; +import { + NativeNetworkLogger, + NativeNetworkLoggerEvent, + NetworkListenerType, + NetworkLoggerEmitter, +} from '../native/NativeNetworkLogger'; + +type ApmNetworkFlags = { + isNativeInterceptionFeatureEnabled: boolean; + hasAPMNetworkPlugin: boolean; + shouldEnableNativeInterception: boolean; +}; + +let apmFlags: ApmNetworkFlags = { + isNativeInterceptionFeatureEnabled: false, + hasAPMNetworkPlugin: false, + shouldEnableNativeInterception: false, +}; + +export function setApmNetworkFlagsIfChanged(flags: ApmNetworkFlags): boolean { + if ( + flags.hasAPMNetworkPlugin === apmFlags.hasAPMNetworkPlugin && + flags.isNativeInterceptionFeatureEnabled === apmFlags.isNativeInterceptionFeatureEnabled && + flags.shouldEnableNativeInterception === apmFlags.shouldEnableNativeInterception + ) { + return false; + } + apmFlags = flags; + return true; +} export const parseErrorStack = (error: ExtendedError): StackFrame[] => { return parseErrorStackLib(error); @@ -178,7 +209,7 @@ export function isContentTypeNotAllowed(contentType: string) { return allowed.every((type) => !contentType.includes(type)); } -export function reportNetworkLog(network: NetworkData) { +export const reportNetworkLog = (network: NetworkData) => { if (Platform.OS === 'android') { const requestHeaders = JSON.stringify(network.requestHeaders); const responseHeaders = JSON.stringify(network.responseHeaders); @@ -193,32 +224,37 @@ export function reportNetworkLog(network: NetworkData) { responseHeaders, network.duration, ); - - NativeAPM.networkLogAndroid( - network.startTime, - network.duration, - requestHeaders, - network.requestBody, - network.requestBodySize, - network.method, - network.url, - network.requestContentType, - responseHeaders, - network.responseBody, - network.responseBodySize, - network.responseCode, - network.contentType, - network.errorDomain, - { - isW3cHeaderFound: network.isW3cHeaderFound, - partialId: network.partialId, - networkStartTimeInSeconds: network.networkStartTimeInSeconds, - w3cGeneratedHeader: network.w3cGeneratedHeader, - w3cCaughtHeader: network.w3cCaughtHeader, - }, - network.gqlQueryName, - network.serverErrorMessage, - ); + if ( + !apmFlags.isNativeInterceptionFeatureEnabled || + !apmFlags.hasAPMNetworkPlugin || + !apmFlags.shouldEnableNativeInterception + ) { + NativeAPM.networkLogAndroid( + network.startTime, + network.duration, + requestHeaders, + network.requestBody, + network.requestBodySize, + network.method, + network.url, + network.requestContentType, + responseHeaders, + network.responseBody, + network.responseBodySize, + network.responseCode, + network.contentType, + network.errorDomain, + { + isW3cHeaderFound: network.isW3cHeaderFound, + partialId: network.partialId, + networkStartTimeInSeconds: network.networkStartTimeInSeconds, + w3cGeneratedHeader: network.w3cGeneratedHeader, + w3cCaughtHeader: network.w3cCaughtHeader, + }, + network.gqlQueryName, + network.serverErrorMessage, + ); + } } else { NativeInstabug.networkLogIOS( network.url, @@ -246,6 +282,125 @@ export function reportNetworkLog(network: NetworkData) { }, ); } +}; + +/** + * @internal + * This method is for internal use only. + */ +export function registerObfuscationListener() { + NetworkLogger.registerNetworkLogsListener( + NetworkListenerType.obfuscation, + async (networkSnapshot) => { + const _networkDataObfuscationHandler = NetworkLogger.getNetworkDataObfuscationHandler(); + if (_networkDataObfuscationHandler) { + networkSnapshot = await _networkDataObfuscationHandler(networkSnapshot); + } + updateNetworkLogSnapshot(networkSnapshot); + }, + ); +} + +/** + * @internal + * This method is for internal use only. + */ +export function registerFilteringListener(filterExpression: string) { + NetworkLogger.registerNetworkLogsListener( + NetworkListenerType.filtering, + async (networkSnapshot) => { + // eslint-disable-next-line no-new-func + const predicate = Function('network', 'return ' + filterExpression); + const value = predicate(networkSnapshot); + if (Platform.OS === 'ios') { + // For iOS True == Request will be saved, False == will be ignored + NativeNetworkLogger.setNetworkLoggingRequestFilterPredicateIOS(networkSnapshot.id, !value); + } else { + // For Android Setting the [url] to an empty string will ignore the request; + if (value) { + networkSnapshot.url = ''; + updateNetworkLogSnapshot(networkSnapshot); + } + } + }, + ); +} + +/** + * @internal + * This method is for internal use only. + */ +export function registerFilteringAndObfuscationListener(filterExpression: string) { + NetworkLogger.registerNetworkLogsListener(NetworkListenerType.both, async (networkSnapshot) => { + // eslint-disable-next-line no-new-func + const predicate = Function('network', 'return ' + filterExpression); + const value = predicate(networkSnapshot); + if (Platform.OS === 'ios') { + // For iOS True == Request will be saved, False == will be ignored + NativeNetworkLogger.setNetworkLoggingRequestFilterPredicateIOS(networkSnapshot.id, !value); + } else { + // For Android Setting the [url] to an empty string will ignore the request; + if (value) { + networkSnapshot.url = ''; + updateNetworkLogSnapshot(networkSnapshot); + } + } + if (!value) { + const _networkDataObfuscationHandler = NetworkLogger.getNetworkDataObfuscationHandler(); + if (_networkDataObfuscationHandler) { + networkSnapshot = await _networkDataObfuscationHandler(networkSnapshot); + } + updateNetworkLogSnapshot(networkSnapshot); + } + }); +} + +/** + * @internal + * This method is for internal use only. + */ +export function checkNetworkRequestHandlers() { + const obfuscationHandler = NetworkLogger.getNetworkDataObfuscationHandler(); + const hasFilterExpression = NetworkLogger.hasRequestFilterExpression(); + + if (hasFilterExpression && obfuscationHandler) { + // Register listener that handles both (Filtering & Obfuscation) + registerFilteringAndObfuscationListener(NetworkLogger.getRequestFilterExpression()); + return; + } + if (obfuscationHandler) { + // Register listener that handles only (Obfuscation) + registerObfuscationListener(); + return; + } + + if (hasFilterExpression) { + // Register listener that handles only (Filtering) + registerFilteringListener(NetworkLogger.getRequestFilterExpression()); + return; + } +} +export function resetNativeObfuscationListener() { + if (Platform.OS === 'android') { + NativeNetworkLogger.resetNetworkLogsListener(); + } + NetworkLoggerEmitter.removeAllListeners(NativeNetworkLoggerEvent.NETWORK_LOGGER_HANDLER); +} + +/** + * @internal + * This method is for internal use only. + */ +export function updateNetworkLogSnapshot(networkSnapshot: NetworkData) { + NativeNetworkLogger.updateNetworkLogSnapshot( + networkSnapshot.url, + networkSnapshot.id, + networkSnapshot.requestBody, + networkSnapshot.responseBody, + networkSnapshot.responseCode ?? 200, + networkSnapshot.requestHeaders, + networkSnapshot.responseHeaders, + ); } export default { @@ -256,6 +411,7 @@ export default { getStackTrace, stringifyIfNotString, sendCrashReport, + reportNetworkLog, generateTracePartialId, generateW3CHeader, }; diff --git a/src/utils/XhrNetworkInterceptor.ts b/src/utils/XhrNetworkInterceptor.ts index 4eee6dd90c..7485a48539 100644 --- a/src/utils/XhrNetworkInterceptor.ts +++ b/src/utils/XhrNetworkInterceptor.ts @@ -7,6 +7,7 @@ export type ProgressCallback = (totalBytesSent: number, totalBytesExpectedToSend export type NetworkDataCallback = (data: NetworkData) => void; export interface NetworkData { + readonly id: string; url: string; method: string; requestBody: string; @@ -43,6 +44,7 @@ let network: NetworkData; const _reset = () => { network = { + id: '', url: '', method: '', requestBody: '', diff --git a/test/mocks/mockInstabug.ts b/test/mocks/mockInstabug.ts index 04203a307d..05d817b353 100644 --- a/test/mocks/mockInstabug.ts +++ b/test/mocks/mockInstabug.ts @@ -74,6 +74,7 @@ const mockInstabug: InstabugNativeModule = { isW3CaughtHeaderEnabled: jest.fn(), registerW3CFlagsChangeListener: jest.fn(), setNetworkLogBodyEnabled: jest.fn(), + setOnFeaturesUpdatedListener: jest.fn(), enableAutoMasking: jest.fn(), }; diff --git a/test/mocks/mockNativeModules.ts b/test/mocks/mockNativeModules.ts index 5618930e83..6d4c19face 100644 --- a/test/mocks/mockNativeModules.ts +++ b/test/mocks/mockNativeModules.ts @@ -7,10 +7,12 @@ import mockSessionReplay from './mockSessionReplay'; import mockInstabug from './mockInstabug'; import mockReplies from './mockReplies'; import mockSurveys from './mockSurveys'; +import mockNetworkLogger from './mockNetworkLogger'; jest.mock('react-native', () => { const RN = jest.requireActual('react-native'); const mockNativeModules: InstabugNativePackage = { + IBGNetworkLogger: mockNetworkLogger, IBGAPM: mockAPM, IBGBugReporting: mockBugReporting, IBGCrashReporting: mockCrashReporting, diff --git a/test/mocks/mockNetworkLogger.ts b/test/mocks/mockNetworkLogger.ts index 0eaed26e0d..88608016ce 100644 --- a/test/mocks/mockNetworkLogger.ts +++ b/test/mocks/mockNetworkLogger.ts @@ -1 +1,16 @@ -jest.mock('../../src/modules/NetworkLogger'); +import type { NetworkLoggerNativeModule } from '../../src/native/NativeNetworkLogger'; + +const mockNetworkLogger: NetworkLoggerNativeModule = { + addListener: jest.fn(), + removeListeners: jest.fn(), + hasAPMNetworkPlugin: jest.fn(), + isNativeInterceptionEnabled: jest.fn(), + forceStartNetworkLoggingIOS: jest.fn(), + forceStopNetworkLoggingIOS: jest.fn(), + registerNetworkLogsListener: jest.fn(), + updateNetworkLogSnapshot: jest.fn(), + setNetworkLoggingRequestFilterPredicateIOS: jest.fn(), + resetNetworkLogsListener: jest.fn(), +}; + +export default mockNetworkLogger; diff --git a/test/modules/Instabug.spec.ts b/test/modules/Instabug.spec.ts index cb42475110..904cefb7d7 100644 --- a/test/modules/Instabug.spec.ts +++ b/test/modules/Instabug.spec.ts @@ -1,19 +1,19 @@ import '../mocks/mockInstabugUtils'; import '../mocks/mockNetworkLogger'; -import { Platform, findNodeHandle, processColor } from 'react-native'; +import { findNodeHandle, Platform, processColor } from 'react-native'; import type { NavigationContainerRefWithCurrent } from '@react-navigation/native'; // Import the hook - import { mocked } from 'jest-mock'; import waitForExpect from 'wait-for-expect'; import Report from '../../src/models/Report'; import * as Instabug from '../../src/modules/Instabug'; import * as NetworkLogger from '../../src/modules/NetworkLogger'; -import { NativeEvents, NativeInstabug, emitter } from '../../src/native/NativeInstabug'; +import { emitter, NativeEvents, NativeInstabug } from '../../src/native/NativeInstabug'; import { AutoMaskingType, ColorTheme, + type InstabugConfig, InvocationEvent, Locale, LogLevel, @@ -21,11 +21,18 @@ import { ReproStepsMode, StringKey, WelcomeMessageMode, -} from '../../src/utils/Enums'; +} from '../../src'; import InstabugUtils from '../../src/utils/InstabugUtils'; import type { FeatureFlag } from '../../src/models/FeatureFlag'; -import InstabugConstants from '../../src/utils/InstabugConstants'; import { Logger } from '../../src/utils/logger'; +import { NativeNetworkLogger } from '../../src/native/NativeNetworkLogger'; +import InstabugConstants from '../../src/utils/InstabugConstants'; + +jest.mock('../../src/modules/NetworkLogger'); + +function fakeTimer(callback: () => void) { + setTimeout(callback, 100); +} describe('Instabug Module', () => { beforeEach(() => { @@ -63,7 +70,7 @@ describe('Instabug Module', () => { }); it("componentDidAppearListener shouldn't call the native method reportScreenChange if first screen", async () => { - Instabug.init({ + await Instabug.init({ token: 'some-token', invocationEvents: [InvocationEvent.none], }); @@ -276,7 +283,7 @@ describe('Instabug Module', () => { expect(onStateChangeMock).toHaveBeenCalledWith(mockNavigationContainerRef.getRootState()); }); - it('should call the native method init', () => { + it('should call the native method init', async () => { const instabugConfig = { token: 'some-token', invocationEvents: [InvocationEvent.floatingButton, InvocationEvent.shake], @@ -285,7 +292,7 @@ describe('Instabug Module', () => { }; const usesNativeNetworkInterception = false; - Instabug.init(instabugConfig); + await Instabug.init(instabugConfig); expect(NetworkLogger.setEnabled).toBeCalledWith(true); expect(NativeInstabug.init).toBeCalledTimes(1); @@ -307,7 +314,7 @@ describe('Instabug Module', () => { expect(NativeInstabug.setCodePushVersion).toBeCalledWith(codePushVersion); }); - it('init should disable JavaScript interceptor when using native interception mode', () => { + it('init should disable JavaScript interceptor when using native interception mode', async () => { const instabugConfig = { token: 'some-token', invocationEvents: [InvocationEvent.floatingButton, InvocationEvent.shake], @@ -316,18 +323,38 @@ describe('Instabug Module', () => { codePushVersion: '1.1.0', }; - Instabug.init(instabugConfig); - - expect(NetworkLogger.setEnabled).not.toBeCalled(); - expect(NativeInstabug.init).toBeCalledTimes(1); - expect(NativeInstabug.init).toBeCalledWith( - instabugConfig.token, - instabugConfig.invocationEvents, - instabugConfig.debugLogsLevel, - // usesNativeNetworkInterception should be true when using native interception mode - true, - instabugConfig.codePushVersion, - ); + // Stubbing Network feature flags + jest + .spyOn(NativeNetworkLogger, 'isNativeInterceptionEnabled') + .mockReturnValue(Promise.resolve(true)); + jest.spyOn(NativeNetworkLogger, 'hasAPMNetworkPlugin').mockReturnValue(Promise.resolve(true)); + + await Instabug.init(instabugConfig); + + if (Platform.OS === 'android') { + expect(NetworkLogger.setEnabled).not.toBeCalled(); + expect(NativeInstabug.init).toBeCalledTimes(1); + + expect(NativeInstabug.init).toBeCalledWith( + instabugConfig.token, + instabugConfig.invocationEvents, + instabugConfig.debugLogsLevel, + // usesNativeNetworkInterception should be false when using native interception mode with Android + false, + instabugConfig.codePushVersion, + ); + } else { + expect(NativeInstabug.init).toBeCalledTimes(1); + + expect(NativeInstabug.init).toBeCalledWith( + instabugConfig.token, + instabugConfig.invocationEvents, + instabugConfig.debugLogsLevel, + // usesNativeNetworkInterception should be true when using native interception mode with iOS + true, + instabugConfig.codePushVersion, + ); + } }); it('should report the first screen on SDK initialization', async () => { @@ -896,3 +923,188 @@ describe('Instabug Module', () => { expect(NativeInstabug.enableAutoMasking).toBeCalledWith([AutoMaskingType.labels]); }); }); + +describe('Instabug iOS initialization tests', () => { + let config: InstabugConfig; + beforeEach(() => { + Platform.OS = 'ios'; + config = { + token: 'some-token', + invocationEvents: [InvocationEvent.floatingButton, InvocationEvent.shake], + debugLogsLevel: LogLevel.debug, + networkInterceptionMode: NetworkInterceptionMode.native, + codePushVersion: '1.1.0', + }; + // Fast-forward until all timers have been executed + jest.advanceTimersByTime(1000); + }); + + it('should initialize correctly with javascript interception mode', async () => { + config.networkInterceptionMode = NetworkInterceptionMode.javascript; + + await Instabug.init(config); + + expect(NativeNetworkLogger.isNativeInterceptionEnabled).toHaveBeenCalled(); + expect(NetworkLogger.setEnabled).toHaveBeenCalledWith(true); + expect(NativeInstabug.init).toHaveBeenCalledWith( + config.token, + config.invocationEvents, + config.debugLogsLevel, + false, // Disable native interception + config.codePushVersion, + ); + }); + + it('should initialize correctly with native interception mode when [isNativeInterceptionEnabled] == ture', async () => { + jest + .spyOn(NativeNetworkLogger, 'isNativeInterceptionEnabled') + .mockReturnValue(Promise.resolve(true)); + + await Instabug.init(config); + + expect(NativeNetworkLogger.isNativeInterceptionEnabled).toHaveBeenCalled(); + expect(NetworkLogger.setEnabled).toHaveBeenCalledWith(false); + expect(NativeInstabug.init).toHaveBeenCalledWith( + config.token, + config.invocationEvents, + config.debugLogsLevel, + true, // Enable native interception + config.codePushVersion, + ); + }); + + it('should disable native interception mode when user sets networkInterceptionMode to native and [isNativeInterceptionEnabled] == false', async () => { + jest + .spyOn(NativeNetworkLogger, 'isNativeInterceptionEnabled') + .mockReturnValue(Promise.resolve(false)); + + await Instabug.init(config); + + expect(NativeNetworkLogger.isNativeInterceptionEnabled).toHaveBeenCalled(); + expect(NetworkLogger.setEnabled).toHaveBeenCalled(); + expect(NativeInstabug.init).toHaveBeenCalledWith( + config.token, + config.invocationEvents, + config.debugLogsLevel, + false, // Disable native interception + config.codePushVersion, + ); + }); + + it('should display error message when user sets networkInterceptionMode to native and [isNativeInterceptionEnabled] == false', async () => { + jest + .spyOn(NativeNetworkLogger, 'isNativeInterceptionEnabled') + .mockReturnValue(Promise.resolve(false)); + const logSpy = jest.spyOn(global.console, 'error'); + + await Instabug.init(config); + + expect(logSpy).toBeCalledTimes(1); + expect(logSpy).toBeCalledWith( + InstabugConstants.IBG_APM_TAG + InstabugConstants.NATIVE_INTERCEPTION_DISABLED_MESSAGE, + ); + }); +}); + +describe('Instabug Android initialization tests', () => { + let config: InstabugConfig; + + beforeEach(() => { + Platform.OS = 'android'; + config = { + token: 'some-token', + invocationEvents: [InvocationEvent.floatingButton, InvocationEvent.shake], + debugLogsLevel: LogLevel.debug, + networkInterceptionMode: NetworkInterceptionMode.javascript, + codePushVersion: '1.1.0', + }; + }); + + it('should initialize correctly with native interception enabled', async () => { + config.networkInterceptionMode = NetworkInterceptionMode.native; + await Instabug.init(config); + fakeTimer(() => { + expect(NativeInstabug.setOnFeaturesUpdatedListener).toHaveBeenCalled(); + expect(NetworkLogger.setEnabled).toHaveBeenCalledWith(true); + expect(NativeInstabug.init).toHaveBeenCalledWith( + config.token, + config.invocationEvents, + config.debugLogsLevel, + false, // always disable native interception to insure sending network logs to core (Bugs & Crashes). + config.codePushVersion, + ); + }); + }); + + it('should show warning message when networkInterceptionMode == javascript and user added APM plugin', async () => { + jest + .spyOn(NativeNetworkLogger, 'isNativeInterceptionEnabled') + .mockReturnValue(Promise.resolve(true)); + jest.spyOn(NativeNetworkLogger, 'hasAPMNetworkPlugin').mockReturnValue(Promise.resolve(true)); + const logSpy = jest.spyOn(global.console, 'warn'); + + await Instabug.init(config); + fakeTimer(() => { + expect(logSpy).toBeCalledTimes(1); + expect(logSpy).toBeCalledWith( + InstabugConstants.IBG_APM_TAG + InstabugConstants.SWITCHED_TO_NATIVE_INTERCEPTION_MESSAGE, + ); + }); + }); + + it('should show error message when networkInterceptionMode == native and user did not add APM plugin', async () => { + config.networkInterceptionMode = NetworkInterceptionMode.native; + + jest + .spyOn(NativeNetworkLogger, 'isNativeInterceptionEnabled') + .mockReturnValue(Promise.resolve(true)); + jest.spyOn(NativeNetworkLogger, 'hasAPMNetworkPlugin').mockReturnValue(Promise.resolve(false)); + const logSpy = jest.spyOn(global.console, 'error'); + + await Instabug.init(config); + + fakeTimer(() => { + expect(logSpy).toBeCalledTimes(1); + expect(logSpy).toBeCalledWith( + InstabugConstants.IBG_APM_TAG + InstabugConstants.PLUGIN_NOT_INSTALLED_MESSAGE, + ); + }); + }); + + it('should show error message when networkInterceptionMode == native and user did not add APM plugin and the isNativeInterceptionEnabled is disabled', async () => { + config.networkInterceptionMode = NetworkInterceptionMode.native; + + jest + .spyOn(NativeNetworkLogger, 'isNativeInterceptionEnabled') + .mockReturnValue(Promise.resolve(false)); + jest.spyOn(NativeNetworkLogger, 'hasAPMNetworkPlugin').mockReturnValue(Promise.resolve(false)); + const logSpy = jest.spyOn(global.console, 'error'); + + await Instabug.init(config); + + fakeTimer(() => { + expect(logSpy).toBeCalledTimes(1); + expect(logSpy).toBeCalledWith( + InstabugConstants.IBG_APM_TAG + InstabugConstants.NATIVE_INTERCEPTION_DISABLED_MESSAGE, + ); + }); + }); + + it('should show error message when networkInterceptionMode == native and the isNativeInterceptionEnabled is disabled', async () => { + config.networkInterceptionMode = NetworkInterceptionMode.native; + jest + .spyOn(NativeNetworkLogger, 'isNativeInterceptionEnabled') + .mockReturnValue(Promise.resolve(false)); + jest.spyOn(NativeNetworkLogger, 'hasAPMNetworkPlugin').mockReturnValue(Promise.resolve(true)); + const logSpy = jest.spyOn(global.console, 'error'); + + await Instabug.init(config); + + fakeTimer(() => { + expect(logSpy).toBeCalledTimes(1); + expect(logSpy).toBeCalledWith( + InstabugConstants.IBG_APM_TAG + InstabugConstants.NATIVE_INTERCEPTION_DISABLED_MESSAGE, + ); + }); + }); +}); diff --git a/test/modules/NetworkLogger.spec.ts b/test/modules/NetworkLogger.spec.ts index d46b10aff5..be567b8d1f 100644 --- a/test/modules/NetworkLogger.spec.ts +++ b/test/modules/NetworkLogger.spec.ts @@ -7,6 +7,15 @@ import * as NetworkLogger from '../../src/modules/NetworkLogger'; import Interceptor from '../../src/utils/XhrNetworkInterceptor'; import { isContentTypeNotAllowed, reportNetworkLog } from '../../src/utils/InstabugUtils'; import InstabugConstants from '../../src/utils/InstabugConstants'; +import * as Instabug from '../../src/modules/Instabug'; +import { + NativeNetworkLogger, + NativeNetworkLoggerEvent, + NetworkListenerType, + NetworkLoggerEmitter, +} from '../../src/native/NativeNetworkLogger'; +import { InvocationEvent, LogLevel, NetworkInterceptionMode } from '../../src'; +import { Platform } from 'react-native'; import { Logger } from '../../src/utils/logger'; import { NativeInstabug } from '../../src/native/NativeInstabug'; @@ -14,8 +23,11 @@ const clone = (obj: T): T => { return JSON.parse(JSON.stringify(obj)); }; +jest.mock('../../src/native/NativeNetworkLogger'); + describe('NetworkLogger Module', () => { const network: NetworkLogger.NetworkData = { + id: '', url: 'https://api.instabug.com', requestBody: '', requestHeaders: { 'content-type': 'application/json' }, @@ -290,4 +302,118 @@ describe('NetworkLogger Module', () => { expect(NativeInstabug.setNetworkLogBodyEnabled).toBeCalledTimes(1); expect(NativeInstabug.setNetworkLogBodyEnabled).toBeCalledWith(true); }); + + it('Instabug.init should call NativeNetworkLogger.isNativeInterceptionEnabled and not call NativeNetworkLogger.hasAPMNetworkPlugin with iOS', async () => { + Platform.OS = 'ios'; + const config = { + token: 'some-token', + invocationEvents: [InvocationEvent.floatingButton, InvocationEvent.shake], + debugLogsLevel: LogLevel.debug, + networkInterceptionMode: NetworkInterceptionMode.native, + codePushVersion: '1.1.0', + }; + await Instabug.init(config); + + expect(NativeNetworkLogger.isNativeInterceptionEnabled).toHaveBeenCalled(); + expect(NativeNetworkLogger.hasAPMNetworkPlugin).not.toHaveBeenCalled(); + }); +}); + +describe('_registerNetworkLogsListener', () => { + let handlerMock: jest.Mock; + let type: NetworkListenerType; + + beforeEach(() => { + handlerMock = jest.fn(); + type = NetworkListenerType.both; + jest.resetAllMocks(); // Reset mock implementation and calls + NetworkLogger.resetNetworkListener(); // Clear only calls, keeping implementation intact + }); + + it('should remove old listeners if they exist', () => { + Platform.OS = 'ios'; + + // Simulate that there are existing listeners + jest.spyOn(NetworkLoggerEmitter, 'listenerCount').mockReturnValue(2); + + NetworkLogger.registerNetworkLogsListener(type, handlerMock); + + expect(NetworkLoggerEmitter.removeAllListeners).toHaveBeenCalledWith( + NativeNetworkLoggerEvent.NETWORK_LOGGER_HANDLER, + ); + }); + + it('should set the new listener if _networkListener is null', () => { + Platform.OS = 'ios'; + // No existing listener + jest.spyOn(NetworkLoggerEmitter, 'listenerCount').mockReturnValue(0); + + NetworkLogger.registerNetworkLogsListener(type, handlerMock); + + expect(NetworkLoggerEmitter.addListener).toHaveBeenCalled(); + expect(NativeNetworkLogger.registerNetworkLogsListener).toHaveBeenCalledWith(type); + }); + + it('should attach a new listener to the existing one if _networkListener is set', () => { + Platform.OS = 'ios'; + + type = NetworkListenerType.filtering; + const newType = NetworkListenerType.both; + + // First call to set the listener + NetworkLogger.registerNetworkLogsListener(type, handlerMock); + + // Second call with a different type to trigger setting to `both` + NetworkLogger.registerNetworkLogsListener(newType, handlerMock); + + expect(NetworkLoggerEmitter.addListener).toHaveBeenCalledTimes(2); + expect(NativeNetworkLogger.registerNetworkLogsListener).toHaveBeenCalledWith( + NetworkListenerType.both, + ); + }); + + it('should map networkSnapshot data correctly and call handler', () => { + const mockNetworkSnapshot = { + id: '123', + url: 'http://example.com', + requestHeader: {}, + requestBody: 'test request body', + responseHeader: {}, + response: 'test response', + responseCode: 200, + }; + + (NetworkLoggerEmitter.addListener as jest.Mock).mockImplementation((_, callback) => { + callback(mockNetworkSnapshot); + }); + + NetworkLogger.registerNetworkLogsListener(type, handlerMock); + + const expectedNetworkData: NetworkLogger.NetworkData = { + id: '123', + url: 'http://example.com', + requestBody: 'test request body', + requestHeaders: {}, + method: '', + responseBody: 'test response', + responseCode: 200, + responseHeaders: {}, + contentType: '', + duration: 0, + requestBodySize: 0, + responseBodySize: 0, + errorDomain: '', + errorCode: 0, + startTime: 0, + serverErrorMessage: '', + requestContentType: '', + isW3cHeaderFound: true, + networkStartTimeInSeconds: 0, + partialId: 0, + w3cCaughtHeader: '', + w3cGeneratedHeader: '', + }; + + expect(handlerMock).toHaveBeenCalledWith(expectedNetworkData); + }); }); diff --git a/test/utils/AppStatesHandler.spec.ts b/test/utils/AppStatesHandler.spec.ts new file mode 100644 index 0000000000..c784da86a5 --- /dev/null +++ b/test/utils/AppStatesHandler.spec.ts @@ -0,0 +1,60 @@ +import { AppState } from 'react-native'; +import { addAppStateListener, removeAppStateListener } from '../../src/utils/AppStatesHandler'; + +jest.mock('react-native', () => ({ + AppState: { + addEventListener: jest.fn(), + }, +})); + +describe('AppState Listener', () => { + const mockHandleAppStateChange = jest.fn(); + const mockRemove = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + (AppState.addEventListener as jest.Mock).mockReturnValue({ remove: mockRemove }); + }); + + afterEach(() => { + removeAppStateListener(); // Ensure no leftover subscriptions between tests + }); + + it('should add an AppState listener if none exists', () => { + addAppStateListener(mockHandleAppStateChange); + + expect(AppState.addEventListener).toHaveBeenCalledTimes(1); + expect(AppState.addEventListener).toHaveBeenCalledWith('change', mockHandleAppStateChange); + }); + + it('should not add another listener if one already exists', () => { + addAppStateListener(mockHandleAppStateChange); + addAppStateListener(mockHandleAppStateChange); + + expect(AppState.addEventListener).toHaveBeenCalledTimes(1); // Only called once + }); + + it('should remove the AppState listener if one exists', () => { + addAppStateListener(mockHandleAppStateChange); + removeAppStateListener(); + + expect(mockRemove).toHaveBeenCalledTimes(1); // The remove function is called + }); + + it('should do nothing if removeAppStateListener is called without an existing subscription', () => { + removeAppStateListener(); + + expect(mockRemove).not.toHaveBeenCalled(); // No remove is called + }); + + it('should handle multiple add/remove calls properly', () => { + addAppStateListener(mockHandleAppStateChange); + removeAppStateListener(); + + addAppStateListener(mockHandleAppStateChange); + removeAppStateListener(); + + expect(AppState.addEventListener).toHaveBeenCalledTimes(2); // Listener is added twice + expect(mockRemove).toHaveBeenCalledTimes(2); // Listener is removed twice + }); +}); diff --git a/test/utils/InstabugUtils.spec.ts b/test/utils/InstabugUtils.spec.ts index fd389d7f0b..deb2eebd0f 100644 --- a/test/utils/InstabugUtils.spec.ts +++ b/test/utils/InstabugUtils.spec.ts @@ -4,16 +4,30 @@ import { Platform } from 'react-native'; import parseErrorStackLib from 'react-native/Libraries/Core/Devtools/parseErrorStack'; import * as Instabug from '../../src/modules/Instabug'; +import * as NetworkLogger from '../../src/modules/NetworkLogger'; import { NativeCrashReporting } from '../../src/native/NativeCrashReporting'; import { InvocationEvent, NetworkData, NonFatalErrorLevel } from '../../src'; import InstabugUtils, { getStackTrace, + registerFilteringAndObfuscationListener, + registerFilteringListener, + registerObfuscationListener, reportNetworkLog, + resetNativeObfuscationListener, sendCrashReport, + updateNetworkLogSnapshot, } from '../../src/utils/InstabugUtils'; + +import { + NativeNetworkLogger, + NetworkListenerType, + NetworkLoggerEmitter, +} from '../../src/native/NativeNetworkLogger'; import { NativeInstabug } from '../../src/native/NativeInstabug'; import { NativeAPM } from '../../src/native/NativeAPM'; +jest.mock('../../src/modules/NetworkLogger'); + describe('Test global error handler', () => { beforeEach(() => { Instabug.init({ token: '', invocationEvents: [InvocationEvent.none] }); @@ -242,6 +256,7 @@ describe('Instabug Utils', () => { describe('reportNetworkLog', () => { const network: NetworkData = { + id: 'id', url: 'https://api.instabug.com', method: 'GET', requestBody: 'requestBody', @@ -265,12 +280,16 @@ describe('reportNetworkLog', () => { w3cCaughtHeader: null, }; - it('reportNetworkLog should send network logs to native with the correct parameters on Android', () => { + it('reportNetworkLog should send network logs to native with the correct parameters on Android', async () => { Platform.OS = 'android'; + jest + .spyOn(NativeNetworkLogger, 'isNativeInterceptionEnabled') + .mockReturnValue(Promise.resolve(false)); + jest.spyOn(NativeNetworkLogger, 'hasAPMNetworkPlugin').mockReturnValue(Promise.resolve(false)); + await Instabug.init({ token: '', invocationEvents: [InvocationEvent.none] }); const requestHeaders = JSON.stringify(network.requestHeaders); const responseHeaders = JSON.stringify(network.responseHeaders); - reportNetworkLog(network); expect(NativeInstabug.networkLogAndroid).toHaveBeenCalledTimes(1); @@ -346,3 +365,96 @@ describe('reportNetworkLog', () => { ); }); }); + +describe('test registerNetworkLogsListener usage', () => { + beforeEach(() => { + jest.clearAllMocks(); // Clear all mocks before each test + }); + + const network: NetworkLogger.NetworkData = { + id: '', + url: 'https://api.instabug.com', + requestBody: '', + requestHeaders: { 'content-type': 'application/json' }, + method: 'GET', + responseBody: '', + responseCode: 200, + responseHeaders: { 'content-type': 'application/json' }, + contentType: 'application/json', + duration: 0, + requestBodySize: 0, + responseBodySize: 0, + errorDomain: '', + errorCode: 0, + startTime: 0, + serverErrorMessage: '', + requestContentType: 'application/json', + isW3cHeaderFound: true, + networkStartTimeInSeconds: 0, + partialId: 0, + w3cCaughtHeader: '', + w3cGeneratedHeader: '', + }; + + it('registerObfuscationListener should call NetworkLogger.registerNetworkLogsListener() with NetworkListenerType = NetworkListenerType.obfuscation', () => { + registerObfuscationListener(); + expect(NetworkLogger.registerNetworkLogsListener).toBeCalledTimes(1); + expect(NetworkLogger.registerNetworkLogsListener).toBeCalledWith( + NetworkListenerType.obfuscation, + expect.any(Function), + ); + }); + + it('registerFilteringListener should call NetworkLogger.registerNetworkLogsListener() with NetworkListenerType = NetworkListenerType.filtering', () => { + const testText = 'true'; + registerFilteringListener(testText); + + expect(NetworkLogger.registerNetworkLogsListener).toBeCalledTimes(1); + expect(NetworkLogger.registerNetworkLogsListener).toBeCalledWith( + NetworkListenerType.filtering, + expect.any(Function), + ); + }); + + it('registerFilteringAndObfuscationListener should call NetworkLogger.registerNetworkLogsListener() with NetworkListenerType = NetworkListenerType.both', () => { + const testText = 'true'; + registerFilteringAndObfuscationListener(testText); + + expect(NetworkLogger.registerNetworkLogsListener).toBeCalledTimes(1); + expect(NetworkLogger.registerNetworkLogsListener).toBeCalledWith( + NetworkListenerType.both, + expect.any(Function), + ); + }); + + it('should call NetworkLoggerEmitter.removeAllListeners when call resetNativeObfuscationListener', () => { + jest.spyOn(NetworkLoggerEmitter, 'removeAllListeners').mockImplementation(); + resetNativeObfuscationListener(); + expect(NetworkLoggerEmitter.removeAllListeners).toBeCalledTimes(1); + }); + + it('should call NativeNetworkLogger.resetNetworkLogsListener when call resetNativeObfuscationListener on android platform', () => { + Platform.OS = 'android'; + jest.spyOn(NativeNetworkLogger, 'resetNetworkLogsListener').mockImplementation(); + jest.spyOn(NetworkLoggerEmitter, 'removeAllListeners').mockImplementation(); + resetNativeObfuscationListener(); + expect(NativeNetworkLogger.resetNetworkLogsListener).toBeCalledTimes(1); + expect(NetworkLoggerEmitter.removeAllListeners).toBeCalledTimes(1); + }); + + it('should call NativeNetworkLogger.updateNetworkLogSnapshot when call updateNetworkLogSnapshot with correct parameters', () => { + jest.spyOn(NativeNetworkLogger, 'updateNetworkLogSnapshot').mockImplementation(); + + updateNetworkLogSnapshot(network); + expect(NativeNetworkLogger.updateNetworkLogSnapshot).toBeCalledTimes(1); + expect(NativeNetworkLogger.updateNetworkLogSnapshot).toHaveBeenCalledWith( + network.url, + network.id, + network.requestBody, + network.responseBody, + network.responseCode ?? 200, + network.requestHeaders, + network.responseHeaders, + ); + }); +});