diff --git a/android/src/main/java/io/didomi/reactnative/DidomiModule.kt b/android/src/main/java/io/didomi/reactnative/DidomiModule.kt index 7043818d..c5f3581c 100644 --- a/android/src/main/java/io/didomi/reactnative/DidomiModule.kt +++ b/android/src/main/java/io/didomi/reactnative/DidomiModule.kt @@ -6,7 +6,6 @@ import androidx.fragment.app.FragmentActivity import com.facebook.react.bridge.* import com.facebook.react.bridge.UiThreadUtil.runOnUiThread import com.facebook.react.common.MapBuilder -import com.facebook.react.modules.core.DeviceEventManagerModule import com.google.gson.Gson import com.google.gson.reflect.TypeToken import io.didomi.sdk.Didomi @@ -1228,14 +1227,11 @@ class DidomiModule(reactContext: ReactApplicationContext) : DidomiModuleSpec(rea private fun prepareEvent(eventName: String, params: Any?) { Log.d("prepareEvent", "Sending $eventName") - reactContext - .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) - .emit(eventName, params) + reactContext.emitDeviceEvent(eventName, params) } private fun prepareSyncReadyEvent(event: SyncReadyEvent) { val eventName = EventTypes.SYNC_READY.event - Log.d("prepareEvent", "Sending $eventName") val callbackIndex = syncAcknowledgedCallbackIndex++ syncAcknowledgedCallbacks[callbackIndex] = event.syncAcknowledged val params = WritableNativeMap().apply { @@ -1243,33 +1239,25 @@ class DidomiModule(reactContext: ReactApplicationContext) : DidomiModuleSpec(rea putBoolean("statusApplied", event.statusApplied) putInt("syncAcknowledgedIndex", callbackIndex) } - reactContext - .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) - .emit(eventName, params) + prepareEvent(eventName, params) } private fun prepareIntegrationErrorEvent(event: IntegrationErrorEvent) { val eventName = EventTypes.INTEGRATION_ERROR_EVENT.event - Log.d("prepareEvent", "Sending $eventName") val params = WritableNativeMap().apply { putString("integrationName", event.integrationName) putString("reason", event.reason) } - reactContext - .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) - .emit(eventName, params) + prepareEvent(eventName, params) } private fun prepareShowWidgetEvent(event: ShowWidgetEvent) { val eventName = EventTypes.SHOW_WIDGET.event - Log.d("prepareEvent", "Sending $eventName") val params = WritableNativeMap().apply { putString("widgetId", event.widgetId) putString("layerName", event.layerName) } - reactContext - .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) - .emit(eventName, params) + prepareEvent(eventName, params) } // Required to transform from array to variadic. diff --git a/ios/RNDidomi.swift b/ios/RNDidomi.swift index 3f237539..98c69aeb 100644 --- a/ios/RNDidomi.swift +++ b/ios/RNDidomi.swift @@ -10,6 +10,7 @@ class RNDidomi: RCTEventEmitter { var vendorStatusListeners: Set = Set() var syncAcknowledgedCallbacks: Dictionary Bool)> = [:] private var syncAcknowledgedCallbackIndex: Int = 0 + private var hasListeners = false override init() { didomiEventListener = EventListener() @@ -21,6 +22,20 @@ class RNDidomi: RCTEventEmitter { return true } + override func startObserving() { + hasListeners = true + } + + override func stopObserving() { + hasListeners = false + } + + override func invalidate() { + hasListeners = false + Didomi.shared.removeEventListener(listener: didomiEventListener) + super.invalidate() + } + @objc(initialize:userAgentVersion:apiKey:localConfigurationPath:remoteConfigurationURL:providerId:disableDidomiRemoteConfig:languageCode:noticeId:androidTvNoticeId:androidTvEnabled:countryCode:regionCode:resolve:reject:) func initialize( userAgentName: String, @@ -973,13 +988,10 @@ extension RNDidomi { Didomi.shared.addEventListener(listener: didomiEventListener) } - /// Sends the specified event only if the react-native bridge is still valid + /// Observation state works with both the legacy bridge and Bridgeless mode. private func dispatchEvent(withName: String, body: Any?) { - if self.bridge != nil { + if hasListeners { self.sendEvent(withName: withName, body: body) - } else { - // Event emitter is not valid anymore, remove event listener - Didomi.shared.removeEventListener(listener: didomiEventListener) } } diff --git a/src/DidomiListener.ts b/src/DidomiListener.ts index 11405c65..4881b9f5 100644 --- a/src/DidomiListener.ts +++ b/src/DidomiListener.ts @@ -85,16 +85,16 @@ export const DidomiListener = { setOnReadyListener: (): Promise => { return new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { - DidomiListener.eventEmitter.removeAllListeners(InternalEventType.READY_CALLBACK); + subscription.remove(); reject(new Error('Didomi SDK ready timeout')); }, 30000); const listener = (_event: any) => { clearTimeout(timeoutId); resolve(); - DidomiListener.eventEmitter.removeAllListeners(InternalEventType.READY_CALLBACK); + subscription.remove(); }; - DidomiListener.eventEmitter.addListener( + const subscription = DidomiListener.eventEmitter.addListener( InternalEventType.READY_CALLBACK, listener ); @@ -104,16 +104,16 @@ export const DidomiListener = { setOnErrorListener: (): Promise => { return new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { - DidomiListener.eventEmitter.removeAllListeners(InternalEventType.ERROR_CALLBACK); + subscription.remove(); reject(new Error('Didomi SDK error listener timeout')); }, 30000); const listener = (_event: any) => { clearTimeout(timeoutId); resolve(_event); - DidomiListener.eventEmitter.removeAllListeners(InternalEventType.ERROR_CALLBACK); + subscription.remove(); }; - DidomiListener.eventEmitter.addListener( + const subscription = DidomiListener.eventEmitter.addListener( InternalEventType.ERROR_CALLBACK, listener ); diff --git a/src/__tests__/DidomiCallbackListeners.test.ts b/src/__tests__/DidomiCallbackListeners.test.ts new file mode 100644 index 00000000..23a6f406 --- /dev/null +++ b/src/__tests__/DidomiCallbackListeners.test.ts @@ -0,0 +1,106 @@ +// Use React Native's event emitter so subscription removal behaves as it does in JS. +jest.mock('../specs/NativeDidomi', () => ({ + __esModule: true, + default: { + addListener: jest.fn(), + removeListeners: jest.fn(), + }, +})); + +import { DidomiListener } from '../DidomiListener'; + +describe.each([ + { + name: 'ready', + eventName: 'on_ready_callback', + subscribe: () => DidomiListener.setOnReadyListener(), + timeoutMessage: 'Didomi SDK ready timeout', + payload: undefined, + }, + { + name: 'error', + eventName: 'on_error_callback', + subscribe: () => DidomiListener.setOnErrorListener(), + timeoutMessage: 'Didomi SDK error listener timeout', + payload: 'Native SDK initialization failed', + }, +])('$name callback subscriptions', ({ eventName, subscribe, timeoutMessage, payload }) => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + DidomiListener.eventEmitter.removeAllListeners(eventName); + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + it('resolves with the callback payload and cancels its timeout', async () => { + const promise = subscribe(); + + DidomiListener.eventEmitter.emit(eventName, payload); + + await expect(promise).resolves.toBe(payload); + expect(DidomiListener.eventEmitter.listenerCount(eventName)).toBe(0); + expect(jest.getTimerCount()).toBe(0); + }); + + it('resolves all concurrent calls when the native callback arrives', async () => { + const first = subscribe(); + const second = subscribe(); + + DidomiListener.eventEmitter.emit(eventName, payload); + + await expect(first).resolves.toBe(payload); + await expect(second).resolves.toBe(payload); + expect(DidomiListener.eventEmitter.listenerCount(eventName)).toBe(0); + expect(jest.getTimerCount()).toBe(0); + }); + + it('keeps a newer call subscribed when an older call times out', async () => { + const first = subscribe(); + const firstRejection = expect(first).rejects.toThrow(timeoutMessage); + jest.advanceTimersByTime(10000); + const second = subscribe(); + + jest.advanceTimersByTime(20000); + await firstRejection; + expect(DidomiListener.eventEmitter.listenerCount(eventName)).toBe(1); + + DidomiListener.eventEmitter.emit(eventName, payload); + + await expect(second).resolves.toBe(payload); + expect(DidomiListener.eventEmitter.listenerCount(eventName)).toBe(0); + expect(jest.getTimerCount()).toBe(0); + }); + + it('does not remove another subscriber when the callback resolves', async () => { + const callback = jest.fn(); + const otherSubscription = DidomiListener.eventEmitter.addListener(eventName, callback); + const promise = subscribe(); + + DidomiListener.eventEmitter.emit(eventName, payload); + await expect(promise).resolves.toBe(payload); + DidomiListener.eventEmitter.emit(eventName, payload); + + expect(callback).toHaveBeenCalledTimes(2); + expect(DidomiListener.eventEmitter.listenerCount(eventName)).toBe(1); + otherSubscription.remove(); + }); + + it('rejects after 30 seconds and allows a fresh subscription afterward', async () => { + const promise = subscribe(); + const rejection = expect(promise).rejects.toThrow(timeoutMessage); + + jest.advanceTimersByTime(29999); + expect(DidomiListener.eventEmitter.listenerCount(eventName)).toBe(1); + jest.advanceTimersByTime(1); + await rejection; + expect(DidomiListener.eventEmitter.listenerCount(eventName)).toBe(0); + + const retry = subscribe(); + DidomiListener.eventEmitter.emit(eventName, payload); + await expect(retry).resolves.toBe(payload); + expect(jest.getTimerCount()).toBe(0); + }); +}); diff --git a/test/ios/DidomiUITests/DidomiUITests.swift b/test/ios/DidomiUITests/DidomiUITests.swift index bf3a76bc..af8dff73 100644 --- a/test/ios/DidomiUITests/DidomiUITests.swift +++ b/test/ios/DidomiUITests/DidomiUITests.swift @@ -711,8 +711,33 @@ class DidomiUITests: XCTestCase { func testClearUser() throws { let app = initApp() + let scrollView = app.scrollViews.firstMatch + let button = app.buttons["clearUser"] + scrollView.wait() + button.wait() + + // Scroll explicitly so the tap does not also have to reveal the button. + for _ in 0..<12 { + if scrollView.frame.contains(button.frame) { + break + } + if button.frame.maxY > scrollView.frame.maxY { + scrollView.swipeUp() + } else { + scrollView.swipeDown() + } + } + + let readyToTap = NSPredicate { _, _ in + button.exists && scrollView.frame.contains(button.frame) && button.isHittable + } + let expectation = XCTNSPredicateExpectation(predicate: readyToTap, object: button) + guard XCTWaiter.wait(for: [expectation], timeout: 10) == .completed else { + XCTFail("clearUser button did not become fully visible and hittable") + return + } - tapButton(in: app, name: "clearUser") + button.tap() assertResult(in: app, name: "clearUser", expected: "clearUser-OK") }