Skip to content
Merged
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
63 changes: 54 additions & 9 deletions libraries/expo-iap/ios/ExpoIapHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ final class IapException: GenericException<(code: String, message: String, produ

enum ExpoIapHelper {
// Disambiguate Subscription type to the one provided by OpenIAP
private static let listenerLock = NSRecursiveLock()
private static var listeners: [OpenIAP.Subscription] = []
private static var purchaseUpdatedSub: OpenIAP.Subscription?
private static var purchaseUpdatedHandler: ((Purchase) -> Void)?
private static var purchaseUpdatedOptions = PurchaseUpdatedListenerOptions()
Comment thread
hyochan marked this conversation as resolved.

static func sanitizeDictionary(_ dictionary: [String: Any?]) -> [String: Any] {
var result: [String: Any] = [:]
Expand Down Expand Up @@ -132,14 +136,14 @@ enum ExpoIapHelper {
promotedProduct: @escaping (String) async -> Void,
subscriptionBillingIssue: @escaping (Purchase) -> Void
) {
listenerLock.lock()
defer { listenerLock.unlock() }

// Clean up any existing listeners first
cleanupListeners()
cleanupListenersLocked()

let purchaseUpdatedSub = OpenIapModule.shared.purchaseUpdatedListener { purchase in
Task { @MainActor in
purchaseUpdated(purchase)
}
}
purchaseUpdatedHandler = purchaseUpdated
attachPurchaseUpdatedListenerLocked()

let purchaseErrorSub = OpenIapModule.shared.purchaseErrorListener { error in
Task { @MainActor in
Expand All @@ -159,13 +163,54 @@ enum ExpoIapHelper {
}
}

listeners = [purchaseUpdatedSub, purchaseErrorSub, promotedProductSub, billingIssueSub]
listeners = [
purchaseErrorSub,
promotedProductSub,
billingIssueSub,
]
}

static func setPurchaseUpdatedListenerOptions(_ options: PurchaseUpdatedListenerOptions?) {
listenerLock.lock()
defer { listenerLock.unlock() }

purchaseUpdatedOptions = options ?? PurchaseUpdatedListenerOptions()
guard purchaseUpdatedHandler != nil else { return }
if let purchaseUpdatedSub {
OpenIapModule.shared.removeListener(purchaseUpdatedSub)
self.purchaseUpdatedSub = nil
}
attachPurchaseUpdatedListenerLocked()
}

private static func attachPurchaseUpdatedListenerLocked() {
guard let purchaseUpdatedHandler, purchaseUpdatedSub == nil else { return }

purchaseUpdatedSub = OpenIapModule.shared.purchaseUpdatedListener({ purchase in
Task { @MainActor in
purchaseUpdatedHandler(purchase)
}
}, options: purchaseUpdatedOptions)
}

static func cleanupListeners() {
// Clear subscriptions to prevent memory leaks
// Subscription deinit will automatically call onRemove closure
listenerLock.lock()
defer { listenerLock.unlock() }

cleanupListenersLocked()
}

private static func cleanupListenersLocked() {
if let purchaseUpdatedSub {
OpenIapModule.shared.removeListener(purchaseUpdatedSub)
self.purchaseUpdatedSub = nil
}
for subscription in listeners {
OpenIapModule.shared.removeListener(subscription)
}
listeners.removeAll()
purchaseUpdatedHandler = nil
purchaseUpdatedOptions = PurchaseUpdatedListenerOptions()
}

static func setupStore(module: ExpoIapModule) {
Expand Down
8 changes: 8 additions & 0 deletions libraries/expo-iap/ios/ExpoIapModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,14 @@ public final class ExpoIapModule: Module {
return succeeded
}

AsyncFunction("setPurchaseUpdatedListenerOptions") { (options: [String: Any]?) async throws -> Void in
let listenerOptions = PurchaseUpdatedListenerOptions(
dedupeTransactionIOS:
options?["dedupeTransactionIOS"] as? Bool
)
ExpoIapHelper.setPurchaseUpdatedListenerOptions(listenerOptions)
}

AsyncFunction("fetchProducts") { (params: [String: Any]) async throws -> [[String: Any]] in
ExpoIapLog.payload("fetchProducts", payload: params)
let request = try ExpoIapHelper.decodeProductRequest(from: params)
Expand Down
2 changes: 2 additions & 0 deletions libraries/expo-iap/src/__mocks__/expo-modules-core.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const mockNativeModule = {
verifyPurchaseWithProvider: jest.fn(),
initConnection: jest.fn(),
endConnection: jest.fn(),
setPurchaseUpdatedListenerOptions: jest.fn().mockResolvedValue(undefined),
// Android-specific methods
acknowledgePurchaseAndroid: jest.fn(),
consumeProductAndroid: jest.fn(),
Expand All @@ -40,6 +41,7 @@ const mockNativeModule = {
launchExternalLinkAndroid: jest.fn(),
createBillingProgramReportingDetailsAndroid: jest.fn(),
addListener: jest.fn(),
removeListener: jest.fn(),
removeListeners: jest.fn(),
};

Expand Down
152 changes: 151 additions & 1 deletion libraries/expo-iap/src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ describe('Public API (index.ts)', () => {
it('registers purchase updated listener', () => {
const addListener = (ExpoIapModule as any).addListener as jest.Mock;
const fn = jest.fn();
purchaseUpdatedListener(fn);
const subscription = purchaseUpdatedListener(fn);
expect(addListener).toHaveBeenCalledWith(
OpenIapEvent.PurchaseUpdated,
expect.any(Function),
Expand All @@ -69,6 +69,156 @@ describe('Public API (index.ts)', () => {
const event = {id: 't', productId: 'p', platform: 'IOS'} as any;
passed(event);
expect(fn).toHaveBeenCalledWith({...event, platform: 'ios'});
expect(typeof subscription.remove).toBe('function');
});

it('registers non-deduping purchase updated listener on iOS', () => {
const addListener = (ExpoIapModule as any).addListener as jest.Mock;
const setOptions = (ExpoIapModule as any)
.setPurchaseUpdatedListenerOptions as jest.Mock;
const fn = jest.fn();
const subscription = purchaseUpdatedListener(fn, {
dedupeTransactionIOS: false,
});
expect(setOptions).toHaveBeenCalledWith({
dedupeTransactionIOS: false,
});
expect(addListener).toHaveBeenCalledWith(
OpenIapEvent.PurchaseUpdated,
expect.any(Function),
);
subscription.remove();
});

it('removes listener through native subscription when available', () => {
const addListener = (ExpoIapModule as any).addListener as jest.Mock;
const nativeRemove = jest.fn();
addListener.mockReturnValueOnce({remove: nativeRemove});

const subscription = purchaseUpdatedListener(jest.fn());
subscription.remove();
subscription.remove();

expect(nativeRemove).toHaveBeenCalledTimes(1);
});

it('falls back to native removeListener when addListener returns void', () => {
const addListener = (ExpoIapModule as any).addListener as jest.Mock;
const removeListener = (ExpoIapModule as any).removeListener as jest.Mock;
addListener.mockReturnValueOnce(undefined);

const subscription = purchaseUpdatedListener(jest.fn());
const nativeListener = addListener.mock.calls[0][1];
subscription.remove();
subscription.remove();

expect(removeListener).toHaveBeenCalledTimes(1);
expect(removeListener).toHaveBeenCalledWith(
OpenIapEvent.PurchaseUpdated,
nativeListener,
);
});

it('filters duplicate replay events for default listeners when a non-deduping listener is active', () => {
const addListener = (ExpoIapModule as any).addListener as jest.Mock;
const defaultListener = jest.fn();
const nonDedupingListener = jest.fn();

purchaseUpdatedListener(defaultListener);
const nonDedupingSubscription = purchaseUpdatedListener(
nonDedupingListener,
{
dedupeTransactionIOS: false,
},
);

const defaultHandler = addListener.mock.calls[0][1];
const nonDedupingHandler = addListener.mock.calls[1][1];
const event = {
id: 'expo-dedupe-replay',
productId: 'p',
platform: 'IOS',
} as any;

defaultHandler(event);
nonDedupingHandler(event);
defaultHandler(event);
nonDedupingHandler(event);

expect(defaultListener).toHaveBeenCalledTimes(1);
expect(nonDedupingListener).toHaveBeenCalledTimes(2);
nonDedupingSubscription.remove();
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it('resets default listener duplicate history after endConnection', async () => {
const addListener = (ExpoIapModule as any).addListener as jest.Mock;
(ExpoIapModule.endConnection as jest.Mock).mockResolvedValue(true);
const listener = jest.fn();

purchaseUpdatedListener(listener);
const handler = addListener.mock.calls[0][1];
const event = {
id: 'expo-dedupe-after-reconnect',
productId: 'p',
platform: 'IOS',
} as any;

handler(event);
handler(event);
expect(listener).toHaveBeenCalledTimes(1);

await endConnection();
handler(event);

expect(listener).toHaveBeenCalledTimes(2);
});

it('reapplies non-deduping purchase updated option after reconnect', async () => {
(Platform as any).OS = 'ios';
(ExpoIapModule.initConnection as jest.Mock).mockResolvedValue(true);
(ExpoIapModule.endConnection as jest.Mock).mockResolvedValue(true);
const setOptions = (ExpoIapModule as any)
.setPurchaseUpdatedListenerOptions as jest.Mock;

const subscription = purchaseUpdatedListener(jest.fn(), {
dedupeTransactionIOS: false,
});
expect(setOptions).toHaveBeenLastCalledWith({
dedupeTransactionIOS: false,
});

setOptions.mockClear();
await endConnection();
await initConnection();

expect(setOptions).toHaveBeenCalledTimes(1);
expect(setOptions).toHaveBeenCalledWith({
dedupeTransactionIOS: false,
});
subscription.remove();
});

it('removes non-deduping purchase updated listeners idempotently', () => {
const setOptions = (ExpoIapModule as any)
.setPurchaseUpdatedListenerOptions as jest.Mock;

const firstSubscription = purchaseUpdatedListener(jest.fn(), {
dedupeTransactionIOS: false,
});
const secondSubscription = purchaseUpdatedListener(jest.fn(), {
dedupeTransactionIOS: false,
});

firstSubscription.remove();
firstSubscription.remove();
secondSubscription.remove();

expect(setOptions.mock.calls).toEqual([
[{dedupeTransactionIOS: false}],
[{dedupeTransactionIOS: false}],
[{dedupeTransactionIOS: false}],
[{dedupeTransactionIOS: true}],
]);
});

it('registers purchase error listener', () => {
Expand Down
Loading
Loading