From 1cc1a9d3db92fb97f043a236aa9da51cfcca4642 Mon Sep 17 00:00:00 2001 From: hyochan Date: Sat, 25 Apr 2026 03:44:38 +0900 Subject: [PATCH 01/11] fix: wire every type-declared handler across all wrapper SDKs Closes #104 (Flutter `beginRefundRequestIOS` declared but not implemented), plus every other instance of the same bug pattern found via a systematic types-to-bridge audit across the 5 wrapper libraries. - packages/apple: add ObjC bridge for requestPurchaseOnPromotedProductIOS and deepLinkToSubscriptions so KMP cinterop can reach them. - flutter_inapp_purchase: add beginRefundRequestIOS + 6 iOS query handlers (currentEntitlementIOS, latestTransactionIOS, isTransactionVerifiedIOS, getTransactionJwsIOS, getReceiptDataIOS, canPresentExternalPurchaseNoticeIOS) across Swift plugin, Dart impl, and QueryHandlers/MutationHandlers bundles; fix channel-name drift on syncIOS/subscriptionStatusIOS/getAppTransaction; wire verifyPurchase + 3 Android billing-program handlers into the bundle. - kmp-iap: replace 5 UnsupportedOperationException stubs in iosMain with real ObjC bridge calls (beginRefundRequestIOS, syncIOS, getAllTransactionsIOS, requestPurchaseOnPromotedProductIOS, deepLinkToSubscriptions). - expo-iap: export consumePurchaseAndroid (asymmetric with acknowledgePurchaseAndroid) and getStorefrontIOS deprecated alias. - react-native-iap: export validateReceiptIOS deprecated alias. - godot-iap: add 5 missing APIs (validate_receipt, validate_receipt_ios, and three ExternalPurchaseCustomLink iOS 18.1+ methods) across GDExtension Swift bridge and GDScript public API. - knowledge: add SDK Parity Checklist to 04-platform-packages.md with a 5-layer table per library, platform-suffix rules, four observed failure patterns, and a grep-based audit command so future schema additions do not create phantom interfaces again. After this PR every handler declared in each library's generated types has a complete runtime path (public API + native bridge + bundle wiring where applicable). Verified: packages/apple 87 tests, Flutter 257, RN-IAP 272, expo-iap 269, kmp-iap allTests, godot-iap swift build. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 1 + knowledge/internal/04-platform-packages.md | 73 ++++++ libraries/expo-iap/src/modules/android.ts | 27 ++ libraries/expo-iap/src/modules/ios.ts | 16 ++ .../Classes/FlutterInappPurchasePlugin.swift | 242 ++++++++++++++++++ .../lib/flutter_inapp_purchase.dart | 208 ++++++++++++++- .../flutter_inapp_purchase_channel_test.dart | 36 +-- .../test/ios_methods_test.dart | 126 +++++++++ .../test/ios_module_methods_test.dart | 3 +- .../test/subscription_handlers_test.dart | 3 +- .../godot-iap/addons/godot-iap/godot_iap.gd | 76 ++++++ .../Sources/GodotIap/GodotIap.swift | 134 ++++++++++ .../github/hyochan/kmpiap/InAppPurchaseIOS.kt | 68 +++-- libraries/react-native-iap/src/index.ts | 20 ++ .../apple/Sources/OpenIapModule+ObjC.swift | 23 ++ 15 files changed, 1006 insertions(+), 50 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7ba649db..04599a64 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,6 +43,7 @@ openiap/ **CRITICAL**: Before writing or editing anything in a package or library: 1. **Read the relevant knowledge files** from `knowledge/internal/` + - When the GraphQL schema adds or changes an API, follow the **SDK Parity Checklist** in [`knowledge/internal/04-platform-packages.md`](knowledge/internal/04-platform-packages.md#sdk-parity-checklist-critical--prevents-declared-but-not-implemented) to avoid phantom interfaces (declared in types but never wired end-to-end — the class of bug behind GitHub issue #104). 2. **Check the package-specific CONVENTION.md**: - [`packages/gql/CONVENTION.md`](packages/gql/CONVENTION.md) - [`packages/google/CONVENTION.md`](packages/google/CONVENTION.md) diff --git a/knowledge/internal/04-platform-packages.md b/knowledge/internal/04-platform-packages.md index 2fe594e9..a27de763 100644 --- a/knowledge/internal/04-platform-packages.md +++ b/knowledge/internal/04-platform-packages.md @@ -107,6 +107,79 @@ swift build # Verifies ObjC bridge compiles --- +## SDK Parity Checklist (CRITICAL — prevents "declared but not implemented") + +When the GraphQL schema in [`packages/gql`](../../packages/gql) adds or changes an API, the regenerated `types.*` files **declare** the handler but do not **implement** it. Every wrapper library must wire the new API end-to-end or users will see silent nulls, phantom interfaces (GitHub issue #104), or `UnsupportedOperationException` at runtime. + +### The bug pattern + +A symptom like "interface exists in `types.dart` / `types.ts` / `Types.kt` but calling it does nothing / throws" means one or more of these layers is missing: + +```text +GraphQL schema ─► generated types ─► public API ─► native bridge ─► core module impl + (SSOT) (auto-generated) (hand-written) (hand-written) (shared Swift/Kotlin) + ▲ ▲ ▲ + │ │ │ + must match must be exported must dispatch +``` + +### Per-library completion checklist + +For every new/changed handler in the generated types, verify **all five** of these per target library before considering the change shippable: + +| Library | 1. Type declared | 2. Public API exposed | 3. Platform bridge | 4. Wired into handlers bundle | 5. Test coverage | +|---------|------------------|-----------------------|--------------------|-------------------------------|------------------| +| **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) | +| **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 | + +### Platform suffix rule (who needs what) + +The suffix on the handler name tells you which native bridges are required: + +- **`…IOS` suffix** → iOS bridge only. Non-iOS platforms should return the type's zero value (`false`, `null`, empty list) or throw a documented `PlatformException` for void ops. **Do not** wire into Android bridges. +- **`…Android` suffix** → Android bridge only. Same rule in reverse. +- **No suffix** → both iOS and Android bridges required. + +Wiring an iOS-suffixed method into an Android bridge is a bug — the earlier audit agents produced false positives like this. + +### Common failure modes observed in the codebase + +1. **Phantom interface** (GitHub issue #104, Flutter `beginRefundRequestIOS` pre-2026-04): generated type exists, nothing else does. Users see an uncallable interface. +2. **`UnsupportedOperationException` stub** (KMP pattern): method declared, iOS impl deliberately throws with "not implemented in OpenIAP". Usually a stale stub — the ObjC bridge method may already exist. Always `grep OpenIapModule+ObjC.swift` for `With*` before assuming the bridge is missing. +3. **Channel-name drift** (Flutter `getAppTransactionIOS` pre-2026-04): Dart calls `_channel.invokeMethod('getAppTransaction')` but the Swift plugin only handles `"getAppTransactionIOS"` (or vice versa). Mocked tests passed because the test intercepted the wrong name too. +4. **Handler bundle omission** (Flutter): Dart getter exists, Swift bridge exists, but the new handler is not listed in `queryHandlers` / `mutationHandlers`. Consumers using the generated handler bundle (e.g., for cross-platform dispatch) silently miss the API. + +### Audit command for a new handler + +After regenerating types, run for each library: + +```bash +# Replace with the new handler name (camelCase, e.g., beginRefundRequestIOS) +NAME= + +echo "=== Type declared? ===" +rg -n "$NAME" libraries/*/lib/types.dart libraries/*/src/types.ts libraries/*/library/src/commonMain/kotlin/**/Types.kt libraries/*/addons/godot-iap/types.gd + +echo "=== Public API exposed? ===" +rg -n "^export (const|async function|function) $NAME\b|get $NAME\b|func $NAME\b|snake_case equivalent" libraries/ + +echo "=== Native bridge? ===" +rg -n "\"$NAME\"|\.$NAME\b" libraries/*/ios libraries/*/android libraries/*/ios-gdextension + +echo "=== Wired into handlers bundle? (Flutter only) ===" +rg -n "$NAME:" libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart + +echo "=== Throws stub? ===" +rg -n "UnsupportedOperationException.*$NAME" libraries/ +``` + +Any empty result for a layer that *should* have the handler (per the suffix rule) is a gap that must be filled before merging. + +--- + ## Google Package (packages/google) ### Required Pre-Work (Google) diff --git a/libraries/expo-iap/src/modules/android.ts b/libraries/expo-iap/src/modules/android.ts index c4732c29..7889af97 100644 --- a/libraries/expo-iap/src/modules/android.ts +++ b/libraries/expo-iap/src/modules/android.ts @@ -127,6 +127,33 @@ export const validateReceiptAndroid = async ({ return response.json(); }; +/** + * Consume a purchase token so the user can purchase the same product again + * (Android consumable products). Prefer using `finishTransaction` with + * `isConsumable: true`, which dispatches to this under the hood. + */ +export const consumePurchaseAndroid: MutationField< + 'consumePurchaseAndroid' +> = async (purchaseToken) => { + const result = await ExpoIapModule.consumePurchaseAndroid(purchaseToken); + + if (typeof result === 'boolean') { + return result; + } + + if (result && typeof result === 'object') { + const record = result as Record; + if (typeof record.success === 'boolean') { + return record.success; + } + if (typeof record.responseCode === 'number') { + return record.responseCode === 0; + } + } + + return true; +}; + /** * Acknowledge a product (on Android.) No-op on iOS. * @param {Object} params - The parameters object diff --git a/libraries/expo-iap/src/modules/ios.ts b/libraries/expo-iap/src/modules/ios.ts index 55a30cc8..1e8ea80e 100644 --- a/libraries/expo-iap/src/modules/ios.ts +++ b/libraries/expo-iap/src/modules/ios.ts @@ -184,6 +184,22 @@ export const getReceiptDataIOS: QueryField<'getReceiptDataIOS'> = async () => { export const getReceiptIOS = getReceiptDataIOS; +/** + * Get the current App Store storefront country code on iOS. + * + * @deprecated Use cross-platform `getStorefront` from the main index instead. + * The native module exposes a single `getStorefront` AsyncFunction that already + * resolves to the iOS storefront on iOS. This helper is kept as an iOS-only + * alias so consumers who previously imported `getStorefrontIOS` do not break. + * + * @returns {Promise} ISO 3166-1 alpha-2 country code (e.g. "US") + * + * @platform iOS + */ +export const getStorefrontIOS: QueryField<'getStorefrontIOS'> = async () => { + return ExpoIapModule.getStorefront(); +}; + /** * Refresh the receipt data from Apple's servers and return the updated receipt. * This calls AppStore.sync() before reading the receipt, ensuring the latest diff --git a/libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift b/libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift index bbcef3da..cb2ff6f4 100644 --- a/libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift +++ b/libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift @@ -158,6 +158,17 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { 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) @@ -174,6 +185,75 @@ 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": guard let args = call.arguments as? [String: Any] else { let code: ErrorCode = .developerError @@ -564,6 +644,22 @@ 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 { + await MainActor.run { + 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") @@ -794,6 +890,152 @@ 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 { + await MainActor.run { + 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 { + await MainActor.run { + 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 { + await MainActor.run { + 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 { + await MainActor.run { + 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 { + await MainActor.run { + 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 { + await MainActor.run { + 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") + result(jws) + } catch { + await MainActor.run { + 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") + result(receipt) + } catch { + await MainActor.run { + let code: ErrorCode = .serviceError + result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) + } + } + } + } + // MARK: - Alternative Billing (iOS 18.2+) @available(iOS 18.2, macOS 14.0, tvOS 18.2, *) diff --git a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart index 1200f520..6e6fef12 100644 --- a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart +++ b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart @@ -703,12 +703,11 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { } try { - await _channel.invokeMethod('endConnection'); - await _channel.invokeMethod('initConnection'); - return true; - } catch (error) { - debugPrint('Error syncing iOS purchases: $error'); - rethrow; + final result = await _channel.invokeMethod('syncIOS'); + return result ?? false; + } on PlatformException catch (error) { + throw _purchaseErrorFromPlatformException( + error, 'sync iOS purchases'); } }; @@ -914,6 +913,40 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { } }; + /// iOS specific: Begin a refund request for a purchase + /// + /// Opens the StoreKit 2 refund sheet for the given SKU and returns the + /// resulting status string (`"success"` or `"userCancelled"`). Returns + /// `null` when StoreKit reports an unknown status. Requires iOS 15.0+. + gentype.MutationBeginRefundRequestIOSHandler get beginRefundRequestIOS => + (String sku) async { + if (!_platform.isIOS || _platform.isMacOS) { + throw PlatformException( + code: 'platform', + message: 'beginRefundRequestIOS is only supported on iOS', + ); + } + + try { + final status = await channel.invokeMethod( + 'beginRefundRequestIOS', + {'sku': sku}, + ); + return status; + } on PlatformException catch (error) { + throw _purchaseErrorFromPlatformException( + error, + 'begin refund request', + ); + } catch (error) { + if (error is PurchaseError) rethrow; + throw PurchaseError( + code: gentype.ErrorCode.ServiceError, + message: 'Failed to begin refund request: ${error.toString()}', + ); + } + }; + /// iOS specific: Show manage subscriptions gentype.MutationShowManageSubscriptionsIOSHandler get showManageSubscriptionsIOS => () async { @@ -984,6 +1017,140 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { return const []; }; + /// iOS specific: Return the current active entitlement for a SKU, if any. + gentype.QueryCurrentEntitlementIOSHandler get currentEntitlementIOS => + (String sku) async { + if (!_platform.isIOS || _platform.isMacOS) { + return null; + } + try { + final result = await _channel.invokeMethod>( + 'currentEntitlementIOS', + {'sku': sku}, + ); + if (result == null) return null; + final purchases = extractPurchases( + [result], + platformIsAndroid: _platform.isAndroid, + platformIsIOS: _platform.isIOS || _platform.isMacOS, + acknowledgedAndroidPurchaseTokens: + _acknowledgedAndroidPurchaseTokens, + ); + return purchases.whereType().firstOrNull; + } on PlatformException catch (error) { + throw _purchaseErrorFromPlatformException( + error, + 'fetch current entitlement', + ); + } + }; + + /// iOS specific: Return the most recent transaction for a SKU, including + /// expired or revoked ones. + gentype.QueryLatestTransactionIOSHandler get latestTransactionIOS => + (String sku) async { + if (!_platform.isIOS || _platform.isMacOS) { + return null; + } + try { + final result = await _channel.invokeMethod>( + 'latestTransactionIOS', + {'sku': sku}, + ); + if (result == null) return null; + final purchases = extractPurchases( + [result], + platformIsAndroid: _platform.isAndroid, + platformIsIOS: _platform.isIOS || _platform.isMacOS, + acknowledgedAndroidPurchaseTokens: + _acknowledgedAndroidPurchaseTokens, + ); + return purchases.whereType().firstOrNull; + } on PlatformException catch (error) { + throw _purchaseErrorFromPlatformException( + error, + 'fetch latest transaction', + ); + } + }; + + /// iOS specific: Whether the latest transaction for a SKU passes StoreKit 2 + /// verification. + gentype.QueryIsTransactionVerifiedIOSHandler get isTransactionVerifiedIOS => + (String sku) async { + if (!_platform.isIOS || _platform.isMacOS) { + return false; + } + try { + final verified = await _channel.invokeMethod( + 'isTransactionVerifiedIOS', + {'sku': sku}, + ); + return verified ?? false; + } on PlatformException catch (error) { + throw _purchaseErrorFromPlatformException( + error, + 'verify transaction', + ); + } + }; + + /// iOS specific: Return the signed JWS representation of the latest + /// transaction for a SKU, suitable for server-side verification. + gentype.QueryGetTransactionJwsIOSHandler get getTransactionJwsIOS => + (String sku) async { + if (!_platform.isIOS || _platform.isMacOS) { + return null; + } + try { + return await _channel.invokeMethod( + 'getTransactionJwsIOS', + {'sku': sku}, + ); + } on PlatformException catch (error) { + throw _purchaseErrorFromPlatformException( + error, + 'fetch transaction jws', + ); + } + }; + + /// iOS specific: Return the base64-encoded App Store receipt data (legacy + /// StoreKit 1 API). Use JWS-based verification for StoreKit 2. + gentype.QueryGetReceiptDataIOSHandler get getReceiptDataIOS => () async { + if (!_platform.isIOS || _platform.isMacOS) { + return null; + } + try { + return await _channel.invokeMethod('getReceiptDataIOS'); + } on PlatformException catch (error) { + throw _purchaseErrorFromPlatformException( + error, + 'fetch receipt data', + ); + } + }; + + /// iOS 18.2+: Whether the current device/region can present the external + /// purchase notice sheet. + gentype.QueryCanPresentExternalPurchaseNoticeIOSHandler + get canPresentExternalPurchaseNoticeIOS => () async { + if (!_platform.isIOS || _platform.isMacOS) { + return false; + } + try { + final result = await _channel.invokeMethod( + 'canPresentExternalPurchaseNoticeIOS', + ); + return result ?? false; + } on PlatformException catch (error) { + throw _purchaseErrorFromPlatformException( + error, + 'check external purchase notice eligibility', + ); + } + }; + gentype.MutationAcknowledgePurchaseAndroidHandler get acknowledgePurchaseAndroid => (purchaseToken) async { if (!_platform.isAndroid) { @@ -2268,6 +2435,9 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { }; gentype.QueryHandlers get queryHandlers => gentype.QueryHandlers( + canPresentExternalPurchaseNoticeIOS: + canPresentExternalPurchaseNoticeIOS, + currentEntitlementIOS: currentEntitlementIOS, fetchProducts: _fetchProductsHandler, getActiveSubscriptions: getActiveSubscriptions, getAppTransactionIOS: getAppTransactionIOS, @@ -2277,24 +2447,49 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { getAllTransactionsIOS: getAllTransactionsIOS, getPendingTransactionsIOS: getPendingTransactionsIOS, getPromotedProductIOS: getPromotedProductIOS, + getReceiptDataIOS: getReceiptDataIOS, getStorefront: getStorefront, getStorefrontIOS: getStorefrontIOS, + getTransactionJwsIOS: getTransactionJwsIOS, hasActiveSubscriptions: hasActiveSubscriptions, isEligibleForExternalPurchaseCustomLinkIOS: isEligibleForExternalPurchaseCustomLinkIOS, isEligibleForIntroOfferIOS: isEligibleForIntroOfferIOS, + isTransactionVerifiedIOS: isTransactionVerifiedIOS, + latestTransactionIOS: latestTransactionIOS, subscriptionStatusIOS: subscriptionStatusIOS, validateReceiptIOS: validateReceiptIOS, ); + gentype.MutationLaunchExternalLinkAndroidHandler + get _launchExternalLinkAndroidHandler => ({ + required gentype.BillingProgramAndroid billingProgram, + required gentype.ExternalLinkLaunchModeAndroid launchMode, + required gentype.ExternalLinkTypeAndroid linkType, + required String linkUri, + }) => + launchExternalLinkAndroid( + gentype.LaunchExternalLinkParamsAndroid( + billingProgram: billingProgram, + launchMode: launchMode, + linkType: linkType, + linkUri: linkUri, + ), + ); + // ignore: deprecated_member_use_from_same_package gentype.MutationHandlers get mutationHandlers => gentype.MutationHandlers( acknowledgePurchaseAndroid: acknowledgePurchaseAndroid, + beginRefundRequestIOS: beginRefundRequestIOS, consumePurchaseAndroid: consumePurchaseAndroid, + createBillingProgramReportingDetailsAndroid: + createBillingProgramReportingDetailsAndroid, deepLinkToSubscriptions: deepLinkToSubscriptions, endConnection: endConnection, finishTransaction: finishTransaction, initConnection: initConnection, + isBillingProgramAvailableAndroid: isBillingProgramAvailableAndroid, + launchExternalLinkAndroid: _launchExternalLinkAndroidHandler, presentCodeRedemptionSheetIOS: presentCodeRedemptionSheetIOS, requestPurchase: requestPurchase, requestPurchaseOnPromotedProductIOS: @@ -2304,6 +2499,7 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { showManageSubscriptionsIOS: showManageSubscriptionsIOS, syncIOS: syncIOS, validateReceipt: validateReceipt, + verifyPurchase: verifyPurchase, clearTransactionIOS: clearTransactionIOS, // Alternative Billing APIs checkAlternativeBillingAvailabilityAndroid: diff --git a/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart b/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart index 1fef951e..2195a774 100644 --- a/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart +++ b/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart @@ -1373,18 +1373,16 @@ void main() { group('sync and restore helpers', () { test('restorePurchases triggers sync and fetch on iOS', () async { - int initCalls = 0; - int endCalls = 0; + int syncCalls = 0; int availableCalls = 0; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (MethodCall call) async { switch (call.method) { case 'initConnection': - initCalls += 1; return true; - case 'endConnection': - endCalls += 1; + case 'syncIOS': + syncCalls += 1; return true; case 'getAvailableItems': availableCalls += 1; @@ -1400,8 +1398,7 @@ void main() { expect(await iap.initConnection(), isTrue); await iap.restorePurchases(); - expect(endCalls, greaterThanOrEqualTo(1)); - expect(initCalls, greaterThanOrEqualTo(2)); + expect(syncCalls, 1); expect(availableCalls, 1); }); @@ -1413,7 +1410,7 @@ void main() { if (call.method == 'initConnection') { return true; } - if (call.method == 'endConnection') { + if (call.method == 'syncIOS') { throw PlatformException(code: '500', message: 'boom'); } if (call.method == 'getAvailableItems') { @@ -1465,17 +1462,12 @@ void main() { }); test('syncIOS returns true when native calls succeed', () async { - int endCalls = 0; - int initCalls = 0; + int syncCalls = 0; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (MethodCall call) async { - if (call.method == 'endConnection') { - endCalls += 1; - return true; - } - if (call.method == 'initConnection') { - initCalls += 1; + if (call.method == 'syncIOS') { + syncCalls += 1; return true; } return null; @@ -1486,19 +1478,15 @@ void main() { ); expect(await iap.syncIOS(), isTrue); - expect(endCalls, 1); - expect(initCalls, 1); + expect(syncCalls, 1); }); - test('syncIOS rethrows platform exceptions', () async { + test('syncIOS wraps platform exceptions as PurchaseError', () async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (MethodCall call) async { - if (call.method == 'endConnection') { + if (call.method == 'syncIOS') { throw PlatformException(code: '500', message: 'boom'); } - if (call.method == 'initConnection') { - return true; - } return null; }); @@ -1506,7 +1494,7 @@ void main() { FakePlatform(operatingSystem: 'ios'), ); - await expectLater(iap.syncIOS(), throwsA(isA())); + await expectLater(iap.syncIOS(), throwsA(isA())); }); test('syncIOS returns false on unsupported platforms', () async { diff --git a/libraries/flutter_inapp_purchase/test/ios_methods_test.dart b/libraries/flutter_inapp_purchase/test/ios_methods_test.dart index da473ae0..ce13e722 100644 --- a/libraries/flutter_inapp_purchase/test/ios_methods_test.dart +++ b/libraries/flutter_inapp_purchase/test/ios_methods_test.dart @@ -111,6 +111,42 @@ void main() { 'type': 'IN_APP', 'typeIOS': 'CONSUMABLE', }; + case 'beginRefundRequestIOS': + return 'success'; + case 'currentEntitlementIOS': + return { + '__typename': 'PurchaseIOS', + 'id': 'txn-entitlement', + 'productId': 'com.example.prod1', + 'platform': 'IOS', + 'store': 'apple', + 'purchaseState': 'PURCHASED', + 'quantity': 1, + 'transactionDate': 1700000000000, + 'transactionId': 'txn-entitlement', + 'isAutoRenewing': false, + }; + case 'latestTransactionIOS': + return { + '__typename': 'PurchaseIOS', + 'id': 'txn-latest', + 'productId': 'com.example.prod1', + 'platform': 'IOS', + 'store': 'apple', + 'purchaseState': 'PURCHASED', + 'quantity': 1, + 'transactionDate': 1700000000000, + 'transactionId': 'txn-latest', + 'isAutoRenewing': false, + }; + case 'isTransactionVerifiedIOS': + return true; + case 'getTransactionJwsIOS': + return 'jws-representation-token'; + case 'getReceiptDataIOS': + return 'base64-receipt-data'; + case 'canPresentExternalPurchaseNoticeIOS': + return true; case 'getPendingTransactionsIOS': // Return a list of purchases (as native would) return >[ @@ -474,6 +510,96 @@ void main() { expect(result, isA()); }, ); + + test('beginRefundRequestIOS invokes channel and returns status', () async { + final status = await iap.beginRefundRequestIOS('com.example.prod1'); + expect(status, 'success'); + expect(calls.last.method, 'beginRefundRequestIOS'); + expect( + calls.last.arguments, + {'sku': 'com.example.prod1'}, + ); + }); + + test('beginRefundRequestIOS throws PlatformException on non-iOS', () async { + final androidIap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: 'android'), + ); + await expectLater( + androidIap.beginRefundRequestIOS('com.example.prod1'), + throwsA(isA()), + ); + }); + + test('currentEntitlementIOS returns typed PurchaseIOS', () async { + final purchase = await iap.currentEntitlementIOS('com.example.prod1'); + expect(purchase, isA()); + expect(purchase!.productId, 'com.example.prod1'); + expect(calls.last.method, 'currentEntitlementIOS'); + }); + + test('currentEntitlementIOS returns null on non-iOS', () async { + final androidIap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: 'android'), + ); + expect(await androidIap.currentEntitlementIOS('sku'), isNull); + }); + + test('latestTransactionIOS returns typed PurchaseIOS', () async { + final purchase = await iap.latestTransactionIOS('com.example.prod1'); + expect(purchase, isA()); + expect(purchase!.transactionId, 'txn-latest'); + expect(calls.last.method, 'latestTransactionIOS'); + }); + + test('latestTransactionIOS returns null when native returns null', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + if (methodCall.method == 'latestTransactionIOS') { + return null; + } + return null; + }); + expect(await iap.latestTransactionIOS('sku'), isNull); + }); + + test('isTransactionVerifiedIOS returns bool from native', () async { + expect(await iap.isTransactionVerifiedIOS('com.example.prod1'), isTrue); + expect(calls.last.method, 'isTransactionVerifiedIOS'); + }); + + test('isTransactionVerifiedIOS returns false on non-iOS', () async { + final androidIap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: 'android'), + ); + expect(await androidIap.isTransactionVerifiedIOS('sku'), isFalse); + }); + + test('getTransactionJwsIOS returns JWS string', () async { + final jws = await iap.getTransactionJwsIOS('com.example.prod1'); + expect(jws, 'jws-representation-token'); + expect(calls.last.method, 'getTransactionJwsIOS'); + }); + + test('getReceiptDataIOS returns base64 receipt string', () async { + final receipt = await iap.getReceiptDataIOS(); + expect(receipt, 'base64-receipt-data'); + expect(calls.last.method, 'getReceiptDataIOS'); + }); + + test('canPresentExternalPurchaseNoticeIOS returns bool', () async { + expect(await iap.canPresentExternalPurchaseNoticeIOS(), isTrue); + expect(calls.last.method, 'canPresentExternalPurchaseNoticeIOS'); + }); + + test('canPresentExternalPurchaseNoticeIOS returns false on non-iOS', + () async { + final androidIap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: 'android'), + ); + expect(await androidIap.canPresentExternalPurchaseNoticeIOS(), isFalse); + }); }); group('ExternalPurchaseCustomLink APIs (iOS 18.1+)', () { diff --git a/libraries/flutter_inapp_purchase/test/ios_module_methods_test.dart b/libraries/flutter_inapp_purchase/test/ios_module_methods_test.dart index e5884430..a6c239ef 100644 --- a/libraries/flutter_inapp_purchase/test/ios_module_methods_test.dart +++ b/libraries/flutter_inapp_purchase/test/ios_module_methods_test.dart @@ -21,6 +21,7 @@ void main() { switch (call.method) { case 'endConnection': case 'initConnection': + case 'syncIOS': return true; case 'isEligibleForIntroOfferIOS': return true; @@ -90,7 +91,7 @@ void main() { .setMockMethodCallHandler(iapIOS.channel, null); }); - test('syncIOS calls end/init on iOS and false on Android', () async { + test('syncIOS calls AppStore.sync on iOS and false on Android', () async { expect(await iapIOS.syncIOS(), true); final iapAndroid = FlutterInappPurchase.private( FakePlatform(operatingSystem: 'android'), diff --git a/libraries/flutter_inapp_purchase/test/subscription_handlers_test.dart b/libraries/flutter_inapp_purchase/test/subscription_handlers_test.dart index 691c2be8..2a6fafff 100644 --- a/libraries/flutter_inapp_purchase/test/subscription_handlers_test.dart +++ b/libraries/flutter_inapp_purchase/test/subscription_handlers_test.dart @@ -17,8 +17,7 @@ void main() { expect( handlers.subscriptionBillingIssue, isNotNull, - reason: - 'subscriptionHandlers.subscriptionBillingIssue must be wired so ' + reason: 'subscriptionHandlers.subscriptionBillingIssue must be wired so ' 'consumers using the generated handler bundle can await the event.', ); }); diff --git a/libraries/godot-iap/addons/godot-iap/godot_iap.gd b/libraries/godot-iap/addons/godot-iap/godot_iap.gd index e0bb9c57..b4f22554 100644 --- a/libraries/godot-iap/addons/godot-iap/godot_iap.gd +++ b/libraries/godot-iap/addons/godot-iap/godot_iap.gd @@ -860,6 +860,82 @@ func get_transaction_jws_ios(sku: String) -> String: return result.get("jws", "") return "" +## Get the current App Store storefront country code (iOS). +## @deprecated Prefer cross-platform get_storefront() which also works on iOS. +## @return String ISO 3166-1 alpha-2 country code, or empty string on failure +func get_storefront_ios() -> String: + if _native_plugin and _platform == "iOS": + return str(_native_plugin.call("getStorefrontIOS")) + return "" + +## Validate a receipt with the App Store for a specific SKU (iOS). +## @deprecated Use verify_purchase or verify_purchase_with_provider instead. +## @param props: Types.VerifyPurchaseProps with `apple: {sku: String}` set +## @return Variant Types.VerifyPurchaseResultIOS on success, null otherwise +func validate_receipt_ios(props) -> Variant: + if not (_native_plugin and _platform == "iOS"): + return null + var props_dict: Dictionary = props.to_dict() if props is Object and props.has_method("to_dict") else (props if props is Dictionary else {}) + var props_json = JSON.stringify(props_dict) + var result_json = _native_plugin.call("validateReceiptIOS", props_json) + var result = JSON.parse_string(result_json) + if result is Dictionary and result.get("status", "") == "pending": + return null # async; consumer should listen to products_fetched signal + if result is Dictionary and result.get("success", false): + var payload_json = result.get("resultJson", "") + var payload = JSON.parse_string(payload_json) + if payload is Dictionary: + return Types.VerifyPurchaseResultIOS.from_dict(payload) + return null + +## Cross-platform wrapper for receipt validation. +## @deprecated Use verify_purchase instead. +## @param props: Types.VerifyPurchaseProps with platform-specific fields +## @return Variant Types.VerifyPurchaseResultIOS | Types.VerifyPurchaseResultAndroid | null +func validate_receipt(props) -> Variant: + if _platform == "iOS": + return validate_receipt_ios(props) + # Android: delegate to verify_purchase which already exists + return _verify_purchase_raw(props.to_dict() if props is Object and props.has_method("to_dict") else (props if props is Dictionary else {})) + +## ExternalPurchaseCustomLink: check eligibility (iOS 18.1+). +## @return bool true if the current context can show external purchase custom link +func is_eligible_for_external_purchase_custom_link_ios() -> bool: + if _native_plugin and _platform == "iOS": + return bool(_native_plugin.call("isEligibleForExternalPurchaseCustomLinkIOS")) + return false + +## ExternalPurchaseCustomLink: request a token for Apple reporting (iOS 18.1+). +## Result is emitted asynchronously via the products_fetched signal. +## @param token_type: String "acquisition" | "services" +## @return Variant Pending status string; actual result arrives via signal +func get_external_purchase_custom_link_token_ios(token_type: String) -> Variant: + if _native_plugin and _platform == "iOS": + var result_json = _native_plugin.call("getExternalPurchaseCustomLinkTokenIOS", token_type) + var result = JSON.parse_string(result_json) + if result is Dictionary and result.get("success", false): + var payload_json = result.get("resultJson", "") + var payload = JSON.parse_string(payload_json) + if payload is Dictionary: + return Types.ExternalPurchaseCustomLinkTokenResultIOS.from_dict(payload) + return null + return null + +## ExternalPurchaseCustomLink: show the disclosure notice sheet (iOS 18.1+). +## @param notice_type: String "browser" +## @return Variant Types.ExternalPurchaseCustomLinkNoticeResultIOS or null +func show_external_purchase_custom_link_notice_ios(notice_type: String) -> Variant: + if _native_plugin and _platform == "iOS": + var result_json = _native_plugin.call("showExternalPurchaseCustomLinkNoticeIOS", notice_type) + var result = JSON.parse_string(result_json) + if result is Dictionary and result.get("success", false): + var payload_json = result.get("resultJson", "") + var payload = JSON.parse_string(payload_json) + if payload is Dictionary: + return Types.ExternalPurchaseCustomLinkNoticeResultIOS.from_dict(payload) + return null + return null + # ========================================== # Android-Specific (OpenIAP) # ========================================== diff --git a/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift b/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift index ee5174ac..87ec299f 100644 --- a/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift +++ b/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift @@ -1088,6 +1088,140 @@ public class GodotIap: RefCounted, @unchecked Sendable { return "{\"status\": \"pending\"}" } + // MARK: - StoreKit 2 Deprecated / Alias APIs + + @Callable + public func validateReceiptIOS(propsJson: String) -> String { + GodotIapLog.payload("validateReceiptIOS", payload: propsJson) + Task { [weak self] in + guard let self = self else { return } + do { + guard let data = propsJson.data(using: .utf8) else { + throw NSError(domain: "GodotIap", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid JSON"]) + } + let props = try JSONDecoder().decode(VerifyPurchaseProps.self, from: data) + let result = try await self.openIap.validateReceiptIOS(props) + let resultDict = OpenIapSerialization.encode(result) + await MainActor.run { [self] in + let dict = VariantDictionary() + dict["success"] = Variant(true) + if let jsonData = try? JSONSerialization.data(withJSONObject: resultDict), + let jsonString = String(data: jsonData, encoding: .utf8) { + dict["resultJson"] = Variant(jsonString) + } + self.productsFetched.emit(dict) + } + } catch { + GodotIapLog.debug("[GodotIap] validateReceiptIOS error: \(error.localizedDescription)") + await MainActor.run { [self] in + let dict = VariantDictionary() + dict["success"] = Variant(false) + dict["error"] = Variant(error.localizedDescription) + self.productsFetched.emit(dict) + } + } + } + return "{\"status\": \"pending\"}" + } + + // MARK: - ExternalPurchaseCustomLink (iOS 18.1+) + + @Callable + public func isEligibleForExternalPurchaseCustomLinkIOS() -> Bool { + GodotIapLog.payload("isEligibleForExternalPurchaseCustomLinkIOS", payload: nil) + if #available(iOS 18.1, macOS 15.0, tvOS 18.1, *) { + let semaphore = DispatchSemaphore(value: 0) + var eligible = false + Task { [weak self] in + guard let self = self else { + semaphore.signal() + return + } + do { + eligible = try await self.openIap.isEligibleForExternalPurchaseCustomLinkIOS() + } catch { + GodotIapLog.debug("[GodotIap] isEligibleForExternalPurchaseCustomLinkIOS error: \(error.localizedDescription)") + } + semaphore.signal() + } + _ = semaphore.wait(timeout: .now() + 5.0) + return eligible + } + return false + } + + @Callable + public func getExternalPurchaseCustomLinkTokenIOS(tokenType: String) -> String { + GodotIapLog.payload("getExternalPurchaseCustomLinkTokenIOS", payload: tokenType) + if #available(iOS 18.1, macOS 15.0, tvOS 18.1, *) { + Task { [weak self] in + guard let self = self else { return } + do { + guard let type = ExternalPurchaseCustomLinkTokenTypeIOS(rawValue: tokenType) else { + throw NSError(domain: "GodotIap", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid token type: \(tokenType)"]) + } + let result = try await self.openIap.getExternalPurchaseCustomLinkTokenIOS(type) + let resultDict = OpenIapSerialization.encode(result) + await MainActor.run { [self] in + let dict = VariantDictionary() + dict["success"] = Variant(true) + if let jsonData = try? JSONSerialization.data(withJSONObject: resultDict), + let jsonString = String(data: jsonData, encoding: .utf8) { + dict["resultJson"] = Variant(jsonString) + } + self.productsFetched.emit(dict) + } + } catch { + GodotIapLog.debug("[GodotIap] getExternalPurchaseCustomLinkTokenIOS error: \(error.localizedDescription)") + await MainActor.run { [self] in + let dict = VariantDictionary() + dict["success"] = Variant(false) + dict["error"] = Variant(error.localizedDescription) + self.productsFetched.emit(dict) + } + } + } + return "{\"status\": \"pending\"}" + } + return "{\"status\": \"unsupported\"}" + } + + @Callable + public func showExternalPurchaseCustomLinkNoticeIOS(noticeType: String) -> String { + GodotIapLog.payload("showExternalPurchaseCustomLinkNoticeIOS", payload: noticeType) + if #available(iOS 18.1, macOS 15.0, tvOS 18.1, *) { + Task { [weak self] in + guard let self = self else { return } + do { + guard let type = ExternalPurchaseCustomLinkNoticeTypeIOS(rawValue: noticeType) else { + throw NSError(domain: "GodotIap", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid notice type: \(noticeType)"]) + } + let result = try await self.openIap.showExternalPurchaseCustomLinkNoticeIOS(type) + let resultDict = OpenIapSerialization.encode(result) + await MainActor.run { [self] in + let dict = VariantDictionary() + dict["success"] = Variant(true) + if let jsonData = try? JSONSerialization.data(withJSONObject: resultDict), + let jsonString = String(data: jsonData, encoding: .utf8) { + dict["resultJson"] = Variant(jsonString) + } + self.productsFetched.emit(dict) + } + } catch { + GodotIapLog.debug("[GodotIap] showExternalPurchaseCustomLinkNoticeIOS error: \(error.localizedDescription)") + await MainActor.run { [self] in + let dict = VariantDictionary() + dict["success"] = Variant(false) + dict["error"] = Variant(error.localizedDescription) + self.productsFetched.emit(dict) + } + } + } + return "{\"status\": \"pending\"}" + } + return "{\"status\": \"unsupported\"}" + } + // MARK: - Private Helpers private func setupListeners() { diff --git a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt index 9131ce60..f9a592d7 100644 --- a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt +++ b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt @@ -243,9 +243,16 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { } } - override suspend fun requestPurchaseOnPromotedProductIOS(): Boolean { - throw UnsupportedOperationException("requestPurchaseOnPromotedProductIOS not implemented in OpenIAP") - } + override suspend fun requestPurchaseOnPromotedProductIOS(): Boolean = + suspendCoroutine { continuation -> + openIapModule.requestPurchaseOnPromotedProductIOSWithCompletion { success, error -> + if (error != null) { + continuation.resumeWithException(Exception(error.localizedDescription)) + } else { + continuation.resume(success) + } + } + } override suspend fun restorePurchases(): Unit = suspendCoroutine { continuation -> openIapModule.restorePurchasesWithCompletion { error -> @@ -275,9 +282,16 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { } } - override suspend fun deepLinkToSubscriptions(options: DeepLinkOptions?): Unit { - throw UnsupportedOperationException("deepLinkToSubscriptions not implemented in OpenIAP") - } + override suspend fun deepLinkToSubscriptions(options: DeepLinkOptions?): Unit = + suspendCoroutine { continuation -> + openIapModule.deepLinkToSubscriptionsWithCompletion { error -> + if (error != null) { + continuation.resumeWithException(Exception(error.localizedDescription)) + } else { + continuation.resume(Unit) + } + } + } override suspend fun presentCodeRedemptionSheetIOS(): Boolean = suspendCoroutine { continuation -> @@ -290,9 +304,16 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { } } - override suspend fun beginRefundRequestIOS(sku: String): String? { - throw UnsupportedOperationException("beginRefundRequestIOS not implemented in OpenIAP") - } + override suspend fun beginRefundRequestIOS(sku: String): String? = + suspendCoroutine { continuation -> + openIapModule.beginRefundRequestIOSWithSku(sku) { status, error -> + if (error != null) { + continuation.resumeWithException(Exception(error.localizedDescription)) + } else { + continuation.resume(status) + } + } + } override suspend fun clearTransactionIOS(): Boolean = suspendCoroutine { continuation -> openIapModule.clearTransactionIOSWithCompletion { success, error -> @@ -318,8 +339,14 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { } } - override suspend fun syncIOS(): Boolean { - throw UnsupportedOperationException("syncIOS not implemented in OpenIAP") + override suspend fun syncIOS(): Boolean = suspendCoroutine { continuation -> + openIapModule.syncIOSWithCompletion { success, error -> + if (error != null) { + continuation.resumeWithException(Exception(error.localizedDescription)) + } else { + continuation.resume(success) + } + } } override suspend fun validateReceipt(options: VerifyPurchaseProps): VerifyPurchaseResult { @@ -411,12 +438,19 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { } } - // TODO: Wire to ObjC bridge once getAllTransactionsIOSWithCompletion is available in consumed CocoaPods artifacts. - override suspend fun getAllTransactionsIOS(): List { - throw UnsupportedOperationException( - "getAllTransactionsIOS is not available in this kmp-iap iOS build yet" - ) - } + override suspend fun getAllTransactionsIOS(): List = + suspendCoroutine { continuation -> + openIapModule.getAllTransactionsIOSWithCompletion { result, error -> + if (error != null) { + continuation.resumeWithException(Exception(error.localizedDescription)) + } else if (result != null) { + val purchases = convertAnyListToPurchaseIOSList(result) + continuation.resume(purchases) + } else { + continuation.resume(emptyList()) + } + } + } override suspend fun getReceiptDataIOS(): String? = suspendCoroutine { continuation -> openIapModule.getReceiptDataIOSWithCompletion { result, error -> diff --git a/libraries/react-native-iap/src/index.ts b/libraries/react-native-iap/src/index.ts index c5fc07dc..556bfc5f 100644 --- a/libraries/react-native-iap/src/index.ts +++ b/libraries/react-native-iap/src/index.ts @@ -1819,6 +1819,26 @@ export const validateReceipt: MutationField<'validateReceipt'> = async ( */ export const verifyPurchase: MutationField<'verifyPurchase'> = validateReceipt; +/** + * iOS-only receipt validation alias. + * + * @deprecated Use `verifyPurchase` (or `validateReceipt`) instead. Kept so + * consumers who imported `validateReceiptIOS` — which is still declared on the + * OpenIAP Query interface — keep working. Throws on non-iOS platforms. + */ +export const validateReceiptIOS: QueryField<'validateReceiptIOS'> = async ( + options, +) => { + if (Platform.OS !== 'ios') { + throw new Error('validateReceiptIOS is only available on iOS'); + } + const result = await validateReceipt(options); + if ((result as {platform?: string}).platform !== 'ios') { + throw new Error('validateReceiptIOS: unexpected non-iOS result'); + } + return result as VerifyPurchaseResultIOS; +}; + /** * Verify purchase with a specific provider (e.g., IAPKit) * diff --git a/packages/apple/Sources/OpenIapModule+ObjC.swift b/packages/apple/Sources/OpenIapModule+ObjC.swift index cfa621ad..0469be27 100644 --- a/packages/apple/Sources/OpenIapModule+ObjC.swift +++ b/packages/apple/Sources/OpenIapModule+ObjC.swift @@ -315,6 +315,29 @@ import StoreKit } } + @available(*, deprecated, message: "Use promotedProductListenerIOS + requestPurchase instead") + @objc func requestPurchaseOnPromotedProductIOSWithCompletion(_ completion: @escaping (Bool, Error?) -> Void) { + Task { + do { + let result = try await requestPurchaseOnPromotedProductIOS() + completion(result, nil) + } catch { + completion(false, error) + } + } + } + + @objc func deepLinkToSubscriptionsWithCompletion(_ completion: @escaping (Error?) -> Void) { + Task { + do { + try await deepLinkToSubscriptions(nil) + completion(nil) + } catch { + completion(error) + } + } + } + // MARK: - Transaction Management @objc func finishTransactionWithPurchaseId( From c074cbff0da507e6bb8491ddbcf40710b96178c0 Mon Sep 17 00:00:00 2001 From: hyochan Date: Sat, 25 Apr 2026 03:49:21 +0900 Subject: [PATCH 02/11] chore: /review-pr now re-triggers bots and polls every 8 minutes After each fix batch is pushed, the skill now re-requests Copilot review and posts /gemini review so the next automated pass starts immediately. It then schedules a wake-up in ~480 seconds via ScheduleWakeup to re-enter /review-pr and pick up any new unresolved threads, stopping only when the PR is clean or the same finding repeats twice in a row. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/commands/review-pr.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/.claude/commands/review-pr.md b/.claude/commands/review-pr.md index 9aeaec51..41787906 100644 --- a/.claude/commands/review-pr.md +++ b/.claude/commands/review-pr.md @@ -42,6 +42,31 @@ For each comment: 4. **Reply directly to the inline review comment** (NOT a general PR comment) 5. **Resolve the conversation** via GraphQL API +After the fix batch is pushed (once per round, not per comment), trigger a fresh round of automated review: + +```bash +# Re-request Copilot review (note: capital C; the bot login is literally "Copilot") +gh api -X POST "repos/hyodotdev/openiap/pulls/$PR_NUMBER/requested_reviewers" \ + -f 'reviewers[]=Copilot' + +# Kick off a new Gemini review pass +gh pr comment "$PR_NUMBER" --body "/gemini review" +``` + +Both are idempotent-ish — Copilot re-request is a no-op if still pending and re-requests if a review was already submitted; `/gemini review` always starts a new pass. Run both so the next polling cycle has something to find. + +## Polling Loop (after fix batch) + +The automated reviewers (Copilot + Gemini) need a few minutes to produce feedback. After pushing a round of fixes and posting `/gemini review`, schedule a wake-up in **~480 seconds (8 minutes)** and re-enter `/review-pr $PR_NUMBER` to: + +1. Re-fetch unresolved review threads (`gh api repos/{owner}/{repo}/pulls/$PR_NUMBER/comments`). +2. If new unresolved threads exist → fix them, push, post `/gemini review` again, and schedule another 8-minute wake-up. +3. If no new unresolved threads exist → the PR is clean. End the loop and report completion to the user. + +Use the `ScheduleWakeup` tool for the wake-up, passing `/review-pr $PR_NUMBER` back as the prompt so the next firing re-enters this skill with full context. Omit the call to stop the loop once all threads are resolved. + +Guard against infinite loops: if a reviewer keeps flagging the same finding after two fix attempts, stop scheduling wake-ups and hand back to the user with a summary of what remains disputed. + ### Replying to Inline Review Comments **CRITICAL:** Always reply to inline review comments using the comment-specific reply API, NOT `gh pr comment`. From cfb6316a9fb79501c4853f54d0821f6ed1422b27 Mon Sep 17 00:00:00 2001 From: hyochan Date: Sat, 25 Apr 2026 03:54:29 +0900 Subject: [PATCH 03/11] docs: add April 25 release note for SDK parity patch Lists the upcoming patch bumps (openiap-apple 2.1.4, react-native-iap 15.2.3, expo-iap 4.2.3, flutter_inapp_purchase 9.2.3, kmp-iap 2.2.3, godot-iap 2.2.3) and summarizes the per-library handler additions shipped with PR #105. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../docs/src/pages/docs/updates/releases.tsx | 198 ++++++++++++++++++ 1 file changed, 198 insertions(+) diff --git a/packages/docs/src/pages/docs/updates/releases.tsx b/packages/docs/src/pages/docs/updates/releases.tsx index 7ede89c6..52e6b899 100644 --- a/packages/docs/src/pages/docs/updates/releases.tsx +++ b/packages/docs/src/pages/docs/updates/releases.tsx @@ -25,6 +25,204 @@ function Releases() { useScrollToHash(); const allNotes: Note[] = [ + // April 25, 2026 — SDK parity patch: wire every type-declared handler end-to-end + { + id: 'releases-2026-04-25', + date: new Date('2026-04-25'), + element: ( +
+ + April 25, 2026 + + +
+
+ SDK parity patch — every type-declared handler now has a runtime + path +
+

