diff --git a/.claude/commands/review-pr.md b/.claude/commands/review-pr.md index 9aeaec51..16069374 100644 --- a/.claude/commands/review-pr.md +++ b/.claude/commands/review-pr.md @@ -42,6 +42,50 @@ 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 from every automated reviewer wired into this repo: + +```bash +# 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' >/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" + +# Kick off a new CodeRabbit review pass +gh pr comment "$PR_NUMBER" --body "@coderabbitai review" +``` + +`/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) + +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`. @@ -90,9 +134,37 @@ 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 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 sweep (run once per round before fetching open findings): + +```bash +PR_NUMBER=... +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 } + } + } + } +}' -F owner=hyodotdev -F name=openiap -F pr=$PR_NUMBER --jq ' + .data.repository.pullRequest.reviewThreads.nodes[] + | select(.isResolved == false) + | 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 outdated $tid" +done +``` + +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/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..a21c77e7 100644 --- a/knowledge/internal/04-platform-packages.md +++ b/knowledge/internal/04-platform-packages.md @@ -107,6 +107,83 @@ 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/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/ + +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..2b2035b5 100644 --- a/libraries/expo-iap/src/modules/android.ts +++ b/libraries/expo-iap/src/modules/android.ts @@ -127,6 +127,37 @@ 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; + } + } + + throw new Error( + `consumePurchaseAndroid returned an unexpected response payload: ${JSON.stringify( + result, + )}`, + ); +}; + /** * 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..166d072d 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,7 +185,76 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { result(FlutterError(code: code.rawValue, message: "productId required", details: nil)) } - case "validateReceiptIOS": + case "currentEntitlementIOS": + if let args = call.arguments as? [String: Any], + let sku = args["sku"] as? String { + currentEntitlementIOS(sku: sku, result: result) + } else if let sku = call.arguments as? String { + currentEntitlementIOS(sku: sku, result: result) + } else { + let code: ErrorCode = .developerError + result(FlutterError(code: code.rawValue, message: "sku required", details: nil)) + } + + case "latestTransactionIOS": + if let args = call.arguments as? [String: Any], + let sku = args["sku"] as? String { + latestTransactionIOS(sku: sku, result: result) + } else if let sku = call.arguments as? String { + latestTransactionIOS(sku: sku, result: result) + } else { + let code: ErrorCode = .developerError + result(FlutterError(code: code.rawValue, message: "sku required", details: nil)) + } + + case "isTransactionVerifiedIOS": + if let args = call.arguments as? [String: Any], + let sku = args["sku"] as? String { + isTransactionVerifiedIOS(sku: sku, result: result) + } else if let sku = call.arguments as? String { + isTransactionVerifiedIOS(sku: sku, result: result) + } else { + let code: ErrorCode = .developerError + result(FlutterError(code: code.rawValue, message: "sku required", details: nil)) + } + + case "getTransactionJwsIOS": + if let args = call.arguments as? [String: Any], + let sku = args["sku"] as? String { + getTransactionJwsIOS(sku: sku, result: result) + } else if let sku = call.arguments as? String { + getTransactionJwsIOS(sku: sku, result: result) + } else { + let code: ErrorCode = .developerError + result(FlutterError(code: code.rawValue, message: "sku required", details: nil)) + } + + case "getReceiptDataIOS": + getReceiptDataIOS(result: result) + + case "getAppTransactionIOS", "getAppTransaction": + if #available(iOS 16.0, macOS 14.0, tvOS 16.0, *) { + getAppTransactionIOS(result: result) + } else { + let code: ErrorCode = .featureNotSupported + result(FlutterError(code: code.rawValue, message: "getAppTransactionIOS requires iOS 16.0+", details: nil)) + } + + case "syncIOS": + syncIOS(result: result) + + case "subscriptionStatusIOS", "getSubscriptionStatus": + if let args = call.arguments as? [String: Any], + let sku = args["sku"] as? String { + subscriptionStatusIOS(sku: sku, result: result) + } else if let sku = call.arguments as? String { + subscriptionStatusIOS(sku: sku, result: result) + } else { + let code: ErrorCode = .developerError + result(FlutterError(code: code.rawValue, message: "sku required", details: nil)) + } + + case "validateReceiptIOS", "verifyPurchase": guard let args = call.arguments as? [String: Any] else { let code: ErrorCode = .developerError result(FlutterError(code: code.rawValue, message: "arguments required", details: nil)) @@ -196,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": @@ -564,6 +644,28 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { } } + private func beginRefundRequestIOS(sku: String, result: @escaping FlutterResult) { + FlutterIapLog.debug("beginRefundRequestIOS called for sku=\(sku)") + Task { @MainActor in + do { + let status = try await OpenIapModule.shared.beginRefundRequestIOS(sku: sku) + FlutterIapLog.result("beginRefundRequestIOS", value: status ?? "nil") + result(status) + } catch let purchaseError as PurchaseError { + FlutterIapLog.failure("beginRefundRequestIOS", error: purchaseError) + result(FlutterError( + code: purchaseError.code.rawValue, + message: purchaseError.message, + details: purchaseError.productId ?? sku + )) + } catch { + FlutterIapLog.failure("beginRefundRequestIOS", error: error) + let code: ErrorCode = .serviceError + result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) + } + } + } + @available(iOS 15.0, macOS 14.0, tvOS 15.0, *) private func showManageSubscriptionsIOS(result: @escaping FlutterResult) { FlutterIapLog.debug("showManageSubscriptionsIOS called") @@ -794,9 +896,203 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { } } - // MARK: - Alternative Billing (iOS 18.2+) + private func syncIOS(result: @escaping FlutterResult) { + FlutterIapLog.debug("syncIOS called") + Task { @MainActor in + do { + let success = try await OpenIapModule.shared.syncIOS() + FlutterIapLog.result("syncIOS", value: success) + result(success) + } catch let purchaseError as PurchaseError { + FlutterIapLog.failure("syncIOS", error: purchaseError) + result(FlutterError( + code: purchaseError.code.rawValue, + message: purchaseError.message, + details: purchaseError.productId + )) + } catch { + FlutterIapLog.failure("syncIOS", error: error) + let code: ErrorCode = .syncError + result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) + } + } + } + + private func subscriptionStatusIOS(sku: String, result: @escaping FlutterResult) { + FlutterIapLog.debug("subscriptionStatusIOS called for sku=\(sku)") + Task { @MainActor in + do { + let statuses = try await OpenIapModule.shared.subscriptionStatusIOS(sku: sku) + let payload = statuses.map { + FlutterIapHelper.sanitizeDictionary(OpenIapSerialization.encode($0)) + } + FlutterIapLog.result("subscriptionStatusIOS", value: payload) + result(payload) + } catch let purchaseError as PurchaseError { + FlutterIapLog.failure("subscriptionStatusIOS", error: purchaseError) + result(FlutterError( + code: purchaseError.code.rawValue, + message: purchaseError.message, + details: purchaseError.productId ?? sku + )) + } catch { + FlutterIapLog.failure("subscriptionStatusIOS", error: error) + let code: ErrorCode = .serviceError + result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) + } + } + } + + @available(iOS 16.0, macOS 14.0, tvOS 16.0, *) + private func getAppTransactionIOS(result: @escaping FlutterResult) { + FlutterIapLog.debug("getAppTransactionIOS called") + Task { @MainActor in + do { + if let tx = try await OpenIapModule.shared.getAppTransactionIOS() { + let payload = FlutterIapHelper.sanitizeDictionary(OpenIapSerialization.encode(tx)) + FlutterIapLog.result("getAppTransactionIOS", value: payload) + result(payload) + } else { + result(nil) + } + } catch let purchaseError as PurchaseError { + FlutterIapLog.failure("getAppTransactionIOS", error: purchaseError) + result(FlutterError( + code: purchaseError.code.rawValue, + message: purchaseError.message, + details: purchaseError.productId + )) + } catch { + FlutterIapLog.failure("getAppTransactionIOS", error: error) + let code: ErrorCode = .serviceError + result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) + } + } + } + + // MARK: - StoreKit 2 entitlement queries (iOS 15.0+) + + private func currentEntitlementIOS(sku: String, result: @escaping FlutterResult) { + FlutterIapLog.debug("currentEntitlementIOS called for sku=\(sku)") + Task { @MainActor in + do { + if let purchase = try await OpenIapModule.shared.currentEntitlementIOS(sku: sku) { + let payload = FlutterIapHelper.sanitizeDictionary(OpenIapSerialization.encode(purchase)) + FlutterIapLog.result("currentEntitlementIOS", value: payload) + result(payload) + } else { + result(nil) + } + } catch let purchaseError as PurchaseError { + FlutterIapLog.failure("currentEntitlementIOS", error: purchaseError) + result(FlutterError( + code: purchaseError.code.rawValue, + message: purchaseError.message, + details: purchaseError.productId ?? sku + )) + } catch { + FlutterIapLog.failure("currentEntitlementIOS", error: error) + let code: ErrorCode = .serviceError + result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) + } + } + } + + private func latestTransactionIOS(sku: String, result: @escaping FlutterResult) { + FlutterIapLog.debug("latestTransactionIOS called for sku=\(sku)") + Task { @MainActor in + do { + if let purchase = try await OpenIapModule.shared.latestTransactionIOS(sku: sku) { + let payload = FlutterIapHelper.sanitizeDictionary(OpenIapSerialization.encode(purchase)) + FlutterIapLog.result("latestTransactionIOS", value: payload) + result(payload) + } else { + result(nil) + } + } catch let purchaseError as PurchaseError { + FlutterIapLog.failure("latestTransactionIOS", error: purchaseError) + result(FlutterError( + code: purchaseError.code.rawValue, + message: purchaseError.message, + details: purchaseError.productId ?? sku + )) + } catch { + FlutterIapLog.failure("latestTransactionIOS", error: error) + let code: ErrorCode = .serviceError + result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) + } + } + } + + private func isTransactionVerifiedIOS(sku: String, result: @escaping FlutterResult) { + FlutterIapLog.debug("isTransactionVerifiedIOS called for sku=\(sku)") + Task { @MainActor in + do { + let verified = try await OpenIapModule.shared.isTransactionVerifiedIOS(sku: sku) + FlutterIapLog.result("isTransactionVerifiedIOS", value: verified) + result(verified) + } catch let purchaseError as PurchaseError { + FlutterIapLog.failure("isTransactionVerifiedIOS", error: purchaseError) + result(FlutterError( + code: purchaseError.code.rawValue, + message: purchaseError.message, + details: purchaseError.productId ?? sku + )) + } catch { + FlutterIapLog.failure("isTransactionVerifiedIOS", error: error) + let code: ErrorCode = .serviceError + result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) + } + } + } + + private func getTransactionJwsIOS(sku: String, result: @escaping FlutterResult) { + FlutterIapLog.debug("getTransactionJwsIOS called for sku=\(sku)") + Task { @MainActor in + do { + let jws = try await OpenIapModule.shared.getTransactionJwsIOS(sku: sku) + FlutterIapLog.result("getTransactionJwsIOS", value: jws ?? "nil") + result(jws) + } catch let purchaseError as PurchaseError { + FlutterIapLog.failure("getTransactionJwsIOS", error: purchaseError) + result(FlutterError( + code: purchaseError.code.rawValue, + message: purchaseError.message, + details: purchaseError.productId ?? sku + )) + } catch { + FlutterIapLog.failure("getTransactionJwsIOS", error: error) + let code: ErrorCode = .serviceError + result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) + } + } + } + + private func getReceiptDataIOS(result: @escaping FlutterResult) { + FlutterIapLog.debug("getReceiptDataIOS called") + Task { @MainActor in + do { + let receipt = try await OpenIapModule.shared.getReceiptDataIOS() + FlutterIapLog.result("getReceiptDataIOS", value: receipt ?? "nil") + result(receipt) + } catch let purchaseError as PurchaseError { + FlutterIapLog.failure("getReceiptDataIOS", error: purchaseError) + result(FlutterError( + code: purchaseError.code.rawValue, + message: purchaseError.message, + details: purchaseError.productId + )) + } catch { + FlutterIapLog.failure("getReceiptDataIOS", error: error) + let code: ErrorCode = .serviceError + result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) + } + } + } + + // MARK: - External Purchase Notice (iOS 17.4+) - @available(iOS 18.2, macOS 14.0, tvOS 18.2, *) + @available(iOS 17.4, macOS 14.4, tvOS 17.4, *) private func canPresentExternalPurchaseNoticeIOS(result: @escaping FlutterResult) { FlutterIapLog.debug("canPresentExternalPurchaseNoticeIOS called") Task { @MainActor in @@ -813,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 1200f520..b37801be 100644 --- a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart +++ b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart @@ -703,12 +703,17 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { } try { - await _channel.invokeMethod('endConnection'); - await _channel.invokeMethod('initConnection'); - return true; + final result = await _channel.invokeMethod('syncIOS'); + return result ?? false; + } on PlatformException catch (error) { + throw _purchaseErrorFromPlatformException( + error, 'sync iOS purchases'); } catch (error) { - debugPrint('Error syncing iOS purchases: $error'); - rethrow; + if (error is PurchaseError) rethrow; + throw PurchaseError( + code: gentype.ErrorCode.SyncError, + message: 'Failed to sync iOS purchases: ${error.toString()}', + ); } }; @@ -914,6 +919,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 +1023,179 @@ 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', + ); + } catch (error) { + if (error is PurchaseError) rethrow; + throw PurchaseError( + code: gentype.ErrorCode.ServiceError, + message: 'Failed to fetch current entitlement: ${error.toString()}', + ); + } + }; + + /// 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', + ); + } catch (error) { + if (error is PurchaseError) rethrow; + throw PurchaseError( + code: gentype.ErrorCode.ServiceError, + message: 'Failed to fetch latest transaction: ${error.toString()}', + ); + } + }; + + /// 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', + ); + } catch (error) { + if (error is PurchaseError) rethrow; + throw PurchaseError( + code: gentype.ErrorCode.ServiceError, + message: 'Failed to verify transaction: ${error.toString()}', + ); + } + }; + + /// 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', + ); + } catch (error) { + if (error is PurchaseError) rethrow; + throw PurchaseError( + code: gentype.ErrorCode.ServiceError, + message: 'Failed to fetch transaction jws: ${error.toString()}', + ); + } + }; + + /// 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', + ); + } catch (error) { + if (error is PurchaseError) rethrow; + throw PurchaseError( + code: gentype.ErrorCode.ServiceError, + message: 'Failed to fetch receipt data: ${error.toString()}', + ); + } + }; + + /// 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) { + return false; + } + try { + final result = await _channel.invokeMethod( + 'canPresentExternalPurchaseNoticeIOS', + ); + return result ?? false; + } on PlatformException catch (error) { + throw _purchaseErrorFromPlatformException( + 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()}', + ); + } + }; + gentype.MutationAcknowledgePurchaseAndroidHandler get acknowledgePurchaseAndroid => (purchaseToken) async { if (!_platform.isAndroid) { @@ -2268,6 +2480,9 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { }; gentype.QueryHandlers get queryHandlers => gentype.QueryHandlers( + canPresentExternalPurchaseNoticeIOS: + canPresentExternalPurchaseNoticeIOS, + currentEntitlementIOS: currentEntitlementIOS, fetchProducts: _fetchProductsHandler, getActiveSubscriptions: getActiveSubscriptions, getAppTransactionIOS: getAppTransactionIOS, @@ -2277,24 +2492,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 +2544,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..579b1675 100644 --- a/libraries/godot-iap/addons/godot-iap/godot_iap.gd +++ b/libraries/godot-iap/addons/godot-iap/godot_iap.gd @@ -860,6 +860,132 @@ 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 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: + 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 +## `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 not (_native_plugin and _platform == "iOS"): + return "" + 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 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 +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 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) + if decoded is Dictionary: + return Types.VerifyPurchaseResultIOS.from_dict(decoded) + 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 await validate_receipt_ios(props) + 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` +## 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 + 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 + +## ExternalPurchaseCustomLink: request a token for Apple reporting (iOS 18.1+). +## Kicks off the native async request and awaits the next `products_fetched` +## 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 + 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) + 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` 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 + 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) + if decoded is Dictionary: + return Types.ExternalPurchaseCustomLinkNoticeResultIOS.from_dict(decoded) + 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..c9fb4ffa 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 } @@ -666,16 +667,26 @@ 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["requestId"] = Variant(requestId) 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["requestId"] = Variant(requestId) + dict["success"] = Variant(false) + dict["error"] = Variant(error.localizedDescription) + self.productsFetched.emit(dict) + } } } - return "{\"status\": \"pending\"}" + return "{\"status\": \"pending\", \"requestId\": \"\(requestId)\"}" } @Callable @@ -1088,6 +1099,205 @@ 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) + let requestId = UUID().uuidString + 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["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) { + 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["method"] = Variant("validateReceiptIOS") + dict["requestId"] = Variant(requestId) + dict["success"] = Variant(false) + dict["error"] = Variant(error.localizedDescription) + self.productsFetched.emit(dict) + } + } + } + return "{\"status\": \"pending\", \"requestId\": \"\(requestId)\"}" + } + + // 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() -> String { + GodotIapLog.payload("isEligibleForExternalPurchaseCustomLinkIOS", payload: nil) + let requestId = UUID().uuidString + if #available(iOS 18.1, macOS 15.1, tvOS 18.1, *) { + Task { [weak self] in + guard let self = self else { return } + do { + let eligible = try await self.openIap.isEligibleForExternalPurchaseCustomLinkIOS() + 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) + } + } catch { + GodotIapLog.debug("[GodotIap] isEligibleForExternalPurchaseCustomLinkIOS error: \(error.localizedDescription)") + 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\", \"requestId\": \"\(requestId)\"}" + } + // 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, *) { + 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["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) { + 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["method"] = Variant("getExternalPurchaseCustomLinkTokenIOS") + dict["requestId"] = Variant(requestId) + dict["success"] = Variant(false) + dict["error"] = Variant(error.localizedDescription) + self.productsFetched.emit(dict) + } + } + } + return "{\"status\": \"pending\", \"requestId\": \"\(requestId)\"}" + } + 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, *) { + 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["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) { + 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["method"] = Variant("showExternalPurchaseCustomLinkNoticeIOS") + dict["requestId"] = Variant(requestId) + dict["success"] = Variant(false) + dict["error"] = Variant(error.localizedDescription) + self.productsFetched.emit(dict) + } + } + } + return "{\"status\": \"pending\", \"requestId\": \"\(requestId)\"}" + } + 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 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..70e1f386 100644 --- a/libraries/react-native-iap/src/index.ts +++ b/libraries/react-native-iap/src/index.ts @@ -1819,6 +1819,23 @@ 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); + 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( 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',