Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 4 additions & 16 deletions android/src/main/java/io/didomi/reactnative/DidomiModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1228,48 +1227,37 @@ 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 {
putString("organizationUserId", event.organizationUserId)
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.
Expand Down
22 changes: 17 additions & 5 deletions ios/RNDidomi.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class RNDidomi: RCTEventEmitter {
var vendorStatusListeners: Set<String> = Set()
var syncAcknowledgedCallbacks: Dictionary<Int, (() -> Bool)> = [:]
private var syncAcknowledgedCallbackIndex: Int = 0
private var hasListeners = false

override init() {
didomiEventListener = EventListener()
Expand All @@ -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,
Expand Down Expand Up @@ -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)
}
}

Expand Down
12 changes: 6 additions & 6 deletions src/DidomiListener.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,16 +85,16 @@ export const DidomiListener = {
setOnReadyListener: (): Promise<void> => {
return new Promise<void>((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
);
Expand All @@ -104,16 +104,16 @@ export const DidomiListener = {
setOnErrorListener: (): Promise<void> => {
return new Promise<void>((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
);
Expand Down
106 changes: 106 additions & 0 deletions src/__tests__/DidomiCallbackListeners.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
27 changes: 26 additions & 1 deletion test/ios/DidomiUITests/DidomiUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}

Expand Down