From 9e799a1bf86c04c8b678353c50a7a6a943f293a5 Mon Sep 17 00:00:00 2001 From: alihassan143 Date: Tue, 19 May 2026 21:48:26 +0500 Subject: [PATCH 01/10] feat(flutter_inapp_purchase): add Swift Package Manager support for iOS and macOS - Rewrite Package.swift with proper iOS 15.0 and macOS 14.0 platform declarations, removing incorrect ObjC-only publicHeadersPath and cSettings - Add OpenIAP SPM dependency (https://github.com/hyodotdev/openiap.git v2.2.1) so the plugin's native OpenIAP import resolves correctly under SPM - Point the SPM target to ios/Classes which already uses #if canImport(FlutterMacOS) conditionals, making a single source tree compile correctly for both platforms - Sync macos/Classes/FlutterInappPurchasePlugin.swift with the canonical iOS version, restoring feature parity for CocoaPods macOS builds (beginRefundRequestIOS, syncIOS, subscriptionStatusIOS, currentEntitlementIOS, latestTransactionIOS, isTransactionVerifiedIOS, getTransactionJwsIOS, getReceiptDataIOS, getAppTransactionIOS, verifyPurchaseWithProvider and proper availability guards) --- .../flutter_inapp_purchase/Package.swift | 29 +- .../Classes/FlutterInappPurchasePlugin.swift | 391 ++++++++++++++++-- 2 files changed, 377 insertions(+), 43 deletions(-) diff --git a/libraries/flutter_inapp_purchase/Package.swift b/libraries/flutter_inapp_purchase/Package.swift index f87e79982..c8ea4bbb3 100644 --- a/libraries/flutter_inapp_purchase/Package.swift +++ b/libraries/flutter_inapp_purchase/Package.swift @@ -1,30 +1,29 @@ // swift-tools-version: 5.9 -// The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription let package = Package( name: "flutter_inapp_purchase", platforms: [ - .iOS("12.0") + .iOS("15.0"), + .macOS("14.0"), ], products: [ - .library(name: "flutter-inapp-purchase", targets: ["flutter_inapp_purchase"]) + .library(name: "flutter_inapp_purchase", targets: ["flutter_inapp_purchase"]), + ], + dependencies: [ + .package( + url: "https://github.com/hyodotdev/openiap.git", + exact: "2.2.1" + ), ], - dependencies: [], targets: [ .target( name: "flutter_inapp_purchase", - dependencies: [], - path: "ios/Classes", - resources: [ - .process("../Assets") + dependencies: [ + .product(name: "OpenIAP", package: "openiap"), ], - publicHeadersPath: "", - cSettings: [ - .headerSearchPath("../Flutter"), - .headerSearchPath("../../../Flutter/Export") - ] - ) + path: "ios/Classes" + ), ] -) \ No newline at end of file +) diff --git a/libraries/flutter_inapp_purchase/macos/Classes/FlutterInappPurchasePlugin.swift b/libraries/flutter_inapp_purchase/macos/Classes/FlutterInappPurchasePlugin.swift index cf1318c5e..aa23951a7 100644 --- a/libraries/flutter_inapp_purchase/macos/Classes/FlutterInappPurchasePlugin.swift +++ b/libraries/flutter_inapp_purchase/macos/Classes/FlutterInappPurchasePlugin.swift @@ -1,5 +1,9 @@ import Foundation +#if canImport(FlutterMacOS) import FlutterMacOS +#else +import Flutter +#endif // StoreKit is not directly used; relying on OpenIAP import OpenIAP @@ -28,7 +32,11 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { public static func register(with registrar: FlutterPluginRegistrar) { FlutterIapLog.debug("Swift register called") + #if canImport(FlutterMacOS) let channel = FlutterMethodChannel(name: "flutter_inapp", binaryMessenger: registrar.messenger) + #else + let channel = FlutterMethodChannel(name: "flutter_inapp", binaryMessenger: registrar.messenger()) + #endif let instance = FlutterInappPurchasePlugin() registrar.addMethodCallDelegate(instance, channel: channel) instance.channel = channel @@ -148,7 +156,23 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { clearTransactionIOS(result: result) case "presentCodeRedemptionSheetIOS": - presentCodeRedemptionSheetIOS(result: result) + if #available(iOS 16.0, macOS 14.0, tvOS 16.0, *) { + presentCodeRedemptionSheetIOS(result: result) + } else { + let code: ErrorCode = .featureNotSupported + result(FlutterError(code: code.rawValue, message: "Code redemption requires iOS 16.0+, macOS 14.0+, or tvOS 16.0+", details: nil)) + } + + case "beginRefundRequestIOS": + if let args = call.arguments as? [String: Any], + let sku = args["sku"] as? String { + beginRefundRequestIOS(sku: sku, result: result) + } else if let sku = call.arguments as? String { + beginRefundRequestIOS(sku: sku, result: result) + } else { + let code: ErrorCode = .developerError + result(FlutterError(code: code.rawValue, message: "sku required", details: nil)) + } case "getPromotedProductIOS": getPromotedProductIOS(result: result) @@ -168,17 +192,88 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { result(FlutterError(code: code.rawValue, message: "productId required", details: nil)) } + case "currentEntitlementIOS": + if let args = call.arguments as? [String: Any], + let sku = args["sku"] as? String { + currentEntitlementIOS(sku: sku, result: result) + } else if let sku = call.arguments as? String { + currentEntitlementIOS(sku: sku, result: result) + } else { + let code: ErrorCode = .developerError + result(FlutterError(code: code.rawValue, message: "sku required", details: nil)) + } + + case "latestTransactionIOS": + if let args = call.arguments as? [String: Any], + let sku = args["sku"] as? String { + latestTransactionIOS(sku: sku, result: result) + } else if let sku = call.arguments as? String { + latestTransactionIOS(sku: sku, result: result) + } else { + let code: ErrorCode = .developerError + result(FlutterError(code: code.rawValue, message: "sku required", details: nil)) + } + + case "isTransactionVerifiedIOS": + if let args = call.arguments as? [String: Any], + let sku = args["sku"] as? String { + isTransactionVerifiedIOS(sku: sku, result: result) + } else if let sku = call.arguments as? String { + isTransactionVerifiedIOS(sku: sku, result: result) + } else { + let code: ErrorCode = .developerError + result(FlutterError(code: code.rawValue, message: "sku required", details: nil)) + } + + case "getTransactionJwsIOS": + if let args = call.arguments as? [String: Any], + let sku = args["sku"] as? String { + getTransactionJwsIOS(sku: sku, result: result) + } else if let sku = call.arguments as? String { + getTransactionJwsIOS(sku: sku, result: result) + } else { + let code: ErrorCode = .developerError + result(FlutterError(code: code.rawValue, message: "sku required", details: nil)) + } + + case "getReceiptDataIOS": + getReceiptDataIOS(result: result) + + case "getAppTransactionIOS", "getAppTransaction": + if #available(iOS 16.0, macOS 14.0, tvOS 16.0, *) { + getAppTransactionIOS(result: result) + } else { + let code: ErrorCode = .featureNotSupported + result(FlutterError(code: code.rawValue, message: "getAppTransactionIOS requires iOS 16.0+", details: nil)) + } + + case "syncIOS": + syncIOS(result: result) + + case "subscriptionStatusIOS", "getSubscriptionStatus": + if let args = call.arguments as? [String: Any], + let sku = args["sku"] as? String { + subscriptionStatusIOS(sku: sku, result: result) + } else if let sku = call.arguments as? String { + subscriptionStatusIOS(sku: sku, result: result) + } else { + let code: ErrorCode = .developerError + result(FlutterError(code: code.rawValue, message: "sku required", details: nil)) + } + case "validateReceiptIOS", "verifyPurchase": guard let args = call.arguments as? [String: Any] else { let code: ErrorCode = .developerError result(FlutterError(code: code.rawValue, message: "arguments required", details: nil)) return } + // Support new API: { apple: { sku: "..." } } or legacy { sku: "..." } let sku: String if let appleOptions = args["apple"] as? [String: Any], let appleSku = appleOptions["sku"] as? String { sku = appleSku } else if let legacySku = args["sku"] as? String { + // Backwards compatibility with legacy API sku = legacySku } else { let code: ErrorCode = .developerError @@ -187,32 +282,39 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { } validateReceiptIOS(productId: sku, result: result) - case "verifyPurchaseWithProvider": - guard let args = call.arguments as? [String: Any], - let providerStr = args["provider"] as? String else { - let code: ErrorCode = .developerError - result(FlutterError(code: code.rawValue, message: "provider required", details: nil)) - return - } - verifyPurchaseWithProvider(args: args, provider: providerStr, result: result) - case "canPresentExternalPurchaseNoticeIOS": - canPresentExternalPurchaseNoticeIOS(result: result) + if #available(iOS 17.4, macOS 14.4, tvOS 17.4, *) { + canPresentExternalPurchaseNoticeIOS(result: result) + } else { + let code: ErrorCode = .featureNotSupported + result(FlutterError(code: code.rawValue, message: "External purchase notice requires iOS 17.4+, macOS 14.4+, or tvOS 17.4+", details: nil)) + } case "presentExternalPurchaseNoticeSheetIOS": - presentExternalPurchaseNoticeSheetIOS(result: result) + if #available(iOS 17.4, macOS 14.4, tvOS 17.4, *) { + presentExternalPurchaseNoticeSheetIOS(result: result) + } else { + let code: ErrorCode = .featureNotSupported + result(FlutterError(code: code.rawValue, message: "External purchase notice requires iOS 17.4+, macOS 14.4+, or tvOS 17.4+", details: nil)) + } case "presentExternalPurchaseLinkIOS": - if let args = call.arguments as? [String: Any], - let url = args["url"] as? String { - presentExternalPurchaseLinkIOS(url: url, result: result) - } else if let url = call.arguments as? String { - presentExternalPurchaseLinkIOS(url: url, result: result) + if #available(iOS 16.0, macOS 14.0, tvOS 16.0, *) { + if let args = call.arguments as? [String: Any], + let url = args["url"] as? String { + presentExternalPurchaseLinkIOS(url: url, result: result) + } else if let url = call.arguments as? String { + presentExternalPurchaseLinkIOS(url: url, result: result) + } else { + let code: ErrorCode = .developerError + result(FlutterError(code: code.rawValue, message: "url required", details: nil)) + } } else { - let code: ErrorCode = .developerError - result(FlutterError(code: code.rawValue, message: "url required", details: nil)) + let code: ErrorCode = .featureNotSupported + result(FlutterError(code: code.rawValue, message: "External purchase link requires iOS 16.0+, macOS 14.0+, or tvOS 16.0+", details: nil)) } + // MARK: - ExternalPurchaseCustomLink (iOS 18.1+) case "isEligibleForExternalPurchaseCustomLinkIOS": if #available(iOS 18.1, macOS 15.0, tvOS 18.1, *) { isEligibleForExternalPurchaseCustomLinkIOS(result: result) @@ -230,7 +332,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { getExternalPurchaseCustomLinkTokenIOS(tokenType: tokenType, result: result) } else { let code: ErrorCode = .developerError - result(FlutterError(code: code.rawValue, message: "tokenType required", details: nil)) + result(FlutterError(code: code.rawValue, message: "tokenType required ('acquisition' or 'services')", details: nil)) } } else { let code: ErrorCode = .featureNotSupported @@ -246,13 +348,22 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { showExternalPurchaseCustomLinkNoticeIOS(noticeType: noticeType, result: result) } else { let code: ErrorCode = .developerError - result(FlutterError(code: code.rawValue, message: "noticeType required", details: nil)) + result(FlutterError(code: code.rawValue, message: "noticeType required ('browser')", details: nil)) } } else { let code: ErrorCode = .featureNotSupported result(FlutterError(code: code.rawValue, message: "ExternalPurchaseCustomLink requires iOS 18.1+, macOS 15.0+, or tvOS 18.1+", details: nil)) } + case "verifyPurchaseWithProvider": + guard let args = call.arguments as? [String: Any], + let providerStr = args["provider"] as? String else { + let code: ErrorCode = .developerError + result(FlutterError(code: code.rawValue, message: "provider required", details: nil)) + return + } + verifyPurchaseWithProvider(args: args, provider: providerStr, result: result) + default: result(FlutterMethodNotImplemented) } @@ -320,12 +431,13 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { } } } - + if promotedProductToken == nil { promotedProductToken = OpenIapModule.shared.promotedProductListenerIOS { [weak self] productId in Task { @MainActor in guard let self = self else { return } FlutterIapLog.debug("promotedProductListenerIOS fired for: \(productId)") + // Emit event that Dart expects: name 'iap-promoted-product' with String payload self.channel?.invokeMethod("iap-promoted-product", arguments: productId) } } @@ -365,7 +477,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { self?.emitPurchaseUpdated(purchase, method: "purchase-updated") }, options: purchaseUpdatedListenerOptions) } - + private func removeOpenIapListeners() { if let token = purchaseUpdatedToken { OpenIapModule.shared.removeListener(token) } if let token = purchaseErrorToken { OpenIapModule.shared.removeListener(token) } @@ -566,6 +678,28 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { } } + private func beginRefundRequestIOS(sku: String, result: @escaping FlutterResult) { + FlutterIapLog.debug("beginRefundRequestIOS called for sku=\(sku)") + Task { @MainActor in + do { + let status = try await OpenIapModule.shared.beginRefundRequestIOS(sku: sku) + FlutterIapLog.result("beginRefundRequestIOS", value: status ?? "nil") + result(status) + } catch let purchaseError as PurchaseError { + FlutterIapLog.failure("beginRefundRequestIOS", error: purchaseError) + result(FlutterError( + code: purchaseError.code.rawValue, + message: purchaseError.message, + details: purchaseError.productId ?? sku + )) + } catch { + FlutterIapLog.failure("beginRefundRequestIOS", error: error) + let code: ErrorCode = .serviceError + result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) + } + } + } + @available(iOS 15.0, macOS 14.0, tvOS 15.0, *) private func showManageSubscriptionsIOS(result: @escaping FlutterResult) { FlutterIapLog.debug("showManageSubscriptionsIOS called") @@ -696,7 +830,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { } } } - + private func getAllTransactionsIOS(result: @escaping FlutterResult) { Task { @MainActor in do { @@ -713,7 +847,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { } } } - + private func clearTransactionIOS(result: @escaping FlutterResult) { FlutterIapLog.debug("clearTransactionIOS called") Task { @MainActor in @@ -787,6 +921,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { FlutterIapLog.debug("verifyPurchaseWithProvider called with provider: \(provider)") Task { @MainActor in do { + // Build props dictionary for OpenIAP var propsDict: [String: Any] = ["provider": provider] if let iapkit = args["iapkit"] as? [String: Any] { var iapkitDict: [String: Any] = [:] @@ -802,10 +937,12 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { propsDict["iapkit"] = iapkitDict } + // Use JSONSerialization + JSONDecoder let jsonData = try JSONSerialization.data(withJSONObject: propsDict) let props = try JSONDecoder().decode(VerifyPurchaseWithProviderProps.self, from: jsonData) let res = try await OpenIapModule.shared.verifyPurchaseWithProvider(props) + // Convert result to dictionary var payload: [String: Any] = [ "provider": res.provider.rawValue ] @@ -827,9 +964,203 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { } } - // MARK: - Alternative Billing (iOS 18.2+) + private func syncIOS(result: @escaping FlutterResult) { + FlutterIapLog.debug("syncIOS called") + Task { @MainActor in + do { + let success = try await OpenIapModule.shared.syncIOS() + FlutterIapLog.result("syncIOS", value: success) + result(success) + } catch let purchaseError as PurchaseError { + FlutterIapLog.failure("syncIOS", error: purchaseError) + result(FlutterError( + code: purchaseError.code.rawValue, + message: purchaseError.message, + details: purchaseError.productId + )) + } catch { + FlutterIapLog.failure("syncIOS", error: error) + let code: ErrorCode = .syncError + result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) + } + } + } + + private func subscriptionStatusIOS(sku: String, result: @escaping FlutterResult) { + FlutterIapLog.debug("subscriptionStatusIOS called for sku=\(sku)") + Task { @MainActor in + do { + let statuses = try await OpenIapModule.shared.subscriptionStatusIOS(sku: sku) + let payload = statuses.map { + FlutterIapHelper.sanitizeDictionary(OpenIapSerialization.encode($0)) + } + FlutterIapLog.result("subscriptionStatusIOS", value: payload) + result(payload) + } catch let purchaseError as PurchaseError { + FlutterIapLog.failure("subscriptionStatusIOS", error: purchaseError) + result(FlutterError( + code: purchaseError.code.rawValue, + message: purchaseError.message, + details: purchaseError.productId ?? sku + )) + } catch { + FlutterIapLog.failure("subscriptionStatusIOS", error: error) + let code: ErrorCode = .serviceError + result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) + } + } + } + + @available(iOS 16.0, macOS 14.0, tvOS 16.0, *) + private func getAppTransactionIOS(result: @escaping FlutterResult) { + FlutterIapLog.debug("getAppTransactionIOS called") + Task { @MainActor in + do { + if let tx = try await OpenIapModule.shared.getAppTransactionIOS() { + let payload = FlutterIapHelper.sanitizeDictionary(OpenIapSerialization.encode(tx)) + FlutterIapLog.result("getAppTransactionIOS", value: payload) + result(payload) + } else { + result(nil) + } + } catch let purchaseError as PurchaseError { + FlutterIapLog.failure("getAppTransactionIOS", error: purchaseError) + result(FlutterError( + code: purchaseError.code.rawValue, + message: purchaseError.message, + details: purchaseError.productId + )) + } catch { + FlutterIapLog.failure("getAppTransactionIOS", error: error) + let code: ErrorCode = .serviceError + result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) + } + } + } + + // MARK: - StoreKit 2 entitlement queries (iOS 15.0+) + + private func currentEntitlementIOS(sku: String, result: @escaping FlutterResult) { + FlutterIapLog.debug("currentEntitlementIOS called for sku=\(sku)") + Task { @MainActor in + do { + if let purchase = try await OpenIapModule.shared.currentEntitlementIOS(sku: sku) { + let payload = FlutterIapHelper.sanitizeDictionary(OpenIapSerialization.encode(purchase)) + FlutterIapLog.result("currentEntitlementIOS", value: payload) + result(payload) + } else { + result(nil) + } + } catch let purchaseError as PurchaseError { + FlutterIapLog.failure("currentEntitlementIOS", error: purchaseError) + result(FlutterError( + code: purchaseError.code.rawValue, + message: purchaseError.message, + details: purchaseError.productId ?? sku + )) + } catch { + FlutterIapLog.failure("currentEntitlementIOS", error: error) + let code: ErrorCode = .serviceError + result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) + } + } + } + + private func latestTransactionIOS(sku: String, result: @escaping FlutterResult) { + FlutterIapLog.debug("latestTransactionIOS called for sku=\(sku)") + Task { @MainActor in + do { + if let purchase = try await OpenIapModule.shared.latestTransactionIOS(sku: sku) { + let payload = FlutterIapHelper.sanitizeDictionary(OpenIapSerialization.encode(purchase)) + FlutterIapLog.result("latestTransactionIOS", value: payload) + result(payload) + } else { + result(nil) + } + } catch let purchaseError as PurchaseError { + FlutterIapLog.failure("latestTransactionIOS", error: purchaseError) + result(FlutterError( + code: purchaseError.code.rawValue, + message: purchaseError.message, + details: purchaseError.productId ?? sku + )) + } catch { + FlutterIapLog.failure("latestTransactionIOS", error: error) + let code: ErrorCode = .serviceError + result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) + } + } + } + + private func isTransactionVerifiedIOS(sku: String, result: @escaping FlutterResult) { + FlutterIapLog.debug("isTransactionVerifiedIOS called for sku=\(sku)") + Task { @MainActor in + do { + let verified = try await OpenIapModule.shared.isTransactionVerifiedIOS(sku: sku) + FlutterIapLog.result("isTransactionVerifiedIOS", value: verified) + result(verified) + } catch let purchaseError as PurchaseError { + FlutterIapLog.failure("isTransactionVerifiedIOS", error: purchaseError) + result(FlutterError( + code: purchaseError.code.rawValue, + message: purchaseError.message, + details: purchaseError.productId ?? sku + )) + } catch { + FlutterIapLog.failure("isTransactionVerifiedIOS", error: error) + let code: ErrorCode = .serviceError + result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) + } + } + } + + private func getTransactionJwsIOS(sku: String, result: @escaping FlutterResult) { + FlutterIapLog.debug("getTransactionJwsIOS called for sku=\(sku)") + Task { @MainActor in + do { + let jws = try await OpenIapModule.shared.getTransactionJwsIOS(sku: sku) + FlutterIapLog.result("getTransactionJwsIOS", value: jws == nil ? nil : "") + result(jws) + } catch let purchaseError as PurchaseError { + FlutterIapLog.failure("getTransactionJwsIOS", error: purchaseError) + result(FlutterError( + code: purchaseError.code.rawValue, + message: purchaseError.message, + details: purchaseError.productId ?? sku + )) + } catch { + FlutterIapLog.failure("getTransactionJwsIOS", error: error) + let code: ErrorCode = .serviceError + result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) + } + } + } + + private func getReceiptDataIOS(result: @escaping FlutterResult) { + FlutterIapLog.debug("getReceiptDataIOS called") + Task { @MainActor in + do { + let receipt = try await OpenIapModule.shared.getReceiptDataIOS() + FlutterIapLog.result("getReceiptDataIOS", value: receipt == nil ? nil : "") + result(receipt) + } catch let purchaseError as PurchaseError { + FlutterIapLog.failure("getReceiptDataIOS", error: purchaseError) + result(FlutterError( + code: purchaseError.code.rawValue, + message: purchaseError.message, + details: purchaseError.productId + )) + } catch { + FlutterIapLog.failure("getReceiptDataIOS", error: error) + let code: ErrorCode = .serviceError + result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) + } + } + } + + // MARK: - External Purchase Notice (iOS 17.4+) - @available(iOS 18.2, macOS 14.0, tvOS 18.2, *) + @available(iOS 17.4, macOS 14.4, tvOS 17.4, *) private func canPresentExternalPurchaseNoticeIOS(result: @escaping FlutterResult) { FlutterIapLog.debug("canPresentExternalPurchaseNoticeIOS called") Task { @MainActor in @@ -846,7 +1177,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { } } - @available(iOS 18.2, macOS 14.0, tvOS 18.2, *) + @available(iOS 17.4, macOS 14.4, tvOS 17.4, *) private func presentExternalPurchaseNoticeSheetIOS(result: @escaping FlutterResult) { FlutterIapLog.debug("presentExternalPurchaseNoticeSheetIOS called") Task { @MainActor in @@ -959,7 +1290,11 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { public class FlutterInappPurchasePluginLegacy: NSObject, FlutterPlugin { public static func register(with registrar: FlutterPluginRegistrar) { if #unavailable(iOS 15.0, macOS 14.0, tvOS 15.0) { + #if canImport(FlutterMacOS) let channel = FlutterMethodChannel(name: "flutter_inapp", binaryMessenger: registrar.messenger) + #else + let channel = FlutterMethodChannel(name: "flutter_inapp", binaryMessenger: registrar.messenger()) + #endif let instance = FlutterInappPurchasePluginLegacy() registrar.addMethodCallDelegate(instance, channel: channel) } From 1a4618e64e8164b10bfc6298c06fde9a30bac290 Mon Sep 17 00:00:00 2001 From: alihassan143 Date: Tue, 19 May 2026 21:51:17 +0500 Subject: [PATCH 02/10] revert: restore macos FlutterInappPurchasePlugin.swift to original --- .../Classes/FlutterInappPurchasePlugin.swift | 391 ++---------------- 1 file changed, 28 insertions(+), 363 deletions(-) diff --git a/libraries/flutter_inapp_purchase/macos/Classes/FlutterInappPurchasePlugin.swift b/libraries/flutter_inapp_purchase/macos/Classes/FlutterInappPurchasePlugin.swift index aa23951a7..cf1318c5e 100644 --- a/libraries/flutter_inapp_purchase/macos/Classes/FlutterInappPurchasePlugin.swift +++ b/libraries/flutter_inapp_purchase/macos/Classes/FlutterInappPurchasePlugin.swift @@ -1,9 +1,5 @@ import Foundation -#if canImport(FlutterMacOS) import FlutterMacOS -#else -import Flutter -#endif // StoreKit is not directly used; relying on OpenIAP import OpenIAP @@ -32,11 +28,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { public static func register(with registrar: FlutterPluginRegistrar) { FlutterIapLog.debug("Swift register called") - #if canImport(FlutterMacOS) let channel = FlutterMethodChannel(name: "flutter_inapp", binaryMessenger: registrar.messenger) - #else - let channel = FlutterMethodChannel(name: "flutter_inapp", binaryMessenger: registrar.messenger()) - #endif let instance = FlutterInappPurchasePlugin() registrar.addMethodCallDelegate(instance, channel: channel) instance.channel = channel @@ -156,23 +148,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { clearTransactionIOS(result: result) case "presentCodeRedemptionSheetIOS": - if #available(iOS 16.0, macOS 14.0, tvOS 16.0, *) { - presentCodeRedemptionSheetIOS(result: result) - } else { - let code: ErrorCode = .featureNotSupported - result(FlutterError(code: code.rawValue, message: "Code redemption requires iOS 16.0+, macOS 14.0+, or tvOS 16.0+", details: nil)) - } - - case "beginRefundRequestIOS": - if let args = call.arguments as? [String: Any], - let sku = args["sku"] as? String { - beginRefundRequestIOS(sku: sku, result: result) - } else if let sku = call.arguments as? String { - beginRefundRequestIOS(sku: sku, result: result) - } else { - let code: ErrorCode = .developerError - result(FlutterError(code: code.rawValue, message: "sku required", details: nil)) - } + presentCodeRedemptionSheetIOS(result: result) case "getPromotedProductIOS": getPromotedProductIOS(result: result) @@ -192,88 +168,17 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { result(FlutterError(code: code.rawValue, message: "productId required", details: nil)) } - case "currentEntitlementIOS": - if let args = call.arguments as? [String: Any], - let sku = args["sku"] as? String { - currentEntitlementIOS(sku: sku, result: result) - } else if let sku = call.arguments as? String { - currentEntitlementIOS(sku: sku, result: result) - } else { - let code: ErrorCode = .developerError - result(FlutterError(code: code.rawValue, message: "sku required", details: nil)) - } - - case "latestTransactionIOS": - if let args = call.arguments as? [String: Any], - let sku = args["sku"] as? String { - latestTransactionIOS(sku: sku, result: result) - } else if let sku = call.arguments as? String { - latestTransactionIOS(sku: sku, result: result) - } else { - let code: ErrorCode = .developerError - result(FlutterError(code: code.rawValue, message: "sku required", details: nil)) - } - - case "isTransactionVerifiedIOS": - if let args = call.arguments as? [String: Any], - let sku = args["sku"] as? String { - isTransactionVerifiedIOS(sku: sku, result: result) - } else if let sku = call.arguments as? String { - isTransactionVerifiedIOS(sku: sku, result: result) - } else { - let code: ErrorCode = .developerError - result(FlutterError(code: code.rawValue, message: "sku required", details: nil)) - } - - case "getTransactionJwsIOS": - if let args = call.arguments as? [String: Any], - let sku = args["sku"] as? String { - getTransactionJwsIOS(sku: sku, result: result) - } else if let sku = call.arguments as? String { - getTransactionJwsIOS(sku: sku, result: result) - } else { - let code: ErrorCode = .developerError - result(FlutterError(code: code.rawValue, message: "sku required", details: nil)) - } - - case "getReceiptDataIOS": - getReceiptDataIOS(result: result) - - case "getAppTransactionIOS", "getAppTransaction": - if #available(iOS 16.0, macOS 14.0, tvOS 16.0, *) { - getAppTransactionIOS(result: result) - } else { - let code: ErrorCode = .featureNotSupported - result(FlutterError(code: code.rawValue, message: "getAppTransactionIOS requires iOS 16.0+", details: nil)) - } - - case "syncIOS": - syncIOS(result: result) - - case "subscriptionStatusIOS", "getSubscriptionStatus": - if let args = call.arguments as? [String: Any], - let sku = args["sku"] as? String { - subscriptionStatusIOS(sku: sku, result: result) - } else if let sku = call.arguments as? String { - subscriptionStatusIOS(sku: sku, result: result) - } else { - let code: ErrorCode = .developerError - result(FlutterError(code: code.rawValue, message: "sku required", details: nil)) - } - case "validateReceiptIOS", "verifyPurchase": guard let args = call.arguments as? [String: Any] else { let code: ErrorCode = .developerError result(FlutterError(code: code.rawValue, message: "arguments required", details: nil)) return } - // Support new API: { apple: { sku: "..." } } or legacy { sku: "..." } let sku: String if let appleOptions = args["apple"] as? [String: Any], let appleSku = appleOptions["sku"] as? String { sku = appleSku } else if let legacySku = args["sku"] as? String { - // Backwards compatibility with legacy API sku = legacySku } else { let code: ErrorCode = .developerError @@ -282,39 +187,32 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { } validateReceiptIOS(productId: sku, result: result) - case "canPresentExternalPurchaseNoticeIOS": - if #available(iOS 17.4, macOS 14.4, tvOS 17.4, *) { - canPresentExternalPurchaseNoticeIOS(result: result) - } else { - let code: ErrorCode = .featureNotSupported - result(FlutterError(code: code.rawValue, message: "External purchase notice requires iOS 17.4+, macOS 14.4+, or tvOS 17.4+", details: nil)) + case "verifyPurchaseWithProvider": + guard let args = call.arguments as? [String: Any], + let providerStr = args["provider"] as? String else { + let code: ErrorCode = .developerError + result(FlutterError(code: code.rawValue, message: "provider required", details: nil)) + return } + verifyPurchaseWithProvider(args: args, provider: providerStr, result: result) + + case "canPresentExternalPurchaseNoticeIOS": + canPresentExternalPurchaseNoticeIOS(result: result) case "presentExternalPurchaseNoticeSheetIOS": - if #available(iOS 17.4, macOS 14.4, tvOS 17.4, *) { - presentExternalPurchaseNoticeSheetIOS(result: result) - } else { - let code: ErrorCode = .featureNotSupported - result(FlutterError(code: code.rawValue, message: "External purchase notice requires iOS 17.4+, macOS 14.4+, or tvOS 17.4+", details: nil)) - } + presentExternalPurchaseNoticeSheetIOS(result: result) case "presentExternalPurchaseLinkIOS": - if #available(iOS 16.0, macOS 14.0, tvOS 16.0, *) { - if let args = call.arguments as? [String: Any], - let url = args["url"] as? String { - presentExternalPurchaseLinkIOS(url: url, result: result) - } else if let url = call.arguments as? String { - presentExternalPurchaseLinkIOS(url: url, result: result) - } else { - let code: ErrorCode = .developerError - result(FlutterError(code: code.rawValue, message: "url required", details: nil)) - } + if let args = call.arguments as? [String: Any], + let url = args["url"] as? String { + presentExternalPurchaseLinkIOS(url: url, result: result) + } else if let url = call.arguments as? String { + presentExternalPurchaseLinkIOS(url: url, result: result) } else { - let code: ErrorCode = .featureNotSupported - result(FlutterError(code: code.rawValue, message: "External purchase link requires iOS 16.0+, macOS 14.0+, or tvOS 16.0+", details: nil)) + let code: ErrorCode = .developerError + result(FlutterError(code: code.rawValue, message: "url required", details: nil)) } - // MARK: - ExternalPurchaseCustomLink (iOS 18.1+) case "isEligibleForExternalPurchaseCustomLinkIOS": if #available(iOS 18.1, macOS 15.0, tvOS 18.1, *) { isEligibleForExternalPurchaseCustomLinkIOS(result: result) @@ -332,7 +230,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { getExternalPurchaseCustomLinkTokenIOS(tokenType: tokenType, result: result) } else { let code: ErrorCode = .developerError - result(FlutterError(code: code.rawValue, message: "tokenType required ('acquisition' or 'services')", details: nil)) + result(FlutterError(code: code.rawValue, message: "tokenType required", details: nil)) } } else { let code: ErrorCode = .featureNotSupported @@ -348,22 +246,13 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { showExternalPurchaseCustomLinkNoticeIOS(noticeType: noticeType, result: result) } else { let code: ErrorCode = .developerError - result(FlutterError(code: code.rawValue, message: "noticeType required ('browser')", details: nil)) + result(FlutterError(code: code.rawValue, message: "noticeType required", details: nil)) } } else { let code: ErrorCode = .featureNotSupported result(FlutterError(code: code.rawValue, message: "ExternalPurchaseCustomLink requires iOS 18.1+, macOS 15.0+, or tvOS 18.1+", details: nil)) } - case "verifyPurchaseWithProvider": - guard let args = call.arguments as? [String: Any], - let providerStr = args["provider"] as? String else { - let code: ErrorCode = .developerError - result(FlutterError(code: code.rawValue, message: "provider required", details: nil)) - return - } - verifyPurchaseWithProvider(args: args, provider: providerStr, result: result) - default: result(FlutterMethodNotImplemented) } @@ -431,13 +320,12 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { } } } - + if promotedProductToken == nil { promotedProductToken = OpenIapModule.shared.promotedProductListenerIOS { [weak self] productId in Task { @MainActor in guard let self = self else { return } FlutterIapLog.debug("promotedProductListenerIOS fired for: \(productId)") - // Emit event that Dart expects: name 'iap-promoted-product' with String payload self.channel?.invokeMethod("iap-promoted-product", arguments: productId) } } @@ -477,7 +365,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { self?.emitPurchaseUpdated(purchase, method: "purchase-updated") }, options: purchaseUpdatedListenerOptions) } - + private func removeOpenIapListeners() { if let token = purchaseUpdatedToken { OpenIapModule.shared.removeListener(token) } if let token = purchaseErrorToken { OpenIapModule.shared.removeListener(token) } @@ -678,28 +566,6 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { } } - private func beginRefundRequestIOS(sku: String, result: @escaping FlutterResult) { - FlutterIapLog.debug("beginRefundRequestIOS called for sku=\(sku)") - Task { @MainActor in - do { - let status = try await OpenIapModule.shared.beginRefundRequestIOS(sku: sku) - FlutterIapLog.result("beginRefundRequestIOS", value: status ?? "nil") - result(status) - } catch let purchaseError as PurchaseError { - FlutterIapLog.failure("beginRefundRequestIOS", error: purchaseError) - result(FlutterError( - code: purchaseError.code.rawValue, - message: purchaseError.message, - details: purchaseError.productId ?? sku - )) - } catch { - FlutterIapLog.failure("beginRefundRequestIOS", error: error) - let code: ErrorCode = .serviceError - result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) - } - } - } - @available(iOS 15.0, macOS 14.0, tvOS 15.0, *) private func showManageSubscriptionsIOS(result: @escaping FlutterResult) { FlutterIapLog.debug("showManageSubscriptionsIOS called") @@ -830,7 +696,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { } } } - + private func getAllTransactionsIOS(result: @escaping FlutterResult) { Task { @MainActor in do { @@ -847,7 +713,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { } } } - + private func clearTransactionIOS(result: @escaping FlutterResult) { FlutterIapLog.debug("clearTransactionIOS called") Task { @MainActor in @@ -921,7 +787,6 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { FlutterIapLog.debug("verifyPurchaseWithProvider called with provider: \(provider)") Task { @MainActor in do { - // Build props dictionary for OpenIAP var propsDict: [String: Any] = ["provider": provider] if let iapkit = args["iapkit"] as? [String: Any] { var iapkitDict: [String: Any] = [:] @@ -937,12 +802,10 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { propsDict["iapkit"] = iapkitDict } - // Use JSONSerialization + JSONDecoder let jsonData = try JSONSerialization.data(withJSONObject: propsDict) let props = try JSONDecoder().decode(VerifyPurchaseWithProviderProps.self, from: jsonData) let res = try await OpenIapModule.shared.verifyPurchaseWithProvider(props) - // Convert result to dictionary var payload: [String: Any] = [ "provider": res.provider.rawValue ] @@ -964,203 +827,9 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { } } - private func syncIOS(result: @escaping FlutterResult) { - FlutterIapLog.debug("syncIOS called") - Task { @MainActor in - do { - let success = try await OpenIapModule.shared.syncIOS() - FlutterIapLog.result("syncIOS", value: success) - result(success) - } catch let purchaseError as PurchaseError { - FlutterIapLog.failure("syncIOS", error: purchaseError) - result(FlutterError( - code: purchaseError.code.rawValue, - message: purchaseError.message, - details: purchaseError.productId - )) - } catch { - FlutterIapLog.failure("syncIOS", error: error) - let code: ErrorCode = .syncError - result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) - } - } - } - - private func subscriptionStatusIOS(sku: String, result: @escaping FlutterResult) { - FlutterIapLog.debug("subscriptionStatusIOS called for sku=\(sku)") - Task { @MainActor in - do { - let statuses = try await OpenIapModule.shared.subscriptionStatusIOS(sku: sku) - let payload = statuses.map { - FlutterIapHelper.sanitizeDictionary(OpenIapSerialization.encode($0)) - } - FlutterIapLog.result("subscriptionStatusIOS", value: payload) - result(payload) - } catch let purchaseError as PurchaseError { - FlutterIapLog.failure("subscriptionStatusIOS", error: purchaseError) - result(FlutterError( - code: purchaseError.code.rawValue, - message: purchaseError.message, - details: purchaseError.productId ?? sku - )) - } catch { - FlutterIapLog.failure("subscriptionStatusIOS", error: error) - let code: ErrorCode = .serviceError - result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) - } - } - } - - @available(iOS 16.0, macOS 14.0, tvOS 16.0, *) - private func getAppTransactionIOS(result: @escaping FlutterResult) { - FlutterIapLog.debug("getAppTransactionIOS called") - Task { @MainActor in - do { - if let tx = try await OpenIapModule.shared.getAppTransactionIOS() { - let payload = FlutterIapHelper.sanitizeDictionary(OpenIapSerialization.encode(tx)) - FlutterIapLog.result("getAppTransactionIOS", value: payload) - result(payload) - } else { - result(nil) - } - } catch let purchaseError as PurchaseError { - FlutterIapLog.failure("getAppTransactionIOS", error: purchaseError) - result(FlutterError( - code: purchaseError.code.rawValue, - message: purchaseError.message, - details: purchaseError.productId - )) - } catch { - FlutterIapLog.failure("getAppTransactionIOS", error: error) - let code: ErrorCode = .serviceError - result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) - } - } - } - - // MARK: - StoreKit 2 entitlement queries (iOS 15.0+) - - private func currentEntitlementIOS(sku: String, result: @escaping FlutterResult) { - FlutterIapLog.debug("currentEntitlementIOS called for sku=\(sku)") - Task { @MainActor in - do { - if let purchase = try await OpenIapModule.shared.currentEntitlementIOS(sku: sku) { - let payload = FlutterIapHelper.sanitizeDictionary(OpenIapSerialization.encode(purchase)) - FlutterIapLog.result("currentEntitlementIOS", value: payload) - result(payload) - } else { - result(nil) - } - } catch let purchaseError as PurchaseError { - FlutterIapLog.failure("currentEntitlementIOS", error: purchaseError) - result(FlutterError( - code: purchaseError.code.rawValue, - message: purchaseError.message, - details: purchaseError.productId ?? sku - )) - } catch { - FlutterIapLog.failure("currentEntitlementIOS", error: error) - let code: ErrorCode = .serviceError - result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) - } - } - } - - private func latestTransactionIOS(sku: String, result: @escaping FlutterResult) { - FlutterIapLog.debug("latestTransactionIOS called for sku=\(sku)") - Task { @MainActor in - do { - if let purchase = try await OpenIapModule.shared.latestTransactionIOS(sku: sku) { - let payload = FlutterIapHelper.sanitizeDictionary(OpenIapSerialization.encode(purchase)) - FlutterIapLog.result("latestTransactionIOS", value: payload) - result(payload) - } else { - result(nil) - } - } catch let purchaseError as PurchaseError { - FlutterIapLog.failure("latestTransactionIOS", error: purchaseError) - result(FlutterError( - code: purchaseError.code.rawValue, - message: purchaseError.message, - details: purchaseError.productId ?? sku - )) - } catch { - FlutterIapLog.failure("latestTransactionIOS", error: error) - let code: ErrorCode = .serviceError - result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) - } - } - } - - private func isTransactionVerifiedIOS(sku: String, result: @escaping FlutterResult) { - FlutterIapLog.debug("isTransactionVerifiedIOS called for sku=\(sku)") - Task { @MainActor in - do { - let verified = try await OpenIapModule.shared.isTransactionVerifiedIOS(sku: sku) - FlutterIapLog.result("isTransactionVerifiedIOS", value: verified) - result(verified) - } catch let purchaseError as PurchaseError { - FlutterIapLog.failure("isTransactionVerifiedIOS", error: purchaseError) - result(FlutterError( - code: purchaseError.code.rawValue, - message: purchaseError.message, - details: purchaseError.productId ?? sku - )) - } catch { - FlutterIapLog.failure("isTransactionVerifiedIOS", error: error) - let code: ErrorCode = .serviceError - result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) - } - } - } - - private func getTransactionJwsIOS(sku: String, result: @escaping FlutterResult) { - FlutterIapLog.debug("getTransactionJwsIOS called for sku=\(sku)") - Task { @MainActor in - do { - let jws = try await OpenIapModule.shared.getTransactionJwsIOS(sku: sku) - FlutterIapLog.result("getTransactionJwsIOS", value: jws == nil ? nil : "") - result(jws) - } catch let purchaseError as PurchaseError { - FlutterIapLog.failure("getTransactionJwsIOS", error: purchaseError) - result(FlutterError( - code: purchaseError.code.rawValue, - message: purchaseError.message, - details: purchaseError.productId ?? sku - )) - } catch { - FlutterIapLog.failure("getTransactionJwsIOS", error: error) - let code: ErrorCode = .serviceError - result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) - } - } - } - - private func getReceiptDataIOS(result: @escaping FlutterResult) { - FlutterIapLog.debug("getReceiptDataIOS called") - Task { @MainActor in - do { - let receipt = try await OpenIapModule.shared.getReceiptDataIOS() - FlutterIapLog.result("getReceiptDataIOS", value: receipt == nil ? nil : "") - result(receipt) - } catch let purchaseError as PurchaseError { - FlutterIapLog.failure("getReceiptDataIOS", error: purchaseError) - result(FlutterError( - code: purchaseError.code.rawValue, - message: purchaseError.message, - details: purchaseError.productId - )) - } catch { - FlutterIapLog.failure("getReceiptDataIOS", error: error) - let code: ErrorCode = .serviceError - result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) - } - } - } - - // MARK: - External Purchase Notice (iOS 17.4+) + // MARK: - Alternative Billing (iOS 18.2+) - @available(iOS 17.4, macOS 14.4, tvOS 17.4, *) + @available(iOS 18.2, macOS 14.0, tvOS 18.2, *) private func canPresentExternalPurchaseNoticeIOS(result: @escaping FlutterResult) { FlutterIapLog.debug("canPresentExternalPurchaseNoticeIOS called") Task { @MainActor in @@ -1177,7 +846,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { } } - @available(iOS 17.4, macOS 14.4, tvOS 17.4, *) + @available(iOS 18.2, macOS 14.0, tvOS 18.2, *) private func presentExternalPurchaseNoticeSheetIOS(result: @escaping FlutterResult) { FlutterIapLog.debug("presentExternalPurchaseNoticeSheetIOS called") Task { @MainActor in @@ -1290,11 +959,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { public class FlutterInappPurchasePluginLegacy: NSObject, FlutterPlugin { public static func register(with registrar: FlutterPluginRegistrar) { if #unavailable(iOS 15.0, macOS 14.0, tvOS 15.0) { - #if canImport(FlutterMacOS) let channel = FlutterMethodChannel(name: "flutter_inapp", binaryMessenger: registrar.messenger) - #else - let channel = FlutterMethodChannel(name: "flutter_inapp", binaryMessenger: registrar.messenger()) - #endif let instance = FlutterInappPurchasePluginLegacy() registrar.addMethodCallDelegate(instance, channel: channel) } From 9d4d97e267e4fa6f43aa1a6a91da7aa1ba787fe1 Mon Sep 17 00:00:00 2001 From: alihassan143 Date: Tue, 19 May 2026 21:54:33 +0500 Subject: [PATCH 03/10] feat(flutter_inapp_purchase): add tvOS 15.0 platform to Package.swift --- libraries/flutter_inapp_purchase/Package.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/libraries/flutter_inapp_purchase/Package.swift b/libraries/flutter_inapp_purchase/Package.swift index c8ea4bbb3..93e0ca058 100644 --- a/libraries/flutter_inapp_purchase/Package.swift +++ b/libraries/flutter_inapp_purchase/Package.swift @@ -7,6 +7,7 @@ let package = Package( platforms: [ .iOS("15.0"), .macOS("14.0"), + .tvOS("15.0"), ], products: [ .library(name: "flutter_inapp_purchase", targets: ["flutter_inapp_purchase"]), From 5c184a07d7fc39bffaf70d6a1b38a805658f0140 Mon Sep 17 00:00:00 2001 From: alihassan143 Date: Tue, 19 May 2026 21:55:30 +0500 Subject: [PATCH 04/10] revert: remove tvOS platform, keep only iOS and macOS --- libraries/flutter_inapp_purchase/Package.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/libraries/flutter_inapp_purchase/Package.swift b/libraries/flutter_inapp_purchase/Package.swift index 93e0ca058..c8ea4bbb3 100644 --- a/libraries/flutter_inapp_purchase/Package.swift +++ b/libraries/flutter_inapp_purchase/Package.swift @@ -7,7 +7,6 @@ let package = Package( platforms: [ .iOS("15.0"), .macOS("14.0"), - .tvOS("15.0"), ], products: [ .library(name: "flutter_inapp_purchase", targets: ["flutter_inapp_purchase"]), From aaf5b612876011fe9098520f17345c0495fa6d13 Mon Sep 17 00:00:00 2001 From: alihassan143 Date: Tue, 2 Jun 2026 22:04:39 +0500 Subject: [PATCH 05/10] feat(flutter): add SPM support for iOS and macOS Restructure iOS and macOS Swift sources into SPM-compatible layout under flutter_inapp_purchase/Sources/flutter_inapp_purchase/. Add Package.swift manifests for both platforms, update podspecs to point to the new source paths, and wire up Xcode projects in the example app to use FlutterGeneratedPluginSwiftPackage. Fix missing env asset that blocked macOS and iOS example builds. --- .../flutter_inapp_purchase/Package.swift | 11 ++--- .../flutter_inapp_purchase/example/.gitignore | 3 ++ .../ios/Runner.xcodeproj/project.pbxproj | 30 +++++++++++- .../xcshareddata/xcschemes/Runner.xcscheme | 18 ++++++++ .../example/ios/Runner/AppDelegate.h | 2 +- .../example/ios/Runner/AppDelegate.m | 10 ++-- .../example/ios/Runner/Info.plist | 21 +++++++++ .../macos/Runner.xcodeproj/project.pbxproj | 46 +++++++++++-------- .../xcshareddata/xcschemes/Runner.xcscheme | 18 ++++++++ .../ios/Assets/.gitkeep | 0 .../ios/flutter_inapp_purchase.podspec | 2 +- .../ios/flutter_inapp_purchase/Package.swift | 27 +++++++++++ .../FlutterIapHelper.swift | 0 .../FlutterIapLog.swift | 0 .../FlutterInappPurchasePlugin.swift | 0 .../macos/flutter_inapp_purchase.podspec | 2 +- .../flutter_inapp_purchase/Package.swift | 27 +++++++++++ .../FlutterIapHelper.swift | 0 .../FlutterIapLog.swift | 0 .../FlutterInappPurchasePlugin.swift | 0 20 files changed, 184 insertions(+), 33 deletions(-) delete mode 100644 libraries/flutter_inapp_purchase/ios/Assets/.gitkeep create mode 100644 libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Package.swift rename libraries/flutter_inapp_purchase/ios/{Classes => flutter_inapp_purchase/Sources/flutter_inapp_purchase}/FlutterIapHelper.swift (100%) rename libraries/flutter_inapp_purchase/ios/{Classes => flutter_inapp_purchase/Sources/flutter_inapp_purchase}/FlutterIapLog.swift (100%) rename libraries/flutter_inapp_purchase/ios/{Classes => flutter_inapp_purchase/Sources/flutter_inapp_purchase}/FlutterInappPurchasePlugin.swift (100%) create mode 100644 libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Package.swift rename libraries/flutter_inapp_purchase/macos/{Classes => flutter_inapp_purchase/Sources/flutter_inapp_purchase}/FlutterIapHelper.swift (100%) rename libraries/flutter_inapp_purchase/macos/{Classes => flutter_inapp_purchase/Sources/flutter_inapp_purchase}/FlutterIapLog.swift (100%) rename libraries/flutter_inapp_purchase/macos/{Classes => flutter_inapp_purchase/Sources/flutter_inapp_purchase}/FlutterInappPurchasePlugin.swift (100%) diff --git a/libraries/flutter_inapp_purchase/Package.swift b/libraries/flutter_inapp_purchase/Package.swift index c8ea4bbb3..1083fba8a 100644 --- a/libraries/flutter_inapp_purchase/Package.swift +++ b/libraries/flutter_inapp_purchase/Package.swift @@ -9,13 +9,10 @@ let package = Package( .macOS("14.0"), ], products: [ - .library(name: "flutter_inapp_purchase", targets: ["flutter_inapp_purchase"]), + .library(name: "flutter-inapp-purchase", targets: ["flutter_inapp_purchase"]) ], dependencies: [ - .package( - url: "https://github.com/hyodotdev/openiap.git", - exact: "2.2.1" - ), + .package(url: "https://github.com/hyodotdev/openiap.git", from: "2.2.1"), ], targets: [ .target( @@ -23,7 +20,7 @@ let package = Package( dependencies: [ .product(name: "OpenIAP", package: "openiap"), ], - path: "ios/Classes" - ), + path: "ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase" + ) ] ) diff --git a/libraries/flutter_inapp_purchase/example/.gitignore b/libraries/flutter_inapp_purchase/example/.gitignore index dee655cc4..3dc945995 100644 --- a/libraries/flutter_inapp_purchase/example/.gitignore +++ b/libraries/flutter_inapp_purchase/example/.gitignore @@ -7,3 +7,6 @@ build/ .flutter-plugins + +# Local env file with API keys - copy from env.example and fill in values +env diff --git a/libraries/flutter_inapp_purchase/example/ios/Runner.xcodeproj/project.pbxproj b/libraries/flutter_inapp_purchase/example/ios/Runner.xcodeproj/project.pbxproj index 21541b124..55a7267ec 100644 --- a/libraries/flutter_inapp_purchase/example/ios/Runner.xcodeproj/project.pbxproj +++ b/libraries/flutter_inapp_purchase/example/ios/Runner.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 2D5378271FAD1A9400D5DBA9 /* StoreKit.storekit in Resources */ = {isa = PBXBuildFile; fileRef = 2D5378251FAD1A9400D5DBA9 /* StoreKit.storekit */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 3CCED4E62E30A89200CE4F79 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3CCED4E52E30A89200CE4F79 /* StoreKit.framework */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; 9740EEB41CF90195004384FC /* Debug.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = 9740EEB21CF90195004384FC /* Debug.xcconfig */; }; 9740EEB51CF90195004384FC /* Generated.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = 9740EEB31CF90195004384FC /* Generated.xcconfig */; }; 978B8F6F1D3862AE00F588F7 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */; }; @@ -42,6 +43,9 @@ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 3CCED4E52E30A89200CE4F79 /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; }; 49A6FFBAD3488E7D435E2D63 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 784666492D4C4C64000A1A5F /* FlutterFramework */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterFramework; path = Flutter/ephemeral/Packages/.packages/FlutterFramework; sourceTree = ""; }; + 78DABEA22ED26510000E7860 /* flutter_inapp_purchase */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = flutter_inapp_purchase; path = ../../ios/flutter_inapp_purchase; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 7AFFD8ED1D35381100E5BB4D /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; @@ -62,6 +66,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, 3CCED4E62E30A89200CE4F79 /* StoreKit.framework in Frameworks */, 079B74431F6B5F836BD33DBC /* Pods_Runner.framework in Frameworks */, ); @@ -82,6 +87,9 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( + 78DABEA22ED26510000E7860 /* flutter_inapp_purchase */, + 784666492D4C4C64000A1A5F /* FlutterFramework */, + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, @@ -165,6 +173,9 @@ dependencies = ( ); name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; @@ -198,6 +209,9 @@ Base, ); mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + ); productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; projectRoot = ""; @@ -248,12 +262,10 @@ ); inputPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh", - "${BUILT_PRODUCTS_DIR}/flutter_inapp_purchase/flutter_inapp_purchase.framework", "${BUILT_PRODUCTS_DIR}/openiap/OpenIAP.framework", ); name = "[CP] Embed Pods Frameworks"; outputPaths = ( - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/flutter_inapp_purchase.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/OpenIAP.framework", ); runOnlyForDeploymentPostprocessing = 0; @@ -511,6 +523,20 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; } diff --git a/libraries/flutter_inapp_purchase/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/libraries/flutter_inapp_purchase/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 1a629b631..3f2248af8 100644 --- a/libraries/flutter_inapp_purchase/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/libraries/flutter_inapp_purchase/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -5,6 +5,24 @@ + + + + + + + + + + #import -@interface AppDelegate : FlutterAppDelegate +@interface AppDelegate : FlutterAppDelegate @end diff --git a/libraries/flutter_inapp_purchase/example/ios/Runner/AppDelegate.m b/libraries/flutter_inapp_purchase/example/ios/Runner/AppDelegate.m index 59a72e90b..64973b989 100644 --- a/libraries/flutter_inapp_purchase/example/ios/Runner/AppDelegate.m +++ b/libraries/flutter_inapp_purchase/example/ios/Runner/AppDelegate.m @@ -5,9 +5,13 @@ @implementation AppDelegate - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { - [GeneratedPluginRegistrant registerWithRegistry:self]; + // Override point for customization after application launch. - return [super application:application didFinishLaunchingWithOptions:launchOptions]; + return [super application:application + didFinishLaunchingWithOptions:launchOptions]; +} +- (void)didInitializeImplicitFlutterEngine: + (NSObject *)engineBridge { + [GeneratedPluginRegistrant registerWithRegistry:engineBridge.pluginRegistry]; } - @end diff --git a/libraries/flutter_inapp_purchase/example/ios/Runner/Info.plist b/libraries/flutter_inapp_purchase/example/ios/Runner/Info.plist index 4d62b3f9d..40fb0311e 100644 --- a/libraries/flutter_inapp_purchase/example/ios/Runner/Info.plist +++ b/libraries/flutter_inapp_purchase/example/ios/Runner/Info.plist @@ -52,5 +52,26 @@ cstr6suwn9.skadnetwork + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneDelegateClassName + FlutterSceneDelegate + UISceneConfigurationName + flutter + UISceneStoryboardFile + Main + + + + diff --git a/libraries/flutter_inapp_purchase/example/macos/Runner.xcodeproj/project.pbxproj b/libraries/flutter_inapp_purchase/example/macos/Runner.xcodeproj/project.pbxproj index 6e9e94798..5e1ce7fa4 100644 --- a/libraries/flutter_inapp_purchase/example/macos/Runner.xcodeproj/project.pbxproj +++ b/libraries/flutter_inapp_purchase/example/macos/Runner.xcodeproj/project.pbxproj @@ -28,6 +28,7 @@ 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; D0626713F80DD2BD49E91E5F /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B5637C7071BCB7780E65D7EB /* Pods_RunnerTests.framework */; }; /* End PBXBuildFile section */ @@ -78,6 +79,9 @@ 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 784666492D4C4C64000A1A5F /* FlutterFramework */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterFramework; path = ephemeral/Packages/.packages/FlutterFramework; sourceTree = ""; }; + 78DABEA22ED26510000E7860 /* flutter_inapp_purchase */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = flutter_inapp_purchase; path = ../../../macos/flutter_inapp_purchase; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; 8464B893525EE848211C4C1B /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; @@ -103,6 +107,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, 157D99104874B164A5EC7598 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -178,6 +183,9 @@ 33CEB47122A05771004F2AC0 /* Flutter */ = { isa = PBXGroup; children = ( + 78DABEA22ED26510000E7860 /* flutter_inapp_purchase */, + 784666492D4C4C64000A1A5F /* FlutterFramework */, + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, @@ -240,7 +248,6 @@ 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, - 45BEBE97C1FC6AE6D9522D26 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -248,6 +255,9 @@ 33CC11202044C79F0003C045 /* PBXTargetDependency */, ); name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); productName = Runner; productReference = 33CC10ED2044A3C60003C045 /* flutter_inapp_purchase_example.app */; productType = "com.apple.product-type.application"; @@ -292,6 +302,9 @@ Base, ); mainGroup = 33CC10E42044A3C60003C045; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + ); productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -384,23 +397,6 @@ shellPath = /bin/sh; shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; }; - 45BEBE97C1FC6AE6D9522D26 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; 49A51C8CD36F5E5A826ADFB4 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -797,6 +793,20 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 33CC10E52044A3C60003C045 /* Project object */; } diff --git a/libraries/flutter_inapp_purchase/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/libraries/flutter_inapp_purchase/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 4dfe7012e..0b7220cea 100644 --- a/libraries/flutter_inapp_purchase/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/libraries/flutter_inapp_purchase/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -5,6 +5,24 @@ + + + + + + + + + + '../LICENSE' } s.author = { 'Hyo Dev' => 'hyo@hyo.dev' } s.source = { :path => '.' } - s.source_files = 'Classes/**/*.swift' + s.source_files = 'flutter_inapp_purchase/Sources/flutter_inapp_purchase/**/*.swift' s.dependency 'Flutter' # Use OpenIAP Apple native module (via CocoaPods) s.dependency 'openiap', openiap_versions['apple'] diff --git a/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Package.swift b/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Package.swift new file mode 100644 index 000000000..9ec6af7eb --- /dev/null +++ b/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Package.swift @@ -0,0 +1,27 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "flutter_inapp_purchase", + platforms: [ + .iOS("15.0"), + ], + products: [ + .library(name: "flutter-inapp-purchase", targets: ["flutter_inapp_purchase"]) + ], + dependencies: [ + .package(name: "FlutterFramework", path: "../FlutterFramework"), + .package(url: "https://github.com/hyodotdev/openiap.git", from: "2.2.1"), + ], + targets: [ + .target( + name: "flutter_inapp_purchase", + dependencies: [ + .product(name: "FlutterFramework", package: "FlutterFramework"), + .product(name: "OpenIAP", package: "openiap"), + ], + path: "Sources/flutter_inapp_purchase" + ) + ] +) diff --git a/libraries/flutter_inapp_purchase/ios/Classes/FlutterIapHelper.swift b/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterIapHelper.swift similarity index 100% rename from libraries/flutter_inapp_purchase/ios/Classes/FlutterIapHelper.swift rename to libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterIapHelper.swift diff --git a/libraries/flutter_inapp_purchase/ios/Classes/FlutterIapLog.swift b/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterIapLog.swift similarity index 100% rename from libraries/flutter_inapp_purchase/ios/Classes/FlutterIapLog.swift rename to libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterIapLog.swift diff --git a/libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift b/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift similarity index 100% rename from libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift rename to libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift diff --git a/libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase.podspec b/libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase.podspec index 9c8ab76e9..5523feccf 100644 --- a/libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase.podspec +++ b/libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase.podspec @@ -22,7 +22,7 @@ In App Purchase plugin for flutter. This project has been forked by react-native s.license = { :file => '../LICENSE' } s.author = { 'Hyo Dev' => 'hyo@hyo.dev' } s.source = { :path => '.' } - s.source_files = 'Classes/**/*.swift' + s.source_files = 'flutter_inapp_purchase/Sources/flutter_inapp_purchase/**/*.swift' s.dependency 'FlutterMacOS' # Use OpenIAP Apple native module (via CocoaPods) s.dependency 'openiap', openiap_versions['apple'] diff --git a/libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Package.swift b/libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Package.swift new file mode 100644 index 000000000..87985b964 --- /dev/null +++ b/libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Package.swift @@ -0,0 +1,27 @@ +// swift-tools-version: 5.9 + +import PackageDescription + +let package = Package( + name: "flutter_inapp_purchase", + platforms: [ + .macOS("14.0"), + ], + products: [ + .library(name: "flutter-inapp-purchase", targets: ["flutter_inapp_purchase"]) + ], + dependencies: [ + .package(name: "FlutterFramework", path: "../FlutterFramework"), + .package(url: "https://github.com/hyodotdev/openiap.git", from: "2.2.1"), + ], + targets: [ + .target( + name: "flutter_inapp_purchase", + dependencies: [ + .product(name: "FlutterFramework", package: "FlutterFramework"), + .product(name: "OpenIAP", package: "openiap"), + ], + path: "Sources/flutter_inapp_purchase" + ) + ] +) diff --git a/libraries/flutter_inapp_purchase/macos/Classes/FlutterIapHelper.swift b/libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterIapHelper.swift similarity index 100% rename from libraries/flutter_inapp_purchase/macos/Classes/FlutterIapHelper.swift rename to libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterIapHelper.swift diff --git a/libraries/flutter_inapp_purchase/macos/Classes/FlutterIapLog.swift b/libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterIapLog.swift similarity index 100% rename from libraries/flutter_inapp_purchase/macos/Classes/FlutterIapLog.swift rename to libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterIapLog.swift diff --git a/libraries/flutter_inapp_purchase/macos/Classes/FlutterInappPurchasePlugin.swift b/libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift similarity index 100% rename from libraries/flutter_inapp_purchase/macos/Classes/FlutterInappPurchasePlugin.swift rename to libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift From 75c6b1a2e94870af5659e976edd527235e3ddae2 Mon Sep 17 00:00:00 2001 From: alihassan143 Date: Mon, 15 Jun 2026 22:48:53 +0500 Subject: [PATCH 06/10] fix(flutter): remove broken root Package.swift The root Package.swift pointed its target at ios sources that import Flutter/FlutterMacOS, but only declared OpenIAP as a dependency. This caused `swift build` to fail with "no such module 'Flutter'". Flutter's SPM integration discovers the platform-specific manifests in ios/flutter_inapp_purchase/ and macos/flutter_inapp_purchase/ directly; a root manifest is neither needed nor compilable without a local FlutterFramework path that only exists inside the Flutter build env. Co-Authored-By: Claude Sonnet 4.6 --- .../flutter_inapp_purchase/Package.swift | 26 ------------------- 1 file changed, 26 deletions(-) delete mode 100644 libraries/flutter_inapp_purchase/Package.swift diff --git a/libraries/flutter_inapp_purchase/Package.swift b/libraries/flutter_inapp_purchase/Package.swift deleted file mode 100644 index 1083fba8a..000000000 --- a/libraries/flutter_inapp_purchase/Package.swift +++ /dev/null @@ -1,26 +0,0 @@ -// swift-tools-version: 5.9 - -import PackageDescription - -let package = Package( - name: "flutter_inapp_purchase", - platforms: [ - .iOS("15.0"), - .macOS("14.0"), - ], - products: [ - .library(name: "flutter-inapp-purchase", targets: ["flutter_inapp_purchase"]) - ], - dependencies: [ - .package(url: "https://github.com/hyodotdev/openiap.git", from: "2.2.1"), - ], - targets: [ - .target( - name: "flutter_inapp_purchase", - dependencies: [ - .product(name: "OpenIAP", package: "openiap"), - ], - path: "ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase" - ) - ] -) From 14ff12cf8145bd336c00835ad004e8f183908373 Mon Sep 17 00:00:00 2001 From: alihassan143 Date: Mon, 15 Jun 2026 22:59:12 +0500 Subject: [PATCH 07/10] fix(flutter): use correct package name OpenIAP in platform SPM manifests Co-Authored-By: Claude Sonnet 4.6 --- .../ios/flutter_inapp_purchase/Package.swift | 2 +- .../macos/flutter_inapp_purchase/Package.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Package.swift b/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Package.swift index 9ec6af7eb..f54e792c9 100644 --- a/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Package.swift +++ b/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Package.swift @@ -19,7 +19,7 @@ let package = Package( name: "flutter_inapp_purchase", dependencies: [ .product(name: "FlutterFramework", package: "FlutterFramework"), - .product(name: "OpenIAP", package: "openiap"), + .product(name: "OpenIAP", package: "OpenIAP"), ], path: "Sources/flutter_inapp_purchase" ) diff --git a/libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Package.swift b/libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Package.swift index 87985b964..9a198039f 100644 --- a/libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Package.swift +++ b/libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Package.swift @@ -19,7 +19,7 @@ let package = Package( name: "flutter_inapp_purchase", dependencies: [ .product(name: "FlutterFramework", package: "FlutterFramework"), - .product(name: "OpenIAP", package: "openiap"), + .product(name: "OpenIAP", package: "OpenIAP"), ], path: "Sources/flutter_inapp_purchase" ) From c4b32e64ec82cd28101842d8685571e91d83bfd9 Mon Sep 17 00:00:00 2001 From: hyochan Date: Tue, 23 Jun 2026 23:34:44 +0900 Subject: [PATCH 08/10] fix(flutter): update parity audit paths --- scripts/audit-non-godot-parity.mjs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/scripts/audit-non-godot-parity.mjs b/scripts/audit-non-godot-parity.mjs index b74a45d17..6ada1c989 100644 --- a/scripts/audit-non-godot-parity.mjs +++ b/scripts/audit-non-godot-parity.mjs @@ -574,11 +574,16 @@ function checkFlutter() { "invokeMethod('deepLinkToSubscriptions')", 'deepLinkToSubscriptionsAndroid', ], 'Flutter deepLinkToSubscriptions bridge'); - expectIncludes('libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift', [ + const flutterIosPlugin = + 'libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift'; + const flutterMacosPlugin = + 'libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift'; + + expectIncludes(flutterIosPlugin, [ 'case "deepLinkToSubscriptions"', 'OpenIapModule.shared.deepLinkToSubscriptions(nil)', ], 'Flutter iOS deepLinkToSubscriptions bridge'); - expectIncludes('libraries/flutter_inapp_purchase/macos/Classes/FlutterInappPurchasePlugin.swift', [ + expectIncludes(flutterMacosPlugin, [ 'case "setPurchaseUpdatedListenerOptions"', 'case "deepLinkToSubscriptions"', 'case "getAllTransactionsIOS"', @@ -599,18 +604,18 @@ function checkFlutter() { ], 'Flutter Android must inherit Play Billing from openiap-google'); for (const nativePlugin of [ 'libraries/expo-iap/ios/ExpoIapModule.swift', - 'libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift', - 'libraries/flutter_inapp_purchase/macos/Classes/FlutterInappPurchasePlugin.swift', + flutterIosPlugin, + flutterMacosPlugin, ]) { expectNotIncludes(nativePlugin, [ 'OpenIapModule.shared.validateReceiptIOS', 'OpenIapModule.shared.getStorefrontIOS()', ], 'Flutter deprecated native OpenIAP calls'); } - expectIncludes('libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift', [ + expectIncludes(flutterIosPlugin, [ 'OpenIapModule.shared.requestPurchaseOnPromotedProductIOS()', ], 'Flutter iOS promoted purchase bridge'); - expectIncludes('libraries/flutter_inapp_purchase/macos/Classes/FlutterInappPurchasePlugin.swift', [ + expectIncludes(flutterMacosPlugin, [ 'OpenIapModule.shared.requestPurchaseOnPromotedProductIOS()', ], 'Flutter macOS promoted purchase bridge'); expectIncludes('libraries/react-native-iap/ios/HybridRnIap.swift', [ From 97a37e56726cd55eb9bc9b8f0b13640c96b701a2 Mon Sep 17 00:00:00 2001 From: hyochan Date: Tue, 23 Jun 2026 23:48:15 +0900 Subject: [PATCH 09/10] docs(flutter): document swiftpm install path --- .../flutter_inapp_purchase/CONTRIBUTING.md | 15 ++++-- libraries/flutter_inapp_purchase/README.md | 20 ++++++++ .../docs/src/pages/docs/setup/flutter.tsx | 48 ++++++++++++++++++- 3 files changed, 77 insertions(+), 6 deletions(-) diff --git a/libraries/flutter_inapp_purchase/CONTRIBUTING.md b/libraries/flutter_inapp_purchase/CONTRIBUTING.md index ba66eaaab..e67b99dbe 100644 --- a/libraries/flutter_inapp_purchase/CONTRIBUTING.md +++ b/libraries/flutter_inapp_purchase/CONTRIBUTING.md @@ -19,11 +19,16 @@ flutter pub get ### Platform-Specific Setup -#### iOS - -- This plugin uses the OpenIAP Apple native module via CocoaPods. See [openiap-versions.json](./openiap-versions.json) for the current version. -- After upgrading or cloning, run `pod install` in your iOS project (e.g., `example/ios`). -- Minimum iOS deployment target is `15.0` for StoreKit 2 support. +#### iOS and macOS + +- This plugin declares the OpenIAP Apple native module for both Swift Package Manager and CocoaPods. See [openiap-versions.json](./openiap-versions.json) for the current version. +- Flutter 3.44 and newer enables Swift Package Manager by default. When testing SwiftPM locally, run `flutter config --enable-swift-package-manager`, then build from `example`. +- CocoaPods remains supported for older Flutter projects or projects that disable SwiftPM. After upgrading or cloning, run `pod install` in each Apple example target you use: + ```bash + (cd example/ios && pod install) + (cd example/macos && pod install) + ``` +- Minimum deployment targets are iOS `15.0` and macOS `14.0`. #### Android diff --git a/libraries/flutter_inapp_purchase/README.md b/libraries/flutter_inapp_purchase/README.md index 1ad1ed646..24ea68893 100644 --- a/libraries/flutter_inapp_purchase/README.md +++ b/libraries/flutter_inapp_purchase/README.md @@ -24,6 +24,25 @@ flutter pub add flutter_inapp_purchase For manual `pubspec.yaml` edits, copy the current dependency from the [flutter_inapp_purchase pub.dev package page](https://pub.dev/packages/flutter_inapp_purchase). +### iOS/macOS Native Dependency Resolution + +No manual `Package.swift` or `Podfile` entry is required. On Flutter 3.44 and +newer, Swift Package Manager is enabled by default and the Flutter CLI resolves +the native OpenIAP dependency automatically when you run or build the app. + +Projects that disable Swift Package Manager, or projects using an older Flutter +toolchain, continue to use CocoaPods. Run `pod install` after `flutter pub get` +for each Apple target you use: + +```bash +(cd ios && pod install) + +# If your app also has a macOS target: +(cd macos && pod install) +``` + +Apple platform targets require iOS 15.0+ or macOS 14.0+. + ## 🔧 Quick Start ### Basic Usage @@ -61,6 +80,7 @@ flutter_inapp_purchase provides AI-friendly documentation for Cursor, GitHub Cop **[AI Assistants Guide](https://openiap.dev/docs/guides/ai-assistants)** Quick links: + - [llms.txt](https://openiap.dev/llms.txt) - Quick reference - [llms-full.txt](https://openiap.dev/llms-full.txt) - Full API reference diff --git a/packages/docs/src/pages/docs/setup/flutter.tsx b/packages/docs/src/pages/docs/setup/flutter.tsx index 6f4da4386..49e900fa9 100644 --- a/packages/docs/src/pages/docs/setup/flutter.tsx +++ b/packages/docs/src/pages/docs/setup/flutter.tsx @@ -14,7 +14,7 @@ function FlutterSetup() {

Flutter Setup

flutter_inapp_purchase provides in-app purchase support for - Flutter apps on iOS and Android. + Flutter apps on iOS, Android, and macOS.

+

+ iOS/macOS Native Dependency Manager + + # + +

+

+ The Dart install command above is the only package install step for + most apps. On Flutter 3.44 and newer, Swift Package Manager is enabled + by default, and the Flutter CLI resolves the native OpenIAP dependency + automatically when you run or build the app. +

+

+ Projects that disable Swift Package Manager, or projects using an + older Flutter toolchain, continue to use CocoaPods. In that case, run + CocoaPods after flutter pub get: +

+ + {`(cd ios && pod install) + +# If your app also has a macOS target: +(cd macos && pod install)`} + +

+ Do not add OpenIAP manually to your app's{' '} + Package.swift or Podfile; the Flutter plugin + declares the native dependency. +

+

iOS Configuration @@ -77,6 +106,23 @@ function FlutterSetup() { `} +

+ macOS Configuration + + # + +

+
    +
  • + Requires macOS 14.0+ +
  • +
  • + Enable In-App Purchase capability in Xcode: Target >{' '} + Signing & Capabilities >{' '} + + Capability > In-App Purchase +
  • +
+

Android Configuration From 853266079a31d2f2f4f26e8cf01499cf8df7b8ac Mon Sep 17 00:00:00 2001 From: hyochan Date: Wed, 24 Jun 2026 00:47:11 +0900 Subject: [PATCH 10/10] fix(flutter): harden swiftpm support --- .gitignore | 1 + knowledge/_claude-context/context.md | 31 +- knowledge/internal/04-platform-packages.md | 2 +- libraries/flutter_inapp_purchase/.gitignore | 3 +- .../example/ios/.gitignore | 2 + .../example/macos/.gitignore | 1 + llms-full.txt | 2215 +---------------- llms.txt | 232 +- packages/docs/public/llms-full.txt | 12 +- packages/docs/public/llms.txt | 10 +- scripts/agent/README.md | 6 +- scripts/agent/compile-context.ts | 63 +- scripts/audit-non-godot-parity.mjs | 48 + 13 files changed, 140 insertions(+), 2486 deletions(-) mode change 100644 => 120000 llms-full.txt mode change 100644 => 120000 llms.txt diff --git a/.gitignore b/.gitignore index 41d655840..578c1c52b 100644 --- a/.gitignore +++ b/.gitignore @@ -72,6 +72,7 @@ coverage/ tmp/ temp/ .build +.swiftpm # RAG Agent generated outputs (benchmark comparisons) scripts/agent/_generated/ diff --git a/knowledge/_claude-context/context.md b/knowledge/_claude-context/context.md index 6a85902b7..63e5afd8d 100644 --- a/knowledge/_claude-context/context.md +++ b/knowledge/_claude-context/context.md @@ -1,7 +1,7 @@ # OpenIAP Project Context > **Auto-generated for Claude Code** -> Last updated: 2026-06-15T14:57:16.333Z +> Last updated: 2026-06-23T15:25:38.042Z > > Usage: `claude --context knowledge/_claude-context/context.md` @@ -909,7 +909,7 @@ For every new/changed handler in the generated types, verify **all five** of the | -------------------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **react-native-iap** | `src/types.ts` (generated) | `src/index.ts` export (Nitro or composed TS) | `ios/HybridRnIap.swift` (iOS), `android/.../HybridRnIap.kt` (Android) | Not required (flat exports) | Mock stub in all 4 `mockIap` objects in `__tests__/` (per memory) | | **expo-iap** | `src/types.ts` (generated) | `src/modules/ios.ts` / `android.ts` export, re-exported from `src/index.ts` | `ios/ExpoIapModule.swift` `AsyncFunction`, `android/.../ExpoIapModule.kt` | Not required (flat exports) | `src/modules/__tests__/*.test.ts` | -| **flutter_inapp_purchase** | `lib/types.dart` (generated) | getter on `FlutterInappPurchase` in `lib/flutter_inapp_purchase.dart` | `case "":` in `ios/Classes/FlutterInappPurchasePlugin.swift`, Android plugin `onMethodCall` | `queryHandlers` / `mutationHandlers` / `subscriptionHandlers` bundles near the bottom of `flutter_inapp_purchase.dart` | Mock + test in `test/ios_methods_test.dart` (and the `errors_unit_test.dart` error-mapping test) | +| **flutter_inapp_purchase** | `lib/types.dart` (generated) | getter on `FlutterInappPurchase` in `lib/flutter_inapp_purchase.dart` | `case "":` in `ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift` and `macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift`, Android plugin `onMethodCall` | `queryHandlers` / `mutationHandlers` / `subscriptionHandlers` bundles near the bottom of `flutter_inapp_purchase.dart` | Mock + test in `test/ios_methods_test.dart` (and the `errors_unit_test.dart` error-mapping test) | | **kmp-iap** | `library/src/commonMain/.../openiap/Types.kt` (generated interface) | exposed via `KmpInAppPurchase` / `kmpIapInstance` | `library/src/iosMain/.../InAppPurchaseIOS.kt` — must call `openIapModule.WithCompletion { ... }`, **never** `throw UnsupportedOperationException` | Not required (interface dispatch) | `library/src/commonTest/` if testable cross-platform | | **godot-iap** | `addons/godot-iap/types.gd` (generated) | public `snake_case` function in `addons/godot-iap/godot_iap.gd` | `ios-gdextension/Sources/GodotIap/GodotIap.swift` (iOS), `android/src/main/java/.../GodotIap.java` (Android) | Not required | Manual testing — no automated test suite yet | | **maui-iap** | `src/OpenIap.Maui/Types.cs` (generated) | `OpenIap.QueryResolver` / `MutationResolver` interfaces in `Types.cs`; `IOpenIap` adds the listener-stream contract; static facade is `OpenIap.Maui.OpenIapClient` (`OpenIap.Maui.Iap` remains as a legacy shim); IAPKit helpers mirror TypeScript via `OpenIapClient.KitApi(...)`, `OpenIapClient.ConnectWebhookStream(...)`, `OpenIapClient.ParseWebhookEventData(...)`, and `OpenIapClient.WebhookEventTypes` | Android: `OpenIapMauiModule.kt` in `libraries/maui-iap/android/openiap/` (JSON-shaped Java facade over `packages/google`), bound by `OpenIap.Maui.Bindings.Android.csproj`, consumed by `Platforms/Android/OpenIapAndroid.cs`. Google Billing / Play Services / Gson / AndroidX / Kotlin dependencies must stay NuGet `PackageReference`s, not fat-bundled AARs. iOS / macCatalyst: existing `OpenIapModule+ObjC.swift` bridge in `packages/apple`, bound by hand-written `OpenIap.Maui.Bindings.iOS/ApiDefinition.cs`, consumed by `Platforms/iOS/OpenIapIOS.cs` (+ subclass `OpenIapMacCatalyst`). | Not required (interface dispatch) | Example app `libraries/maui-iap/example/OpenIap.Maui.Example` builds for net9.0-android / net9.0-ios / net9.0-maccatalyst; package CI builds net9/net10 shared, Android, iOS, and macCatalyst TFMs (manual device testing for purchase flow); no xUnit tests yet | @@ -1597,6 +1597,33 @@ Godot, KMP, or MAUI versions; that manifest tracks only `spec`, `google`, and - No trailing period - Use imperative mood ("add" not "added") +## Pull Request Preview Recordings + +Every PR that introduces a new feature, visible behavior change, UI change, +documentation page, example flow, or developer workflow must include a preview +recording before it is handed off for review. + +Requirements: + +- Record the actual changed surface after the implementation is complete. Use + the Codex Chrome Extension for web/docs/dashboard previews whenever a browser + can render the change. +- Compress the final video to **under 10 MB** so GitHub accepts it reliably. + Prefer H.264 MP4 with a modest resolution / frame rate when the raw capture is + too large. +- Upload the compressed recording to the GitHub PR as a PR body attachment or a + clearly labeled attached `Preview` comment. +- Do not commit one-off PR preview recordings. Only commit preview media when + the media itself is a product documentation or example asset that should ship + with the repository. +- Link or embed the uploaded preview in the PR body or a clearly labeled + `Preview` PR comment. +- If the change has no visual or interactive surface, include a short note in + the PR explaining why a recording was not applicable and show the most useful + terminal/API proof instead. +- Do not upload secrets, private customer data, unreleased credentials, or local + browser profile details in previews. Redact or use test fixtures. + ### With Tag and Scope When a commit targets a specific package or library, include the scope: diff --git a/knowledge/internal/04-platform-packages.md b/knowledge/internal/04-platform-packages.md index afc3d6605..71cb99294 100644 --- a/knowledge/internal/04-platform-packages.md +++ b/knowledge/internal/04-platform-packages.md @@ -171,7 +171,7 @@ For every new/changed handler in the generated types, verify **all five** of the | -------------------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **react-native-iap** | `src/types.ts` (generated) | `src/index.ts` export (Nitro or composed TS) | `ios/HybridRnIap.swift` (iOS), `android/.../HybridRnIap.kt` (Android) | Not required (flat exports) | Mock stub in all 4 `mockIap` objects in `__tests__/` (per memory) | | **expo-iap** | `src/types.ts` (generated) | `src/modules/ios.ts` / `android.ts` export, re-exported from `src/index.ts` | `ios/ExpoIapModule.swift` `AsyncFunction`, `android/.../ExpoIapModule.kt` | Not required (flat exports) | `src/modules/__tests__/*.test.ts` | -| **flutter_inapp_purchase** | `lib/types.dart` (generated) | getter on `FlutterInappPurchase` in `lib/flutter_inapp_purchase.dart` | `case "":` in `ios/Classes/FlutterInappPurchasePlugin.swift`, Android plugin `onMethodCall` | `queryHandlers` / `mutationHandlers` / `subscriptionHandlers` bundles near the bottom of `flutter_inapp_purchase.dart` | Mock + test in `test/ios_methods_test.dart` (and the `errors_unit_test.dart` error-mapping test) | +| **flutter_inapp_purchase** | `lib/types.dart` (generated) | getter on `FlutterInappPurchase` in `lib/flutter_inapp_purchase.dart` | `case "":` in `ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift` and `macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift`, Android plugin `onMethodCall` | `queryHandlers` / `mutationHandlers` / `subscriptionHandlers` bundles near the bottom of `flutter_inapp_purchase.dart` | Mock + test in `test/ios_methods_test.dart` (and the `errors_unit_test.dart` error-mapping test) | | **kmp-iap** | `library/src/commonMain/.../openiap/Types.kt` (generated interface) | exposed via `KmpInAppPurchase` / `kmpIapInstance` | `library/src/iosMain/.../InAppPurchaseIOS.kt` — must call `openIapModule.WithCompletion { ... }`, **never** `throw UnsupportedOperationException` | Not required (interface dispatch) | `library/src/commonTest/` if testable cross-platform | | **godot-iap** | `addons/godot-iap/types.gd` (generated) | public `snake_case` function in `addons/godot-iap/godot_iap.gd` | `ios-gdextension/Sources/GodotIap/GodotIap.swift` (iOS), `android/src/main/java/.../GodotIap.java` (Android) | Not required | Manual testing — no automated test suite yet | | **maui-iap** | `src/OpenIap.Maui/Types.cs` (generated) | `OpenIap.QueryResolver` / `MutationResolver` interfaces in `Types.cs`; `IOpenIap` adds the listener-stream contract; static facade is `OpenIap.Maui.OpenIapClient` (`OpenIap.Maui.Iap` remains as a legacy shim); IAPKit helpers mirror TypeScript via `OpenIapClient.KitApi(...)`, `OpenIapClient.ConnectWebhookStream(...)`, `OpenIapClient.ParseWebhookEventData(...)`, and `OpenIapClient.WebhookEventTypes` | Android: `OpenIapMauiModule.kt` in `libraries/maui-iap/android/openiap/` (JSON-shaped Java facade over `packages/google`), bound by `OpenIap.Maui.Bindings.Android.csproj`, consumed by `Platforms/Android/OpenIapAndroid.cs`. Google Billing / Play Services / Gson / AndroidX / Kotlin dependencies must stay NuGet `PackageReference`s, not fat-bundled AARs. iOS / macCatalyst: existing `OpenIapModule+ObjC.swift` bridge in `packages/apple`, bound by hand-written `OpenIap.Maui.Bindings.iOS/ApiDefinition.cs`, consumed by `Platforms/iOS/OpenIapIOS.cs` (+ subclass `OpenIapMacCatalyst`). | Not required (interface dispatch) | Example app `libraries/maui-iap/example/OpenIap.Maui.Example` builds for net9.0-android / net9.0-ios / net9.0-maccatalyst; package CI builds net9/net10 shared, Android, iOS, and macCatalyst TFMs (manual device testing for purchase flow); no xUnit tests yet | diff --git a/libraries/flutter_inapp_purchase/.gitignore b/libraries/flutter_inapp_purchase/.gitignore index f7b63c762..8eef611b0 100644 --- a/libraries/flutter_inapp_purchase/.gitignore +++ b/libraries/flutter_inapp_purchase/.gitignore @@ -17,6 +17,7 @@ flutter_export_environment* launch.json .flutter-plugins-* +.swiftpm/ coverage # Node.js @@ -47,4 +48,4 @@ example/android/app/.cxx/ .env.* !.env.example example/env -!example/env.example \ No newline at end of file +!example/env.example diff --git a/libraries/flutter_inapp_purchase/example/ios/.gitignore b/libraries/flutter_inapp_purchase/example/ios/.gitignore index 79cc4da80..8b4f7ad29 100644 --- a/libraries/flutter_inapp_purchase/example/ios/.gitignore +++ b/libraries/flutter_inapp_purchase/example/ios/.gitignore @@ -38,8 +38,10 @@ Icon? /Flutter/flutter_assets/ /Flutter/App.framework /Flutter/Flutter.framework +/Flutter/ephemeral/Packages/ /Flutter/Generated.xcconfig /ServiceDefinitions.json Pods/ .symlinks/ +**/xcshareddata/swiftpm/ diff --git a/libraries/flutter_inapp_purchase/example/macos/.gitignore b/libraries/flutter_inapp_purchase/example/macos/.gitignore index 746adbb6b..36bc3c5b0 100644 --- a/libraries/flutter_inapp_purchase/example/macos/.gitignore +++ b/libraries/flutter_inapp_purchase/example/macos/.gitignore @@ -5,3 +5,4 @@ # Xcode-related **/dgph **/xcuserdata/ +**/xcshareddata/swiftpm/ diff --git a/llms-full.txt b/llms-full.txt deleted file mode 100644 index 86d8fb5c1..000000000 --- a/llms-full.txt +++ /dev/null @@ -1,2214 +0,0 @@ -# OpenIAP Complete Reference - -> OpenIAP: Unified in-app purchase specification for iOS & Android -> Documentation: https://openiap.dev -> Quick Reference: https://openiap.dev/llms.txt -> Generated: 2026-06-15T14:57:16.350Z - -## Table of Contents -1. Installation -2. Core APIs (Connection, Products, Purchase, Subscription) -3. Platform-Specific APIs (iOS, Android) -4. Types Reference -5. Error Codes & Handling -6. Implementation Patterns - ---- - -## 1. Installation - -### React Native / Expo -```bash -# expo-iap (Expo projects - recommended) -npx expo install expo-iap - -# react-native-iap (React Native CLI) -npm install react-native-iap -cd ios && pod install -``` - -### Swift (iOS/macOS) -```swift -// Swift Package Manager -.package(url: "https://github.com/hyodotdev/openiap.git", from: "2.2.1") - -// CocoaPods -pod 'openiap', '~> 2.2.1' -``` - -### Kotlin (Android) -```kotlin -// Gradle (build.gradle.kts) -implementation("io.github.hyochan.openiap:openiap-google:2.2.1") - -// For Meta Horizon OS -implementation("io.github.hyochan.openiap:openiap-google-horizon:2.2.1") -``` - -### Flutter -```bash -flutter pub add flutter_inapp_purchase -``` - -### Godot -Download `godot-iap-2.3.1.zip` from GitHub Releases, extract it to -`addons/godot-iap/`, then enable the plugin in Project Settings. - -### Kotlin Multiplatform -```kotlin -dependencies { - implementation("io.github.hyochan:kmp-iap:2.3.1") -} -``` - -Use the latest version from Maven Central: -https://central.sonatype.com/artifact/io.github.hyochan/kmp-iap - -### .NET MAUI -```bash -dotnet add package OpenIap.Maui -``` - -Current NuGet package version: 1.1.1 - -Requires .NET 9 or .NET 10, the MAUI workload, iOS 15.0+, and Android API 24+. - ---- - -## Framework SDK Implementations - -### react-native-iap -- Package: `react-native-iap` on npm. -- Implementation: Nitro Modules wrapper over `packages/apple` and - `packages/google`. -- Public surface: generated OpenIAP types plus `useIAP`, listener helpers, - and platform-suffixed iOS/Android APIs. -- Example app: `libraries/react-native-iap/example`. - -### expo-iap -- Package: `expo-iap` on npm. -- Implementation: Expo Modules wrapper over the same native OpenIAP packages. -- Public surface: same hook, listener, query, mutation, and platform API - shape as `react-native-iap`, adapted for Expo managed/bare workflows. -- Example app: `libraries/expo-iap/example`. - -### flutter_inapp_purchase -- Package: `flutter_inapp_purchase` on pub.dev. -- Implementation: Dart API plus generated `types.dart`, bridged to native - iOS and Android method channels. -- Public surface: singleton `FlutterInappPurchase.instance`, typed - `fetchProducts`, purchase streams, and resolver-style methods. - -### godot-iap -- Package: `godot-iap` for Godot 4.x. -- Implementation: GDScript API with generated `types.gd`, plus native iOS - GDExtension and Android AAR plugin. -- Public surface: snake_case functions and Godot signals matching OpenIAP. - -### kmp-iap -- Package: `io.github.hyochan:kmp-iap`. -- Implementation: Kotlin Multiplatform common API with Flow-based events, - Android implementation, and iOS cinterop through the OpenIAP ObjC facade. -- Public surface: `KmpIAP` / shared instance resolver methods and flows. - -### maui-iap -- Package: `OpenIap.Maui` on NuGet. -- Distribution: single public NuGet package. The Android/iOS binding projects - are private implementation details and are flattened into `OpenIap.Maui` - instead of being published as separate package dependencies. -- Implementation: .NET MAUI projection with generated `Types.cs`, a static - `OpenIapClient.Instance` facade, legacy `Iap` shim, `IOpenIap` - observables, and per-platform resolvers. -- iOS/macCatalyst bridge: .NET-for-iOS binding over - `OpenIAP.xcframework` and `OpenIapModule+ObjC.swift`; NuGet consumers get - the official `OpenIap.Maui.Bindings.iOS.resources.zip` sidecar so no - app-level `NativeReference` is required. -- Android bridge: Xamarin.Android binding over the MAUI-owned - `openiap-release.aar`, which wraps the unbound - `openiap-play-release.aar` runtime dependency. Google Billing, Play - Services, Gson, AndroidX, and Kotlin Android libraries stay as NuGet - `PackageReference` dependencies so consuming apps can deduplicate them. -- Public surface: `QueryResolver`, `MutationResolver`, and `IOpenIap` - implemented by `OpenIapIOS`, `OpenIapAndroid`, and `OpenIapMacCatalyst`; - IAPKit helpers mirror the TypeScript SDKs via - `OpenIapClient.KitApi(...)`, `OpenIapClient.ConnectWebhookStream(...)`, - `OpenIapClient.ParseWebhookEventData(...)`, and - `OpenIapClient.WebhookEventTypes`. -- Example app: `libraries/maui-iap/example/OpenIap.Maui.Example`, mirroring - the `expo-iap` example flows. - ---- - -## Minimal Usage by Framework - -### React Native / Expo -```typescript -import { useIAP } from 'expo-iap'; // or 'react-native-iap' - -const { connected, fetchProducts, requestPurchase, finishTransaction } = useIAP({ - onPurchaseSuccess: async (purchase) => { - await finishTransaction({ purchase, isConsumable: true }); - }, -}); - -await fetchProducts({ skus: ['premium'], type: 'in-app' }); -await requestPurchase({ - request: { ios: { sku: 'premium' }, android: { skus: ['premium'] } }, - type: 'in-app', -}); -``` - -### Flutter -```dart -final iap = FlutterInappPurchase.instance; -await iap.initConnection(); -final products = await iap.fetchProducts( - skus: ['premium'], - type: ProductQueryType.inApp, -); -iap.purchaseUpdated.listen((purchase) async { - await iap.finishTransaction(purchase, isConsumable: true); -}); -``` - -### Godot -```gdscript -GodotIapPlugin.purchase_updated.connect(_on_purchase_updated) -GodotIapPlugin.init_connection() -GodotIapPlugin.fetch_products(request) -GodotIapPlugin.request_purchase(props) -``` - -### Kotlin Multiplatform -```kotlin -val iap = KmpIAP() -iap.initConnection() -val products = iap.fetchProducts(skus = listOf("premium")) -iap.purchaseUpdatedListener.collect { purchase -> - iap.finishTransaction(purchase = purchase, isConsumable = true) -} -``` - -### .NET MAUI -```csharp -using OpenIap; -using OpenIap.Maui; - -var iap = OpenIapClient.Instance; -await ((MutationResolver)iap).InitConnectionAsync(); - -await ((QueryResolver)iap).FetchProductsAsync(new ProductRequest -{ - Skus = ["premium"], - Type = ProductQueryType.InApp, -}); - -((IOpenIap)iap).PurchaseUpdated.Subscribe(async purchase => -{ - await ((MutationResolver)iap).FinishTransactionAsync( - new PurchaseInput(purchase), - isConsumable: true - ); -}); -``` - ---- - -# expo-iap API Reference - -> Reference documentation for expo-iap (Expo In-App Purchase module) -> Adapt all patterns to match OpenIAP internal conventions. - -## Overview - -expo-iap is the Expo-compatible version of react-native-iap, providing in-app purchase functionality for both iOS and Android in Expo projects. - -## Installation - -```bash -npx expo install expo-iap -``` - -## Connection Management - -### initConnection - -Initialize connection to the app store. - -```typescript -import { initConnection } from 'expo-iap'; - -await initConnection(); -``` - -### endConnection - -Close connection to the app store. - -```typescript -import { endConnection } from 'expo-iap'; - -await endConnection(); -``` - -## Product Operations - -### fetchProducts - -Fetch product information from the store. - -```typescript -import { fetchProducts } from 'expo-iap'; - -const products = await fetchProducts(['com.app.product1', 'com.app.sub_monthly']); -``` - -**Returns:** `Promise` - -### Product Type - -```typescript -interface Product { - productId: string; - title: string; - description: string; - price: string; - currency: string; - localizedPrice: string; - type: ProductType; // 'iap' | 'sub' - - // iOS only - subscriptionPeriodNumberIOS?: string; - subscriptionPeriodUnitIOS?: string; - introductoryPrice?: string; - introductoryPricePaymentModeIOS?: string; - introductoryPriceNumberOfPeriodsIOS?: string; - introductoryPriceSubscriptionPeriodIOS?: string; - - // Android only - subscriptionOfferDetailsAndroid?: SubscriptionOffer[]; - oneTimePurchaseOfferDetailsAndroid?: OneTimePurchaseOffer; -} -``` - -## Purchase Operations - -### requestPurchase - -Initiate a purchase. - -```typescript -import { requestPurchase } from 'expo-iap'; - -// For consumables/non-consumables -await requestPurchase({ sku: 'com.app.product1' }); - -// For subscriptions (Android) -await requestPurchase({ - sku: 'com.app.sub_monthly', - subscriptionOffers: [{ sku: 'com.app.sub_monthly', offerToken: 'token' }] -}); -``` - -### finishTransaction - -Complete a transaction after processing. - -```typescript -import { finishTransaction } from 'expo-iap'; - -await finishTransaction({ purchase, isConsumable: true }); -``` - -### getAvailablePurchases - -Get user's existing purchases (restore purchases). - -```typescript -import { getAvailablePurchases } from 'expo-iap'; - -const purchases = await getAvailablePurchases(); -``` - -## Purchase Type - -```typescript -interface Purchase { - productId: string; - transactionId?: string; - transactionDate: number; - transactionReceipt: string; - purchaseToken?: string; // Android - - // iOS only - originalTransactionDateIOS?: number; - originalTransactionIdentifierIOS?: string; - - // Android only - purchaseStateAndroid?: number; - isAcknowledgedAndroid?: boolean; - packageNameAndroid?: string; - obfuscatedAccountIdAndroid?: string; - obfuscatedProfileIdAndroid?: string; -} -``` - -## iOS-Specific Functions - -### clearTransactionIOS - -Clear finished transactions from the queue. - -```typescript -import { clearTransactionIOS } from 'expo-iap'; - -await clearTransactionIOS(); -``` - -### getReceiptDataIOS - -Get the receipt data for validation. - -```typescript -import { getReceiptDataIOS } from 'expo-iap'; - -const receipt = await getReceiptDataIOS(); -``` - -### syncIOS - -Sync transactions with the App Store. - -```typescript -import { syncIOS } from 'expo-iap'; - -await syncIOS(); -``` - -### presentCodeRedemptionSheetIOS - -Show the offer code redemption sheet. - -```typescript -import { presentCodeRedemptionSheetIOS } from 'expo-iap'; - -await presentCodeRedemptionSheetIOS(); -``` - -### showManageSubscriptionsIOS - -Open subscription management in App Store. - -```typescript -import { showManageSubscriptionsIOS } from 'expo-iap'; - -await showManageSubscriptionsIOS(); -``` - -### isEligibleForIntroOfferIOS - -Check intro offer eligibility. - -```typescript -import { isEligibleForIntroOfferIOS } from 'expo-iap'; - -const eligible = await isEligibleForIntroOfferIOS('com.app.sub_monthly'); -``` - -### beginRefundRequestIOS - -Start a refund request. - -```typescript -import { beginRefundRequestIOS } from 'expo-iap'; - -const result = await beginRefundRequestIOS('transaction_id'); -``` - -## Android-Specific Functions - -### acknowledgePurchaseAndroid - -Acknowledge a purchase (required within 3 days). - -```typescript -import { acknowledgePurchaseAndroid } from 'expo-iap'; - -await acknowledgePurchaseAndroid({ token: purchase.purchaseToken }); -``` - -### consumePurchaseAndroid - -Consume a consumable purchase. - -```typescript -import { consumePurchaseAndroid } from 'expo-iap'; - -await consumePurchaseAndroid({ token: purchase.purchaseToken }); -``` - -### getPackageNameAndroid - -Get the app's package name. - -```typescript -import { getPackageNameAndroid } from 'expo-iap'; - -const packageName = await getPackageNameAndroid(); -``` - -## Cross-Platform Functions - -### getActiveSubscriptions - -Get active subscriptions. - -```typescript -import { getActiveSubscriptions } from 'expo-iap'; - -const subscriptions = await getActiveSubscriptions(['com.app.sub_monthly']); -``` - -### hasActiveSubscriptions - -Check if user has active subscriptions. - -```typescript -import { hasActiveSubscriptions } from 'expo-iap'; - -const hasActive = await hasActiveSubscriptions(['com.app.sub_monthly']); -``` - -### deepLinkToSubscriptions - -Open subscription management on both platforms. - -```typescript -import { deepLinkToSubscriptions } from 'expo-iap'; - -await deepLinkToSubscriptions({ sku: 'com.app.sub_monthly' }); -``` - -### getStorefront - -Get storefront information. - -```typescript -import { getStorefront } from 'expo-iap'; - -const storefront = await getStorefront(); -// { countryCode: 'US', ... } -``` - -## Event Listeners - -### purchaseUpdatedListener - -Listen for purchase updates. - -```typescript -import { purchaseUpdatedListener } from 'expo-iap'; - -const subscription = purchaseUpdatedListener((purchase) => { - console.log('Purchase updated:', purchase); - // Process and finish transaction -}); - -// Cleanup -subscription.remove(); -``` - -### purchaseErrorListener - -Listen for purchase errors. - -```typescript -import { purchaseErrorListener } from 'expo-iap'; - -const subscription = purchaseErrorListener((error) => { - console.error('Purchase error:', error); -}); - -// Cleanup -subscription.remove(); -``` - -## Error Codes - -| Code | Description | -|------|-------------| -| `E_UNKNOWN` | Unknown error | -| `E_USER_CANCELLED` | User cancelled | -| `E_ITEM_UNAVAILABLE` | Item not available | -| `E_NETWORK_ERROR` | Network error | -| `E_SERVICE_ERROR` | Store service error | -| `E_ALREADY_OWNED` | Item already owned | -| `E_NOT_PREPARED` | Not initialized | -| `E_NOT_ENDED` | Connection not ended | -| `E_DEVELOPER_ERROR` | Developer error | - -## Usage Pattern - -```typescript -import { - initConnection, - endConnection, - fetchProducts, - requestPurchase, - finishTransaction, - purchaseUpdatedListener, - purchaseErrorListener, -} from 'expo-iap'; - -// Setup -await initConnection(); - -const purchaseListener = purchaseUpdatedListener(async (purchase) => { - // Verify purchase server-side - // Then finish transaction - await finishTransaction({ purchase, isConsumable: false }); -}); - -const errorListener = purchaseErrorListener((error) => { - console.error(error); -}); - -// Fetch products -const products = await fetchProducts(['com.app.premium']); - -// Make purchase -await requestPurchase({ sku: 'com.app.premium' }); - -// Cleanup -purchaseListener.remove(); -errorListener.remove(); -await endConnection(); -``` - - ---- - -# Google Play Billing Library API Reference - -> Reference documentation for Google Play Billing Library 8.x -> Adapt all patterns to match OpenIAP internal conventions. - -## Overview - -Google Play Billing Library enables in-app purchases and subscriptions on Android devices. - -## Version History - -| Version | Release Date | Key Features | -|---------|--------------|--------------| -| 8.0 | 2025-06-30 | Auto-reconnect, product-level status codes, one-time products with multiple offers, sub-response codes | -| 8.1 | 2025-11-06 | Suspended subscriptions (`isSuspended`), `includeSuspended` parameter, pre-order details, product-level subscription replacement, `KEEP_EXISTING` mode | -| 8.2 | 2025-12-09 | Billing Programs API (external content links, external offers), deprecates old External Offers API | -| 8.2.1 | 2025-12-15 | Bug fix for `isBillingProgramAvailableAsync()` and `createBillingProgramReportingDetailsAsync()` | -| 8.3 | 2025-12-23 | External Payments program (Japan only), developer billing options | - -**Current Version**: 8.3.0 (as of April 2026) - -## Core Classes - -### BillingClient - -The main interface for communicating with Google Play Billing. - -```kotlin -val billingClient = BillingClient.newBuilder(context) - .setListener(purchasesUpdatedListener) - .enablePendingPurchases() - // New in 8.0: Auto-reconnect on service disconnect - .enableAutoServiceReconnection() - .build() -``` - -### Auto Service Reconnection (8.0+) - -```kotlin -// Enables automatic reconnection when service disconnects -BillingClient.newBuilder(context) - .enableAutoServiceReconnection() - .build() -``` - -When enabled, the library automatically re-establishes the connection if an API call is made while disconnected. This reduces `SERVICE_DISCONNECTED` errors. - -> **OpenIAP Note**: Auto-reconnection is **always enabled** internally since OpenIAP uses Billing Library 8.3.0+. No configuration needed. - -### Connection Management - -```kotlin -billingClient.startConnection(object : BillingClientStateListener { - override fun onBillingSetupFinished(billingResult: BillingResult) { - if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { - // Ready to query purchases - } - } - - override fun onBillingServiceDisconnected() { - // Reconnect on next request - } -}) -``` - -## Product Details - -### QueryProductDetailsParams - -```kotlin -val productList = listOf( - QueryProductDetailsParams.Product.newBuilder() - .setProductId("product_id") - .setProductType(BillingClient.ProductType.SUBS) // or INAPP - .build() -) - -val params = QueryProductDetailsParams.newBuilder() - .setProductList(productList) - .build() - -billingClient.queryProductDetailsAsync(params) { billingResult, productDetailsList -> - // Handle product details -} -``` - -### ProductDetails Properties - -| Property | Type | Description | -|----------|------|-------------| -| `productId` | String | Unique product identifier | -| `productType` | String | "subs" or "inapp" | -| `title` | String | Localized product title | -| `name` | String | Product name | -| `description` | String | Localized description | -| `oneTimePurchaseOfferDetails` | Object | For INAPP products | -| `subscriptionOfferDetails` | List | For subscription products | - -### Subscription Offer Details - -```kotlin -data class SubscriptionOfferDetails( - val basePlanId: String, - val offerId: String?, - val offerToken: String, - val pricingPhases: PricingPhases, - val offerTags: List -) -``` - -### Pricing Phases - -```kotlin -data class PricingPhase( - val formattedPrice: String, - val priceAmountMicros: Long, - val priceCurrencyCode: String, - val billingPeriod: String, // ISO 8601 (P1W, P1M, P1Y) - val billingCycleCount: Int, - val recurrenceMode: Int // FINITE or INFINITE -) -``` - -## Purchase Flow - -### Launch Purchase Flow - -```kotlin -val productDetailsParams = BillingFlowParams.ProductDetailsParams.newBuilder() - .setProductDetails(productDetails) - .setOfferToken(offerToken) // For subscriptions - .build() - -val billingFlowParams = BillingFlowParams.newBuilder() - .setProductDetailsParamsList(listOf(productDetailsParams)) - .build() - -val billingResult = billingClient.launchBillingFlow(activity, billingFlowParams) -``` - -### PurchasesUpdatedListener - -```kotlin -val purchasesUpdatedListener = PurchasesUpdatedListener { billingResult, purchases -> - when (billingResult.responseCode) { - BillingClient.BillingResponseCode.OK -> { - purchases?.forEach { purchase -> - handlePurchase(purchase) - } - } - BillingClient.BillingResponseCode.USER_CANCELED -> { - // User cancelled - } - else -> { - // Handle error - } - } -} -``` - -## Purchase Verification & Acknowledgement - -### Verify Purchase - -```kotlin -val purchase: Purchase - -// Check purchase state -if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) { - // Verify signature server-side - // Then acknowledge or consume -} -``` - -### Acknowledge Purchase (Subscriptions/Non-consumables) - -```kotlin -if (!purchase.isAcknowledged) { - val acknowledgePurchaseParams = AcknowledgePurchaseParams.newBuilder() - .setPurchaseToken(purchase.purchaseToken) - .build() - - billingClient.acknowledgePurchase(acknowledgePurchaseParams) { billingResult -> - // Handle result - } -} -``` - -### Consume Purchase (Consumables) - -```kotlin -val consumeParams = ConsumeParams.newBuilder() - .setPurchaseToken(purchase.purchaseToken) - .build() - -billingClient.consumeAsync(consumeParams) { billingResult, purchaseToken -> - // Handle result -} -``` - -## Query Existing Purchases - -```kotlin -// Query subscriptions -billingClient.queryPurchasesAsync( - QueryPurchasesParams.newBuilder() - .setProductType(BillingClient.ProductType.SUBS) - .build() -) { billingResult, purchasesList -> - // Handle existing subscriptions -} - -// Query in-app products -billingClient.queryPurchasesAsync( - QueryPurchasesParams.newBuilder() - .setProductType(BillingClient.ProductType.INAPP) - .build() -) { billingResult, purchasesList -> - // Handle existing purchases -} -``` - -## Purchase Properties - -| Property | Type | Description | -|----------|------|-------------| -| `orderId` | String | Unique order identifier | -| `purchaseToken` | String | Token for verification | -| `purchaseState` | Int | PENDING, PURCHASED, UNSPECIFIED | -| `purchaseTime` | Long | Timestamp in milliseconds | -| `products` | List | Product IDs in purchase | -| `isAcknowledged` | Boolean | Whether acknowledged | -| `isAutoRenewing` | Boolean | Auto-renewal status | -| `quantity` | Int | Quantity purchased | - -## Response Codes - -| Code | Constant | Description | -|------|----------|-------------| -| 0 | OK | Success | -| 1 | USER_CANCELED | User cancelled | -| 2 | SERVICE_UNAVAILABLE | Network error | -| 3 | BILLING_UNAVAILABLE | Billing not available | -| 4 | ITEM_UNAVAILABLE | Item not available | -| 5 | DEVELOPER_ERROR | Invalid arguments | -| 6 | ERROR | Fatal error | -| 7 | ITEM_ALREADY_OWNED | Already owned | -| 8 | ITEM_NOT_OWNED | Not owned | - -## Feature Support - -```kotlin -// Check if feature is supported -val result = billingClient.isFeatureSupported(BillingClient.FeatureType.SUBSCRIPTIONS) -if (result.responseCode == BillingClient.BillingResponseCode.OK) { - // Subscriptions are supported -} -``` - -### Feature Types - -- `SUBSCRIPTIONS` - Subscription support -- `SUBSCRIPTIONS_UPDATE` - Subscription upgrades/downgrades -- `PRICE_CHANGE_CONFIRMATION` - Price change confirmation -- `PRODUCT_DETAILS` - Product details API - -## Product-Level Status Codes (8.0+) - -In Billing Library 8.0+, `queryProductDetailsAsync()` returns products that couldn't be fetched with a status code explaining why. - -```kotlin -billingClient.queryProductDetailsAsync(params) { billingResult, productDetailsList -> - productDetailsList.forEach { productDetails -> - when (productDetails.productStatus) { - ProductDetails.ProductStatus.OK -> { - // Product fetched successfully - } - ProductDetails.ProductStatus.NOT_FOUND -> { - // SKU doesn't exist in Play Console - } - ProductDetails.ProductStatus.NO_OFFERS_AVAILABLE -> { - // User not eligible for any offers - } - } - } -} -``` - -| Status | Description | -|--------|-------------| -| `OK` | Product fetched successfully | -| `NOT_FOUND` | SKU doesn't exist in Play Console | -| `NO_OFFERS_AVAILABLE` | User not eligible for any offers | - -## Suspended Subscriptions (8.1+) - -```kotlin -val purchase: Purchase - -// Check if subscription is suspended due to billing issue -if (purchase.isSuspended) { - // User's payment method failed - // Do NOT grant entitlements - // Direct user to subscription center to fix payment -} -``` - -### Query Suspended Subscriptions (8.1+) - -```kotlin -// Include suspended subscriptions in query results -val params = QueryPurchasesParams.newBuilder() - .setProductType(BillingClient.ProductType.SUBS) - .setIncludeSuspended(true) // New in 8.1 - .build() - -billingClient.queryPurchasesAsync(params) { billingResult, purchases -> - purchases.forEach { purchase -> - if (purchase.isSuspended) { - // Handle suspended subscription - } - } -} -``` - -> **OpenIAP Note**: Use `includeSuspendedAndroid: true` in `PurchaseOptions` when calling `getAvailablePurchases()`. The `isSuspendedAndroid` field on purchases indicates suspension status. - -## Sub-Response Codes (8.0+) - -`BillingResult` includes a sub-response code for more granular error information: - -```kotlin -val result = billingClient.launchBillingFlow(activity, params) -when (result.subResponseCode) { - BillingResult.SUB_RESPONSE_CODE_INSUFFICIENT_FUNDS -> { - // User's payment method has insufficient funds - } - BillingResult.SUB_RESPONSE_CODE_USER_INELIGIBLE -> { - // User doesn't meet offer eligibility requirements - } -} -``` - -| Sub-Response Code | Description | -|-------------------|-------------| -| `PAYMENT_DECLINED_DUE_TO_INSUFFICIENT_FUNDS` | User's payment method has insufficient funds | -| `USER_INELIGIBLE` | User doesn't meet subscription offer eligibility | -| `NO_APPLICABLE_SUB_RESPONSE_CODE` | No specific sub-code applies | - -## Subscription Product Replacement (8.1+) - -Product-level replacement parameters for subscription upgrades/downgrades: - -```kotlin -val replacementParams = SubscriptionProductReplacementParams.newBuilder() - .setOldProductId("old_subscription_id") - .setReplacementMode(ReplacementMode.WITH_TIME_PRORATION) - .build() - -val productDetailsParams = BillingFlowParams.ProductDetailsParams.newBuilder() - .setProductDetails(newProductDetails) - .setOfferToken(offerToken) - .setSubscriptionProductReplacementParams(replacementParams) // New in 8.1 - .build() -``` - -### Replacement Modes - -| Mode | Description | -|------|-------------| -| `WITH_TIME_PRORATION` | Immediate, expiration time prorated | -| `CHARGE_PRORATED_PRICE` | Immediate, same billing cycle | -| `CHARGE_FULL_PRICE` | Immediate, full price charged | -| `WITHOUT_PRORATION` | Takes effect on old plan expiration | -| `DEFERRED` | Deferred, no charge | -| `KEEP_EXISTING` | Keep existing payment schedule (8.1+) | - -## External Payments Program (8.3+) - -Billing Library 8.3 (December 2025) added support for the External Payments program (Japan-only, as of launch). Developers enrolled in the program can offer alternative payment methods alongside Google Play billing. - -### Enable Developer Billing Option - -```kotlin -// During BillingClient setup -val billingClient = BillingClient.newBuilder(context) - .setListener(purchasesUpdatedListener) - .enablePendingPurchases() - .enableAutoServiceReconnection() - .enableDeveloperBillingOption( - DeveloperBillingOptionParams.newBuilder() - .setDeveloperProvidedBillingListener(developerBillingListener) - .build() - ) - .build() -``` - -### DeveloperProvidedBillingListener - -```kotlin -val developerBillingListener = DeveloperProvidedBillingListener { - userInitiatedBillingDetails -> - // User chose the developer-provided billing flow. - // Launch your external payment UI here. -} -``` - -### Launch Purchase with External Payments Option - -```kotlin -val params = BillingFlowParams.newBuilder() - .setProductDetailsParamsList(listOf(productDetailsParams)) - .setBillingOption(BillingOption.EXTERNAL_PAYMENTS) // 8.3+ - .build() - -billingClient.launchBillingFlow(activity, params) -``` - -### Key Types (8.3+) - -| Type | Purpose | -|------|---------| -| `DeveloperBillingOptionParams` | Configures developer-billing support on `BillingClient` | -| `DeveloperProvidedBillingListener` | Callback when user picks developer-provided billing | -| `DeveloperProvidedBillingDetails` | Billing details to report back for reconciliation | -| `BillingOption.EXTERNAL_PAYMENTS` | Purchase-flow flag requesting external payments | - -> **OpenIAP Note**: Exposed through the Android-specific `AlternativeBilling*` surface in OpenIAP. Enrolment with Google Play's External Payments program is required; availability is currently restricted to Japan. The Horizon flavor does NOT implement this. - -## Best Practices - -1. **Always acknowledge purchases** within 3 days or they will be refunded -2. **Verify purchases server-side** using Google Play Developer API -3. **Handle pending purchases** for payment methods that require additional steps -4. **Auto-reconnect is enabled by default** in OpenIAP (8.0+) -5. **Check product status codes** (8.0+) to understand why products weren't fetched -6. **Check isSuspended** (8.1+) before granting entitlements -7. **Cache product details** to avoid repeated queries - - ---- - -# Meta Horizon IAP API Reference - -> External reference for Meta Horizon Store in-app purchase APIs. -> Source: [Meta Horizon Documentation](https://developers.meta.com/horizon/documentation/) - -## Overview - -Meta Horizon provides IAP functionality for Quest VR applications. There are two main integration paths: - -1. **Platform SDK IAP** - Native Horizon IAP APIs -2. **Billing Compatibility SDK** - Google Play Billing Library-compatible wrapper - -## Version Compatibility Matrix - -| Library | Version | Compatible With | -|---------|---------|-----------------| -| horizon-billing-compatibility | **1.1.1** (latest) | Google Play Billing **7.0** API | -| Google Play Billing (Play flavor) | **8.3.0** (latest) | N/A | -| react-native-iap | v14+ | Billing 7.0+, RN 0.79+, Kotlin 2.0+ | -| expo-iap | latest | Billing 7.0+, Kotlin 2.0+ | - -**CRITICAL**: Horizon Billing Compatibility SDK implements Google Play Billing **7.0** API surface, NOT 8.x. - -When writing shared code for both Play and Horizon flavors: -- Use only APIs that exist in **both** Billing 7.0 and 8.x -- Horizon SDK does NOT support Billing 8.x features like auto-reconnect, product status codes, or `includeSuspended` -- OpenIAP handles this automatically with flavor-specific implementations - -### APIs Available in Both (Safe to use in shared code) - -- `BillingClient.Builder`, `BillingClient.newBuilder()` -- `queryProductDetailsAsync()` - Core product query -- `launchBillingFlow()` - Purchase flow -- `acknowledgePurchase()` - Acknowledge (no-op in Horizon) -- `consumeAsync()` - Consume purchase -- `queryPurchasesAsync()` - Query purchases - -### APIs Only in Billing 8.x (DO NOT use in shared code) - -- `enableAutoServiceReconnection()` - Auto-reconnect feature (8.0+) -- Product-level status codes in `queryProductDetailsAsync()` response (8.0+) -- One-time products with multiple offers (8.0+) -- Sub-response codes in `BillingResult` (8.0+) -- `isSuspended` on Purchase (8.1+) -- `includeSuspended` parameter in `QueryPurchasesParams` (8.1+) -- `SubscriptionProductReplacementParams` (8.1+) -- Billing Programs API (`isBillingProgramAvailableAsync`, etc.) (8.2+) -- External Payments / Developer Billing Options (8.3+) - -## Billing Compatibility SDK - -For apps already using Google Play Billing Library, the Horizon Billing Compatibility SDK provides a minimal migration path. - -### Compatibility - -- Compatible with **Google Play Billing Library 7.0** API -- Supports: consumable, durable, and subscription IAP -- Kotlin 2+ required - -### Migration Steps - -Replace imports from: -```kotlin -import com.android.billingclient.api.* -``` - -To: -```kotlin -import com.meta.horizon.billingclient.api.* -``` - -### Key Differences from Google Play Billing - -| Feature | Google Play | Horizon | -|---------|-------------|---------| -| `acknowledgePurchase()` | Required within 3 days | No-op (not required) | -| Non-acknowledgement | Auto-refund after 3 days | No auto-refund | -| `enablePendingPurchases()` | Enables pending purchases | No-op (for compatibility) | -| `onBillingServiceDisconnected()` | Called on disconnect | Never invoked | - -### Important Notes - -- Keep SKUs on Meta Horizon Developer Center same as Google Play Console product IDs -- Only call `consumeAsync()` on consumable items -- `acknowledgePurchase()` is no-op - no acknowledgement requirements - -## Server-to-Server (S2S) APIs - -### Authentication - -Access token format: `OC|App_ID|App_Secret` - -### Verify Entitlement - -Verify that a user owns an item (app or add-on). - -**Endpoint:** - -```http -POST https://graph.oculus.com/$APP_ID/verify_entitlement -``` - -**Parameters:** - -| Parameter | Type | Description | -|-----------|------|-------------| -| `access_token` | string | `OC\|App_ID\|App_Secret` format | -| `user_id` | string | The user ID to verify | -| `sku` | string | (Optional) SKU for add-on verification | - -**Example - Verify App Ownership:** -```bash -curl -d "access_token=OC|$APP_ID|$APP_SECRET" \ - -d "user_id=$USER_ID" \ - https://graph.oculus.com/$APP_ID/verify_entitlement -``` - -**Example - Verify Add-on/IAP:** -```bash -curl -d "access_token=OC|$APP_ID|$APP_SECRET" \ - -d "user_id=$USER_ID" \ - -d "sku=$SKU" \ - https://graph.oculus.com/$APP_ID/verify_entitlement -``` - -**Response:** -```json -{ - "success": true -} -``` - -### Refund IAP Entitlement - -Refund a DURABLE or CONSUMABLE entitlement (not yet consumed). - -**Endpoint:** - -```http -POST https://graph.oculus.com/$APP_ID/refund_iap_entitlement -``` - -**Parameters:** - -| Parameter | Type | Description | -|-----------|------|-------------| -| `access_token` | string | `OC\|App_ID\|App_Secret` format | -| `user_id` | string | The user ID | -| `sku` | string | SKU of item to refund | - -**Note:** Can only refund items not yet consumed via `consumeAsync()`. - -## Platform SDK IAP (Native) - -### Product Types - -| Type | Description | -|------|-------------| -| `CONSUMABLE` | Can be purchased multiple times (e.g., coins) | -| `DURABLE` | One-time purchase, permanent ownership | -| `SUBSCRIPTION` | Recurring billing | - -### Key APIs - -#### Get Products - -Retrieve product information and pricing. - -#### Launch Purchase Flow - -Initiate purchase for an item. - -#### Query Purchase History - -Get user's purchase history. - -#### Consume Purchase - -Mark consumable item as used (required for re-purchase). - -## OpenIAP Type Mapping - -| OpenIAP Type | Description | -|--------------|-------------| -| `IapStore.Horizon` | Store identifier for Horizon | -| `VerifyPurchaseHorizonOptions` | Horizon verification parameters | -| `VerifyPurchaseResultHorizon` | Horizon verification result | - -### VerifyPurchaseHorizonOptions - -```typescript -interface VerifyPurchaseHorizonOptions { - userId: string; // Horizon user ID - sku: string; // Product SKU - accessToken: string; // Format: "OC|APP_ID|APP_SECRET" -} -``` - -> **OpenIAP Note**: The GraphQL schema takes a single `accessToken` formatted as `OC|APP_ID|APP_SECRET` rather than separate `appId` / `appSecret` fields. Build the token server-side and pass it as one string. - -### VerifyPurchaseResultHorizon - -```typescript -interface VerifyPurchaseResultHorizon { - success: boolean; // Verification result -} -``` - -## Entitlement Check - -Apps must perform entitlement check within 10 seconds of launch for VRC.Quest.Security.1 compliance. - -## React Native / Expo Support - -Meta Quest supports React Native and Expo applications. - -### Requirements - -| Library | Minimum Version | Notes | -|---------|-----------------|-------| -| react-native-iap | v14+ | Billing 7.0+, Kotlin 2.0+, RN 0.79+ | -| expo-iap | latest | Uses expo-horizon-core plugin | -| React Native | 0.79+ | Required for Nitro modules | -| Kotlin | 2.0+ | Required for both billing SDKs | - -### Expo Integration - -Use `expo-horizon-core` plugin for Quest support: - -```bash -npx expo install expo-horizon-core -``` - -The plugin: -- Removes unsupported dependencies/permissions -- Configures Android product flavors -- Specifies Meta Horizon App ID -- Provides Quest-specific JS utilities - -### Known Limitations on Quest - -- No GPS sensor (limited location accuracy) -- No geocoding support -- No device heading -- No background location -- Some Expo libraries need forks (expo-location, expo-notifications) - -## Documentation Links - -- [Platform SDK IAP Package](https://developers.meta.com/horizon/documentation/android-apps/ps-platform-sdk-iap) -- [S2S APIs](https://developers.meta.com/horizon/documentation/unity/ps-iap-s2s/) -- [Billing Compatibility SDK](https://developers.meta.com/horizon/documentation/spatial-sdk/horizon-billing-compatibility-sdk/) -- [Entitlement Check](https://developers.meta.com/horizon/documentation/android-apps/ps-entitlement-check/) -- [React Native on Quest](https://developers.meta.com/horizon/documentation/android-apps/react-native-apps) -- [Expo Quest Setup](https://blog.swmansion.com/how-to-add-meta-quest-support-to-your-expo-app-68c52778b1fe) -- [Subscriptions](https://developers.meta.com/horizon/resources/subscriptions/) -- [Setting up Add-ons](https://developers.meta.com/horizon/resources/add-ons-setup/) - - ---- - -# react-native-iap API Reference (Legacy) - -> **WARNING**: This file contains outdated API names from older versions. -> For the current API spec, refer to the official [OpenIAP documentation](https://openiap.dev/docs/apis). -> -> Key renames from legacy to current: -> -> - `getProducts` → `fetchProducts` -> - `getSubscriptions` → `fetchProducts({ type: 'subs' })` -> - `getPurchaseHistory` → `getAvailablePurchases` -> - `requestSubscription` → `requestPurchase({ type: 'subs' })` -> - `completePurchase` → `finishTransaction` - -## Overview - -react-native-iap is a React Native library for in-app purchases on iOS and Android. expo-iap is built on top of this library. - -## Installation - -```bash -npm install react-native-iap -# or -yarn add react-native-iap -``` - -## Hook-Based API (Recommended) - -### useIAP Hook - -```typescript -import { useIAP } from 'react-native-iap'; - -function PurchaseScreen() { - const { - connected, - products, - subscriptions, - purchaseHistory, - availablePurchases, - currentPurchase, - currentPurchaseError, - initConnectionError, - finishTransaction, - fetchProducts, - getSubscriptions, - getAvailablePurchases, - getPurchaseHistory, - requestPurchase, - requestSubscription, - } = useIAP(); - - useEffect(() => { - if (currentPurchase) { - // Process purchase - finishTransaction({ purchase: currentPurchase }); - } - }, [currentPurchase]); - - return (/* ... */); -} -``` - -### withIAPContext HOC - -Wrap your app with IAP context provider. - -```typescript -import { withIAPContext } from 'react-native-iap'; - -function App() { - return ; -} - -export default withIAPContext(App); -``` - -## Imperative API - -### Connection Management - -```typescript -import { - initConnection, - endConnection, - fetchProducts, - getSubscriptions, -} from 'react-native-iap'; - -// Initialize -const connected = await initConnection(); - -// Fetch products -const products = await fetchProducts({ skus: ['com.app.product1'] }); -const subs = await getSubscriptions({ skus: ['com.app.sub_monthly'] }); - -// Cleanup -await endConnection(); -``` - -### Product Types - -```typescript -interface Product { - productId: string; - price: string; - currency: string; - localizedPrice: string; - title: string; - description: string; - type: 'inapp' | 'subs'; - - // iOS - introductoryPrice?: string; - introductoryPriceAsAmountIOS?: string; - introductoryPricePaymentModeIOS?: string; - introductoryPriceNumberOfPeriodsIOS?: string; - introductoryPriceSubscriptionPeriodIOS?: string; - subscriptionPeriodNumberIOS?: string; - subscriptionPeriodUnitIOS?: string; - discounts?: Discount[]; - - // Android - subscriptionOfferDetails?: SubscriptionOffer[]; - oneTimePurchaseOfferDetails?: OneTimePurchaseOffer; -} - -interface SubscriptionOffer { - basePlanId: string; - offerId?: string; - offerToken: string; - offerTags: string[]; - pricingPhases: PricingPhase[]; -} - -interface PricingPhase { - formattedPrice: string; - priceCurrencyCode: string; - priceAmountMicros: string; - billingPeriod: string; - billingCycleCount: number; - recurrenceMode: number; -} -``` - -### Purchase Operations - -```typescript -import { - requestPurchase, - requestSubscription, - finishTransaction, - getAvailablePurchases, - getPurchaseHistory, -} from 'react-native-iap'; - -// Purchase consumable/non-consumable -await requestPurchase({ sku: 'com.app.product1' }); - -// Purchase subscription (Android with offer token) -await requestSubscription({ - sku: 'com.app.sub_monthly', - subscriptionOffers: [{ sku: 'com.app.sub_monthly', offerToken: 'token' }], -}); - -// Finish transaction -await finishTransaction({ purchase, isConsumable: true }); - -// Get available purchases (restore) -const available = await getAvailablePurchases(); - -// Get purchase history -const history = await getPurchaseHistory(); -``` - -### Purchase Type - -```typescript -interface Purchase { - productId: string; - transactionId?: string; - transactionDate: number; - transactionReceipt: string; - purchaseToken?: string; - quantityIOS?: number; - originalTransactionDateIOS?: number; - originalTransactionIdentifierIOS?: string; - verificationResultIOS?: string; - appAccountToken?: string; - - // Android - purchaseStateAndroid?: PurchaseStateAndroid; - isAcknowledgedAndroid?: boolean; - packageNameAndroid?: string; - developerPayloadAndroid?: string; - obfuscatedAccountIdAndroid?: string; - obfuscatedProfileIdAndroid?: string; - autoRenewingAndroid?: boolean; -} -``` - -## Event Listeners - -```typescript -import { - purchaseUpdatedListener, - purchaseErrorListener, -} from 'react-native-iap'; - -// Purchase updates -const purchaseUpdateSubscription = purchaseUpdatedListener( - async (purchase: Purchase) => { - const receipt = purchase.transactionReceipt; - if (receipt) { - // Verify with server - await finishTransaction({ purchase }); - } - } -); - -// Purchase errors -const purchaseErrorSubscription = purchaseErrorListener( - (error: PurchaseError) => { - console.warn('purchaseErrorListener', error); - } -); - -// Cleanup -purchaseUpdateSubscription.remove(); -purchaseErrorSubscription.remove(); -``` - -## iOS-Specific Functions - -```typescript -import { - clearTransactionIOS, - clearProductsIOS, - getReceiptIOS, - getPendingPurchasesIOS, - getPromotedProductIOS, - buyPromotedProductIOS, - presentCodeRedemptionSheetIOS, - validateReceiptIos, -} from 'react-native-iap'; - -// Clear finished transactions -await clearTransactionIOS(); - -// Clear cached products -await clearProductsIOS(); - -// Get receipt for validation -const receipt = await getReceiptIOS(); - -// Get pending purchases -const pending = await getPendingPurchasesIOS(); - -// Handle promoted products -const promotedProduct = await getPromotedProductIOS(); -if (promotedProduct) { - await buyPromotedProductIOS(); -} - -// Show offer code redemption -await presentCodeRedemptionSheetIOS(); -``` - -## Android-Specific Functions - -```typescript -import { - acknowledgePurchaseAndroid, - consumePurchaseAndroid, - flushFailedPurchasesCachedAsPendingAndroid, - getPackageNameAndroid, - isFeatureSupported, - getBillingConfigAndroid, -} from 'react-native-iap'; - -// Acknowledge purchase (non-consumables, subscriptions) -await acknowledgePurchaseAndroid({ token: purchase.purchaseToken }); - -// Consume purchase (consumables) -await consumePurchaseAndroid({ token: purchase.purchaseToken }); - -// Clear failed pending purchases -await flushFailedPurchasesCachedAsPendingAndroid(); - -// Get package name -const packageName = getPackageNameAndroid(); - -// Check feature support -const supported = await isFeatureSupported('subscriptions'); - -// Get billing config -const config = await getBillingConfigAndroid(); -``` - -## Subscription Status (iOS) - -```typescript -import { - getSubscriptionStatusIOS, - getSubscriptionStatusesIOS, -} from 'react-native-iap'; - -// Get status for single product -const status = await getSubscriptionStatusIOS('com.app.sub_monthly'); - -// Get status for multiple products -const statuses = await getSubscriptionStatusesIOS(); -``` - -## Error Handling - -```typescript -import { IapIosSk2, ErrorCode } from 'react-native-iap'; - -try { - await requestPurchase({ sku: 'com.app.product1' }); -} catch (err) { - if (err.code === ErrorCode.E_USER_CANCELLED) { - // User cancelled - } else if (err.code === ErrorCode.E_ITEM_UNAVAILABLE) { - // Item not available - } else if (err.code === ErrorCode.E_ALREADY_OWNED) { - // Already owned - } else { - // Other error - } -} -``` - -### Error Codes - -| Code | Description | -|------|-------------| -| `E_UNKNOWN` | Unknown error | -| `E_USER_CANCELLED` | User cancelled | -| `E_ITEM_UNAVAILABLE` | Item not available | -| `E_NETWORK_ERROR` | Network error | -| `E_SERVICE_ERROR` | Store service error | -| `E_ALREADY_OWNED` | Item already owned | -| `E_REMOTE_ERROR` | Remote error | -| `E_NOT_PREPARED` | Not initialized | -| `E_NOT_ENDED` | Not ended | -| `E_DEVELOPER_ERROR` | Developer error | -| `E_BILLING_RESPONSE_JSON_PARSE_ERROR` | JSON parse error | -| `E_DEFERRED_PAYMENT` | Deferred payment | - -## Complete Usage Example - -```typescript -import React, { useEffect } from 'react'; -import { - withIAPContext, - useIAP, - requestPurchase, - finishTransaction, - purchaseUpdatedListener, - purchaseErrorListener, - ProductPurchase, -} from 'react-native-iap'; - -const productIds = ['com.app.product1']; -const subscriptionIds = ['com.app.sub_monthly']; - -function Store() { - const { - connected, - products, - subscriptions, - fetchProducts, - getSubscriptions, - } = useIAP(); - - useEffect(() => { - if (connected) { - fetchProducts({ skus: productIds }); - getSubscriptions({ skus: subscriptionIds }); - } - }, [connected]); - - useEffect(() => { - const purchaseSub = purchaseUpdatedListener( - async (purchase: ProductPurchase) => { - await finishTransaction({ purchase, isConsumable: false }); - } - ); - - const errorSub = purchaseErrorListener((error) => { - console.error('Purchase error:', error); - }); - - return () => { - purchaseSub.remove(); - errorSub.remove(); - }; - }, []); - - const handlePurchase = async (sku: string) => { - try { - await requestPurchase({ sku }); - } catch (err) { - console.error(err); - } - }; - - return (/* Render products and subscriptions */); -} - -export default withIAPContext(Store); -``` - -## Platform Differences - -| Feature | iOS | Android | -|---------|-----|---------| -| Subscription offers | Introductory price, Discounts | Offer tokens, Pricing phases | -| Acknowledge | Automatic | Required within 3 days | -| Consume | finishTransaction | consumePurchaseAndroid | -| Receipt | getReceiptIOS | transactionReceipt in Purchase | -| Promoted products | Supported | Not supported | -| Offer codes | Supported | Promo codes via Play Store | - - ---- - -# StoreKit 2 API Reference - -This document provides external API reference for Apple's StoreKit 2 framework. - -## iOS 18+ Features - -| Feature | iOS Version | Description | -|---------|-------------|-------------| -| Win-back offers | iOS 18.0 | Re-engage churned subscribers | -| `eligibleWinBackOfferIDs` | iOS 18.0 | Query win-back offer eligibility before purchase | -| Consumable transaction history | iOS 18.0 | Opt-in via `SK2ConsumableTransactionHistory` Info.plist key | -| StoreKit `Message` API | iOS 18.0 | Listener for billing issues, win-back, price increase, generic | -| UI context for purchases | iOS 18.2 | Required for proper payment sheet display | -| External purchase notice | iOS 17.4 | `ExternalPurchase.presentNoticeSheet()` | -| `appTransactionID` | iOS 18.4 | Globally unique app transaction identifier (back-deployed to iOS 15) | -| `originalPlatform` | iOS 18.4 | Original purchase platform (back-deployed to iOS 15) | -| `Transaction.offerPeriod` | iOS 18.4 | Offer period information on Transaction | -| `Transaction.advancedCommerceInfo` | iOS 18.4 | Advanced Commerce API data on Transaction | -| `Transaction.appTransactionID` | iOS 18.4 | Per-Apple-Account identifier on Transaction | -| Expanded offer codes | iOS 18.4 | Offer codes for consumables/non-consumables | -| JWS promotional offers | WWDC 2025 | New `promotionalOffer` purchase option with JWS format | -| `introductoryOfferEligibility` | WWDC 2025 | Set eligibility via purchase option | -| `SubscriptionStatus` by Transaction ID | WWDC 2025 | `status(for: transactionID:)` | - -### WWDC 2025 Updates - -- **SubscriptionStatus by Transaction ID**: `SubscriptionInfo.Status.status(for: transactionID:)` accepts any transaction ID, not just SKU. -- **JWS-based promotional offers**: New `promotionalOffer` purchase option with compact JWS string. -- **Introductory offer eligibility**: Override eligibility check with `introductoryOfferEligibility` purchase option. -- Both new purchase options are back-deployed to iOS 15. - -## appAccountToken - -A UUID that associates a purchase with a user account in your system. This property allows you to correlate App Store transactions with users in your backend. - -### Important: UUID Format Requirement - -**The `appAccountToken` must be a valid UUID format.** If you provide a non-UUID string (e.g., `"user-123"` or `"my-account-id"`), Apple's StoreKit will silently return `null` for this field in the transaction response. - -#### Valid UUID Examples - -```swift -// Valid UUIDs - these will be returned correctly -"550e8400-e29b-41d4-a716-446655440000" -"6ba7b810-9dad-11d1-80b4-00c04fd430c8" -UUID().uuidString // Generate new UUID -``` - -#### Invalid Examples (Will Return null) - -```swift -// Invalid - NOT UUID format, Apple returns null silently -"user-123" -"my-account-token" -"abc123" -``` - -### Usage in Purchase Options - -```swift -let appAccountToken = UUID() -let result = try await product.purchase(options: [ - .appAccountToken(appAccountToken) -]) -``` - -### Retrieving from Transaction - -```swift -let transaction: Transaction -if let token = transaction.appAccountToken { - // Token will only be present if a valid UUID was provided during purchase - print("App Account Token: \(token)") -} -``` - -### Best Practices - -1. **Generate UUIDs per user**: Create and store a UUID for each user in your system -2. **Use consistent tokens**: Use the same UUID for all purchases from the same user -3. **Server-side mapping**: Map the UUID to your internal user ID on your server -4. **Don't use user IDs directly**: Convert your user IDs to UUIDs rather than using them directly - -### References - -- [Apple Developer Documentation: appAccountToken](https://developer.apple.com/documentation/storekit/transaction/appaccounttoken) -- [GitHub Issue: expo-iap #128](https://github.com/hyochan/expo-iap/issues/128) - -## Product - -A type that describes an in-app purchase product. - -### Properties - -```swift -let id: String // The product identifier -let type: Product.ProductType // The type of product -let displayName: String // Localized display name -let description: String // Localized description -let displayPrice: String // Localized price string -let price: Decimal // Price as decimal -let subscription: Product.SubscriptionInfo? // Subscription details -``` - -### Methods - -#### products(for:) - -```swift -static func products(for identifiers: [String]) async throws -> [Product] -``` - -Fetches products from the App Store. - -#### purchase(options:) - -```swift -func purchase(options: Set = []) async throws -> Product.PurchaseResult -``` - -Initiates a purchase for this product. - -## Transaction - -Represents a completed purchase transaction. - -### Properties - -```swift -let id: UInt64 // Unique transaction ID -let originalID: UInt64 // Original transaction ID -let productID: String // Product identifier -let purchaseDate: Date // When the purchase occurred -let expirationDate: Date? // Subscription expiration date -let revocationDate: Date? // When the transaction was revoked -let isUpgraded: Bool // Whether this subscription was upgraded -let environment: AppStore.Environment // sandbox or production -``` - -### Methods - -#### currentEntitlements - -```swift -static var currentEntitlements: Transaction.Entitlements -``` - -A sequence of the customer's current entitlements. - -#### latest(for:) - -```swift -static func latest(for productID: String) async -> VerificationResult? -``` - -Gets the latest transaction for a product. - -#### finish() - -```swift -func finish() async -``` - -Marks the transaction as finished. - -## AppStore - -Provides access to App Store functionality. - -### Methods - -#### sync() - -```swift -static func sync() async throws -``` - -Syncs transactions with the App Store. - -#### showManageSubscriptions(in:) - -```swift -static func showManageSubscriptions(in scene: UIWindowScene) async throws -``` - -Shows the subscription management UI. - -#### beginRefundRequest(for:in:) - -```swift -static func beginRefundRequest(for transactionID: UInt64, in scene: UIWindowScene) async throws -> Transaction.RefundRequestStatus -``` - -Begins a refund request for a transaction. - -## Win-Back Offers (iOS 18+) - -Win-back offers are a new offer type to re-engage churned subscribers. - -### Automatic Presentation - -StoreKit Message automatically presents win-back offers when a user is eligible: - -```swift -// Message reason for win-back offers -StoreKit.Message.Reason.winBackOffer -``` - -### Manual Application - -Apply a win-back offer during purchase: - -```swift -let product: Product -let winBackOffer: Product.SubscriptionOffer - -let result = try await product.purchase(options: [ - .winBackOffer(winBackOffer) -]) -``` - -### Checking Eligibility - -Discover eligible win-back offers before purchase via `Product.SubscriptionInfo.eligibleWinBackOfferIDs` (iOS 18+): - -```swift -let status = try await product.subscription?.status.first -guard let renewalInfo = try status?.renewalInfo.payloadValue else { return } - -// iOS 18+: offer IDs the current Apple Account is eligible for -let eligibleIDs = renewalInfo.eligibleWinBackOfferIDs -let eligibleOffers = (product.subscription?.promotionalOffers ?? []).filter { - $0.type == .winBack && eligibleIDs.contains($0.id ?? "") -} -``` - -### RenewalInfo - -Win-back offer information is available in renewal info: - -```swift -let renewalInfo: Product.SubscriptionInfo.RenewalInfo - -// Check if win-back offer is applied to next renewal -if renewalInfo.renewalOfferType == .winBack { - // Win-back offer will be applied -} -``` - -## UI Context for Purchases (iOS 18.2+) - -Beginning in iOS 18.2, purchase methods require a UI context to properly display payment sheets: - -```swift -// iOS/iPadOS/tvOS/visionOS: UIViewController -let result = try await product.purchase(confirmIn: viewController) - -// macOS: NSWindow -let result = try await product.purchase(confirmIn: window) - -// watchOS: No UI context required -``` - -> **OpenIAP Note**: UI context is handled automatically in OpenIAP using the active window scene. - -## AppTransaction Updates (iOS 18.4+) - -```swift -let appTransaction = try await AppTransaction.shared - -// New in iOS 18.4 (back-deployed to iOS 15) -let appTransactionID = appTransaction.appTransactionID // Globally unique per Apple Account -let originalPlatform = appTransaction.originalPlatform // Original purchase platform -``` - -### appTransactionID - -- Globally unique identifier for each Apple Account that downloads your app -- Remains consistent across redownloads, refunds, repurchases, and storefront changes -- Works with Family Sharing (each family member gets unique ID) -- Back-deployed to iOS 15 - -## Transaction Updates (iOS 18.4+) - -iOS 18.4 added three new read-only properties to `Transaction` (not just `AppTransaction`): - -```swift -let transaction: Transaction - -// iOS 18.4+ — all back-deployed to iOS 15 -let txAppTransactionID = transaction.appTransactionID // Apple Account identifier -let offerPeriod = transaction.offerPeriod // Offer.Period? -let advancedCommerce = transaction.advancedCommerceInfo // AdvancedCommerceInfo? -``` - -| Property | Type | Notes | -|----------|------|-------| -| `appTransactionID` | String | Mirrors AppTransaction's identifier | -| `offerPeriod` | Offer.Period? | Phase of the promotional/intro offer | -| `advancedCommerceInfo` | AdvancedCommerceInfo? | Present for Advanced Commerce SKUs only | - -## Advanced Commerce API (iOS 18.4+) - -For apps with large product catalogs: - -```swift -// Check if product has advanced commerce info -if let advancedInfo = product.advancedCommerceInfo { - // Handle large catalog monetization -} -``` - -## StoreKit Message API (iOS 18+) - -Listen for App Store–generated messages (billing issues, win-back offers, price increases, generic). - -```swift -// Somewhere near app launch -Task { - for await message in Message.messages { - switch message.reason { - case .billingIssue: - // Show UI when user is ready; display from message.display(in:) - break - case .winBackOffer: - break - case .priceIncrease: - break - case .generic: - break - @unknown default: - break - } - } -} -``` - -| Reason | Trigger | -|--------|---------| -| `.billingIssue` | User has an unresolved billing problem on a subscription | -| `.priceIncrease` | Price change that requires user consent | -| `.winBackOffer` | User is eligible for a win-back offer | -| `.generic` | All other system-initiated messages | - -> **OpenIAP Note**: To be surfaced by the cross-platform event layer — see `event.graphql` additions for message events. - -## SubscriptionStatus by Transaction ID (WWDC 2025) - -```swift -// WWDC 2025: look up status using any transactionID, not just a SKU -let status = try await Product.SubscriptionInfo.Status.status(for: transactionID) -``` - -## Consumable Transaction History (iOS 18+) - -By default, `Transaction.all` omits finished consumables. Opt in by adding this key to **Info.plist**: - -```xml -SK2ConsumableTransactionHistory - -``` - -With the key set, finished consumable transactions are included in `Transaction.all` and `Transaction.currentEntitlements`. - -## External Purchase Support (iOS 17.4+) - -`ExternalPurchase.presentNoticeSheet()` / `ExternalPurchase.open(url:)` ship on iOS 17.4+. The follow-on custom-link APIs (`ExternalPurchaseCustomLink.isEligible`, `showNotice(type:)`, `token(for:)`) are iOS 18.1+. - -### Present External Purchase Notice - -```swift -// Check if external purchase notice can be presented -if await ExternalPurchase.canPresent { - let result = try await ExternalPurchase.presentNoticeSheet() - switch result { - case .continue: - // User wants to continue to external purchase - case .dismissed: - // User dismissed the notice - } -} -``` - -### Present External Purchase Link - -```swift -let result = try await ExternalPurchase.open(url: externalURL) -``` - -> **OpenIAP Note**: `presentExternalPurchaseNoticeSheetIOS` and `presentExternalPurchaseLinkIOS` are available in the iOS package. - - ---- - -# Webhook Event Mapping (ASN v2 ↔ RTDN ↔ openiap) - -This document is the source of truth for how kit normalizes Apple App Store Server -Notifications v2 (ASN v2) and Google Play Real-Time Developer Notifications (RTDN) -into the unified `WebhookEvent` shape defined in [`packages/gql/src/webhook.graphql`](../../packages/gql/src/webhook.graphql). - -When kit's webhook receivers are implemented (Phase 1, PR #2), they MUST follow -this table. When extending the spec (new event types, new stores), update this -document in the same PR. - -## Subscription lifecycle - -| openiap `WebhookEventType` | Apple ASN v2 `notificationType` (`subtype`) | Google RTDN `subscriptionNotification.notificationType` | -|---|---|---| -| `SubscriptionStarted` | `SUBSCRIBED` (`INITIAL_BUY`, `RESUBSCRIBE`) | `SUBSCRIPTION_PURCHASED` (4) | -| `SubscriptionRenewed` | `DID_RENEW` | `SUBSCRIPTION_RENEWED` (2) | -| `SubscriptionExpired` | `EXPIRED` | `SUBSCRIPTION_EXPIRED` (13) | -| `SubscriptionInGracePeriod` | `DID_FAIL_TO_RENEW` (`GRACE_PERIOD`) | `SUBSCRIPTION_IN_GRACE_PERIOD` (6) | -| `SubscriptionInBillingRetry` | `DID_FAIL_TO_RENEW` (no subtype) | `SUBSCRIPTION_ON_HOLD` (5) | -| `SubscriptionRecovered` | `DID_RENEW` (after a prior failure) | `SUBSCRIPTION_RECOVERED` (1) | -| `SubscriptionCanceled` | `DID_CHANGE_RENEWAL_STATUS` (`AUTO_RENEW_DISABLED`) | `SUBSCRIPTION_CANCELED` (3) | -| `SubscriptionUncanceled` | `DID_CHANGE_RENEWAL_STATUS` (`AUTO_RENEW_ENABLED`) | `SUBSCRIPTION_RESTARTED` (7) — fired when auto-renew is re-enabled while the period is still active | -| `SubscriptionRevoked` | `REVOKE` | `SUBSCRIPTION_REVOKED` (12) | -| `SubscriptionPriceChange` | `PRICE_INCREASE` | `SUBSCRIPTION_PRICE_CHANGE_CONFIRMED` (8), `SUBSCRIPTION_PRICE_CHANGE_UPDATED` (19) | -| `SubscriptionProductChanged` | `DID_CHANGE_RENEWAL_PREF` | `SUBSCRIPTION_DEFERRED` (9) | -| `SubscriptionPaused` | (no equivalent — iOS has no pause) | `SUBSCRIPTION_PAUSED` (10), `SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED` (11) — schedule update, not actual resume | -| `SubscriptionResumed` | (no equivalent) | `SUBSCRIPTION_RECOVERED` (1) when fired after a `SUBSCRIPTION_PAUSED` — kit chooses Resumed vs Recovered based on the prior `subscriptions` row state | - -PR #123 review caught the earlier draft where codes 1 and 4 were swapped -(`SUBSCRIPTION_RECOVERED` is code 1, `SUBSCRIPTION_PURCHASED` is code 4) -and where `SUBSCRIPTION_RESTARTED` (7) was incorrectly mapped to -`SubscriptionRecovered` instead of `SubscriptionUncanceled`. The mapping -above reflects the corrected RTDN reference. - -## One-time / common - -| openiap `WebhookEventType` | Apple ASN v2 | Google RTDN | -|---|---|---| -| `PurchaseRefunded` | `REFUND` | `oneTimeProductNotification.notificationType = ONE_TIME_PRODUCT_CANCELED` (2), or `voidedPurchaseNotification` | -| `PurchaseConsumptionRequest` | `CONSUMPTION_REQUEST` | (no equivalent — Play handles consumption client-side) | -| `TestNotification` | `TEST` | `testNotification` field present on the RTDN message | - -## Field mapping - -| `WebhookEvent` field | Apple ASN v2 source | Google RTDN source | -|---|---|---| -| `id` | `notificationUUID` | Pub/Sub `messageId` | -| `occurredAt` | `signedDate` | `eventTimeMillis` | -| `environment` | `data.environment` (`Production` \| `Sandbox` \| `Xcode`) | `testNotification` present → `Sandbox`, else `Production` | -| `purchaseToken` | `data.signedTransactionInfo.originalTransactionId` | `subscriptionNotification.purchaseToken` or `oneTimeProductNotification.purchaseToken` | -| `productId` | `data.signedTransactionInfo.productId` | `subscriptionNotification.subscriptionId` or `oneTimeProductNotification.sku` | -| `expiresAt` | `data.signedRenewalInfo.expirationDate` (decoded JWS) | resolved by calling `purchases.subscriptionsv2.get` (ASN/RTDN do not embed it directly) | -| `renewsAt` | `data.signedRenewalInfo.renewalDate` | resolved by calling `purchases.subscriptionsv2.get` | -| `cancellationReason` | `data.signedTransactionInfo.revocationReason` + ASN `subtype` | `purchases.subscriptionsv2.get` → `canceledStateContext.userInitiatedCancellation` / `systemInitiatedCancellation` | -| `currency` | `data.signedTransactionInfo.currency` | from `purchases.subscriptionsv2.get` linked product price | -| `priceAmountMicros` | `data.signedTransactionInfo.price` × 1000 (Apple's `price` field is in **milliunits** = 1/1000 of a currency unit; multiply by 1000 to convert to micros) | `purchases.subscriptionsv2.get` → `lineItems[*].autoRenewingPlan.recurringPrice` — `units * 1_000_000 + Math.round(nanos / 1000)` (Money type combines whole units + nanos = 10⁻⁹ units) | -| `rawSignedPayload` | The complete `signedPayload` JWS string from the ASN body | The base64-decoded Pub/Sub message `data` (JSON) | - -## Validation requirements (kit Phase 1, PR #2) - -Both stores require signature verification before any event is emitted: - -- **Apple ASN v2**: verify the JWS using Apple's public root certificates (refresh - via the App Store Connect API). The receiver must reject unverified payloads - with HTTP 401. -- **Google RTDN**: validate the Pub/Sub push request against the configured - service account audience (OIDC token verification). Reject missing or invalid - tokens with HTTP 401. - -Idempotency: - -- Use `(source, sourceNotificationId)` as the dedup key, where - `sourceNotificationId` is `notificationUUID` for ASN v2 or `messageId` for - RTDN. Convex idempotency table records the first-seen event and silently - acknowledges duplicates with HTTP 200. - -Replay window: - -- Events MUST be retained for at least 30 days so `webhookEventsSince` can - service reconnecting clients. Older events are pruned by a Convex cron job. - - ---- - -## Links & Resources - -- Documentation: https://openiap.dev/docs -- Types Reference: https://openiap.dev/docs/types -- APIs Reference: https://openiap.dev/docs/apis -- Error Codes: https://openiap.dev/docs/errors -- GitHub: https://github.com/hyodotdev/openiap - -### Ecosystem Libraries -- expo-iap: https://github.com/hyodotdev/openiap/tree/main/libraries/expo-iap -- react-native-iap: https://github.com/hyodotdev/openiap/tree/main/libraries/react-native-iap -- flutter_inapp_purchase: https://github.com/hyodotdev/openiap/tree/main/libraries/flutter_inapp_purchase -- godot-iap: https://github.com/hyodotdev/openiap/tree/main/libraries/godot-iap -- kmp-iap: https://github.com/hyodotdev/openiap/tree/main/libraries/kmp-iap -- maui-iap: https://github.com/hyodotdev/openiap/tree/main/libraries/maui-iap diff --git a/llms-full.txt b/llms-full.txt new file mode 120000 index 000000000..7ae46db64 --- /dev/null +++ b/llms-full.txt @@ -0,0 +1 @@ +packages/docs/public/llms-full.txt \ No newline at end of file diff --git a/llms.txt b/llms.txt deleted file mode 100644 index 7194caf9c..000000000 --- a/llms.txt +++ /dev/null @@ -1,231 +0,0 @@ -# OpenIAP Quick Reference - -> OpenIAP: Unified in-app purchase specification for iOS & Android -> Documentation: https://openiap.dev -> Full Reference: https://openiap.dev/llms-full.txt -> Generated: 2026-06-15T14:57:16.350Z - -## Installation - -### React Native / Expo -```bash -# expo-iap (Expo projects) -npx expo install expo-iap - -# react-native-iap (React Native CLI) -npm install react-native-iap -``` - -### Native -```swift -// Swift Package Manager -.package(url: "https://github.com/hyodotdev/openiap.git", from: "2.2.1") -``` - -```kotlin -// Gradle -implementation("io.github.hyochan.openiap:openiap-google:2.2.1") -``` - -```bash -# Flutter -flutter pub add flutter_inapp_purchase -``` - -```gdscript -# Godot -# Install godot-iap 2.3.1 to addons/godot-iap and enable the plugin -``` - -```kotlin -// Kotlin Multiplatform -implementation("io.github.hyochan:kmp-iap:2.3.1") -``` - -```bash -# .NET MAUI -dotnet add package OpenIap.Maui -``` - -Current NuGet package version: 1.1.1 - -## Framework Libraries - -- `expo-iap`: Expo Modules wrapper, same OpenIAP API as React Native. -- `react-native-iap`: Nitro Modules wrapper for React Native CLI apps. -- `flutter_inapp_purchase`: Dart API with generated OpenIAP types and streams. -- `godot-iap`: Godot 4.x plugin with GDScript functions and signals. -- `kmp-iap`: Kotlin Multiplatform API with Flow-based purchase events. -- `maui-iap`: `OpenIap.Maui` package with `OpenIapClient.Instance`, - generated `Types.cs`, IAPKit helpers (`OpenIapClient.KitApi`, - `OpenIapClient.ConnectWebhookStream`, - `OpenIapClient.ParseWebhookEventData`), flattened OpenIAP-owned iOS - xcframework / Android AAR bindings, Google and AndroidX Android - dependencies as NuGet package references, and MAUI example flows matching - `expo-iap`. - -## Core APIs - -### Connection -```typescript -// Initialize (required before any operation) -await initConnection(); - -// With alternative billing (Android) -await initConnection({ alternativeBillingModeAndroid: 'user-choice' }); - -// Cleanup on unmount -await endConnection(); -``` - -### Fetch Products -```typescript -const products = await fetchProducts({ - products: [ - { id: 'com.app.premium', type: 'inapp' }, - { id: 'com.app.monthly', type: 'subs' }, - ], -}); -``` - -### Request Purchase -```typescript -// IMPORTANT: requestPurchase is event-based, not promise-based -// Set up purchaseUpdatedListener before calling -await requestPurchase({ - request: { - apple: { sku: 'com.app.premium' }, - google: { skus: ['com.app.premium'] }, - }, - type: 'inapp', // 'inapp' | 'subs' -}); -``` - -### Finish Transaction -```typescript -// CRITICAL: Must call after verification -// Android: purchases auto-refund after 3 days if not acknowledged -await finishTransaction(purchase, isConsumable); -``` - -### Get Available Purchases -```typescript -const purchases = await getAvailablePurchases(); -// Returns user's current entitlements -``` - -### Restore Purchases -```typescript -await restorePurchases(); -const purchases = await getAvailablePurchases(); -``` - -## Events (React Native/Expo) - -```typescript -import { purchaseUpdatedListener, purchaseErrorListener } from 'expo-iap'; - -// Set up before any purchase request -const purchaseUpdateSubscription = purchaseUpdatedListener(async (purchase) => { - // 1. Verify purchase on server - // 2. Grant entitlement - // 3. Finish transaction - await finishTransaction(purchase); -}); - -const purchaseErrorSubscription = purchaseErrorListener((error) => { - if (error.code === 'UserCancelled') return; // Normal flow - console.error('Purchase error:', error.message); -}); - -// Cleanup -purchaseUpdateSubscription.remove(); -purchaseErrorSubscription.remove(); -``` - -## Core Types - -### Product -```typescript -interface Product { - id: string; // Product identifier (SKU) - title: string; // Display name - description: string; // Product description - price: string; // Formatted price string - priceAmount: number; // Price as number - currency: string; // ISO 4217 currency code - type: 'inapp' | 'subs'; -} -``` - -### Purchase -```typescript -interface Purchase { - productId: string; // Purchased product ID - transactionId: string; // Platform transaction ID - transactionDate: number; // Purchase timestamp - purchaseState: PurchaseState; - // iOS specific - originalTransactionId?: string; - // Android specific - purchaseToken?: string; - orderId?: string; -} - -type PurchaseState = 'purchased' | 'pending' | 'restored'; -``` - -### PurchaseError -```typescript -interface PurchaseError { - code: string; // Error code - message: string; // Human-readable message - productId?: string; // Related SKU -} -``` - -## Common Error Codes - -| Code | Description | Action | -|------|-------------|--------| -| UserCancelled | User cancelled purchase | No action needed | -| ItemUnavailable | Product not in store | Check store config | -| AlreadyOwned | Already purchased | Restore purchases | -| NetworkError | Network issue | Retry with backoff | -| ServiceError | Store service error | Retry later | -| NotPrepared | initConnection not called | Call initConnection first | - -## API Naming Convention - -- **Cross-platform**: No suffix (fetchProducts, requestPurchase) -- **iOS-only**: `IOS` suffix (syncIOS, getStorefrontIOS) -- **Android-only**: `Android` suffix (acknowledgePurchaseAndroid) - -## Platform-Specific APIs - -### iOS -- syncIOS() - Sync with App Store -- presentCodeRedemptionSheetIOS() - Show offer code UI -- showManageSubscriptionsIOS() - Open subscription management -- beginRefundRequestIOS() - Start refund flow - -### Android -- acknowledgePurchaseAndroid() - Acknowledge purchase -- consumePurchaseAndroid() - Consume for re-purchase - -## Purchase Flow Summary - -1. initConnection() -2. fetchProducts([...skus]) -3. Set up purchaseUpdatedListener -4. requestPurchase({ sku }) -5. In listener: verify -> grant -> finishTransaction() -6. endConnection() on cleanup - -## Links - -- Docs: https://openiap.dev/docs -- Types: https://openiap.dev/docs/types -- APIs: https://openiap.dev/docs/apis -- Errors: https://openiap.dev/docs/errors -- GitHub: https://github.com/hyodotdev/openiap diff --git a/llms.txt b/llms.txt new file mode 120000 index 000000000..8099ba3ee --- /dev/null +++ b/llms.txt @@ -0,0 +1 @@ +packages/docs/public/llms.txt \ No newline at end of file diff --git a/packages/docs/public/llms-full.txt b/packages/docs/public/llms-full.txt index 86d8fb5c1..dd3f18fd6 100644 --- a/packages/docs/public/llms-full.txt +++ b/packages/docs/public/llms-full.txt @@ -3,7 +3,7 @@ > OpenIAP: Unified in-app purchase specification for iOS & Android > Documentation: https://openiap.dev > Quick Reference: https://openiap.dev/llms.txt -> Generated: 2026-06-15T14:57:16.350Z +> Generated: 2026-06-23T15:25:38.051Z ## Table of Contents 1. Installation @@ -39,10 +39,10 @@ pod 'openiap', '~> 2.2.1' ### Kotlin (Android) ```kotlin // Gradle (build.gradle.kts) -implementation("io.github.hyochan.openiap:openiap-google:2.2.1") +implementation("io.github.hyochan.openiap:openiap-google:2.2.2") // For Meta Horizon OS -implementation("io.github.hyochan.openiap:openiap-google-horizon:2.2.1") +implementation("io.github.hyochan.openiap:openiap-google-horizon:2.2.2") ``` ### Flutter @@ -51,13 +51,13 @@ flutter pub add flutter_inapp_purchase ``` ### Godot -Download `godot-iap-2.3.1.zip` from GitHub Releases, extract it to +Download `godot-iap-2.3.2.zip` from GitHub Releases, extract it to `addons/godot-iap/`, then enable the plugin in Project Settings. ### Kotlin Multiplatform ```kotlin dependencies { - implementation("io.github.hyochan:kmp-iap:2.3.1") + implementation("io.github.hyochan:kmp-iap:2.3.2") } ``` @@ -69,7 +69,7 @@ https://central.sonatype.com/artifact/io.github.hyochan/kmp-iap dotnet add package OpenIap.Maui ``` -Current NuGet package version: 1.1.1 +Current NuGet package version: 1.1.5 Requires .NET 9 or .NET 10, the MAUI workload, iOS 15.0+, and Android API 24+. diff --git a/packages/docs/public/llms.txt b/packages/docs/public/llms.txt index 7194caf9c..bd86e01fa 100644 --- a/packages/docs/public/llms.txt +++ b/packages/docs/public/llms.txt @@ -3,7 +3,7 @@ > OpenIAP: Unified in-app purchase specification for iOS & Android > Documentation: https://openiap.dev > Full Reference: https://openiap.dev/llms-full.txt -> Generated: 2026-06-15T14:57:16.350Z +> Generated: 2026-06-23T15:25:38.051Z ## Installation @@ -24,7 +24,7 @@ npm install react-native-iap ```kotlin // Gradle -implementation("io.github.hyochan.openiap:openiap-google:2.2.1") +implementation("io.github.hyochan.openiap:openiap-google:2.2.2") ``` ```bash @@ -34,12 +34,12 @@ flutter pub add flutter_inapp_purchase ```gdscript # Godot -# Install godot-iap 2.3.1 to addons/godot-iap and enable the plugin +# Install godot-iap 2.3.2 to addons/godot-iap and enable the plugin ``` ```kotlin // Kotlin Multiplatform -implementation("io.github.hyochan:kmp-iap:2.3.1") +implementation("io.github.hyochan:kmp-iap:2.3.2") ``` ```bash @@ -47,7 +47,7 @@ implementation("io.github.hyochan:kmp-iap:2.3.1") dotnet add package OpenIap.Maui ``` -Current NuGet package version: 1.1.1 +Current NuGet package version: 1.1.5 ## Framework Libraries diff --git a/scripts/agent/README.md b/scripts/agent/README.md index e7eba02f1..9c992db39 100644 --- a/scripts/agent/README.md +++ b/scripts/agent/README.md @@ -109,7 +109,7 @@ cp .env.example .env ```bash # Compile for AI assistants (no Ollama required) bun run compile:ai -# → generates context.md, llms.txt, llms-full.txt +# → generates context.md and docs public llms files; root llms files are symlinks # Compile for Local RAG (Ollama required) bun run compile:local @@ -212,7 +212,7 @@ openiap/ | Script | Description | Ollama Required | |--------|-------------|-----------------| | `bun run compile` | Compile all (AI context + Local RAG) | Yes | -| `bun run compile:ai` | Generate context.md, llms.txt, llms-full.txt | No | +| `bun run compile:ai` | Generate context.md and docs public llms files | No | | `bun run compile:local` | Index knowledge + code map (LanceDB) | Yes | | `bun run compile:local:knowledge` | Index only knowledge files | Yes | | `bun run compile:local:code` | Build only code map | Yes | @@ -398,5 +398,5 @@ bun run compile:ai ```bash bun run compile:ai -# → regenerates packages/docs/public/llms.txt +# → regenerates packages/docs/public/llms*.txt and refreshes root symlinks ``` diff --git a/scripts/agent/compile-context.ts b/scripts/agent/compile-context.ts index 7e8d37b38..5b44db5b6 100644 --- a/scripts/agent/compile-context.ts +++ b/scripts/agent/compile-context.ts @@ -35,7 +35,10 @@ const CONFIG = { outputFile: "context.md", // LLMs.txt output (for AI assistants on web) llmsOutputDir: path.resolve(scriptDir, "../../packages/docs/public"), - rootLlmsOutputDir: path.resolve(scriptDir, "../.."), + rootLlmsSymlinks: { + "llms.txt": "packages/docs/public/llms.txt", + "llms-full.txt": "packages/docs/public/llms-full.txt", + }, }; function readInstallationVersions(): { @@ -89,6 +92,26 @@ function withSingleTrailingNewline(content: string): string { return `${content.trimEnd()}\n`; } +function ensureSymlink(linkPath: string, targetPath: string): void { + try { + const currentTarget = fs.readlinkSync(linkPath); + if (currentTarget === targetPath) { + return; + } + fs.unlinkSync(linkPath); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT" && code !== "EINVAL") { + throw error; + } + if (fs.existsSync(linkPath)) { + fs.unlinkSync(linkPath); + } + } + + fs.symlinkSync(targetPath, linkPath); +} + // ============================================================================ // LLMs.txt Generator // ============================================================================ @@ -583,20 +606,19 @@ interface PurchaseError { - GitHub: https://github.com/hyodotdev/openiap `; - // Write files to the deployed docs public directory and the repository root. - // The website serves packages/docs/public, while the root copies keep local - // repository consumers from reading stale LLM context. - const outputDirs = [CONFIG.llmsOutputDir, CONFIG.rootLlmsOutputDir]; - for (const outputDir of outputDirs) { - fs.mkdirSync(outputDir, { recursive: true }); - fs.writeFileSync( - path.join(outputDir, "llms.txt"), - withSingleTrailingNewline(quickContent), - ); - fs.writeFileSync( - path.join(outputDir, "llms-full.txt"), - withSingleTrailingNewline(fullContent), - ); + // The website serves packages/docs/public. Root files are symlinks to avoid + // drift between local repository readers and deployed docs. + fs.mkdirSync(CONFIG.llmsOutputDir, { recursive: true }); + fs.writeFileSync( + path.join(CONFIG.llmsOutputDir, "llms.txt"), + withSingleTrailingNewline(quickContent), + ); + fs.writeFileSync( + path.join(CONFIG.llmsOutputDir, "llms-full.txt"), + withSingleTrailingNewline(fullContent), + ); + for (const [filename, targetPath] of Object.entries(CONFIG.rootLlmsSymlinks)) { + ensureSymlink(path.join(CONFIG.projectRoot, filename), targetPath); } console.log( @@ -781,14 +803,9 @@ openiap/ ` ✓ Output: ${path.join(CONFIG.llmsOutputDir, "llms-full.txt")}`, ), ); - console.log( - chalk.green(` ✓ Output: ${path.join(CONFIG.rootLlmsOutputDir, "llms.txt")}`), - ); - console.log( - chalk.green( - ` ✓ Output: ${path.join(CONFIG.rootLlmsOutputDir, "llms-full.txt")}`, - ), - ); + for (const [filename, targetPath] of Object.entries(CONFIG.rootLlmsSymlinks)) { + console.log(chalk.green(` ✓ Symlink: ${filename} -> ${targetPath}`)); + } console.log(chalk.bold.green("\n✅ Context compilation complete!\n")); console.log(chalk.white("Usage with Claude Code:")); diff --git a/scripts/audit-non-godot-parity.mjs b/scripts/audit-non-godot-parity.mjs index 6ada1c989..f282c5e61 100644 --- a/scripts/audit-non-godot-parity.mjs +++ b/scripts/audit-non-godot-parity.mjs @@ -578,6 +578,35 @@ function checkFlutter() { 'libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift'; const flutterMacosPlugin = 'libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift'; + const flutterSwiftPackagePaths = [ + 'libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Package.swift', + 'libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Package.swift', + ]; + + for (const flutterSwiftPackage of flutterSwiftPackagePaths) { + expectFile(flutterSwiftPackage); + if (!exists(flutterSwiftPackage)) continue; + const flutterSwiftPackageText = read(flutterSwiftPackage); + const openIapDependencyVersion = flutterSwiftPackageText.match( + /\.package\(url: "https:\/\/github\.com\/hyodotdev\/openiap\.git", from: "([^"]+)"\),/, + )?.[1]; + if (!openIapDependencyVersion) { + fail(`${flutterSwiftPackage} is missing the OpenIAP SwiftPM dependency`); + } else if ( + !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/.test( + openIapDependencyVersion, + ) + ) { + fail(`${flutterSwiftPackage} OpenIAP dependency version must be semver`); + } + if ( + !flutterSwiftPackageText.includes( + '.product(name: "OpenIAP", package: "OpenIAP")', + ) + ) { + fail(`${flutterSwiftPackage} is missing the OpenIAP product dependency`); + } + } expectIncludes(flutterIosPlugin, [ 'case "deepLinkToSubscriptions"', @@ -986,6 +1015,25 @@ function checkFrameworkDependencyHygiene() { 'packages/docs/openiap-versions.json', 'Docs package version copy', ); + expectSymlinkTarget( + 'llms.txt', + 'packages/docs/public/llms.txt', + 'Root llms.txt', + ); + expectSymlinkTarget( + 'llms-full.txt', + 'packages/docs/public/llms-full.txt', + 'Root llms-full.txt', + ); + for (const docsLlmsFile of [ + 'packages/docs/public/llms.txt', + 'packages/docs/public/llms-full.txt', + ]) { + expectFile(docsLlmsFile); + if (exists(docsLlmsFile) && fs.lstatSync(abs(docsLlmsFile)).isSymbolicLink()) { + fail(`${docsLlmsFile} must be a real file for docs deployment`); + } + } expectFile('packages/docs/src/generated/version-metadata.json'); if (exists('packages/docs/src/generated/version-metadata.json')) { const docsVersionMetadata = readJson('packages/docs/src/generated/version-metadata.json');