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
56 changes: 47 additions & 9 deletions libraries/expo-iap/ios/ExpoIapHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ enum ExpoIapHelper {
private static var purchaseUpdatedSub: OpenIAP.Subscription?
private static var purchaseUpdatedHandler: ((Purchase) -> Void)?
private static var purchaseUpdatedOptions = PurchaseUpdatedListenerOptions()
private static var listenerGeneration: UInt64 = 0
private static var activeListenerGeneration: UInt64?
private static var pendingConnectionCleanup: (
generation: UInt64,
task: Task<Void, Never>
)?

static func sanitizeDictionary(_ dictionary: [String: Any?]) -> [String: Any] {
var result: [String: Any] = [:]
Expand Down Expand Up @@ -170,17 +176,18 @@ enum ExpoIapHelper {
}

static func setupListeners(
module: ExpoIapModule,
purchaseUpdated: @escaping (Purchase) -> Void,
purchaseError: @escaping (PurchaseError) -> Void,
promotedProduct: @escaping (String) async -> Void,
subscriptionBillingIssue: @escaping (Purchase) -> Void
) {
) -> UInt64 {
listenerLock.lock()
defer { listenerLock.unlock() }

// Clean up any existing listeners first
cleanupListenersLocked()
listenerGeneration &+= 1
let generation = listenerGeneration
activeListenerGeneration = generation

purchaseUpdatedHandler = purchaseUpdated
attachPurchaseUpdatedListenerLocked()
Expand Down Expand Up @@ -208,6 +215,7 @@ enum ExpoIapHelper {
promotedProductSub,
billingIssueSub,
]
return generation
}

static func setPurchaseUpdatedListenerOptions(_ options: PurchaseUpdatedListenerOptions?) {
Expand All @@ -233,11 +241,37 @@ enum ExpoIapHelper {
}, options: purchaseUpdatedOptions)
}

static func cleanupListeners() {
private static func beginStoreCleanup(
ifOwnedBy listenerGeneration: UInt64
) -> Task<Void, Never>? {
listenerLock.lock()
defer { listenerLock.unlock() }

guard activeListenerGeneration == listenerGeneration else { return nil }
cleanupListenersLocked()
activeListenerGeneration = nil
let previousTask = pendingConnectionCleanup?.task
let task = Task {
await previousTask?.value
_ = try? await OpenIapModule.shared.endConnection()
}
pendingConnectionCleanup = (listenerGeneration, task)
return task
}

private static func pendingConnectionCleanupTask() -> Task<Void, Never>? {
listenerLock.lock()
defer { listenerLock.unlock() }

return pendingConnectionCleanup?.task
}

private static func finishStoreCleanup(listenerGeneration: UInt64) {
listenerLock.lock()
defer { listenerLock.unlock() }

guard pendingConnectionCleanup?.generation == listenerGeneration else { return }
pendingConnectionCleanup = nil
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

private static func cleanupListenersLocked() {
Expand All @@ -253,9 +287,8 @@ enum ExpoIapHelper {
purchaseUpdatedOptions = PurchaseUpdatedListenerOptions()
}

static func setupStore(module: ExpoIapModule) {
static func setupStore(module: ExpoIapModule) -> UInt64 {
setupListeners(
module: module,
purchaseUpdated: { [weak module] purchase in
guard let module else { return }
let payload = sanitizeDictionary(OpenIapSerialization.purchase(purchase))
Expand Down Expand Up @@ -291,8 +324,13 @@ enum ExpoIapHelper {
)
}

static func cleanupStore() async {
cleanupListeners()
_ = try? await OpenIapModule.shared.endConnection()
static func cleanupStore(listenerGeneration: UInt64) async {
guard let task = beginStoreCleanup(ifOwnedBy: listenerGeneration) else { return }
await task.value
finishStoreCleanup(listenerGeneration: listenerGeneration)
}

static func waitForStoreCleanup() async {
await pendingConnectionCleanupTask()?.value
}
}
18 changes: 12 additions & 6 deletions libraries/expo-iap/ios/ExpoIapModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import UIKit
@MainActor
public final class ExpoIapModule: Module {
private var isInitialized = false
private var listenerGeneration: UInt64?

nonisolated public func definition() -> ModuleDefinition {
Name("ExpoIap")
Expand All @@ -25,21 +26,26 @@ public final class ExpoIapModule: Module {
IapEvent.subscriptionBillingIssue.rawValue
)

OnCreate {
Task { @MainActor in
ExpoIapHelper.setupStore(module: self)
OnCreate { [weak self] in
Task { @MainActor [weak self] in
guard let self else { return }
self.listenerGeneration = ExpoIapHelper.setupStore(module: self)
}
}

OnDestroy {
Task { @MainActor in
await ExpoIapHelper.cleanupStore()
OnDestroy { [weak self] in
guard let self else { return }
Task { @MainActor [self] in
guard let listenerGeneration = self.listenerGeneration else { return }
self.listenerGeneration = nil
await ExpoIapHelper.cleanupStore(listenerGeneration: listenerGeneration)
}
}

AsyncFunction("initConnection") { (config: [String: Any]?) async throws -> Bool in
// Note: iOS doesn't support alternative billing config parameter
// Config is ignored on iOS platform
await ExpoIapHelper.waitForStoreCleanup()
let isConnected = try await OpenIapModule.shared.initConnection()
await MainActor.run { self.isInitialized = isConnected }
return isConnected
Expand Down
11 changes: 6 additions & 5 deletions libraries/expo-iap/ios/onside/OnsideIapModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,14 +73,15 @@ public final class ExpoIapOnsideModule: Module {
OnsideEvent.subscriptionBillingIssue.rawValue
)

OnCreate {
Task { @MainActor in
self.configureObserverCallbacks()
OnCreate { [weak self] in
Task { @MainActor [weak self] in
self?.configureObserverCallbacks()
}
}

OnDestroy {
Task { @MainActor in
OnDestroy { [weak self] in
guard let self else { return }
Task { @MainActor [self] in
self.cleanup()
}
}
Expand Down
42 changes: 42 additions & 0 deletions libraries/expo-iap/src/__tests__/ios-module-lifecycle.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
const {readFileSync} = require('fs');
const {resolve} = require('path');

const rootDir = resolve(__dirname, '../..');

function readExpoFile(path) {
return readFileSync(resolve(rootDir, path), 'utf8');
}

describe('iOS module lifecycle', () => {
it('scopes StoreKit teardown to the active module generation', () => {
const module = readExpoFile('ios/ExpoIapModule.swift');
const helper = readExpoFile('ios/ExpoIapHelper.swift');

expect(module).toMatch(/OnCreate \{ \[weak self\] in/);
expect(module).toMatch(/Task \{ @MainActor \[weak self\] in/);
expect(module).toMatch(/OnDestroy \{ \[weak self\] in/);
expect(module).toMatch(/Task \{ @MainActor \[self\] in/);
expect(module).toContain(
'ExpoIapHelper.cleanupStore(listenerGeneration: listenerGeneration)',
);
expect(module).toContain('await ExpoIapHelper.waitForStoreCleanup()');
expect(helper).toContain('private static var activeListenerGeneration');
expect(helper).toContain(
'guard activeListenerGeneration == listenerGeneration else { return nil }',
);
expect(helper).toContain('private static var pendingConnectionCleanup');
expect(helper).toMatch(
/let previousTask = pendingConnectionCleanup\?\.task\s+let task = Task \{\s+await previousTask\?\.value\s+_ = try\? await OpenIapModule\.shared\.endConnection\(\)/,
);
expect(helper).toContain('await pendingConnectionCleanupTask()?.value');
});

it('does not retain Onside modules through lifecycle definitions', () => {
const module = readExpoFile('ios/onside/OnsideIapModule.swift');

expect(module).toMatch(/OnCreate \{ \[weak self\] in/);
expect(module).toMatch(/Task \{ @MainActor \[weak self\] in/);
expect(module).toMatch(/OnDestroy \{ \[weak self\] in/);
expect(module).toMatch(/Task \{ @MainActor \[self\] in/);
});
});
Loading