+ Closes{' '} + + issue #104 + {' '} + (Flutter beginRefundRequestIOS declared but never + implemented) and every other instance of the same pattern found + via a systematic 3-pass audit across all five wrapper SDKs. After + this release, every handler declared in each library's generated + types file has a complete runtime path (public API + native bridge + + handlers-bundle wiring where applicable). +

+ +
    +
  • + packages/apple — added @objc{' '} + bridge for requestPurchaseOnPromotedProductIOS and{' '} + deepLinkToSubscriptions so kmp-iap cinterop can + reach them. +
  • +
  • + flutter_inapp_purchase — added 7 missing iOS + query handlers (beginRefundRequestIOS,{' '} + currentEntitlementIOS,{' '} + latestTransactionIOS,{' '} + isTransactionVerifiedIOS,{' '} + getTransactionJwsIOS,{' '} + getReceiptDataIOS,{' '} + canPresentExternalPurchaseNoticeIOS), fixed + channel-name drift on syncIOS/ + subscriptionStatusIOS/ + getAppTransactionIOS, and wired{' '} + verifyPurchase plus three Android billing-program + handlers into the MutationHandlers bundle. +
  • +
  • + kmp-iap — replaced 5{' '} + UnsupportedOperationException stubs in{' '} + iosMain with real ObjC bridge calls for{' '} + beginRefundRequestIOS, syncIOS,{' '} + getAllTransactionsIOS,{' '} + requestPurchaseOnPromotedProductIOS, and{' '} + deepLinkToSubscriptions. +
  • +
  • + expo-iap — exported{' '} + consumePurchaseAndroid (previously asymmetric vs{' '} + acknowledgePurchaseAndroid) and{' '} + getStorefrontIOS deprecated alias. +
  • +
  • + react-native-iap — exported{' '} + validateReceiptIOS deprecated alias. +
  • +
  • + godot-iap — added validate_receipt + , validate_receipt_ios, and three iOS 18.1+{' '} + ExternalPurchaseCustomLink methods across the + GDExtension Swift bridge and GDScript public API. +
  • +
+ +

+ The knowledge base gained an{' '} + + SDK Parity Checklist + {' '} + so future schema additions cannot reintroduce phantom interfaces. + See{' '} + + PR #105 + {' '} + for the full diff. +

+
+ + {/* Package Releases */} + +
+ ), + }, + // April 24, 2026 — IAPKit verification host migration shipped in native patches { id: 'releases-2026-04-24', From c46a1fa513ae0629b65722ef152f42d3f17e358c Mon Sep 17 00:00:00 2001 From: hyochan Date: Sat, 25 Apr 2026 04:04:11 +0900 Subject: [PATCH 04/11] fix(review): address Gemini + CodeRabbit review on #105 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rn-iap validateReceiptIOS: drop bogus result.platform check that always threw because the iOS payload does not carry a `platform` field. - flutter plugin: add verifyPurchase method-channel case aliased to the validateReceiptIOS handler so the MutationHandlers bundle's verifyPurchase call works on iOS. - flutter plugin: switch the six new iOS query handlers' platform guards from `!_platform.isIOS || _platform.isMacOS` to `!isIOS` so macOS (where the iOS getter is true) is correctly included — these StoreKit 2 APIs are available on macOS 12+. - flutter plugin: drop the redundant `await MainActor.run` inside the new `Task { @MainActor in }` blocks (the whole task is already on MainActor). - expo-iap consumePurchaseAndroid: throw with the unexpected payload instead of silently returning true when the native response is an unknown shape. - godot-iap GDScript: parse the JSON from getStorefrontIOS into the country code instead of stringifying the whole dict; switch is_eligible_for_external_purchase_custom_link_ios, get_external_purchase_custom_link_token_ios, show_external_purchase_custom_link_notice_ios, and validate_receipt_ios to `await products_fetched` so callers get the result inline without subscribing to the signal by hand. - godot-iap Swift: stop blocking the Godot main thread with DispatchSemaphore in isEligibleForExternalPurchaseCustomLinkIOS — return a pending status and emit through productsFetched like the sibling token/notice calls. Also correct the #available gate from macOS 15.0 to 15.1 (matches the OpenIapModule availability). - knowledge parity checklist: replace the broken recursive glob `libraries/*/library/src/commonMain/kotlin/**/Types.kt` with an explicit kmp-iap directory path so the audit command actually finds the generated KMP types. Co-Authored-By: Claude Opus 4.7 (1M context) --- knowledge/internal/04-platform-packages.md | 6 +- libraries/expo-iap/src/modules/android.ts | 6 +- .../Classes/FlutterInappPurchasePlugin.swift | 56 +++++--------- .../lib/flutter_inapp_purchase.dart | 12 +-- .../godot-iap/addons/godot-iap/godot_iap.gd | 75 +++++++++++-------- .../Sources/GodotIap/GodotIap.swift | 40 ++++++---- libraries/react-native-iap/src/index.ts | 3 - 7 files changed, 104 insertions(+), 94 deletions(-) diff --git a/knowledge/internal/04-platform-packages.md b/knowledge/internal/04-platform-packages.md index a27de763..a21c77e7 100644 --- a/knowledge/internal/04-platform-packages.md +++ b/knowledge/internal/04-platform-packages.md @@ -161,7 +161,11 @@ After regenerating types, run for each library: NAME= echo "=== Type declared? ===" -rg -n "$NAME" libraries/*/lib/types.dart libraries/*/src/types.ts libraries/*/library/src/commonMain/kotlin/**/Types.kt libraries/*/addons/godot-iap/types.gd +rg -n "$NAME" \ + libraries/*/lib/types.dart \ + libraries/*/src/types.ts \ + libraries/kmp-iap/library/src/commonMain/kotlin \ + libraries/*/addons/godot-iap/types.gd echo "=== Public API exposed? ===" rg -n "^export (const|async function|function) $NAME\b|get $NAME\b|func $NAME\b|snake_case equivalent" libraries/ diff --git a/libraries/expo-iap/src/modules/android.ts b/libraries/expo-iap/src/modules/android.ts index 7889af97..2b2035b5 100644 --- a/libraries/expo-iap/src/modules/android.ts +++ b/libraries/expo-iap/src/modules/android.ts @@ -151,7 +151,11 @@ export const consumePurchaseAndroid: MutationField< } } - return true; + throw new Error( + `consumePurchaseAndroid returned an unexpected response payload: ${JSON.stringify( + result, + )}`, + ); }; /** diff --git a/libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift b/libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift index cb2ff6f4..e8c8a68f 100644 --- a/libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift +++ b/libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift @@ -254,7 +254,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { result(FlutterError(code: code.rawValue, message: "sku required", details: nil)) } - case "validateReceiptIOS": + 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)) @@ -652,10 +652,8 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { FlutterIapLog.result("beginRefundRequestIOS", value: status ?? "nil") result(status) } catch { - await MainActor.run { - let code: ErrorCode = .serviceError - result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) - } + let code: ErrorCode = .serviceError + result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) } } } @@ -898,10 +896,8 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { FlutterIapLog.result("syncIOS", value: success) result(success) } catch { - await MainActor.run { - let code: ErrorCode = .syncError - result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) - } + let code: ErrorCode = .syncError + result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) } } } @@ -917,10 +913,8 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { FlutterIapLog.result("subscriptionStatusIOS", value: payload) result(payload) } catch { - await MainActor.run { - let code: ErrorCode = .serviceError - result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) - } + let code: ErrorCode = .serviceError + result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) } } } @@ -938,10 +932,8 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { result(nil) } } catch { - await MainActor.run { - let code: ErrorCode = .serviceError - result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) - } + let code: ErrorCode = .serviceError + result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) } } } @@ -960,10 +952,8 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { result(nil) } } catch { - await MainActor.run { - let code: ErrorCode = .serviceError - result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) - } + let code: ErrorCode = .serviceError + result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) } } } @@ -980,10 +970,8 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { result(nil) } } catch { - await MainActor.run { - let code: ErrorCode = .serviceError - result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) - } + let code: ErrorCode = .serviceError + result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) } } } @@ -996,10 +984,8 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { FlutterIapLog.result("isTransactionVerifiedIOS", value: verified) result(verified) } catch { - await MainActor.run { - let code: ErrorCode = .serviceError - result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) - } + let code: ErrorCode = .serviceError + result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) } } } @@ -1012,10 +998,8 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { FlutterIapLog.result("getTransactionJwsIOS", value: jws ?? "nil") result(jws) } catch { - await MainActor.run { - let code: ErrorCode = .serviceError - result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) - } + let code: ErrorCode = .serviceError + result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) } } } @@ -1028,10 +1012,8 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { FlutterIapLog.result("getReceiptDataIOS", value: receipt ?? "nil") result(receipt) } catch { - await MainActor.run { - let code: ErrorCode = .serviceError - result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) - } + let code: ErrorCode = .serviceError + result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) } } } diff --git a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart index 6e6fef12..052de754 100644 --- a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart +++ b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart @@ -1020,7 +1020,7 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { /// iOS specific: Return the current active entitlement for a SKU, if any. gentype.QueryCurrentEntitlementIOSHandler get currentEntitlementIOS => (String sku) async { - if (!_platform.isIOS || _platform.isMacOS) { + if (!isIOS) { return null; } try { @@ -1049,7 +1049,7 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { /// expired or revoked ones. gentype.QueryLatestTransactionIOSHandler get latestTransactionIOS => (String sku) async { - if (!_platform.isIOS || _platform.isMacOS) { + if (!isIOS) { return null; } try { @@ -1078,7 +1078,7 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { /// verification. gentype.QueryIsTransactionVerifiedIOSHandler get isTransactionVerifiedIOS => (String sku) async { - if (!_platform.isIOS || _platform.isMacOS) { + if (!isIOS) { return false; } try { @@ -1099,7 +1099,7 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { /// transaction for a SKU, suitable for server-side verification. gentype.QueryGetTransactionJwsIOSHandler get getTransactionJwsIOS => (String sku) async { - if (!_platform.isIOS || _platform.isMacOS) { + if (!isIOS) { return null; } try { @@ -1118,7 +1118,7 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { /// iOS specific: Return the base64-encoded App Store receipt data (legacy /// StoreKit 1 API). Use JWS-based verification for StoreKit 2. gentype.QueryGetReceiptDataIOSHandler get getReceiptDataIOS => () async { - if (!_platform.isIOS || _platform.isMacOS) { + if (!isIOS) { return null; } try { @@ -1135,7 +1135,7 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { /// purchase notice sheet. gentype.QueryCanPresentExternalPurchaseNoticeIOSHandler get canPresentExternalPurchaseNoticeIOS => () async { - if (!_platform.isIOS || _platform.isMacOS) { + if (!isIOS) { return false; } try { diff --git a/libraries/godot-iap/addons/godot-iap/godot_iap.gd b/libraries/godot-iap/addons/godot-iap/godot_iap.gd index b4f22554..9a6e77fb 100644 --- a/libraries/godot-iap/addons/godot-iap/godot_iap.gd +++ b/libraries/godot-iap/addons/godot-iap/godot_iap.gd @@ -865,10 +865,15 @@ func get_transaction_jws_ios(sku: String) -> String: ## @return String ISO 3166-1 alpha-2 country code, or empty string on failure func get_storefront_ios() -> String: if _native_plugin and _platform == "iOS": - return str(_native_plugin.call("getStorefrontIOS")) + var result_json = _native_plugin.call("getStorefrontIOS") + var result = JSON.parse_string(result_json) + if result is Dictionary and result.get("success", false): + return result.get("storefront", "") return "" ## Validate a receipt with the App Store for a specific SKU (iOS). +## Kicks off the native async validation and awaits the next `products_fetched` +## signal carrying the result payload. Returns null on error. ## @deprecated Use verify_purchase or verify_purchase_with_provider instead. ## @param props: Types.VerifyPurchaseProps with `apple: {sku: String}` set ## @return Variant Types.VerifyPurchaseResultIOS on success, null otherwise @@ -877,15 +882,13 @@ func validate_receipt_ios(props) -> Variant: return null var props_dict: Dictionary = props.to_dict() if props is Object and props.has_method("to_dict") else (props if props is Dictionary else {}) var props_json = JSON.stringify(props_dict) - var result_json = _native_plugin.call("validateReceiptIOS", props_json) - var result = JSON.parse_string(result_json) - if result is Dictionary and result.get("status", "") == "pending": - return null # async; consumer should listen to products_fetched signal - if result is Dictionary and result.get("success", false): - var payload_json = result.get("resultJson", "") - var payload = JSON.parse_string(payload_json) - if payload is Dictionary: - return Types.VerifyPurchaseResultIOS.from_dict(payload) + _native_plugin.call("validateReceiptIOS", props_json) + var payload = await products_fetched + if payload is Dictionary and payload.get("success", false): + var payload_json = payload.get("resultJson", "") + var decoded = JSON.parse_string(payload_json) + if decoded is Dictionary: + return Types.VerifyPurchaseResultIOS.from_dict(decoded) return null ## Cross-platform wrapper for receipt validation. @@ -894,46 +897,56 @@ func validate_receipt_ios(props) -> Variant: ## @return Variant Types.VerifyPurchaseResultIOS | Types.VerifyPurchaseResultAndroid | null func validate_receipt(props) -> Variant: if _platform == "iOS": - return validate_receipt_ios(props) + return await validate_receipt_ios(props) # Android: delegate to verify_purchase which already exists return _verify_purchase_raw(props.to_dict() if props is Object and props.has_method("to_dict") else (props if props is Dictionary else {})) ## ExternalPurchaseCustomLink: check eligibility (iOS 18.1+). +## Kicks off the native async check and awaits the next `products_fetched` +## signal carrying an `eligible` boolean; returns false on any error. ## @return bool true if the current context can show external purchase custom link func is_eligible_for_external_purchase_custom_link_ios() -> bool: - if _native_plugin and _platform == "iOS": - return bool(_native_plugin.call("isEligibleForExternalPurchaseCustomLinkIOS")) + if not (_native_plugin and _platform == "iOS"): + return false + _native_plugin.call("isEligibleForExternalPurchaseCustomLinkIOS") + var payload = await products_fetched + if payload is Dictionary and payload.get("success", false): + return bool(payload.get("eligible", false)) return false ## ExternalPurchaseCustomLink: request a token for Apple reporting (iOS 18.1+). -## Result is emitted asynchronously via the products_fetched signal. +## Kicks off the native async request and awaits the next `products_fetched` +## signal carrying the token payload. Returns null on error or on unsupported +## platforms (i.e. iOS < 18.1). ## @param token_type: String "acquisition" | "services" -## @return Variant Pending status string; actual result arrives via signal +## @return Variant Types.ExternalPurchaseCustomLinkTokenResultIOS or null func get_external_purchase_custom_link_token_ios(token_type: String) -> Variant: - if _native_plugin and _platform == "iOS": - var result_json = _native_plugin.call("getExternalPurchaseCustomLinkTokenIOS", token_type) - var result = JSON.parse_string(result_json) - if result is Dictionary and result.get("success", false): - var payload_json = result.get("resultJson", "") - var payload = JSON.parse_string(payload_json) - if payload is Dictionary: - return Types.ExternalPurchaseCustomLinkTokenResultIOS.from_dict(payload) + if not (_native_plugin and _platform == "iOS"): return null + _native_plugin.call("getExternalPurchaseCustomLinkTokenIOS", token_type) + var payload = await products_fetched + if payload is Dictionary and payload.get("success", false): + var payload_json = payload.get("resultJson", "") + var decoded = JSON.parse_string(payload_json) + if decoded is Dictionary: + return Types.ExternalPurchaseCustomLinkTokenResultIOS.from_dict(decoded) return null ## ExternalPurchaseCustomLink: show the disclosure notice sheet (iOS 18.1+). +## Kicks off the native async UI and awaits the next `products_fetched` signal +## carrying the notice result payload. Returns null on error. ## @param notice_type: String "browser" ## @return Variant Types.ExternalPurchaseCustomLinkNoticeResultIOS or null func show_external_purchase_custom_link_notice_ios(notice_type: String) -> Variant: - if _native_plugin and _platform == "iOS": - var result_json = _native_plugin.call("showExternalPurchaseCustomLinkNoticeIOS", notice_type) - var result = JSON.parse_string(result_json) - if result is Dictionary and result.get("success", false): - var payload_json = result.get("resultJson", "") - var payload = JSON.parse_string(payload_json) - if payload is Dictionary: - return Types.ExternalPurchaseCustomLinkNoticeResultIOS.from_dict(payload) + if not (_native_plugin and _platform == "iOS"): return null + _native_plugin.call("showExternalPurchaseCustomLinkNoticeIOS", notice_type) + var payload = await products_fetched + if payload is Dictionary and payload.get("success", false): + var payload_json = payload.get("resultJson", "") + var decoded = JSON.parse_string(payload_json) + if decoded is Dictionary: + return Types.ExternalPurchaseCustomLinkNoticeResultIOS.from_dict(decoded) return null # ========================================== diff --git a/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift b/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift index 87ec299f..93f55ef3 100644 --- a/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift +++ b/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift @@ -1126,34 +1126,44 @@ public class GodotIap: RefCounted, @unchecked Sendable { // MARK: - ExternalPurchaseCustomLink (iOS 18.1+) + /// Check eligibility asynchronously. Returns a pending-status JSON string + /// immediately and emits the actual result through ``productsFetched`` with + /// ``eligible`` set to true/false. Avoids the main-thread deadlock that a + /// blocking ``DispatchSemaphore`` would cause when ``OpenIapModule`` hops + /// back to the main actor. @Callable - public func isEligibleForExternalPurchaseCustomLinkIOS() -> Bool { + public func isEligibleForExternalPurchaseCustomLinkIOS() -> String { GodotIapLog.payload("isEligibleForExternalPurchaseCustomLinkIOS", payload: nil) - if #available(iOS 18.1, macOS 15.0, tvOS 18.1, *) { - let semaphore = DispatchSemaphore(value: 0) - var eligible = false + if #available(iOS 18.1, macOS 15.1, tvOS 18.1, *) { Task { [weak self] in - guard let self = self else { - semaphore.signal() - return - } + guard let self = self else { return } do { - eligible = try await self.openIap.isEligibleForExternalPurchaseCustomLinkIOS() + let eligible = try await self.openIap.isEligibleForExternalPurchaseCustomLinkIOS() + await MainActor.run { [self] in + let dict = VariantDictionary() + dict["success"] = Variant(true) + dict["eligible"] = Variant(eligible) + self.productsFetched.emit(dict) + } } catch { GodotIapLog.debug("[GodotIap] isEligibleForExternalPurchaseCustomLinkIOS error: \(error.localizedDescription)") + await MainActor.run { [self] in + let dict = VariantDictionary() + dict["success"] = Variant(false) + dict["error"] = Variant(error.localizedDescription) + self.productsFetched.emit(dict) + } } - semaphore.signal() } - _ = semaphore.wait(timeout: .now() + 5.0) - return eligible + return "{\"status\": \"pending\"}" } - return false + return "{\"status\": \"unsupported\"}" } @Callable public func getExternalPurchaseCustomLinkTokenIOS(tokenType: String) -> String { GodotIapLog.payload("getExternalPurchaseCustomLinkTokenIOS", payload: tokenType) - if #available(iOS 18.1, macOS 15.0, tvOS 18.1, *) { + if #available(iOS 18.1, macOS 15.1, tvOS 18.1, *) { Task { [weak self] in guard let self = self else { return } do { @@ -1189,7 +1199,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { @Callable public func showExternalPurchaseCustomLinkNoticeIOS(noticeType: String) -> String { GodotIapLog.payload("showExternalPurchaseCustomLinkNoticeIOS", payload: noticeType) - if #available(iOS 18.1, macOS 15.0, tvOS 18.1, *) { + if #available(iOS 18.1, macOS 15.1, tvOS 18.1, *) { Task { [weak self] in guard let self = self else { return } do { diff --git a/libraries/react-native-iap/src/index.ts b/libraries/react-native-iap/src/index.ts index 556bfc5f..70e1f386 100644 --- a/libraries/react-native-iap/src/index.ts +++ b/libraries/react-native-iap/src/index.ts @@ -1833,9 +1833,6 @@ export const validateReceiptIOS: QueryField<'validateReceiptIOS'> = async ( throw new Error('validateReceiptIOS is only available on iOS'); } const result = await validateReceipt(options); - if ((result as {platform?: string}).platform !== 'ios') { - throw new Error('validateReceiptIOS: unexpected non-iOS result'); - } return result as VerifyPurchaseResultIOS; }; From 0771e9f525f4657edaebd7762115f2b5b72caee2 Mon Sep 17 00:00:00 2001 From: hyochan Date: Sat, 25 Apr 2026 04:15:17 +0900 Subject: [PATCH 05/11] chore(review-pr): also trigger @coderabbitai review each round After pushing a fix batch, the skill now re-requests Copilot, posts /gemini review, and posts @coderabbitai review so every automated reviewer wired into this repo runs against the new commit before the next 8-minute polling wake-up. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/commands/review-pr.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.claude/commands/review-pr.md b/.claude/commands/review-pr.md index 41787906..65f3a18a 100644 --- a/.claude/commands/review-pr.md +++ b/.claude/commands/review-pr.md @@ -42,7 +42,7 @@ For each comment: 4. **Reply directly to the inline review comment** (NOT a general PR comment) 5. **Resolve the conversation** via GraphQL API -After the fix batch is pushed (once per round, not per comment), trigger a fresh round of automated review: +After the fix batch is pushed (once per round, not per comment), trigger a fresh round from every automated reviewer wired into this repo: ```bash # Re-request Copilot review (note: capital C; the bot login is literally "Copilot") @@ -51,9 +51,12 @@ gh api -X POST "repos/hyodotdev/openiap/pulls/$PR_NUMBER/requested_reviewers" \ # Kick off a new Gemini review pass gh pr comment "$PR_NUMBER" --body "/gemini review" + +# Kick off a new CodeRabbit review pass +gh pr comment "$PR_NUMBER" --body "@coderabbitai review" ``` -Both are idempotent-ish — Copilot re-request is a no-op if still pending and re-requests if a review was already submitted; `/gemini review` always starts a new pass. Run both so the next polling cycle has something to find. +All three are idempotent-ish — Copilot re-request is a no-op if still pending and re-requests if a review was already submitted; `/gemini review` and `@coderabbitai review` always start new passes. Run all three so the next polling cycle has fresh feedback from every bot to pick up. ## Polling Loop (after fix batch) From 0d7daf1f435ba3412df9961689b9af88747da577 Mon Sep 17 00:00:00 2001 From: hyochan Date: Sat, 25 Apr 2026 04:20:22 +0900 Subject: [PATCH 06/11] fix(godot): address race + storefront/validate_receipt review comments - Tag every async productsFetched emit in GodotIap.swift with a method key (getStorefrontIOS, validateReceiptIOS, isEligibleForExternalPurchaseCustomLinkIOS, getExternalPurchaseCustomLinkTokenIOS, showExternalPurchaseCustomLinkNoticeIOS). Also emit on the error path of getStorefrontIOS so callers are not left hanging. - In godot_iap.gd add a private _await_products_fetched_for(method) helper that loops await until the emit matching the requested method arrives. Every new async GDScript wrapper routes through this helper, so two concurrent async calls no longer race on the shared signal. - Make get_storefront_ios() actually wait for the native async result and return the resolved storefront string instead of trying to parse the synchronous "pending" placeholder. - Fix validate_receipt()'s Android path to call _verify_purchase_raw synchronously and wrap the dict in Types.VerifyPurchaseResultAndroid (matching the iOS path's typed return). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../godot-iap/addons/godot-iap/godot_iap.gd | 62 ++++++++++++------- .../Sources/GodotIap/GodotIap.swift | 16 +++++ 2 files changed, 57 insertions(+), 21 deletions(-) diff --git a/libraries/godot-iap/addons/godot-iap/godot_iap.gd b/libraries/godot-iap/addons/godot-iap/godot_iap.gd index 9a6e77fb..0174acdf 100644 --- a/libraries/godot-iap/addons/godot-iap/godot_iap.gd +++ b/libraries/godot-iap/addons/godot-iap/godot_iap.gd @@ -860,20 +860,33 @@ func get_transaction_jws_ios(sku: String) -> String: return result.get("jws", "") return "" +## Await the next `products_fetched` emit whose `method` key matches the given +## name, ignoring emits from other concurrent async calls. Prevents a race +## where two in-flight methods race for the same signal emit. +func _await_products_fetched_for(method: String) -> Dictionary: + while true: + var payload = await products_fetched + if payload is Dictionary and payload.get("method", "") == method: + return payload as Dictionary + ## Get the current App Store storefront country code (iOS). +## The native method dispatches asynchronously and emits the result via +## `products_fetched`; this wrapper awaits that emit and returns the country +## code, so callers can use it like a synchronous getter. ## @deprecated Prefer cross-platform get_storefront() which also works on iOS. ## @return String ISO 3166-1 alpha-2 country code, or empty string on failure func get_storefront_ios() -> String: - if _native_plugin and _platform == "iOS": - var result_json = _native_plugin.call("getStorefrontIOS") - var result = JSON.parse_string(result_json) - if result is Dictionary and result.get("success", false): - return result.get("storefront", "") + if not (_native_plugin and _platform == "iOS"): + return "" + _native_plugin.call("getStorefrontIOS") + var payload = await _await_products_fetched_for("getStorefrontIOS") + if payload.get("success", false): + return payload.get("storefront", "") return "" ## Validate a receipt with the App Store for a specific SKU (iOS). ## Kicks off the native async validation and awaits the next `products_fetched` -## signal carrying the result payload. Returns null on error. +## emit tagged with method == "validateReceiptIOS". Returns null on error. ## @deprecated Use verify_purchase or verify_purchase_with_provider instead. ## @param props: Types.VerifyPurchaseProps with `apple: {sku: String}` set ## @return Variant Types.VerifyPurchaseResultIOS on success, null otherwise @@ -883,8 +896,8 @@ func validate_receipt_ios(props) -> Variant: var props_dict: Dictionary = props.to_dict() if props is Object and props.has_method("to_dict") else (props if props is Dictionary else {}) var props_json = JSON.stringify(props_dict) _native_plugin.call("validateReceiptIOS", props_json) - var payload = await products_fetched - if payload is Dictionary and payload.get("success", false): + var payload = await _await_products_fetched_for("validateReceiptIOS") + if payload.get("success", false): var payload_json = payload.get("resultJson", "") var decoded = JSON.parse_string(payload_json) if decoded is Dictionary: @@ -898,34 +911,40 @@ func validate_receipt_ios(props) -> Variant: func validate_receipt(props) -> Variant: if _platform == "iOS": return await validate_receipt_ios(props) - # Android: delegate to verify_purchase which already exists - return _verify_purchase_raw(props.to_dict() if props is Object and props.has_method("to_dict") else (props if props is Dictionary else {})) + if _platform == "Android": + # Android path is synchronous via the `verifyPurchase` native call. + var props_dict: Dictionary = props.to_dict() if props is Object and props.has_method("to_dict") else (props if props is Dictionary else {}) + var raw = _verify_purchase_raw(props_dict) + if raw.get("success", false) or raw.get("isValid", false): + return Types.VerifyPurchaseResultAndroid.from_dict(raw) + return null ## ExternalPurchaseCustomLink: check eligibility (iOS 18.1+). ## Kicks off the native async check and awaits the next `products_fetched` -## signal carrying an `eligible` boolean; returns false on any error. +## emit tagged with method == "isEligibleForExternalPurchaseCustomLinkIOS"; +## returns false on any error. ## @return bool true if the current context can show external purchase custom link func is_eligible_for_external_purchase_custom_link_ios() -> bool: if not (_native_plugin and _platform == "iOS"): return false _native_plugin.call("isEligibleForExternalPurchaseCustomLinkIOS") - var payload = await products_fetched - if payload is Dictionary and payload.get("success", false): + var payload = await _await_products_fetched_for("isEligibleForExternalPurchaseCustomLinkIOS") + if payload.get("success", false): return bool(payload.get("eligible", false)) return false ## ExternalPurchaseCustomLink: request a token for Apple reporting (iOS 18.1+). ## Kicks off the native async request and awaits the next `products_fetched` -## signal carrying the token payload. Returns null on error or on unsupported -## platforms (i.e. iOS < 18.1). +## emit tagged with method == "getExternalPurchaseCustomLinkTokenIOS". +## Returns null on error or on unsupported platforms (i.e. iOS < 18.1). ## @param token_type: String "acquisition" | "services" ## @return Variant Types.ExternalPurchaseCustomLinkTokenResultIOS or null func get_external_purchase_custom_link_token_ios(token_type: String) -> Variant: if not (_native_plugin and _platform == "iOS"): return null _native_plugin.call("getExternalPurchaseCustomLinkTokenIOS", token_type) - var payload = await products_fetched - if payload is Dictionary and payload.get("success", false): + var payload = await _await_products_fetched_for("getExternalPurchaseCustomLinkTokenIOS") + if payload.get("success", false): var payload_json = payload.get("resultJson", "") var decoded = JSON.parse_string(payload_json) if decoded is Dictionary: @@ -933,16 +952,17 @@ func get_external_purchase_custom_link_token_ios(token_type: String) -> Variant: return null ## ExternalPurchaseCustomLink: show the disclosure notice sheet (iOS 18.1+). -## Kicks off the native async UI and awaits the next `products_fetched` signal -## carrying the notice result payload. Returns null on error. +## Kicks off the native async UI and awaits the next `products_fetched` emit +## tagged with method == "showExternalPurchaseCustomLinkNoticeIOS". Returns +## null on error. ## @param notice_type: String "browser" ## @return Variant Types.ExternalPurchaseCustomLinkNoticeResultIOS or null func show_external_purchase_custom_link_notice_ios(notice_type: String) -> Variant: if not (_native_plugin and _platform == "iOS"): return null _native_plugin.call("showExternalPurchaseCustomLinkNoticeIOS", notice_type) - var payload = await products_fetched - if payload is Dictionary and payload.get("success", false): + var payload = await _await_products_fetched_for("showExternalPurchaseCustomLinkNoticeIOS") + if payload.get("success", false): var payload_json = payload.get("resultJson", "") var decoded = JSON.parse_string(payload_json) if decoded is Dictionary: diff --git a/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift b/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift index 93f55ef3..9b37ed24 100644 --- a/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift +++ b/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift @@ -666,12 +666,20 @@ public class GodotIap: RefCounted, @unchecked Sendable { let storefront = try await self.openIap.getStorefrontIOS() ?? "" await MainActor.run { [self] in let dict = VariantDictionary() + dict["method"] = Variant("getStorefrontIOS") dict["success"] = Variant(true) dict["storefront"] = Variant(storefront) self.productsFetched.emit(dict) } } catch { GodotIapLog.debug("[GodotIap] getStorefrontIOS error: \(error.localizedDescription)") + await MainActor.run { [self] in + let dict = VariantDictionary() + dict["method"] = Variant("getStorefrontIOS") + dict["success"] = Variant(false) + dict["error"] = Variant(error.localizedDescription) + self.productsFetched.emit(dict) + } } } @@ -1104,6 +1112,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { let resultDict = OpenIapSerialization.encode(result) await MainActor.run { [self] in let dict = VariantDictionary() + dict["method"] = Variant("validateReceiptIOS") dict["success"] = Variant(true) if let jsonData = try? JSONSerialization.data(withJSONObject: resultDict), let jsonString = String(data: jsonData, encoding: .utf8) { @@ -1115,6 +1124,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { GodotIapLog.debug("[GodotIap] validateReceiptIOS error: \(error.localizedDescription)") await MainActor.run { [self] in let dict = VariantDictionary() + dict["method"] = Variant("validateReceiptIOS") dict["success"] = Variant(false) dict["error"] = Variant(error.localizedDescription) self.productsFetched.emit(dict) @@ -1141,6 +1151,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { let eligible = try await self.openIap.isEligibleForExternalPurchaseCustomLinkIOS() await MainActor.run { [self] in let dict = VariantDictionary() + dict["method"] = Variant("isEligibleForExternalPurchaseCustomLinkIOS") dict["success"] = Variant(true) dict["eligible"] = Variant(eligible) self.productsFetched.emit(dict) @@ -1149,6 +1160,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { GodotIapLog.debug("[GodotIap] isEligibleForExternalPurchaseCustomLinkIOS error: \(error.localizedDescription)") await MainActor.run { [self] in let dict = VariantDictionary() + dict["method"] = Variant("isEligibleForExternalPurchaseCustomLinkIOS") dict["success"] = Variant(false) dict["error"] = Variant(error.localizedDescription) self.productsFetched.emit(dict) @@ -1174,6 +1186,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { let resultDict = OpenIapSerialization.encode(result) await MainActor.run { [self] in let dict = VariantDictionary() + dict["method"] = Variant("getExternalPurchaseCustomLinkTokenIOS") dict["success"] = Variant(true) if let jsonData = try? JSONSerialization.data(withJSONObject: resultDict), let jsonString = String(data: jsonData, encoding: .utf8) { @@ -1185,6 +1198,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { GodotIapLog.debug("[GodotIap] getExternalPurchaseCustomLinkTokenIOS error: \(error.localizedDescription)") await MainActor.run { [self] in let dict = VariantDictionary() + dict["method"] = Variant("getExternalPurchaseCustomLinkTokenIOS") dict["success"] = Variant(false) dict["error"] = Variant(error.localizedDescription) self.productsFetched.emit(dict) @@ -1210,6 +1224,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { let resultDict = OpenIapSerialization.encode(result) await MainActor.run { [self] in let dict = VariantDictionary() + dict["method"] = Variant("showExternalPurchaseCustomLinkNoticeIOS") dict["success"] = Variant(true) if let jsonData = try? JSONSerialization.data(withJSONObject: resultDict), let jsonString = String(data: jsonData, encoding: .utf8) { @@ -1221,6 +1236,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { GodotIapLog.debug("[GodotIap] showExternalPurchaseCustomLinkNoticeIOS error: \(error.localizedDescription)") await MainActor.run { [self] in let dict = VariantDictionary() + dict["method"] = Variant("showExternalPurchaseCustomLinkNoticeIOS") dict["success"] = Variant(false) dict["error"] = Variant(error.localizedDescription) self.productsFetched.emit(dict) From e2711e750c0bf71d28be6851021c08b5c61e828a Mon Sep 17 00:00:00 2001 From: hyochan Date: Sat, 25 Apr 2026 04:26:52 +0900 Subject: [PATCH 07/11] fix(review): revert macOS gate + skill auto-resolves outdated threads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - flutter_inapp_purchase: revert the six new "…IOS" query handler guards from `!isIOS` back to `!_platform.isIOS || _platform.isMacOS` to match the existing iOS-only pattern in the rest of the file (Copilot). macOS inclusion via the `isIOS` getter was inconsistent with every other iOS-suffixed handler in this plugin. - .claude/commands/review-pr.md: teach the skill to auto-resolve threads that GitHub marks `isOutdated: true` and threads whose last comment is already from us, so we don't leave stale entries open every polling cycle. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/commands/review-pr.md | 41 +++++++++++++++++-- .../lib/flutter_inapp_purchase.dart | 12 +++--- 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/.claude/commands/review-pr.md b/.claude/commands/review-pr.md index 65f3a18a..abd2cacb 100644 --- a/.claude/commands/review-pr.md +++ b/.claude/commands/review-pr.md @@ -118,9 +118,44 @@ mutation { ``` **Thread Resolution Rules:** -- Only resolve threads where code changes have been made and pushed -- Do not resolve threads that are just suggestions for future improvement -- Do not resolve threads awaiting user clarification +- Resolve threads where code changes have been made and pushed (after posting a reply with the commit hash). +- **Auto-resolve outdated threads**: GitHub marks a thread as `isOutdated: true` when the underlying code has already shifted out from under the comment. Those findings no longer apply to the current diff, so resolve them without a reply at the start of every round — even if the commenter hasn't weighed in again. +- **Auto-resolve threads whose latest comment is already from us**: if the last reply on a thread is yours (the PR author / agent posting as the author), the thread is already addressed — include it in the batch-resolve pass. +- Do not resolve threads that are just suggestions for future improvement unless explicitly acknowledged. +- Do not resolve threads awaiting user clarification. + +Outdated + already-replied sweep (run once per round before fetching open findings): + +```bash +PR_NUMBER=... +# Resolve threads that GitHub itself marks as outdated +gh api graphql -f query=' +query($owner:String!,$name:String!,$pr:Int!) { + repository(owner:$owner, name:$name) { + pullRequest(number:$pr) { + reviewThreads(first:100) { + nodes { + id + isResolved + isOutdated + comments(last:1) { nodes { author { login } } } + } + } + } + } +}' -F owner=hyodotdev -F name=openiap -F pr=$PR_NUMBER --jq ' + .data.repository.pullRequest.reviewThreads.nodes[] + | select(.isResolved == false) + | select(.isOutdated == true or .comments.nodes[-1].author.login == "hyochan") + | .id' | while read tid; do + [ -n "$tid" ] && gh api graphql -f query=' + mutation($id:ID!) { + resolveReviewThread(input:{threadId:$id}) { thread { id isResolved } } + }' -F id="$tid" >/dev/null && echo "auto-resolved $tid" +done +``` + +Replace `hyochan` with the repo owner's GitHub login that posts the replies (or `$(gh api user --jq .login)` for the current authenticated user). Only threads whose last comment is from that login get swept — bot comments or reviewer follow-ups stay open. ## Reply Format Rules (CRITICAL) diff --git a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart index 052de754..6e6fef12 100644 --- a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart +++ b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart @@ -1020,7 +1020,7 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { /// iOS specific: Return the current active entitlement for a SKU, if any. gentype.QueryCurrentEntitlementIOSHandler get currentEntitlementIOS => (String sku) async { - if (!isIOS) { + if (!_platform.isIOS || _platform.isMacOS) { return null; } try { @@ -1049,7 +1049,7 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { /// expired or revoked ones. gentype.QueryLatestTransactionIOSHandler get latestTransactionIOS => (String sku) async { - if (!isIOS) { + if (!_platform.isIOS || _platform.isMacOS) { return null; } try { @@ -1078,7 +1078,7 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { /// verification. gentype.QueryIsTransactionVerifiedIOSHandler get isTransactionVerifiedIOS => (String sku) async { - if (!isIOS) { + if (!_platform.isIOS || _platform.isMacOS) { return false; } try { @@ -1099,7 +1099,7 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { /// transaction for a SKU, suitable for server-side verification. gentype.QueryGetTransactionJwsIOSHandler get getTransactionJwsIOS => (String sku) async { - if (!isIOS) { + if (!_platform.isIOS || _platform.isMacOS) { return null; } try { @@ -1118,7 +1118,7 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { /// iOS specific: Return the base64-encoded App Store receipt data (legacy /// StoreKit 1 API). Use JWS-based verification for StoreKit 2. gentype.QueryGetReceiptDataIOSHandler get getReceiptDataIOS => () async { - if (!isIOS) { + if (!_platform.isIOS || _platform.isMacOS) { return null; } try { @@ -1135,7 +1135,7 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { /// purchase notice sheet. gentype.QueryCanPresentExternalPurchaseNoticeIOSHandler get canPresentExternalPurchaseNoticeIOS => () async { - if (!isIOS) { + if (!_platform.isIOS || _platform.isMacOS) { return false; } try { From f5e47b8d994a360791e49f0137ffa57c88bb40e1 Mon Sep 17 00:00:00 2001 From: hyochan Date: Sat, 25 Apr 2026 04:42:00 +0900 Subject: [PATCH 08/11] fix(review): disambiguate concurrent Godot awaits + Flutter generic catches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - godot-iap Swift: each async @Callable now mints a UUID requestId, includes it in the productsFetched emit dict, and returns it in the synchronous "pending" JSON response so the caller can match its own emit later. - godot-iap GDScript: add _parse_request_id helper + extend _await_products_fetched_for(method, request_id) to filter on both fields. Two concurrent calls to the same method (e.g. two validate_receipt_ios with different SKUs) no longer race — both wake on every emit, but only the one whose requestId matches returns. - flutter_inapp_purchase: add a generic `catch` fallback on syncIOS and all six new iOS query handlers (currentEntitlementIOS, latestTransactionIOS, isTransactionVerifiedIOS, getTransactionJwsIOS, getReceiptDataIOS, canPresentExternalPurchaseNoticeIOS) so non- PlatformException errors get wrapped into PurchaseError consistently with the rest of the library. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../lib/flutter_inapp_purchase.dart | 43 +++++++++++++++++ .../godot-iap/addons/godot-iap/godot_iap.gd | 47 +++++++++++++------ .../Sources/GodotIap/GodotIap.swift | 25 ++++++++-- 3 files changed, 95 insertions(+), 20 deletions(-) diff --git a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart index 6e6fef12..f7350d82 100644 --- a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart +++ b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart @@ -708,6 +708,12 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { } on PlatformException catch (error) { throw _purchaseErrorFromPlatformException( error, 'sync iOS purchases'); + } catch (error) { + if (error is PurchaseError) rethrow; + throw PurchaseError( + code: gentype.ErrorCode.SyncError, + message: 'Failed to sync iOS purchases: ${error.toString()}', + ); } }; @@ -1042,6 +1048,12 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { error, 'fetch current entitlement', ); + } catch (error) { + if (error is PurchaseError) rethrow; + throw PurchaseError( + code: gentype.ErrorCode.ServiceError, + message: 'Failed to fetch current entitlement: ${error.toString()}', + ); } }; @@ -1071,6 +1083,12 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { error, 'fetch latest transaction', ); + } catch (error) { + if (error is PurchaseError) rethrow; + throw PurchaseError( + code: gentype.ErrorCode.ServiceError, + message: 'Failed to fetch latest transaction: ${error.toString()}', + ); } }; @@ -1092,6 +1110,12 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { error, 'verify transaction', ); + } catch (error) { + if (error is PurchaseError) rethrow; + throw PurchaseError( + code: gentype.ErrorCode.ServiceError, + message: 'Failed to verify transaction: ${error.toString()}', + ); } }; @@ -1112,6 +1136,12 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { error, 'fetch transaction jws', ); + } catch (error) { + if (error is PurchaseError) rethrow; + throw PurchaseError( + code: gentype.ErrorCode.ServiceError, + message: 'Failed to fetch transaction jws: ${error.toString()}', + ); } }; @@ -1128,6 +1158,12 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { error, 'fetch receipt data', ); + } catch (error) { + if (error is PurchaseError) rethrow; + throw PurchaseError( + code: gentype.ErrorCode.ServiceError, + message: 'Failed to fetch receipt data: ${error.toString()}', + ); } }; @@ -1148,6 +1184,13 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { error, 'check external purchase notice eligibility', ); + } catch (error) { + if (error is PurchaseError) rethrow; + throw PurchaseError( + code: gentype.ErrorCode.ServiceError, + message: + 'Failed to check external purchase notice eligibility: ${error.toString()}', + ); } }; diff --git a/libraries/godot-iap/addons/godot-iap/godot_iap.gd b/libraries/godot-iap/addons/godot-iap/godot_iap.gd index 0174acdf..579b1675 100644 --- a/libraries/godot-iap/addons/godot-iap/godot_iap.gd +++ b/libraries/godot-iap/addons/godot-iap/godot_iap.gd @@ -861,13 +861,25 @@ func get_transaction_jws_ios(sku: String) -> String: return "" ## Await the next `products_fetched` emit whose `method` key matches the given -## name, ignoring emits from other concurrent async calls. Prevents a race -## where two in-flight methods race for the same signal emit. -func _await_products_fetched_for(method: String) -> Dictionary: +## name AND whose `requestId` matches (if provided). The requestId filter +## prevents two concurrent calls to the same method from stealing each +## other's results — both wake on every emit, but only the matching caller +## returns. +func _await_products_fetched_for(method: String, request_id: String = "") -> Dictionary: while true: var payload = await products_fetched if payload is Dictionary and payload.get("method", "") == method: - return payload as Dictionary + if request_id.is_empty() or payload.get("requestId", "") == request_id: + return payload as Dictionary + +## Extract the native `requestId` token from the synchronous "pending" JSON +## returned by a GDExtension @Callable, or empty string if missing. +func _parse_request_id(pending_json) -> String: + if pending_json is String: + var decoded = JSON.parse_string(pending_json) + if decoded is Dictionary: + return String(decoded.get("requestId", "")) + return "" ## Get the current App Store storefront country code (iOS). ## The native method dispatches asynchronously and emits the result via @@ -878,15 +890,16 @@ func _await_products_fetched_for(method: String) -> Dictionary: func get_storefront_ios() -> String: if not (_native_plugin and _platform == "iOS"): return "" - _native_plugin.call("getStorefrontIOS") - var payload = await _await_products_fetched_for("getStorefrontIOS") + var pending = _native_plugin.call("getStorefrontIOS") + var request_id = _parse_request_id(pending) + var payload = await _await_products_fetched_for("getStorefrontIOS", request_id) if payload.get("success", false): return payload.get("storefront", "") return "" ## Validate a receipt with the App Store for a specific SKU (iOS). ## Kicks off the native async validation and awaits the next `products_fetched` -## emit tagged with method == "validateReceiptIOS". Returns null on error. +## emit matching this call's requestId. Returns null on error. ## @deprecated Use verify_purchase or verify_purchase_with_provider instead. ## @param props: Types.VerifyPurchaseProps with `apple: {sku: String}` set ## @return Variant Types.VerifyPurchaseResultIOS on success, null otherwise @@ -895,8 +908,9 @@ func validate_receipt_ios(props) -> Variant: return null var props_dict: Dictionary = props.to_dict() if props is Object and props.has_method("to_dict") else (props if props is Dictionary else {}) var props_json = JSON.stringify(props_dict) - _native_plugin.call("validateReceiptIOS", props_json) - var payload = await _await_products_fetched_for("validateReceiptIOS") + var pending = _native_plugin.call("validateReceiptIOS", props_json) + var request_id = _parse_request_id(pending) + var payload = await _await_products_fetched_for("validateReceiptIOS", request_id) if payload.get("success", false): var payload_json = payload.get("resultJson", "") var decoded = JSON.parse_string(payload_json) @@ -927,8 +941,9 @@ func validate_receipt(props) -> Variant: func is_eligible_for_external_purchase_custom_link_ios() -> bool: if not (_native_plugin and _platform == "iOS"): return false - _native_plugin.call("isEligibleForExternalPurchaseCustomLinkIOS") - var payload = await _await_products_fetched_for("isEligibleForExternalPurchaseCustomLinkIOS") + var pending = _native_plugin.call("isEligibleForExternalPurchaseCustomLinkIOS") + var request_id = _parse_request_id(pending) + var payload = await _await_products_fetched_for("isEligibleForExternalPurchaseCustomLinkIOS", request_id) if payload.get("success", false): return bool(payload.get("eligible", false)) return false @@ -942,8 +957,9 @@ func is_eligible_for_external_purchase_custom_link_ios() -> bool: func get_external_purchase_custom_link_token_ios(token_type: String) -> Variant: if not (_native_plugin and _platform == "iOS"): return null - _native_plugin.call("getExternalPurchaseCustomLinkTokenIOS", token_type) - var payload = await _await_products_fetched_for("getExternalPurchaseCustomLinkTokenIOS") + var pending = _native_plugin.call("getExternalPurchaseCustomLinkTokenIOS", token_type) + var request_id = _parse_request_id(pending) + var payload = await _await_products_fetched_for("getExternalPurchaseCustomLinkTokenIOS", request_id) if payload.get("success", false): var payload_json = payload.get("resultJson", "") var decoded = JSON.parse_string(payload_json) @@ -960,8 +976,9 @@ func get_external_purchase_custom_link_token_ios(token_type: String) -> Variant: func show_external_purchase_custom_link_notice_ios(notice_type: String) -> Variant: if not (_native_plugin and _platform == "iOS"): return null - _native_plugin.call("showExternalPurchaseCustomLinkNoticeIOS", notice_type) - var payload = await _await_products_fetched_for("showExternalPurchaseCustomLinkNoticeIOS") + var pending = _native_plugin.call("showExternalPurchaseCustomLinkNoticeIOS", notice_type) + var request_id = _parse_request_id(pending) + var payload = await _await_products_fetched_for("showExternalPurchaseCustomLinkNoticeIOS", request_id) if payload.get("success", false): var payload_json = payload.get("resultJson", "") var decoded = JSON.parse_string(payload_json) diff --git a/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift b/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift index 9b37ed24..8423598d 100644 --- a/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift +++ b/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift @@ -659,6 +659,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { @Callable public func getStorefrontIOS() -> String { GodotIapLog.payload("Getting storefront", payload: nil) + let requestId = UUID().uuidString Task { [weak self] in guard let self = self else { return } @@ -667,6 +668,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { await MainActor.run { [self] in let dict = VariantDictionary() dict["method"] = Variant("getStorefrontIOS") + dict["requestId"] = Variant(requestId) dict["success"] = Variant(true) dict["storefront"] = Variant(storefront) self.productsFetched.emit(dict) @@ -676,6 +678,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { await MainActor.run { [self] in let dict = VariantDictionary() dict["method"] = Variant("getStorefrontIOS") + dict["requestId"] = Variant(requestId) dict["success"] = Variant(false) dict["error"] = Variant(error.localizedDescription) self.productsFetched.emit(dict) @@ -683,7 +686,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { } } - return "{\"status\": \"pending\"}" + return "{\"status\": \"pending\", \"requestId\": \"\(requestId)\"}" } @Callable @@ -1101,6 +1104,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { @Callable public func validateReceiptIOS(propsJson: String) -> String { GodotIapLog.payload("validateReceiptIOS", payload: propsJson) + let requestId = UUID().uuidString Task { [weak self] in guard let self = self else { return } do { @@ -1113,6 +1117,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { await MainActor.run { [self] in let dict = VariantDictionary() dict["method"] = Variant("validateReceiptIOS") + dict["requestId"] = Variant(requestId) dict["success"] = Variant(true) if let jsonData = try? JSONSerialization.data(withJSONObject: resultDict), let jsonString = String(data: jsonData, encoding: .utf8) { @@ -1125,13 +1130,14 @@ public class GodotIap: RefCounted, @unchecked Sendable { await MainActor.run { [self] in let dict = VariantDictionary() dict["method"] = Variant("validateReceiptIOS") + dict["requestId"] = Variant(requestId) dict["success"] = Variant(false) dict["error"] = Variant(error.localizedDescription) self.productsFetched.emit(dict) } } } - return "{\"status\": \"pending\"}" + return "{\"status\": \"pending\", \"requestId\": \"\(requestId)\"}" } // MARK: - ExternalPurchaseCustomLink (iOS 18.1+) @@ -1145,6 +1151,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { public func isEligibleForExternalPurchaseCustomLinkIOS() -> String { GodotIapLog.payload("isEligibleForExternalPurchaseCustomLinkIOS", payload: nil) if #available(iOS 18.1, macOS 15.1, tvOS 18.1, *) { + let requestId = UUID().uuidString Task { [weak self] in guard let self = self else { return } do { @@ -1152,6 +1159,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { await MainActor.run { [self] in let dict = VariantDictionary() dict["method"] = Variant("isEligibleForExternalPurchaseCustomLinkIOS") + dict["requestId"] = Variant(requestId) dict["success"] = Variant(true) dict["eligible"] = Variant(eligible) self.productsFetched.emit(dict) @@ -1161,13 +1169,14 @@ public class GodotIap: RefCounted, @unchecked Sendable { await MainActor.run { [self] in let dict = VariantDictionary() dict["method"] = Variant("isEligibleForExternalPurchaseCustomLinkIOS") + dict["requestId"] = Variant(requestId) dict["success"] = Variant(false) dict["error"] = Variant(error.localizedDescription) self.productsFetched.emit(dict) } } } - return "{\"status\": \"pending\"}" + return "{\"status\": \"pending\", \"requestId\": \"\(requestId)\"}" } return "{\"status\": \"unsupported\"}" } @@ -1176,6 +1185,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { public func getExternalPurchaseCustomLinkTokenIOS(tokenType: String) -> String { GodotIapLog.payload("getExternalPurchaseCustomLinkTokenIOS", payload: tokenType) if #available(iOS 18.1, macOS 15.1, tvOS 18.1, *) { + let requestId = UUID().uuidString Task { [weak self] in guard let self = self else { return } do { @@ -1187,6 +1197,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { await MainActor.run { [self] in let dict = VariantDictionary() dict["method"] = Variant("getExternalPurchaseCustomLinkTokenIOS") + dict["requestId"] = Variant(requestId) dict["success"] = Variant(true) if let jsonData = try? JSONSerialization.data(withJSONObject: resultDict), let jsonString = String(data: jsonData, encoding: .utf8) { @@ -1199,13 +1210,14 @@ public class GodotIap: RefCounted, @unchecked Sendable { await MainActor.run { [self] in let dict = VariantDictionary() dict["method"] = Variant("getExternalPurchaseCustomLinkTokenIOS") + dict["requestId"] = Variant(requestId) dict["success"] = Variant(false) dict["error"] = Variant(error.localizedDescription) self.productsFetched.emit(dict) } } } - return "{\"status\": \"pending\"}" + return "{\"status\": \"pending\", \"requestId\": \"\(requestId)\"}" } return "{\"status\": \"unsupported\"}" } @@ -1214,6 +1226,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { public func showExternalPurchaseCustomLinkNoticeIOS(noticeType: String) -> String { GodotIapLog.payload("showExternalPurchaseCustomLinkNoticeIOS", payload: noticeType) if #available(iOS 18.1, macOS 15.1, tvOS 18.1, *) { + let requestId = UUID().uuidString Task { [weak self] in guard let self = self else { return } do { @@ -1225,6 +1238,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { await MainActor.run { [self] in let dict = VariantDictionary() dict["method"] = Variant("showExternalPurchaseCustomLinkNoticeIOS") + dict["requestId"] = Variant(requestId) dict["success"] = Variant(true) if let jsonData = try? JSONSerialization.data(withJSONObject: resultDict), let jsonString = String(data: jsonData, encoding: .utf8) { @@ -1237,13 +1251,14 @@ public class GodotIap: RefCounted, @unchecked Sendable { await MainActor.run { [self] in let dict = VariantDictionary() dict["method"] = Variant("showExternalPurchaseCustomLinkNoticeIOS") + dict["requestId"] = Variant(requestId) dict["success"] = Variant(false) dict["error"] = Variant(error.localizedDescription) self.productsFetched.emit(dict) } } } - return "{\"status\": \"pending\"}" + return "{\"status\": \"pending\", \"requestId\": \"\(requestId)\"}" } return "{\"status\": \"unsupported\"}" } From 50e3561573f39998ccdebb99a4c7b03bd5eea1ba Mon Sep 17 00:00:00 2001 From: hyochan Date: Sat, 25 Apr 2026 04:55:54 +0900 Subject: [PATCH 09/11] chore(review-pr): DELETE+POST Copilot re-request with verify fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Straight POST to /requested_reviewers returns HTTP 201 but silently leaves the list empty when Copilot already submitted a review on an earlier commit — the API treats an already-submitted reviewer as idempotent and refuses to re-add them. Work around by DELETEing the pending reviewer first, sleeping, then POSTing, and verify Copilot actually ended up in requested_reviewers so we can warn (instead of claiming success silently) when the manual UI "Re-request review" button is the only remaining option. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/commands/review-pr.md | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/.claude/commands/review-pr.md b/.claude/commands/review-pr.md index abd2cacb..c0367b95 100644 --- a/.claude/commands/review-pr.md +++ b/.claude/commands/review-pr.md @@ -45,9 +45,25 @@ For each comment: After the fix batch is pushed (once per round, not per comment), trigger a fresh round from every automated reviewer wired into this repo: ```bash -# Re-request Copilot review (note: capital C; the bot login is literally "Copilot") +# Re-request Copilot review (note: capital C; the bot login is literally "Copilot"). +# GOTCHA: if Copilot has already submitted a review on an earlier commit, the +# REST POST below returns HTTP 201 but silently leaves `requested_reviewers` +# empty — it's idempotent against reviewers whose prior review is still on +# record, so a plain re-POST does nothing. The reliable workaround is DELETE +# + POST so GitHub treats it as a fresh request. +gh api -X DELETE "repos/hyodotdev/openiap/pulls/$PR_NUMBER/requested_reviewers" \ + -f 'reviewers[]=Copilot' >/dev/null 2>&1 || true +sleep 2 gh api -X POST "repos/hyodotdev/openiap/pulls/$PR_NUMBER/requested_reviewers" \ - -f 'reviewers[]=Copilot' + -f 'reviewers[]=Copilot' >/dev/null + +# Verify it actually took — GitHub occasionally still drops the re-request +# even after DELETE. If the list is empty, warn so the user can hit "Re-request +# review" in the GitHub UI manually as a last resort (the UI uses a +# privileged endpoint that works even when the API silently no-ops). +if [ -z "$(gh api repos/hyodotdev/openiap/pulls/$PR_NUMBER --jq '.requested_reviewers | map(select(.login == "Copilot")) | .[0].login // empty')" ]; then + echo "WARN: Copilot re-request didn't stick via API; click Re-request review in the GitHub UI if you need it." +fi # Kick off a new Gemini review pass gh pr comment "$PR_NUMBER" --body "/gemini review" @@ -56,7 +72,7 @@ gh pr comment "$PR_NUMBER" --body "/gemini review" gh pr comment "$PR_NUMBER" --body "@coderabbitai review" ``` -All three are idempotent-ish — Copilot re-request is a no-op if still pending and re-requests if a review was already submitted; `/gemini review` and `@coderabbitai review` always start new passes. Run all three so the next polling cycle has fresh feedback from every bot to pick up. +`/gemini review` and `@coderabbitai review` comments always start new passes. Copilot's bot is flakier — the DELETE+POST dance is the best programmatic option, and the verification step flags the cases where manual intervention is needed so we don't pretend the re-request succeeded when it silently didn't. ## Polling Loop (after fix batch) From 74edf413443fe278d2ea03a8a6a1d23a50450370 Mon Sep 17 00:00:00 2001 From: hyochan Date: Sat, 25 Apr 2026 05:17:33 +0900 Subject: [PATCH 10/11] fix(review): preserve PurchaseError codes + Godot unsupported-OS emit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - flutter_inapp_purchase plugin: add `catch let purchaseError as PurchaseError` before the generic catch on all nine new helpers (beginRefundRequestIOS, syncIOS, subscriptionStatusIOS, getAppTransactionIOS, currentEntitlementIOS, latestTransactionIOS, isTransactionVerifiedIOS, getTransactionJwsIOS, getReceiptDataIOS) so native codes like .skuNotFound / .transactionValidationFailed reach Flutter consumers instead of collapsing to .serviceError, matching the fetchProducts/requestPurchase pattern already in this file. - godot-iap Swift: on the unsupported-OS branch of isEligibleForExternalPurchaseCustomLinkIOS, getExternalPurchaseCustomLinkTokenIOS, and showExternalPurchaseCustomLinkNoticeIOS, mint the requestId before the #available gate, emit productsFetched with success=false / error="unsupported", and include requestId in the pending JSON response. Without this emit the GDScript awaiter would hang forever on older iOS/macOS/tvOS runtimes. - .claude/commands/review-pr.md: tighten the auto-resolve sweep — only sweep threads GitHub marks isOutdated. "Last comment is from author" is a false-positive signal because reviewers often want to confirm the fix; leaving those threads open on the next round gives the reviewer a chance to push back or acknowledge. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/commands/review-pr.md | 19 ++--- .../Classes/FlutterInappPurchasePlugin.swift | 72 +++++++++++++++++++ .../Sources/GodotIap/GodotIap.swift | 47 ++++++++++-- 3 files changed, 119 insertions(+), 19 deletions(-) diff --git a/.claude/commands/review-pr.md b/.claude/commands/review-pr.md index c0367b95..16069374 100644 --- a/.claude/commands/review-pr.md +++ b/.claude/commands/review-pr.md @@ -135,43 +135,36 @@ mutation { **Thread Resolution Rules:** - Resolve threads where code changes have been made and pushed (after posting a reply with the commit hash). -- **Auto-resolve outdated threads**: GitHub marks a thread as `isOutdated: true` when the underlying code has already shifted out from under the comment. Those findings no longer apply to the current diff, so resolve them without a reply at the start of every round — even if the commenter hasn't weighed in again. -- **Auto-resolve threads whose latest comment is already from us**: if the last reply on a thread is yours (the PR author / agent posting as the author), the thread is already addressed — include it in the batch-resolve pass. +- **Auto-resolve outdated threads only**: GitHub marks a thread as `isOutdated: true` when the underlying code has already shifted out from under the comment, so those findings no longer apply to the current diff. Sweep those without a reply at the start of every round. **Do not** auto-resolve threads just because the last comment is from the author — the reviewer may still need to confirm the fix. - Do not resolve threads that are just suggestions for future improvement unless explicitly acknowledged. - Do not resolve threads awaiting user clarification. -Outdated + already-replied sweep (run once per round before fetching open findings): +Outdated sweep (run once per round before fetching open findings): ```bash PR_NUMBER=... -# Resolve threads that GitHub itself marks as outdated gh api graphql -f query=' query($owner:String!,$name:String!,$pr:Int!) { repository(owner:$owner, name:$name) { pullRequest(number:$pr) { reviewThreads(first:100) { - nodes { - id - isResolved - isOutdated - comments(last:1) { nodes { author { login } } } - } + nodes { id isResolved isOutdated } } } } }' -F owner=hyodotdev -F name=openiap -F pr=$PR_NUMBER --jq ' .data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false) - | select(.isOutdated == true or .comments.nodes[-1].author.login == "hyochan") + | select(.isOutdated == true) | .id' | while read tid; do [ -n "$tid" ] && gh api graphql -f query=' mutation($id:ID!) { resolveReviewThread(input:{threadId:$id}) { thread { id isResolved } } - }' -F id="$tid" >/dev/null && echo "auto-resolved $tid" + }' -F id="$tid" >/dev/null && echo "auto-resolved outdated $tid" done ``` -Replace `hyochan` with the repo owner's GitHub login that posts the replies (or `$(gh api user --jq .login)` for the current authenticated user). Only threads whose last comment is from that login get swept — bot comments or reviewer follow-ups stay open. +Threads that the author has already replied to still show up in the "unresolved" list on the next round — that is intentional so the reviewer can confirm the fix landed and either agree (resolve manually / mark fixed) or push back. Resolving them as soon as the author replies would silence legitimate follow-up feedback. ## Reply Format Rules (CRITICAL) diff --git a/libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift b/libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift index e8c8a68f..e9af0de7 100644 --- a/libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift +++ b/libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift @@ -651,7 +651,15 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { 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)) } @@ -895,7 +903,15 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { 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)) } @@ -912,7 +928,15 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { } 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)) } @@ -931,7 +955,15 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { } 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)) } @@ -951,7 +983,15 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { } 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)) } @@ -969,7 +1009,15 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { } 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)) } @@ -983,7 +1031,15 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { 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)) } @@ -997,7 +1053,15 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { let jws = try await OpenIapModule.shared.getTransactionJwsIOS(sku: sku) FlutterIapLog.result("getTransactionJwsIOS", value: jws ?? "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)) } @@ -1011,7 +1075,15 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { let receipt = try await OpenIapModule.shared.getReceiptDataIOS() FlutterIapLog.result("getReceiptDataIOS", value: receipt ?? "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)) } diff --git a/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift b/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift index 8423598d..c9fb4ffa 100644 --- a/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift +++ b/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift @@ -1150,8 +1150,8 @@ public class GodotIap: RefCounted, @unchecked Sendable { @Callable public func isEligibleForExternalPurchaseCustomLinkIOS() -> String { GodotIapLog.payload("isEligibleForExternalPurchaseCustomLinkIOS", payload: nil) + let requestId = UUID().uuidString if #available(iOS 18.1, macOS 15.1, tvOS 18.1, *) { - let requestId = UUID().uuidString Task { [weak self] in guard let self = self else { return } do { @@ -1178,14 +1178,27 @@ public class GodotIap: RefCounted, @unchecked Sendable { } return "{\"status\": \"pending\", \"requestId\": \"\(requestId)\"}" } - return "{\"status\": \"unsupported\"}" + // Unsupported OS: still emit the signal so awaiting GDScript wrappers + // unblock with success=false, error="unsupported" instead of deadlocking. + Task { [weak self] in + await MainActor.run { [weak self] in + guard let self = self else { return } + let dict = VariantDictionary() + dict["method"] = Variant("isEligibleForExternalPurchaseCustomLinkIOS") + dict["requestId"] = Variant(requestId) + dict["success"] = Variant(false) + dict["error"] = Variant("unsupported") + self.productsFetched.emit(dict) + } + } + return "{\"status\": \"unsupported\", \"requestId\": \"\(requestId)\"}" } @Callable public func getExternalPurchaseCustomLinkTokenIOS(tokenType: String) -> String { GodotIapLog.payload("getExternalPurchaseCustomLinkTokenIOS", payload: tokenType) + let requestId = UUID().uuidString if #available(iOS 18.1, macOS 15.1, tvOS 18.1, *) { - let requestId = UUID().uuidString Task { [weak self] in guard let self = self else { return } do { @@ -1219,14 +1232,25 @@ public class GodotIap: RefCounted, @unchecked Sendable { } return "{\"status\": \"pending\", \"requestId\": \"\(requestId)\"}" } - return "{\"status\": \"unsupported\"}" + Task { [weak self] in + await MainActor.run { [weak self] in + guard let self = self else { return } + let dict = VariantDictionary() + dict["method"] = Variant("getExternalPurchaseCustomLinkTokenIOS") + dict["requestId"] = Variant(requestId) + dict["success"] = Variant(false) + dict["error"] = Variant("unsupported") + self.productsFetched.emit(dict) + } + } + return "{\"status\": \"unsupported\", \"requestId\": \"\(requestId)\"}" } @Callable public func showExternalPurchaseCustomLinkNoticeIOS(noticeType: String) -> String { GodotIapLog.payload("showExternalPurchaseCustomLinkNoticeIOS", payload: noticeType) + let requestId = UUID().uuidString if #available(iOS 18.1, macOS 15.1, tvOS 18.1, *) { - let requestId = UUID().uuidString Task { [weak self] in guard let self = self else { return } do { @@ -1260,7 +1284,18 @@ public class GodotIap: RefCounted, @unchecked Sendable { } return "{\"status\": \"pending\", \"requestId\": \"\(requestId)\"}" } - return "{\"status\": \"unsupported\"}" + Task { [weak self] in + await MainActor.run { [weak self] in + guard let self = self else { return } + let dict = VariantDictionary() + dict["method"] = Variant("showExternalPurchaseCustomLinkNoticeIOS") + dict["requestId"] = Variant(requestId) + dict["success"] = Variant(false) + dict["error"] = Variant("unsupported") + self.productsFetched.emit(dict) + } + } + return "{\"status\": \"unsupported\", \"requestId\": \"\(requestId)\"}" } // MARK: - Private Helpers From dff9a3a62261867587d7a90b0997fc89b5ada55f Mon Sep 17 00:00:00 2001 From: hyochan Date: Sat, 25 Apr 2026 05:35:22 +0900 Subject: [PATCH 11/11] fix(review): correct iOS version gate for ExternalPurchase notice APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ExternalPurchase.canPresent` and `ExternalPurchase.presentNoticeSheet` ship at iOS 17.4 / macOS 14.4 / tvOS 17.4 / visionOS 1.1, not 18.2. The Flutter Swift plugin's #available gates, the error messages, the @available annotations on the private helpers, and the MARK comment all said 18.2 — so callers were told the feature needed a newer OS than it actually does. Update the Dart doc comment too so consumers gate their UI on the real minimum. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ios/Classes/FlutterInappPurchasePlugin.swift | 14 +++++++------- .../lib/flutter_inapp_purchase.dart | 6 ++++-- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift b/libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift index e9af0de7..166d072d 100644 --- a/libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift +++ b/libraries/flutter_inapp_purchase/ios/Classes/FlutterInappPurchasePlugin.swift @@ -276,19 +276,19 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { validateReceiptIOS(productId: sku, result: result) case "canPresentExternalPurchaseNoticeIOS": - if #available(iOS 18.2, macOS 14.0, tvOS 18.2, *) { + 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 18.2+, macOS 14.0+, or tvOS 18.2+", details: nil)) + result(FlutterError(code: code.rawValue, message: "External purchase notice requires iOS 17.4+, macOS 14.4+, or tvOS 17.4+", details: nil)) } case "presentExternalPurchaseNoticeSheetIOS": - if #available(iOS 18.2, macOS 14.0, tvOS 18.2, *) { + 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 18.2+, macOS 14.0+, or tvOS 18.2+", details: nil)) + result(FlutterError(code: code.rawValue, message: "External purchase notice requires iOS 17.4+, macOS 14.4+, or tvOS 17.4+", details: nil)) } case "presentExternalPurchaseLinkIOS": @@ -1090,9 +1090,9 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { } } - // MARK: - Alternative Billing (iOS 18.2+) + // 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 @@ -1109,7 +1109,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 diff --git a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart index f7350d82..b37801be 100644 --- a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart +++ b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart @@ -1167,8 +1167,10 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { } }; - /// iOS 18.2+: Whether the current device/region can present the external - /// purchase notice sheet. + /// iOS 17.4+: Whether the current device/region can present the external + /// purchase notice sheet. The underlying StoreKit call + /// `ExternalPurchase.canPresent` is available from iOS 17.4 / macOS 14.4 / + /// tvOS 17.4 / visionOS 1.1 — older runtimes return false. gentype.QueryCanPresentExternalPurchaseNoticeIOSHandler get canPresentExternalPurchaseNoticeIOS => () async { if (!_platform.isIOS || _platform.isMacOS) {