From 473adb2bb6321e2ecee52e362db77229dfed6adc Mon Sep 17 00:00:00 2001 From: Hyo Date: Fri, 13 Nov 2026 19:53:32 +0900 Subject: [PATCH 1/5] feat: add shared external purchase link flow Add isExternalPurchaseLinkAvailable and openExternalPurchaseLink to the client spec. The call shows the store disclosure, opens the link only after the user continues, and returns the token to report from the developer's server. - openiap-apple: iOS 18.1+ ExternalPurchaseCustomLink, iOS 17.4+ notice sheet, built on the existing provider primitives - openiap-google: Play external offers; Amazon and Horizon return Unavailable - react-native-iap, expo-iap, flutter_inapp_purchase, godot-iap, kmp-iap and OpenIap.Maui wire both operations; Vega returns Unavailable - API pages, external purchase guide section and release card Refs #611 Co-Authored-By: Claude Opus 5.5 --- .../java/expo/modules/iap/ExpoIapModule.kt | 47 ++++ libraries/expo-iap/example/jest.setup.js | 4 + libraries/expo-iap/ios/ExpoIapModule.swift | 20 ++ libraries/expo-iap/src/ExpoIapModule.ts | 2 + .../src/__mocks__/expo-modules-core.js | 2 + .../src/__tests__/index.kepler.test.ts | 9 + .../expo-iap/src/__tests__/index.test.ts | 76 +++++ .../expo-iap/src/__tests__/useIAP.test.tsx | 12 +- libraries/expo-iap/src/index.kepler.ts | 7 + libraries/expo-iap/src/index.ts | 49 ++++ .../src/modules/__tests__/android.test.ts | 37 ++- libraries/expo-iap/src/types.ts | 83 ++++++ libraries/expo-iap/src/useIAP.ts | 6 + .../AndroidInappPurchasePlugin.kt | 35 +++ .../FlutterInappPurchasePlugin.swift | 47 ++++ .../lib/flutter_inapp_purchase.dart | 62 +++++ .../flutter_inapp_purchase/lib/types.dart | 179 ++++++++++++ .../FlutterInappPurchasePlugin.swift | 47 ++++ .../test/coverage_regression_test.dart | 2 + .../flutter_inapp_purchase_channel_test.dart | 131 +++++++++ .../Example/tests/test_api_surface.gd | 2 +- .../Example/tests/test_envelope_parsing.gd | 66 +++++ .../godot-iap/addons/godot-iap/godot_iap.gd | 40 +++ libraries/godot-iap/addons/godot-iap/types.gd | 230 ++++++++++++++++ .../main/java/dev/hyo/godotiap/GodotIap.kt | 31 ++- .../Sources/GodotIap/GodotIap.swift | 37 +++ .../OpenIapDelegateInAppPurchaseAndroid.kt | 14 + .../kmpiap/CommunityOptionalOperationsTest.kt | 37 +++ .../io/github/hyochan/kmpiap/openiap/Types.kt | 259 ++++++++++++++++-- .../hyochan/kmpiap/InAppPurchaseTest.kt | 15 + .../github/hyochan/kmpiap/InAppPurchaseIOS.kt | 27 ++ .../dev/hyo/openiap/maui/OpenIapMauiModule.kt | 10 + .../ApiDefinition.cs | 8 + .../Android/OpenIapAndroid.Resolvers.cs | 12 + .../OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs | 10 + libraries/maui-iap/src/OpenIap.Maui/Types.cs | 154 +++++++++++ .../src/OpenIap.Maui/UnsupportedOpenIap.cs | 2 + .../MutationResolverContractTests.cs | 20 ++ .../OpenIap.Maui.Tests/RecordJsonTests.cs | 27 ++ .../java/com/margelo/nitro/iap/HybridRnIap.kt | 61 +++++ .../margelo/nitro/iap/HybridRnIapProtocol.kt | 10 + .../exampleTests/ConnectOnDemandTests.swift | 2 + .../react-native-iap/ios/HybridRnIap.swift | 63 +++++ .../ios/HybridRnIapProtocol.swift | 12 + .../src/__tests__/hooks/useIAP.test.ts | 18 ++ .../src/__tests__/index.kepler.test.ts | 7 + .../src/__tests__/index.test.ts | 95 +++++++ .../react-native-iap/src/hooks/useIAP.ts | 17 ++ .../react-native-iap/src/index.kepler.ts | 7 + libraries/react-native-iap/src/index.ts | 50 ++++ .../react-native-iap/src/specs/RnIap.nitro.ts | 28 ++ .../src/specs/RnIapProtocol.nitro.ts | 11 + libraries/react-native-iap/src/types.ts | 83 ++++++ .../src/utils/native-protocol.ts | 2 + .../react-native-iap/src/vega-adapter.ts | 7 + packages/apple/Sources/Models/Types.swift | 119 ++++++++ .../Sources/OpenIapExternalPurchaseLink.swift | 74 +++++ .../apple/Sources/OpenIapModule+ObjC.swift | 24 ++ packages/apple/Sources/OpenIapModule.swift | 8 + packages/apple/Sources/OpenIapProtocol.swift | 2 + .../ExternalPurchaseLinkTests.swift | 179 ++++++++++++ packages/docs/src/lib/searchData.ts | 20 ++ packages/docs/src/pages/docs/apis/index.tsx | 22 ++ .../is-external-purchase-link-available.tsx | 102 +++++++ .../docs/apis/open-external-purchase-link.tsx | 204 ++++++++++++++ .../pages/docs/features/external-purchase.tsx | 57 ++++ packages/docs/src/pages/docs/index.tsx | 18 ++ .../docs/src/pages/docs/updates/releases.tsx | 128 +++++++++ packages/google/core/api/openiap-core.api | 157 +++++++++-- .../hyo/openiap/ExternalPurchaseLinkTest.kt | 114 ++++++++ .../java/dev/hyo/openiap/OpenIapModule.kt | 6 + .../java/dev/hyo/openiap/OpenIapModule.kt | 6 + .../openiap/OpenIapExternalPurchaseLink.kt | 39 +++ .../java/dev/hyo/openiap/OpenIapProtocol.kt | 12 + .../src/main/java/dev/hyo/openiap/Types.kt | 259 ++++++++++++++++-- .../dev/hyo/openiap/store/OpenIapStore.kt | 19 ++ .../java/dev/hyo/openiap/OpenIapModule.kt | 6 + scripts/audit-non-godot-parity.mjs | 3 + specs/client/src/api.graphql | 23 ++ specs/client/src/generated/Types.cs | 154 +++++++++++ specs/client/src/generated/Types.kt | 259 ++++++++++++++++-- specs/client/src/generated/Types.swift | 119 ++++++++ specs/client/src/generated/types.dart | 179 ++++++++++++ specs/client/src/generated/types.gd | 230 ++++++++++++++++ specs/client/src/generated/types.ts | 83 ++++++ specs/client/src/type-android.graphql | 11 + specs/client/src/type-ios.graphql | 13 + specs/client/src/type.graphql | 59 ++++ 88 files changed, 4947 insertions(+), 92 deletions(-) create mode 100644 packages/apple/Sources/OpenIapExternalPurchaseLink.swift create mode 100644 packages/apple/Tests/OpenIapTests/ExternalPurchaseLinkTests.swift create mode 100644 packages/docs/src/pages/docs/apis/is-external-purchase-link-available.tsx create mode 100644 packages/docs/src/pages/docs/apis/open-external-purchase-link.tsx create mode 100644 packages/google/core/src/test/java/dev/hyo/openiap/ExternalPurchaseLinkTest.kt create mode 100644 packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapExternalPurchaseLink.kt diff --git a/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapModule.kt b/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapModule.kt index 538b3924e..1bb6e6c73 100644 --- a/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapModule.kt +++ b/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapModule.kt @@ -2,6 +2,7 @@ package expo.modules.iap import android.content.Context import dev.hyo.openiap.DeepLinkOptions +import dev.hyo.openiap.ExternalPurchaseLinkRequest import dev.hyo.openiap.FetchProductsResultAll import dev.hyo.openiap.FetchProductsResultProducts import dev.hyo.openiap.FetchProductsResultSubscriptions @@ -880,6 +881,52 @@ class ExpoIapModule : Module() { } } + AsyncFunction("isExternalPurchaseLinkAvailable") { promise: Promise -> + ExpoIapLog.payload("isExternalPurchaseLinkAvailable", null) + scope.launch { + try { + val isAvailable = openIapStore.isExternalPurchaseLinkAvailable() + ExpoIapLog.result("isExternalPurchaseLinkAvailable", isAvailable) + promise.resolve(isAvailable) + } catch (e: Exception) { + ExpoIapLog.failure("isExternalPurchaseLinkAvailable", e) + promise.reject((e as? OpenIapError)?.code ?: OpenIapError.ServiceUnavailable.CODE, e.message, e) + } + } + } + + AsyncFunction("openExternalPurchaseLink") { params: Map, promise: Promise -> + ExpoIapLog.payload("openExternalPurchaseLink", params) + scope.launch { + try { + val request = + runCatching { ExternalPurchaseLinkRequest.fromJson(params) } + .getOrNull() ?: run { + promise.reject( + OpenIapError.DeveloperError.CODE, + "Invalid external purchase link request: `url` is required.", + null, + ) + return@launch + } + val activity = + runCatching { currentActivity } + .onFailure { + ExpoIapLog.failure("openExternalPurchaseLink activity", it) + }.getOrNull() ?: run { + promise.reject(OpenIapError.ServiceUnavailable.CODE, "Activity not available", null) + return@launch + } + val result = openIapStore.openExternalPurchaseLink(activity, request).toJson() + ExpoIapLog.result("openExternalPurchaseLink", result) + promise.resolve(result) + } catch (e: Exception) { + ExpoIapLog.failure("openExternalPurchaseLink", e) + promise.reject((e as? OpenIapError)?.code ?: OpenIapError.ServiceUnavailable.CODE, e.message, e) + } + } + } + OnDestroy { try { listenerHandles?.let { ExpoIapHelper.cleanupListeners(openIap, it) } diff --git a/libraries/expo-iap/example/jest.setup.js b/libraries/expo-iap/example/jest.setup.js index b3643391a..3b0d64de8 100644 --- a/libraries/expo-iap/example/jest.setup.js +++ b/libraries/expo-iap/example/jest.setup.js @@ -110,6 +110,10 @@ jest.mock('expo-iap', () => { getStorefront: jest.fn(), deepLinkToSubscriptions: jest.fn(() => Promise.resolve(true)), openRedeemOfferCode: jest.fn(() => Promise.resolve(null)), + isExternalPurchaseLinkAvailable: jest.fn(() => Promise.resolve(false)), + openExternalPurchaseLink: jest.fn(() => + Promise.resolve({status: 'unavailable', platform: 'ios'}), + ), verifyAppOwnership: jest.fn(), // Android functions diff --git a/libraries/expo-iap/ios/ExpoIapModule.swift b/libraries/expo-iap/ios/ExpoIapModule.swift index 90cba7182..ce7468aca 100644 --- a/libraries/expo-iap/ios/ExpoIapModule.swift +++ b/libraries/expo-iap/ios/ExpoIapModule.swift @@ -264,6 +264,26 @@ public final class ExpoIapModule: Module { } } + AsyncFunction("isExternalPurchaseLinkAvailable") { () async throws -> Bool in + try await IapException.bridging("isExternalPurchaseLinkAvailable") { + ExpoIapLog.payload("isExternalPurchaseLinkAvailable", payload: nil) + let isAvailable = try await OpenIapModule.shared.isExternalPurchaseLinkAvailable() + ExpoIapLog.result("isExternalPurchaseLinkAvailable", value: isAvailable) + return isAvailable + } + } + + AsyncFunction("openExternalPurchaseLink") { (params: [String: Any]) async throws -> [String: Any] in + try await IapException.bridging("openExternalPurchaseLink") { + ExpoIapLog.payload("openExternalPurchaseLink", payload: params) + let request = try OpenIapSerialization.decode(object: params, as: ExternalPurchaseLinkRequest.self) + let result = try await OpenIapModule.shared.openExternalPurchaseLink(request) + let sanitized = ExpoIapHelper.sanitizeDictionary(OpenIapSerialization.encode(result)) + ExpoIapLog.result("openExternalPurchaseLink", value: sanitized) + return sanitized + } + } + AsyncFunction("showManageSubscriptionsIOS") { () async throws -> [[String: Any]] in try await IapException.bridging("showManageSubscriptionsIOS") { ExpoIapLog.payload("showManageSubscriptionsIOS", payload: nil) diff --git a/libraries/expo-iap/src/ExpoIapModule.ts b/libraries/expo-iap/src/ExpoIapModule.ts index ee8316125..205b12f00 100644 --- a/libraries/expo-iap/src/ExpoIapModule.ts +++ b/libraries/expo-iap/src/ExpoIapModule.ts @@ -58,6 +58,7 @@ export type ExpoIapNativeModule = NativeEventModule & | 'getTransactionJwsIOS' | 'hasActiveSubscriptions' | 'isEligibleForExternalPurchaseCustomLinkIOS' + | 'isExternalPurchaseLinkAvailable' | 'isEligibleForIntroOfferIOS' | 'isTransactionVerifiedIOS' | 'latestTransactionIOS' @@ -69,6 +70,7 @@ export type ExpoIapNativeModule = NativeEventModule & | 'initConnection' | 'isBillingProgramAvailableAndroid' | 'launchExternalLinkAndroid' + | 'openExternalPurchaseLink' | 'openRedeemOfferCode' | 'presentExternalPurchaseLinkIOS' | 'presentExternalPurchaseNoticeSheetIOS' diff --git a/libraries/expo-iap/src/__mocks__/expo-modules-core.js b/libraries/expo-iap/src/__mocks__/expo-modules-core.js index 81397faa8..a85622d59 100644 --- a/libraries/expo-iap/src/__mocks__/expo-modules-core.js +++ b/libraries/expo-iap/src/__mocks__/expo-modules-core.js @@ -14,6 +14,8 @@ const mockNativeModule = { isTransactionVerifiedIOS: jest.fn(), getTransactionJwsIOS: jest.fn(), openRedeemOfferCode: jest.fn(async () => null), + isExternalPurchaseLinkAvailable: jest.fn(async () => false), + openExternalPurchaseLink: jest.fn(), canPresentExternalPurchaseNoticeIOS: jest.fn(), presentExternalPurchaseNoticeSheetIOS: jest.fn(), presentExternalPurchaseLinkIOS: jest.fn(), diff --git a/libraries/expo-iap/src/__tests__/index.kepler.test.ts b/libraries/expo-iap/src/__tests__/index.kepler.test.ts index a831cb31c..fd9fb64ca 100644 --- a/libraries/expo-iap/src/__tests__/index.kepler.test.ts +++ b/libraries/expo-iap/src/__tests__/index.kepler.test.ts @@ -1,6 +1,8 @@ import { fetchProducts, getAvailablePurchases, + isExternalPurchaseLinkAvailable, + openExternalPurchaseLink, openRedeemOfferCode, requestPurchase, } from '../index.kepler'; @@ -122,6 +124,13 @@ describe('Amazon Vega public API', () => { await expect(openRedeemOfferCode()).resolves.toBeNull(); }); + it('reports external purchase links as unavailable', async () => { + await expect(isExternalPurchaseLinkAvailable()).resolves.toBe(false); + await expect( + openExternalPurchaseLink({url: 'https://example.com/buy'}), + ).resolves.toEqual({status: 'unavailable', platform: 'android'}); + }); + it("rejects the removed 'inapp' product type", async () => { await expect( // @ts-expect-error the removed alias reaches the runtime check diff --git a/libraries/expo-iap/src/__tests__/index.test.ts b/libraries/expo-iap/src/__tests__/index.test.ts index e76b9c5b4..fed777dfd 100644 --- a/libraries/expo-iap/src/__tests__/index.test.ts +++ b/libraries/expo-iap/src/__tests__/index.test.ts @@ -37,6 +37,8 @@ import { getActiveSubscriptions, hasActiveSubscriptions, openRedeemOfferCode, + isExternalPurchaseLinkAvailable, + openExternalPurchaseLink, verifyPurchase, verifyPurchaseWithProvider, ErrorCode, @@ -2164,6 +2166,80 @@ describe('Public API (index.ts)', () => { ); }); + it.each(['ios', 'android'])( + 'isExternalPurchaseLinkAvailable resolves the %s native answer', + async (platform) => { + Object.assign(Platform, {OS: platform}); + ( + ExpoIapModule.isExternalPurchaseLinkAvailable as jest.Mock + ).mockResolvedValueOnce(true); + await expect(isExternalPurchaseLinkAvailable()).resolves.toBe(true); + }, + ); + + it('isExternalPurchaseLinkAvailable resolves false on Vega without calling native', async () => { + Object.assign(Platform, {OS: 'kepler'}); + await expect(isExternalPurchaseLinkAvailable()).resolves.toBe(false); + expect( + ExpoIapModule.isExternalPurchaseLinkAvailable, + ).not.toHaveBeenCalled(); + }); + + it('isExternalPurchaseLinkAvailable rejects on unsupported platform', async () => { + Object.assign(Platform, {OS: 'web'}); + await expect(isExternalPurchaseLinkAvailable()).rejects.toThrow( + /Unsupported platform: web/, + ); + }); + + it.each(['ios', 'android'] as const)( + 'openExternalPurchaseLink passes the request and resolves the %s result', + async (platform) => { + Object.assign(Platform, {OS: platform}); + const request = { + url: 'https://example.com/buy', + apple: {tokenType: 'link-out' as const}, + google: {linkType: 'link-to-digital-content-offer' as const}, + }; + const result = {status: 'opened', reportToken: 'token', platform}; + ( + ExpoIapModule.openExternalPurchaseLink as jest.Mock + ).mockResolvedValueOnce(result); + await expect(openExternalPurchaseLink(request)).resolves.toBe(result); + expect(ExpoIapModule.openExternalPurchaseLink).toHaveBeenCalledWith( + request, + ); + }, + ); + + it('openExternalPurchaseLink maps native errors to PurchaseError', async () => { + Object.assign(Platform, {OS: 'android'}); + ( + ExpoIapModule.openExternalPurchaseLink as jest.Mock + ).mockRejectedValueOnce({ + code: 'developer-error', + message: 'Invalid external purchase link request: `url` is required.', + }); + await expect(openExternalPurchaseLink({url: ''})).rejects.toMatchObject({ + code: ErrorCode.DeveloperError, + }); + }); + + it('openExternalPurchaseLink resolves unavailable on Vega without calling native', async () => { + Object.assign(Platform, {OS: 'kepler'}); + await expect( + openExternalPurchaseLink({url: 'https://example.com/buy'}), + ).resolves.toEqual({status: 'unavailable', platform: 'android'}); + expect(ExpoIapModule.openExternalPurchaseLink).not.toHaveBeenCalled(); + }); + + it('openExternalPurchaseLink rejects on unsupported platform', async () => { + Object.assign(Platform, {OS: 'web'}); + await expect( + openExternalPurchaseLink({url: 'https://example.com/buy'}), + ).rejects.toThrow(/Unsupported platform: web/); + }); + it('requestPurchase rejects on unsupported platform', async () => { Object.assign(Platform, {OS: 'web'}); await expect( diff --git a/libraries/expo-iap/src/__tests__/useIAP.test.tsx b/libraries/expo-iap/src/__tests__/useIAP.test.tsx index 3bc2740b4..30928ea4a 100644 --- a/libraries/expo-iap/src/__tests__/useIAP.test.tsx +++ b/libraries/expo-iap/src/__tests__/useIAP.test.tsx @@ -18,7 +18,11 @@ import ExpoIapModule from '../ExpoIapModule'; import {ErrorCode} from '../types'; import {useIAP, UseIAPOptions} from '../useIAP'; import * as AndroidApi from '../modules/android'; -import {openRedeemOfferCode} from '../index'; +import { + isExternalPurchaseLinkAvailable, + openExternalPurchaseLink, + openRedeemOfferCode, +} from '../index'; import {FIRST_PURCHASE_NOTICE} from '../utils/firstPurchaseNotice'; /* eslint-enable import/first */ @@ -197,6 +201,12 @@ describe('useIAP hook', () => { AndroidApi.showInAppMessagesAndroid, ); expect(hookResult!.openRedeemOfferCode).toBe(openRedeemOfferCode); + expect(hookResult!.isExternalPurchaseLinkAvailable).toBe( + isExternalPurchaseLinkAvailable, + ); + expect(hookResult!.openExternalPurchaseLink).toBe( + openExternalPurchaseLink, + ); }); }); diff --git a/libraries/expo-iap/src/index.kepler.ts b/libraries/expo-iap/src/index.kepler.ts index b79448549..fceaaacee 100644 --- a/libraries/expo-iap/src/index.kepler.ts +++ b/libraries/expo-iap/src/index.kepler.ts @@ -318,6 +318,13 @@ export const deepLinkToSubscriptions: MutationField< export const openRedeemOfferCode: MutationField< 'openRedeemOfferCode' > = async () => null; +// Vega has no external purchase link program. +export const isExternalPurchaseLinkAvailable: QueryField< + 'isExternalPurchaseLinkAvailable' +> = async () => false; +export const openExternalPurchaseLink: MutationField< + 'openExternalPurchaseLink' +> = async () => ({status: 'unavailable', platform: 'android'}); export const promotedProductListenerIOS = (): EventSubscription => ({ remove: () => {}, diff --git a/libraries/expo-iap/src/index.ts b/libraries/expo-iap/src/index.ts index ad72fe5f5..ec7e14780 100644 --- a/libraries/expo-iap/src/index.ts +++ b/libraries/expo-iap/src/index.ts @@ -1355,6 +1355,55 @@ export const openRedeemOfferCode: MutationField< ); }; +/** + * Whether this device and storefront may link out to an external purchase + * with `openExternalPurchaseLink`. Resolves false where no program applies. + * + * @see {@link https://openiap.dev/docs/apis/is-external-purchase-link-available} + */ +export const isExternalPurchaseLinkAvailable: QueryField< + 'isExternalPurchaseLinkAvailable' +> = async () => { + if (isVegaOS()) return false; + if (!isStorePlatform()) { + throw unsupportedPlatformError(); + } + return invokeNativeWithPurchaseError( + () => ExpoIapModule.isExternalPurchaseLinkAvailable(), + nativePlatform(), + { + code: ErrorCode.ServiceError, + message: 'Failed to check external purchase link availability.', + }, + ); +}; + +/** + * Show the store disclosure, open the external purchase link after the user + * continues, and return the token to report from your server. + * + * @param request - URL plus optional Apple and Google options + * @returns Promise resolving to the link status and report token + * + * @see {@link https://openiap.dev/docs/apis/open-external-purchase-link} + */ +export const openExternalPurchaseLink: MutationField< + 'openExternalPurchaseLink' +> = async (request) => { + if (isVegaOS()) return {status: 'unavailable', platform: 'android'}; + if (!isStorePlatform()) { + throw unsupportedPlatformError(); + } + return invokeNativeWithPurchaseError( + () => ExpoIapModule.openExternalPurchaseLink(request), + nativePlatform(), + { + code: ErrorCode.ServiceError, + message: 'Failed to open external purchase link.', + }, + ); +}; + /** * Verify purchase with the configured providers. * diff --git a/libraries/expo-iap/src/modules/__tests__/android.test.ts b/libraries/expo-iap/src/modules/__tests__/android.test.ts index 02ac22b11..ebb9b9708 100644 --- a/libraries/expo-iap/src/modules/__tests__/android.test.ts +++ b/libraries/expo-iap/src/modules/__tests__/android.test.ts @@ -33,7 +33,11 @@ import { showInAppMessagesAndroid, } from '../android'; import {syncIOS} from '../ios'; -import {openRedeemOfferCode} from '../../index'; +import { + isExternalPurchaseLinkAvailable, + openExternalPurchaseLink, + openRedeemOfferCode, +} from '../../index'; /* eslint-enable import/first */ describe('Android Module Functions', () => { @@ -169,6 +173,37 @@ describe('Android Module Functions', () => { }); }); + describe('external purchase link (Android path)', () => { + it('delegates availability to the native module', async () => { + ( + ExpoIapModule.isExternalPurchaseLinkAvailable as jest.Mock + ).mockResolvedValueOnce(false); + await expect(isExternalPurchaseLinkAvailable()).resolves.toBe(false); + expect( + ExpoIapModule.isExternalPurchaseLinkAvailable, + ).toHaveBeenCalledTimes(1); + }); + + it('resolves the Unavailable result that Amazon and Horizon return', async () => { + const result = {status: 'unavailable', platform: 'android'}; + ( + ExpoIapModule.openExternalPurchaseLink as jest.Mock + ).mockResolvedValueOnce(result); + await expect( + openExternalPurchaseLink({url: 'https://example.com/buy'}), + ).resolves.toBe(result); + }); + + it('rethrows native launch failures', async () => { + ( + ExpoIapModule.openExternalPurchaseLink as jest.Mock + ).mockRejectedValueOnce(new Error('Activity not available')); + await expect( + openExternalPurchaseLink({url: 'https://example.com/buy'}), + ).rejects.toThrow('Activity not available'); + }); + }); + describe('Billing Programs API (8.2.0+)', () => { describe('isBillingProgramAvailableAndroid', () => { it('delegates to native module with external-offer', async () => { diff --git a/libraries/expo-iap/src/types.ts b/libraries/expo-iap/src/types.ts index f6ed9d9e5..0b712af98 100644 --- a/libraries/expo-iap/src/types.ts +++ b/libraries/expo-iap/src/types.ts @@ -575,6 +575,60 @@ export interface ExternalPurchaseCustomLinkTokenResultIOS { */ export type ExternalPurchaseCustomLinkTokenTypeIOS = 'acquisition' | 'services' | 'in-app' | 'link-out'; +/** + * Google options for openExternalPurchaseLink. + * Available in Client Protocol 1.5.0 / openiap-google 4.2.0 (requires Play Billing 8.2.1+). + */ +export interface ExternalPurchaseLinkAndroidProps { + /** Link type passed to Play. Defaults to LINK_TO_DIGITAL_CONTENT_OFFER. */ + linkType?: (ExternalLinkTypeAndroid | null); +} + +/** + * Apple options for openExternalPurchaseLink. + * Available in Client Protocol 1.5.0 / openiap-apple 4.2.0. + */ +export interface ExternalPurchaseLinkIosProps { + /** + * ExternalPurchaseCustomLink token type on iOS 18.1+. Defaults to Acquisition; + * use LinkOut in Japan and Brazil. Ignored on iOS 17.4 to 18.0, where the + * notice sheet returns the token. + */ + tokenType?: (ExternalPurchaseCustomLinkTokenTypeIOS | null); +} + +/** + * Request for openExternalPurchaseLink. + * Available in Client Protocol 1.5.0 / openiap-apple 4.2.0 / openiap-google 4.2.0. + */ +export interface ExternalPurchaseLinkRequest { + /** Apple-specific options */ + apple?: (ExternalPurchaseLinkIosProps | null); + /** Google-specific options */ + google?: (ExternalPurchaseLinkAndroidProps | null); + /** + * External purchase URL. On iOS 17.4 to 18.0 it must match the + * SKExternalPurchase link in Info.plist. + */ + url: string; +} + +/** + * Result of openExternalPurchaseLink. + * Available in Client Protocol 1.5.0 / openiap-apple 4.2.0 / openiap-google 4.2.0. + */ +export interface ExternalPurchaseLinkResult { + /** Platform that produced reportToken */ + platform: IapPlatform; + /** + * Token to report from your server: Play's externalTransactionToken or Apple's + * external purchase token. Present only when status is Opened. + */ + reportToken?: (string | null); + /** What happened to the link */ + status: ExternalPurchaseLinkStatus; +} + /** Result of presenting an external purchase link */ export interface ExternalPurchaseLinkResultIOS { /** Optional error message if the presentation failed */ @@ -583,6 +637,12 @@ export interface ExternalPurchaseLinkResultIOS { success: boolean; } +/** + * Outcome of openExternalPurchaseLink. + * Available in Client Protocol 1.5.0 / openiap-apple 4.2.0 / openiap-google 4.2.0. + */ +export type ExternalPurchaseLinkStatus = 'opened' | 'cancelled' | 'unavailable'; + /** User actions on external purchase notice sheet (iOS 17.4+) */ export type ExternalPurchaseNoticeAction = 'continue' | 'dismissed'; @@ -840,6 +900,16 @@ export interface Mutation { * See: https://openiap.dev/docs/apis/android/launch-external-link-android */ launchExternalLinkAndroid: Promise; + /** + * Show the store's required disclosure, open the external purchase link only + * after the user continues, and return the token to report from your server. + * The disclosure cannot be skipped. Unavailable and Cancelled are results, not + * errors. + * Available in Client Protocol 1.5.0 / openiap-apple 4.2.0 / openiap-google 4.2.0 + * (requires iOS 17.4+ or Play Billing 8.2.1+). + * See: https://openiap.dev/docs/apis/open-external-purchase-link + */ + openExternalPurchaseLink: Promise; /** * Open the platform's offer/promo code redemption flow. * Resolves the redeemed purchase only when the store reports it synchronously; @@ -955,6 +1025,8 @@ export type MutationIsBillingProgramAvailableAndroidArgs = BillingProgramAndroid export type MutationLaunchExternalLinkAndroidArgs = LaunchExternalLinkParamsAndroid; +export type MutationOpenExternalPurchaseLinkArgs = ExternalPurchaseLinkRequest; + export type MutationPresentExternalPurchaseLinkIosArgs = string; export type MutationRequestPurchaseArgs = RequestPurchaseProps; @@ -1521,6 +1593,15 @@ export interface Query { * See: https://openiap.dev/docs/apis/ios/is-eligible-for-intro-offer-ios */ isEligibleForIntroOfferIOS: Promise; + /** + * Whether this device and storefront may link out to an external purchase + * with openExternalPurchaseLink. Returns false instead of throwing when the + * store program is not enabled or the user is not eligible. + * Available in Client Protocol 1.5.0 / openiap-apple 4.2.0 / openiap-google 4.2.0 + * (requires iOS 17.4+ or Play Billing 8.2.1+). + * See: https://openiap.dev/docs/apis/is-external-purchase-link-available + */ + isExternalPurchaseLinkAvailable: Promise; /** * Check whether a transaction's JWS verification passed (StoreKit 2). * See: https://openiap.dev/docs/apis/ios/is-transaction-verified-ios @@ -2370,6 +2451,7 @@ export type MutationArgsMap = { initConnection: MutationInitConnectionArgs; isBillingProgramAvailableAndroid: MutationIsBillingProgramAvailableAndroidArgs; launchExternalLinkAndroid: MutationLaunchExternalLinkAndroidArgs; + openExternalPurchaseLink: MutationOpenExternalPurchaseLinkArgs; openRedeemOfferCode: never; presentExternalPurchaseLinkIOS: MutationPresentExternalPurchaseLinkIosArgs; presentExternalPurchaseNoticeSheetIOS: never; @@ -2416,6 +2498,7 @@ export type QueryArgsMap = { hasActiveSubscriptions: QueryHasActiveSubscriptionsArgs; isEligibleForExternalPurchaseCustomLinkIOS: never; isEligibleForIntroOfferIOS: QueryIsEligibleForIntroOfferIosArgs; + isExternalPurchaseLinkAvailable: never; isTransactionVerifiedIOS: QueryIsTransactionVerifiedIosArgs; latestTransactionIOS: QueryLatestTransactionIosArgs; subscriptionStatusIOS: QuerySubscriptionStatusIosArgs; diff --git a/libraries/expo-iap/src/useIAP.ts b/libraries/expo-iap/src/useIAP.ts index 2f9f77f01..408a92436 100644 --- a/libraries/expo-iap/src/useIAP.ts +++ b/libraries/expo-iap/src/useIAP.ts @@ -22,6 +22,8 @@ import { getActiveSubscriptions, hasActiveSubscriptions, openRedeemOfferCode, + isExternalPurchaseLinkAvailable, + openExternalPurchaseLink, type ActiveSubscription, type ProductTypeInput, } from './index'; @@ -126,6 +128,8 @@ type UseIap = { showAlternativeBillingOnlyInformationDialogAndroid: MutationField<'showAlternativeBillingOnlyInformationDialogAndroid'>; showInAppMessagesAndroid: MutationField<'showInAppMessagesAndroid'>; openRedeemOfferCode: MutationField<'openRedeemOfferCode'>; + isExternalPurchaseLinkAvailable: QueryField<'isExternalPurchaseLinkAvailable'>; + openExternalPurchaseLink: MutationField<'openExternalPurchaseLink'>; }; export interface UseIAPOptions { @@ -919,5 +923,7 @@ export function useIAP(options?: UseIAPOptions): UseIap { showInAppMessagesAndroid, // Offer code redemption openRedeemOfferCode, + isExternalPurchaseLinkAvailable, + openExternalPurchaseLink, }; } diff --git a/libraries/flutter_inapp_purchase/android/src/store/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt b/libraries/flutter_inapp_purchase/android/src/store/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt index 5811f1432..d5f4545f7 100644 --- a/libraries/flutter_inapp_purchase/android/src/store/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt +++ b/libraries/flutter_inapp_purchase/android/src/store/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt @@ -13,6 +13,7 @@ import dev.hyo.openiap.DeveloperBillingOptionParamsAndroid import dev.hyo.openiap.DeveloperBillingTypeAndroid import dev.hyo.openiap.ExternalLinkLaunchModeAndroid import dev.hyo.openiap.ExternalLinkTypeAndroid +import dev.hyo.openiap.ExternalPurchaseLinkRequest import dev.hyo.openiap.FetchProductsResult import dev.hyo.openiap.FetchProductsResultAll import dev.hyo.openiap.FetchProductsResultProducts @@ -871,6 +872,40 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler { } } + "isExternalPurchaseLinkAvailable" -> { + scope.launch { + try { + safe.success(requireOpenIap().isExternalPurchaseLinkAvailable()) + } catch (e: Exception) { + replyBillingError(safe, e) + } + } + } + "openExternalPurchaseLink" -> { + val params = (call.arguments as? Map<*, *>)?.entries?.associate { it.key.toString() to it.value } ?: emptyMap() + scope.launch { + try { + val iap = requireOpenIap() + val act = activity + if (act == null) { + safe.error(OpenIapError.BillingError.CODE, OpenIapError.BillingError.MESSAGE, "Activity not available") + return@launch + } + val request = ExternalPurchaseLinkRequest.fromJson(params) + ?.takeIf { it.url.isNotBlank() } + if (request == null) { + safe.error(OpenIapError.DeveloperError.CODE, "url is required for openExternalPurchaseLink", null) + return@launch + } + safe.success(iap.openExternalPurchaseLink(act, request).toJson()) + } catch (e: IllegalArgumentException) { + safe.error(OpenIapError.DeveloperError.CODE, e.message, null) + } catch (e: Exception) { + replyBillingError(safe, e) + } + } + } + "verifyAppOwnership" -> { val params = (call.arguments as? Map<*, *>)?.entries?.associate { it.key.toString() to it.value } ?: emptyMap() scope.launch { diff --git a/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift b/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift index 4c4c70bad..5bfd683f0 100644 --- a/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift +++ b/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift @@ -197,6 +197,12 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { case "openRedeemOfferCode": openRedeemOfferCode(result: result) + case "isExternalPurchaseLinkAvailable": + isExternalPurchaseLinkAvailable(result: result) + + case "openExternalPurchaseLink": + openExternalPurchaseLink(arguments: call.arguments, result: result) + case "beginRefundRequestIOS": if let args = call.arguments as? [String: Any], let sku = args["sku"] as? String { @@ -764,6 +770,47 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { } } + private func isExternalPurchaseLinkAvailable(result: @escaping FlutterResult) { + FlutterIapLog.debug("isExternalPurchaseLinkAvailable called") + Task { @MainActor in + do { + let available = try await OpenIapModule.shared.isExternalPurchaseLinkAvailable() + FlutterIapLog.result("isExternalPurchaseLinkAvailable", value: available) + result(available) + } catch let purchaseError as PurchaseError { + FlutterIapLog.failure("isExternalPurchaseLinkAvailable", error: purchaseError) + result(flutterError(from: purchaseError)) + } catch { + FlutterIapLog.failure("isExternalPurchaseLinkAvailable", error: error) + let code: ErrorCode = .serviceError + result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) + } + } + } + + private func openExternalPurchaseLink(arguments: Any?, result: @escaping FlutterResult) { + FlutterIapLog.debug("openExternalPurchaseLink called") + Task { @MainActor in + do { + let request = try OpenIapSerialization.decode(object: arguments ?? [:], as: ExternalPurchaseLinkRequest.self) + let res = try await OpenIapModule.shared.openExternalPurchaseLink(request) + let payload = FlutterIapHelper.sanitizeDictionary(OpenIapSerialization.encode(res)) + FlutterIapLog.result("openExternalPurchaseLink", value: payload) + result(payload) + } catch is DecodingError { + let code: ErrorCode = .developerError + result(FlutterError(code: code.rawValue, message: "Invalid external purchase link request", details: nil)) + } catch let purchaseError as PurchaseError { + FlutterIapLog.failure("openExternalPurchaseLink", error: purchaseError) + result(flutterError(from: purchaseError)) + } catch { + FlutterIapLog.failure("openExternalPurchaseLink", error: error) + let code: ErrorCode = .serviceError + result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) + } + } + } + private func beginRefundRequestIOS(sku: String, result: @escaping FlutterResult) { FlutterIapLog.debug("beginRefundRequestIOS called for sku=\(sku)") 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 386e98757..fe0f36a57 100644 --- a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart +++ b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart @@ -2837,6 +2837,66 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { } }; + /// Whether this device and storefront may link out with + /// [openExternalPurchaseLink]. Resolves false when the store program is not + /// enabled, the user is not eligible, or the platform has no store link. + /// + /// See: https://openiap.dev/docs/apis/is-external-purchase-link-available + gentype.QueryIsExternalPurchaseLinkAvailableHandler + get isExternalPurchaseLinkAvailable => () async { + if (!isIOS && !_platform.isAndroid) return false; + try { + final available = await channel + .invokeMethod('isExternalPurchaseLinkAvailable'); + return available ?? false; + } on PlatformException catch (error) { + throw _purchaseErrorFromPlatformException( + error, + 'check external purchase link availability', + ); + } + }; + + /// Show the store's required disclosure, then open the external purchase + /// link only after the user continues. Unavailable and Cancelled resolve as + /// results; report the result's reportToken from your server. + /// + /// See: https://openiap.dev/docs/apis/open-external-purchase-link + gentype.MutationOpenExternalPurchaseLinkHandler + get openExternalPurchaseLink => (request) async { + if (!isIOS && !_platform.isAndroid) { + throw PurchaseError( + code: gentype.ErrorCode.IapNotAvailable, + message: 'External purchase links are only available on iOS, ' + 'macOS and Android', + ); + } + try { + final result = await channel.invokeMethod( + 'openExternalPurchaseLink', + request.toJson(), + ); + final payload = normalizeDynamicMap(result); + if (payload == null) { + throw const FormatException( + 'Invalid external purchase link result from native store'); + } + return gentype.ExternalPurchaseLinkResult.fromJson(payload); + } on PlatformException catch (error) { + throw _purchaseErrorFromPlatformException( + error, + 'open external purchase link', + ); + } catch (error) { + if (error is PurchaseError) rethrow; + throw PurchaseError( + code: gentype.ErrorCode.ServiceError, + message: + 'Failed to open external purchase link: ${error.toString()}', + ); + } + }; + /// Present the external purchase notice sheet (iOS 17.4+, macOS 14.4+). /// /// See: https://openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios @@ -3087,6 +3147,7 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { isEligibleForExternalPurchaseCustomLinkIOS: isEligibleForExternalPurchaseCustomLinkIOS, isEligibleForIntroOfferIOS: isEligibleForIntroOfferIOS, + isExternalPurchaseLinkAvailable: isExternalPurchaseLinkAvailable, isTransactionVerifiedIOS: isTransactionVerifiedIOS, latestTransactionIOS: latestTransactionIOS, subscriptionStatusIOS: subscriptionStatusIOS, @@ -3167,6 +3228,7 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { initConnection: initConnection, isBillingProgramAvailableAndroid: isBillingProgramAvailableAndroid, launchExternalLinkAndroid: _launchExternalLinkAndroidHandler, + openExternalPurchaseLink: openExternalPurchaseLink, openRedeemOfferCode: openRedeemOfferCode, requestPurchase: requestPurchase, restorePurchases: restorePurchases, diff --git a/libraries/flutter_inapp_purchase/lib/types.dart b/libraries/flutter_inapp_purchase/lib/types.dart index 6feb0e916..601b366f8 100644 --- a/libraries/flutter_inapp_purchase/lib/types.dart +++ b/libraries/flutter_inapp_purchase/lib/types.dart @@ -736,6 +736,47 @@ enum ExternalPurchaseCustomLinkTokenTypeIOS { String toJson() => value; } +/// Outcome of openExternalPurchaseLink. +/// Available in Client Protocol 1.5.0 / openiap-apple 4.2.0 / openiap-google 4.2.0. +enum ExternalPurchaseLinkStatus { + /// The user continued past the store disclosure and the link was opened + Opened('opened'), + /// The user dismissed the store disclosure; nothing was opened + Cancelled('cancelled'), + /// The store program is not enabled or the user is not eligible; nothing was shown + Unavailable('unavailable'); + + const ExternalPurchaseLinkStatus(this.value); + final String value; + + static ExternalPurchaseLinkStatus? _fromKnownJson(String value) { + final normalized = value.toLowerCase().replaceAll('_', '-'); + switch (normalized) { + case 'opened': + return ExternalPurchaseLinkStatus.Opened; + case 'cancelled': + return ExternalPurchaseLinkStatus.Cancelled; + case 'unavailable': + return ExternalPurchaseLinkStatus.Unavailable; + } + return null; + } + + factory ExternalPurchaseLinkStatus.fromJson(String value) { + final known = _fromKnownJson(value); + if (known != null) return known; + throw ArgumentError('Unknown ExternalPurchaseLinkStatus value: $value'); + } + + factory ExternalPurchaseLinkStatus.fromJsonStrict(String value) { + final known = _fromKnownJson(value); + if (known != null) return known; + throw ArgumentError('Unknown ExternalPurchaseLinkStatus input value: $value'); + } + + String toJson() => value; +} + /// User actions on external purchase notice sheet (iOS 17.4+) enum ExternalPurchaseNoticeAction { /// User chose to continue to external purchase @@ -2608,6 +2649,41 @@ class ExternalPurchaseCustomLinkTokenResultIOS { } } +/// Result of openExternalPurchaseLink. +/// Available in Client Protocol 1.5.0 / openiap-apple 4.2.0 / openiap-google 4.2.0. +class ExternalPurchaseLinkResult { + const ExternalPurchaseLinkResult({ + required this.platform, + this.reportToken, + required this.status, + }); + + /// Platform that produced reportToken + final IapPlatform platform; + /// Token to report from your server: Play's externalTransactionToken or Apple's + /// external purchase token. Present only when status is Opened. + final String? reportToken; + /// What happened to the link + final ExternalPurchaseLinkStatus status; + + factory ExternalPurchaseLinkResult.fromJson(Map json) { + return ExternalPurchaseLinkResult( + platform: IapPlatform.fromJson(json['platform'] as String), + reportToken: json['reportToken'] as String?, + status: ExternalPurchaseLinkStatus.fromJson(json['status'] as String), + ); + } + + Map toJson() { + return { + '__typename': 'ExternalPurchaseLinkResult', + 'platform': platform.toJson(), + 'reportToken': reportToken, + 'status': status.toJson(), + }; + } +} + /// Result of presenting an external purchase link class ExternalPurchaseLinkResultIOS { const ExternalPurchaseLinkResultIOS({ @@ -4869,6 +4945,88 @@ class DiscountOfferInputIOS { } } +/// Google options for openExternalPurchaseLink. +/// Available in Client Protocol 1.5.0 / openiap-google 4.2.0 (requires Play Billing 8.2.1+). +class ExternalPurchaseLinkAndroidProps { + const ExternalPurchaseLinkAndroidProps({ + this.linkType, + }); + + /// Link type passed to Play. Defaults to LINK_TO_DIGITAL_CONTENT_OFFER. + final ExternalLinkTypeAndroid? linkType; + + factory ExternalPurchaseLinkAndroidProps.fromJson(Map json) { + return ExternalPurchaseLinkAndroidProps( + linkType: json['linkType'] != null ? ExternalLinkTypeAndroid.fromJsonStrict(json['linkType'] as String) : null, + ); + } + + Map toJson() { + return { + 'linkType': linkType?.toJson(), + }; + } +} + +/// Apple options for openExternalPurchaseLink. +/// Available in Client Protocol 1.5.0 / openiap-apple 4.2.0. +class ExternalPurchaseLinkIosProps { + const ExternalPurchaseLinkIosProps({ + this.tokenType, + }); + + /// ExternalPurchaseCustomLink token type on iOS 18.1+. Defaults to Acquisition; + /// use LinkOut in Japan and Brazil. Ignored on iOS 17.4 to 18.0, where the + /// notice sheet returns the token. + final ExternalPurchaseCustomLinkTokenTypeIOS? tokenType; + + factory ExternalPurchaseLinkIosProps.fromJson(Map json) { + return ExternalPurchaseLinkIosProps( + tokenType: json['tokenType'] != null ? ExternalPurchaseCustomLinkTokenTypeIOS.fromJsonStrict(json['tokenType'] as String) : null, + ); + } + + Map toJson() { + return { + 'tokenType': tokenType?.toJson(), + }; + } +} + +/// Request for openExternalPurchaseLink. +/// Available in Client Protocol 1.5.0 / openiap-apple 4.2.0 / openiap-google 4.2.0. +class ExternalPurchaseLinkRequest { + const ExternalPurchaseLinkRequest({ + this.apple, + this.google, + required this.url, + }); + + /// Apple-specific options + final ExternalPurchaseLinkIosProps? apple; + /// Google-specific options + final ExternalPurchaseLinkAndroidProps? google; + /// External purchase URL. On iOS 17.4 to 18.0 it must match the + /// SKExternalPurchase link in Info.plist. + final String url; + + factory ExternalPurchaseLinkRequest.fromJson(Map json) { + return ExternalPurchaseLinkRequest( + apple: json['apple'] != null ? ExternalPurchaseLinkIosProps.fromJson(json['apple'] as Map) : null, + google: json['google'] != null ? ExternalPurchaseLinkAndroidProps.fromJson(json['google'] as Map) : null, + url: json['url'] as String, + ); + } + + Map toJson() { + return { + 'apple': apple?.toJson(), + 'google': google?.toJson(), + 'url': url, + }; + } +} + /// Parameters for fetching Billing Choice display information (Android) /// Available in OpenIAP 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). class GetBillingChoiceInfoParamsAndroid { @@ -6223,6 +6381,14 @@ abstract class MutationResolver { required ExternalLinkTypeAndroid linkType, required String linkUri, }); + /// Show the store's required disclosure, open the external purchase link only + /// after the user continues, and return the token to report from your server. + /// The disclosure cannot be skipped. Unavailable and Cancelled are results, not + /// errors. + /// Available in Client Protocol 1.5.0 / openiap-apple 4.2.0 / openiap-google 4.2.0 + /// (requires iOS 17.4+ or Play Billing 8.2.1+). + /// See: https://openiap.dev/docs/apis/open-external-purchase-link + Future openExternalPurchaseLink(ExternalPurchaseLinkRequest request); /// Open the platform's offer/promo code redemption flow. /// Resolves the redeemed purchase only when the store reports it synchronously; /// every other path resolves null, so reconcile through the purchase listeners. @@ -6383,6 +6549,13 @@ abstract class QueryResolver { /// Check intro-offer eligibility for a subscription group. /// See: https://openiap.dev/docs/apis/ios/is-eligible-for-intro-offer-ios Future isEligibleForIntroOfferIOS(String groupID); + /// Whether this device and storefront may link out to an external purchase + /// with openExternalPurchaseLink. Returns false instead of throwing when the + /// store program is not enabled or the user is not eligible. + /// Available in Client Protocol 1.5.0 / openiap-apple 4.2.0 / openiap-google 4.2.0 + /// (requires iOS 17.4+ or Play Billing 8.2.1+). + /// See: https://openiap.dev/docs/apis/is-external-purchase-link-available + Future isExternalPurchaseLinkAvailable(); /// Check whether a transaction's JWS verification passed (StoreKit 2). /// See: https://openiap.dev/docs/apis/ios/is-transaction-verified-ios Future isTransactionVerifiedIOS(String sku); @@ -6477,6 +6650,7 @@ typedef MutationLaunchExternalLinkAndroidHandler = Future Function({ required ExternalLinkTypeAndroid linkType, required String linkUri, }); +typedef MutationOpenExternalPurchaseLinkHandler = Future Function(ExternalPurchaseLinkRequest request); typedef MutationOpenRedeemOfferCodeHandler = Future Function(); typedef MutationPresentExternalPurchaseLinkIOSHandler = Future Function(String url); typedef MutationPresentExternalPurchaseNoticeSheetIOSHandler = Future Function(); @@ -6516,6 +6690,7 @@ class MutationHandlers { this.initConnection, this.isBillingProgramAvailableAndroid, this.launchExternalLinkAndroid, + this.openExternalPurchaseLink, this.openRedeemOfferCode, this.presentExternalPurchaseLinkIOS, this.presentExternalPurchaseNoticeSheetIOS, @@ -6542,6 +6717,7 @@ class MutationHandlers { final MutationInitConnectionHandler? initConnection; final MutationIsBillingProgramAvailableAndroidHandler? isBillingProgramAvailableAndroid; final MutationLaunchExternalLinkAndroidHandler? launchExternalLinkAndroid; + final MutationOpenExternalPurchaseLinkHandler? openExternalPurchaseLink; final MutationOpenRedeemOfferCodeHandler? openRedeemOfferCode; final MutationPresentExternalPurchaseLinkIOSHandler? presentExternalPurchaseLinkIOS; final MutationPresentExternalPurchaseNoticeSheetIOSHandler? presentExternalPurchaseNoticeSheetIOS; @@ -6587,6 +6763,7 @@ typedef QueryGetTransactionJwsIOSHandler = Future Function(String sku); typedef QueryHasActiveSubscriptionsHandler = Future Function([List? subscriptionIds]); typedef QueryIsEligibleForExternalPurchaseCustomLinkIOSHandler = Future Function(); typedef QueryIsEligibleForIntroOfferIOSHandler = Future Function(String groupID); +typedef QueryIsExternalPurchaseLinkAvailableHandler = Future Function(); typedef QueryIsTransactionVerifiedIOSHandler = Future Function(String sku); typedef QueryLatestTransactionIOSHandler = Future Function(String sku); typedef QuerySubscriptionStatusIOSHandler = Future> Function(String sku); @@ -6614,6 +6791,7 @@ class QueryHandlers { this.hasActiveSubscriptions, this.isEligibleForExternalPurchaseCustomLinkIOS, this.isEligibleForIntroOfferIOS, + this.isExternalPurchaseLinkAvailable, this.isTransactionVerifiedIOS, this.latestTransactionIOS, this.subscriptionStatusIOS, @@ -6637,6 +6815,7 @@ class QueryHandlers { final QueryHasActiveSubscriptionsHandler? hasActiveSubscriptions; final QueryIsEligibleForExternalPurchaseCustomLinkIOSHandler? isEligibleForExternalPurchaseCustomLinkIOS; final QueryIsEligibleForIntroOfferIOSHandler? isEligibleForIntroOfferIOS; + final QueryIsExternalPurchaseLinkAvailableHandler? isExternalPurchaseLinkAvailable; final QueryIsTransactionVerifiedIOSHandler? isTransactionVerifiedIOS; final QueryLatestTransactionIOSHandler? latestTransactionIOS; final QuerySubscriptionStatusIOSHandler? subscriptionStatusIOS; diff --git a/libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift b/libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift index db5981a5b..55c88c4cf 100644 --- a/libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift +++ b/libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift @@ -203,6 +203,12 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { case "openRedeemOfferCode": openRedeemOfferCode(result: result) + + case "isExternalPurchaseLinkAvailable": + isExternalPurchaseLinkAvailable(result: result) + + case "openExternalPurchaseLink": + openExternalPurchaseLink(arguments: call.arguments, result: result) case "beginRefundRequestIOS": withSku(from: call.arguments, result: result) { sku in @@ -724,6 +730,47 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { } @available(iOS 15.0, macOS 14.0, tvOS 15.0, *) + private func isExternalPurchaseLinkAvailable(result: @escaping FlutterResult) { + FlutterIapLog.debug("isExternalPurchaseLinkAvailable called") + Task { @MainActor in + do { + let available = try await OpenIapModule.shared.isExternalPurchaseLinkAvailable() + FlutterIapLog.result("isExternalPurchaseLinkAvailable", value: available) + result(available) + } catch let purchaseError as PurchaseError { + FlutterIapLog.failure("isExternalPurchaseLinkAvailable", error: purchaseError) + result(flutterError(from: purchaseError)) + } catch { + FlutterIapLog.failure("isExternalPurchaseLinkAvailable", error: error) + let code: ErrorCode = .serviceError + result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) + } + } + } + + private func openExternalPurchaseLink(arguments: Any?, result: @escaping FlutterResult) { + FlutterIapLog.debug("openExternalPurchaseLink called") + Task { @MainActor in + do { + let request = try OpenIapSerialization.decode(object: arguments ?? [:], as: ExternalPurchaseLinkRequest.self) + let res = try await OpenIapModule.shared.openExternalPurchaseLink(request) + let payload = FlutterIapHelper.sanitizeDictionary(OpenIapSerialization.encode(res)) + FlutterIapLog.result("openExternalPurchaseLink", value: payload) + result(payload) + } catch is DecodingError { + let code: ErrorCode = .developerError + result(FlutterError(code: code.rawValue, message: "Invalid external purchase link request", details: nil)) + } catch let purchaseError as PurchaseError { + FlutterIapLog.failure("openExternalPurchaseLink", error: purchaseError) + result(flutterError(from: purchaseError)) + } catch { + FlutterIapLog.failure("openExternalPurchaseLink", error: error) + let code: ErrorCode = .serviceError + result(FlutterError(code: code.rawValue, message: defaultMessage(for: code), details: error.localizedDescription)) + } + } + } + private func beginRefundRequestIOS(sku: String, result: @escaping FlutterResult) { FlutterIapLog.debug("beginRefundRequestIOS called for sku=\(sku)") Task { @MainActor in diff --git a/libraries/flutter_inapp_purchase/test/coverage_regression_test.dart b/libraries/flutter_inapp_purchase/test/coverage_regression_test.dart index 7b965875c..776b60b60 100644 --- a/libraries/flutter_inapp_purchase/test/coverage_regression_test.dart +++ b/libraries/flutter_inapp_purchase/test/coverage_regression_test.dart @@ -719,6 +719,7 @@ void main() { expect(query.fetchProducts, isNotNull); expect(query.getBillingChoiceInfoAndroid, isNotNull); + expect(query.isExternalPurchaseLinkAvailable, isNotNull); expect(mutation.acknowledgePurchaseAndroid, isNotNull); expect(mutation.beginRefundRequestIOS, isNotNull); expect(mutation.consumePurchaseAndroid, isNotNull); @@ -729,6 +730,7 @@ void main() { expect(mutation.initConnection, isNotNull); expect(mutation.isBillingProgramAvailableAndroid, isNotNull); expect(mutation.launchExternalLinkAndroid, isNotNull); + expect(mutation.openExternalPurchaseLink, isNotNull); expect(mutation.openRedeemOfferCode, isNotNull); expect(mutation.requestPurchase, isNotNull); expect(mutation.restorePurchases, isNotNull); 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 fbca9df3e..fe03dffc3 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 @@ -526,6 +526,137 @@ void main() { }); }); + group('external purchase link', () { + test('isExternalPurchaseLinkAvailable forwards the native answer', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall call) async { + return call.method == 'isExternalPurchaseLinkAvailable' ? true : null; + }); + + for (final os in ['ios', 'macos', 'android']) { + final iap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: os), + ); + expect(await iap.isExternalPurchaseLinkAvailable(), isTrue); + } + }); + + test('isExternalPurchaseLinkAvailable is false without a store', () async { + final iap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: 'windows'), + ); + expect(await iap.isExternalPurchaseLinkAvailable(), isFalse); + }); + + test('openExternalPurchaseLink sends the request and decodes the result', + () async { + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall call) async { + calls.add(call); + return { + 'platform': 'android', + 'reportToken': 'external-token', + 'status': 'opened', + }; + }); + + final iap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: 'android'), + ); + final result = await iap.openExternalPurchaseLink( + const types.ExternalPurchaseLinkRequest( + url: 'https://example.com/buy', + apple: types.ExternalPurchaseLinkIosProps( + tokenType: types.ExternalPurchaseCustomLinkTokenTypeIOS.Services, + ), + google: types.ExternalPurchaseLinkAndroidProps( + linkType: types.ExternalLinkTypeAndroid.LinkToDigitalContentOffer, + ), + ), + ); + + expect(result.status, types.ExternalPurchaseLinkStatus.Opened); + expect(result.reportToken, 'external-token'); + expect(result.platform, types.IapPlatform.Android); + + final call = calls.single; + expect(call.method, 'openExternalPurchaseLink'); + expect(call.arguments, { + 'apple': {'tokenType': 'services'}, + 'google': { + 'linkType': 'link-to-digital-content-offer', + }, + 'url': 'https://example.com/buy', + }); + }); + + test('openExternalPurchaseLink resolves cancelled without a token on iOS', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall call) async { + return {'platform': 'ios', 'status': 'cancelled'}; + }); + + final iap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: 'ios'), + ); + final result = await iap.openExternalPurchaseLink( + const types.ExternalPurchaseLinkRequest(url: 'https://example.com'), + ); + + expect(result.status, types.ExternalPurchaseLinkStatus.Cancelled); + expect(result.reportToken, isNull); + expect(result.platform, types.IapPlatform.IOS); + }); + + test('openExternalPurchaseLink maps native failures to PurchaseError', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall call) async { + throw PlatformException(code: 'service-error', message: 'boom'); + }); + + final iap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: 'android'), + ); + + await expectLater( + iap.openExternalPurchaseLink( + const types.ExternalPurchaseLinkRequest(url: 'https://example.com'), + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + types.ErrorCode.ServiceError, + ), + ), + ); + }); + + test('openExternalPurchaseLink rejects platforms without a store', + () async { + final iap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: 'windows'), + ); + + await expectLater( + iap.openExternalPurchaseLink( + const types.ExternalPurchaseLinkRequest(url: 'https://example.com'), + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + types.ErrorCode.IapNotAvailable, + ), + ), + ); + }); + }); + group('openRedeemOfferCode', () { test('decodes the synchronously reported purchase on iOS', () async { final calls = []; diff --git a/libraries/godot-iap/Example/tests/test_api_surface.gd b/libraries/godot-iap/Example/tests/test_api_surface.gd index 86ffc1305..71481caf5 100644 --- a/libraries/godot-iap/Example/tests/test_api_surface.gd +++ b/libraries/godot-iap/Example/tests/test_api_surface.gd @@ -127,7 +127,7 @@ func test_query_fields_have_wrapper_methods() -> void: func test_mutation_fields_have_wrapper_methods() -> void: var fields = _collect_fields(Types.Mutation) - _assert_true(fields.size() == 24, "Mutation should expose the 24 generated fields (got %d)" % fields.size()) + _assert_true(fields.size() == 25, "Mutation should expose the 25 generated fields (got %d)" % fields.size()) for field in fields: _assert_true( GodotIapPlugin.has_method(field["snake_name"]), diff --git a/libraries/godot-iap/Example/tests/test_envelope_parsing.gd b/libraries/godot-iap/Example/tests/test_envelope_parsing.gd index 9cf3cad46..cde74fcf1 100644 --- a/libraries/godot-iap/Example/tests/test_envelope_parsing.gd +++ b/libraries/godot-iap/Example/tests/test_envelope_parsing.gd @@ -93,6 +93,15 @@ class FakeAndroidJsonPlugin: last_args = [options_json] return _respond("verifyAppOwnership", "{}") + func isExternalPurchaseLinkAvailable() -> String: + last_method = "isExternalPurchaseLinkAvailable" + return _respond("isExternalPurchaseLinkAvailable", "{}") + + func openExternalPurchaseLink(request_json: String) -> String: + last_method = "openExternalPurchaseLink" + last_args = [request_json] + return _respond("openExternalPurchaseLink", "{}") + func verifyPurchase(props_json: String) -> String: last_args = [props_json] return _respond("verifyPurchase", JSON.stringify({"isValid": false})) @@ -133,6 +142,13 @@ class FakeImmediateApplePlugin: last_args = [options_json] return _respond("verifyAppOwnership", "{}") + func isExternalPurchaseLinkAvailable() -> String: + return _respond("isExternalPurchaseLinkAvailable", "{}") + + func openExternalPurchaseLink(request_json: String) -> String: + last_args = [request_json] + return _respond("openExternalPurchaseLink", "{}") + func initConnection() -> String: return _respond("initConnection", JSON.stringify({"success": true})) @@ -283,6 +299,7 @@ func _run_all_tests() -> void: await test_android_open_redeem_offer_code_envelope() await test_app_ownership_evidence() await test_app_ownership_failure_routing() + await test_external_purchase_link_envelopes() await test_android_verify_purchase_envelope() await test_provider_verification_errors() await test_apple_concurrent_verification_failures() @@ -1924,6 +1941,55 @@ func test_app_ownership_evidence() -> void: _assert_equal(unsupported.reason, Types.AppOwnershipVerificationReason.UNSUPPORTED, "Missing native ownership capability stays unknown") +func test_external_purchase_link_envelopes() -> void: + var errors: Array[Dictionary] = [] + var capture = func(error: Dictionary) -> void: errors.append(error) + GodotIapPlugin.purchase_error.connect(capture) + for platform in ["Android", "iOS"]: + var fake = _install_android_fake() if platform == "Android" else _install_ios_fake() + var wire_platform = "android" if platform == "Android" else "ios" + fake.responses["isExternalPurchaseLinkAvailable"] = JSON.stringify({"success": true, "available": true}) + _assert_true(await GodotIapPlugin.is_external_purchase_link_available(), "%s availability reads the native flag" % platform) + fake.responses["isExternalPurchaseLinkAvailable"] = JSON.stringify({"success": false, "code": "service-error", "error": "boom"}) + _assert_false(await GodotIapPlugin.is_external_purchase_link_available(), "%s availability failures read as false" % platform) + + var request = Types.ExternalPurchaseLinkRequest.from_dict({ + "url": "https://example.com/buy", + "apple": {"tokenType": "link-out"}, + "google": {"linkType": "link-to-digital-content-offer"}, + }) + fake.responses["openExternalPurchaseLink"] = JSON.stringify({"success": true, "resultJson": JSON.stringify({"status": "opened", "reportToken": "token-1", "platform": wire_platform})}) + var opened = await GodotIapPlugin.open_external_purchase_link(request) + _assert_true(opened is Types.ExternalPurchaseLinkResult, "%s returns the generated result" % platform) + _assert_equal(opened.status, Types.ExternalPurchaseLinkStatus.OPENED, "%s keeps the opened status" % platform) + _assert_equal(opened.report_token, "token-1", "%s keeps the report token" % platform) + _assert_equal(JSON.parse_string(fake.last_args[0]), { + "url": "https://example.com/buy", + "apple": {"tokenType": "link-out"}, + "google": {"linkType": "link-to-digital-content-offer"}, + }, "%s sends the canonical request wire" % platform) + + fake.responses["openExternalPurchaseLink"] = JSON.stringify({"success": true, "resultJson": JSON.stringify({"status": "unavailable", "platform": wire_platform})}) + var unavailable = await GodotIapPlugin.open_external_purchase_link({"url": "https://example.com/buy"}) + _assert_equal(unavailable.status, Types.ExternalPurchaseLinkStatus.UNAVAILABLE, "%s reports an unavailable store as a status" % platform) + _assert_equal(unavailable.report_token, null, "%s has no token without a link" % platform) + + var error_count := errors.size() + fake.responses["openExternalPurchaseLink"] = JSON.stringify({"success": false, "code": "developer-error", "error": "bad url"}) + _assert_equal(await GodotIapPlugin.open_external_purchase_link({"url": ""}), null, "%s failures return null" % platform) + _assert_equal(errors.size(), error_count + 1, "%s failures emit one purchase_error" % platform) + _assert_equal(errors.back().get("code"), "developer-error", "%s keeps the native error code" % platform) + _assert_equal(errors.back().get("method"), "openExternalPurchaseLink", "%s failures name the method" % platform) + _uninstall_fake() + + GodotIapPlugin._platform = "Linux" + _assert_false(await GodotIapPlugin.is_external_purchase_link_available(), "Unsupported platforms are unavailable") + _assert_equal(await GodotIapPlugin.open_external_purchase_link({"url": "https://example.com/buy"}), null, "Unsupported platforms return null") + _assert_equal(errors.back().get("code"), "feature-not-supported", "Unsupported platforms report feature-not-supported") + GodotIapPlugin._platform = _original_platform + GodotIapPlugin.purchase_error.disconnect(capture) + + func _collect_app_ownership(results: Dictionary) -> void: results["ownership"] = await GodotIapPlugin.verify_app_ownership() diff --git a/libraries/godot-iap/addons/godot-iap/godot_iap.gd b/libraries/godot-iap/addons/godot-iap/godot_iap.gd index 7a9442100..742310e6d 100644 --- a/libraries/godot-iap/addons/godot-iap/godot_iap.gd +++ b/libraries/godot-iap/addons/godot-iap/godot_iap.gd @@ -2489,6 +2489,46 @@ func open_redeem_offer_code() -> Variant: return purchase return null +# ========================================== +# External Purchase Link (OpenIAP) +# ========================================== + +## Whether this device and storefront may link out with [method open_external_purchase_link]. +## Returns false when the store program is unavailable or the check fails. +## +## See: https://openiap.dev/docs/apis/is-external-purchase-link-available +func is_external_purchase_link_available() -> bool: + if not (_is_apple() or _platform == "Android"): + return false + var payload = await _call_native_async("isExternalPurchaseLinkAvailable") + return payload.get("success", false) and bool(payload.get("available", false)) + +## Show the store disclosure, then open [code]request.url[/code] once the user continues. +## Report [code]report_token[/code] from your server when the status is OPENED. +## Failures emit purchase_error and return null. +## @param request: Types.ExternalPurchaseLinkRequest +## @return Types.ExternalPurchaseLinkResult or null +## +## See: https://openiap.dev/docs/apis/open-external-purchase-link +func open_external_purchase_link(request) -> Variant: + var method := "openExternalPurchaseLink" + var payload: Dictionary = {"success": false, "code": "feature-not-supported", "error": "External purchase links are unsupported on this platform"} + if _is_apple() or _platform == "Android": + payload = await _call_native_async(method, [JSON.stringify(_as_dictionary(request))], _apple_async_ui_timeout_seconds) + if payload.get("success", false): + var parsed = JSON.parse_string(String(payload.get("resultJson", ""))) + if parsed is Dictionary: + var result = Types.ExternalPurchaseLinkResult.from_dict(parsed) + if result != null: + return result + payload = {"code": "billing-response-json-parse-error", "error": "Invalid external purchase link result"} + _purchase_failure( + String(payload.get("code", "service-error")), + String(payload.get("error", "Failed to open the external purchase link")), + {"method": method} + ) + return null + # ========================================== # Utility Functions # ========================================== diff --git a/libraries/godot-iap/addons/godot-iap/types.gd b/libraries/godot-iap/addons/godot-iap/types.gd index e2d90fe26..84cac8472 100644 --- a/libraries/godot-iap/addons/godot-iap/types.gd +++ b/libraries/godot-iap/addons/godot-iap/types.gd @@ -194,6 +194,16 @@ enum ExternalPurchaseCustomLinkTokenTypeIOS { LINK_OUT = 3, } +## Outcome of openExternalPurchaseLink. Available in Client Protocol 1.5.0 / openiap-apple 4.2.0 / openiap-google 4.2.0. +enum ExternalPurchaseLinkStatus { + ## The user continued past the store disclosure and the link was opened + OPENED = 0, + ## The user dismissed the store disclosure; nothing was opened + CANCELLED = 1, + ## The store program is not enabled or the user is not eligible; nothing was shown + UNAVAILABLE = 2, +} + ## User actions on external purchase notice sheet (iOS 17.4+) enum ExternalPurchaseNoticeAction { ## User chose to continue to external purchase @@ -1343,6 +1353,59 @@ class ExternalPurchaseCustomLinkTokenResultIOS: dict["error"] = error return dict +## Result of openExternalPurchaseLink. Available in Client Protocol 1.5.0 / openiap-apple 4.2.0 / openiap-google 4.2.0. +class ExternalPurchaseLinkResult: + ## What happened to the link + var status: _Types.ExternalPurchaseLinkStatus + ## Token to report from your server: Play's externalTransactionToken or Apple's external purchase token. Present only when status is Opened. + var report_token: Variant = null + ## Platform that produced reportToken + var platform: _Types.IapPlatform + + static func from_dict(data: Dictionary, report_errors: bool = true) -> _Types.ExternalPurchaseLinkResult: + if not data.has("status") or not ((data["status"] is String and EXTERNAL_PURCHASE_LINK_STATUS_FROM_STRING.has(data["status"])) or (data["status"] is int and EXTERNAL_PURCHASE_LINK_STATUS_VALUES.has(data["status"]))): + if report_errors: + push_error("Invalid ExternalPurchaseLinkResult.status enum value") + return null + if not data.has("platform") or not ((data["platform"] is String and IAP_PLATFORM_FROM_STRING.has(data["platform"])) or (data["platform"] is int and IAP_PLATFORM_VALUES.has(data["platform"]))): + if report_errors: + push_error("Invalid ExternalPurchaseLinkResult.platform enum value") + return null + var obj = ExternalPurchaseLinkResult.new() + if data.has("status") and data["status"] != null: + var enum_str = data["status"] + if enum_str is String and EXTERNAL_PURCHASE_LINK_STATUS_FROM_STRING.has(enum_str): + obj.status = EXTERNAL_PURCHASE_LINK_STATUS_FROM_STRING[enum_str] + elif enum_str is int and EXTERNAL_PURCHASE_LINK_STATUS_VALUES.has(enum_str): + obj.status = enum_str + else: + obj.status = enum_str + if data.has("reportToken") and data["reportToken"] != null: + obj.report_token = data["reportToken"] + if data.has("platform") and data["platform"] != null: + var enum_str = data["platform"] + if enum_str is String and IAP_PLATFORM_FROM_STRING.has(enum_str): + obj.platform = IAP_PLATFORM_FROM_STRING[enum_str] + elif enum_str is int and IAP_PLATFORM_VALUES.has(enum_str): + obj.platform = enum_str + else: + obj.platform = enum_str + return obj + + func to_dict() -> Dictionary: + var dict = {} + if EXTERNAL_PURCHASE_LINK_STATUS_VALUES.has(status): + dict["status"] = EXTERNAL_PURCHASE_LINK_STATUS_VALUES[status] + else: + dict["status"] = status + if report_token != null: + dict["reportToken"] = report_token + if IAP_PLATFORM_VALUES.has(platform): + dict["platform"] = IAP_PLATFORM_VALUES[platform] + else: + dict["platform"] = platform + return dict + ## Result of presenting an external purchase link class ExternalPurchaseLinkResultIOS: ## Whether the user completed the external purchase flow @@ -4168,6 +4231,114 @@ class DiscountOfferInputIOS: dict["timestamp"] = timestamp return dict +## Google options for openExternalPurchaseLink. Available in Client Protocol 1.5.0 / openiap-google 4.2.0 (requires Play Billing 8.2.1+). +class ExternalPurchaseLinkAndroidProps: + ## Link type passed to Play. Defaults to LINK_TO_DIGITAL_CONTENT_OFFER. + var link_type: Variant = null + + static func from_dict(data: Dictionary) -> _Types.ExternalPurchaseLinkAndroidProps: + var obj = ExternalPurchaseLinkAndroidProps.new() + if data.has("linkType") and data["linkType"] != null: + var enum_str = data["linkType"] + if enum_str is String and EXTERNAL_LINK_TYPE_ANDROID_FROM_STRING.has(enum_str): + obj.link_type = EXTERNAL_LINK_TYPE_ANDROID_FROM_STRING[enum_str] + elif enum_str is int and EXTERNAL_LINK_TYPE_ANDROID_VALUES.has(enum_str): + obj.link_type = enum_str + else: + push_error("Invalid ExternalLinkTypeAndroid input value") + return null + return obj + + func to_dict() -> Dictionary: + var dict = {} + if link_type != null: + if EXTERNAL_LINK_TYPE_ANDROID_VALUES.has(link_type): + dict["linkType"] = EXTERNAL_LINK_TYPE_ANDROID_VALUES[link_type] + else: + dict["linkType"] = link_type + return dict + +## Apple options for openExternalPurchaseLink. Available in Client Protocol 1.5.0 / openiap-apple 4.2.0. +class ExternalPurchaseLinkIosProps: + ## ExternalPurchaseCustomLink token type on iOS 18.1+. Defaults to Acquisition; use LinkOut in Japan and Brazil. Ignored on iOS 17.4 to 18.0, where the notice sheet returns the token. + var token_type: Variant = null + + static func from_dict(data: Dictionary) -> _Types.ExternalPurchaseLinkIosProps: + var obj = ExternalPurchaseLinkIosProps.new() + if data.has("tokenType") and data["tokenType"] != null: + var enum_str = data["tokenType"] + if enum_str is String and EXTERNAL_PURCHASE_CUSTOM_LINK_TOKEN_TYPE_IOS_FROM_STRING.has(enum_str): + obj.token_type = EXTERNAL_PURCHASE_CUSTOM_LINK_TOKEN_TYPE_IOS_FROM_STRING[enum_str] + elif enum_str is int and EXTERNAL_PURCHASE_CUSTOM_LINK_TOKEN_TYPE_IOS_VALUES.has(enum_str): + obj.token_type = enum_str + else: + push_error("Invalid ExternalPurchaseCustomLinkTokenTypeIOS input value") + return null + return obj + + func to_dict() -> Dictionary: + var dict = {} + if token_type != null: + if EXTERNAL_PURCHASE_CUSTOM_LINK_TOKEN_TYPE_IOS_VALUES.has(token_type): + dict["tokenType"] = EXTERNAL_PURCHASE_CUSTOM_LINK_TOKEN_TYPE_IOS_VALUES[token_type] + else: + dict["tokenType"] = token_type + return dict + +## Request for openExternalPurchaseLink. Available in Client Protocol 1.5.0 / openiap-apple 4.2.0 / openiap-google 4.2.0. +class ExternalPurchaseLinkRequest: + ## External purchase URL. On iOS 17.4 to 18.0 it must match the SKExternalPurchase link in Info.plist. + var url: String = "" + ## Apple-specific options + var apple: _Types.ExternalPurchaseLinkIosProps + ## Google-specific options + var google: _Types.ExternalPurchaseLinkAndroidProps + + static func from_dict(data: Dictionary) -> _Types.ExternalPurchaseLinkRequest: + if not data.has("url") or not data["url"] is String: + push_error("Invalid required ExternalPurchaseLinkRequest.url value") + return null + var obj = ExternalPurchaseLinkRequest.new() + if data.has("url") and data["url"] != null: + obj.url = data["url"] + if data.has("apple") and data["apple"] != null: + if data["apple"] is Dictionary: + var decoded_apple = ExternalPurchaseLinkIosProps.from_dict(data["apple"]) + if decoded_apple == null: + push_error("Invalid input ExternalPurchaseLinkIosProps value for apple") + return null + obj.apple = decoded_apple + else: + push_error("Expected apple to be a ExternalPurchaseLinkIosProps dictionary") + return null + if data.has("google") and data["google"] != null: + if data["google"] is Dictionary: + var decoded_google = ExternalPurchaseLinkAndroidProps.from_dict(data["google"]) + if decoded_google == null: + push_error("Invalid input ExternalPurchaseLinkAndroidProps value for google") + return null + obj.google = decoded_google + else: + push_error("Expected google to be a ExternalPurchaseLinkAndroidProps dictionary") + return null + return obj + + func to_dict() -> Dictionary: + var dict = {} + if url != null: + dict["url"] = url + if apple != null: + if apple is Object and apple.has_method("to_dict"): + dict["apple"] = apple.to_dict() + else: + dict["apple"] = apple + if google != null: + if google is Object and google.has_method("to_dict"): + dict["google"] = google.to_dict() + else: + dict["google"] = google + return dict + ## Parameters for fetching Billing Choice display information (Android) Available in OpenIAP 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). class GetBillingChoiceInfoParamsAndroid: ## Billing program. Currently only BILLING_CHOICE is supported. @@ -5895,6 +6066,12 @@ const EXTERNAL_PURCHASE_CUSTOM_LINK_TOKEN_TYPE_IOS_VALUES = { ExternalPurchaseCustomLinkTokenTypeIOS.LINK_OUT: "link-out" } +const EXTERNAL_PURCHASE_LINK_STATUS_VALUES = { + ExternalPurchaseLinkStatus.OPENED: "opened", + ExternalPurchaseLinkStatus.CANCELLED: "cancelled", + ExternalPurchaseLinkStatus.UNAVAILABLE: "unavailable" +} + const EXTERNAL_PURCHASE_NOTICE_ACTION_VALUES = { ExternalPurchaseNoticeAction.CONTINUE: "continue", ExternalPurchaseNoticeAction.DISMISSED: "dismissed" @@ -6294,6 +6471,18 @@ const EXTERNAL_PURCHASE_CUSTOM_LINK_TOKEN_TYPE_IOS_FROM_STRING = { "LinkOut": ExternalPurchaseCustomLinkTokenTypeIOS.LINK_OUT } +const EXTERNAL_PURCHASE_LINK_STATUS_FROM_STRING = { + "opened": ExternalPurchaseLinkStatus.OPENED, + "OPENED": ExternalPurchaseLinkStatus.OPENED, + "Opened": ExternalPurchaseLinkStatus.OPENED, + "cancelled": ExternalPurchaseLinkStatus.CANCELLED, + "CANCELLED": ExternalPurchaseLinkStatus.CANCELLED, + "Cancelled": ExternalPurchaseLinkStatus.CANCELLED, + "unavailable": ExternalPurchaseLinkStatus.UNAVAILABLE, + "UNAVAILABLE": ExternalPurchaseLinkStatus.UNAVAILABLE, + "Unavailable": ExternalPurchaseLinkStatus.UNAVAILABLE +} + const EXTERNAL_PURCHASE_NOTICE_ACTION_FROM_STRING = { "continue": ExternalPurchaseNoticeAction.CONTINUE, "CONTINUE": ExternalPurchaseNoticeAction.CONTINUE, @@ -6718,6 +6907,15 @@ class Query: const return_type = "String" const is_array = false + ## Whether this device and storefront may link out to an external purchase with openExternalPurchaseLink. Returns false instead of throwing when the store program is not enabled or the user is not eligible. Available in Client Protocol 1.5.0 / openiap-apple 4.2.0 / openiap-google 4.2.0 (requires iOS 17.4+ or Play Billing 8.2.1+). See: https://openiap.dev/docs/apis/is-external-purchase-link-available + class isExternalPurchaseLinkAvailableField: + const name = "isExternalPurchaseLinkAvailable" + const snake_name = "is_external_purchase_link_available" + class Args: + pass + const return_type = "Boolean" + const is_array = false + ## Read the App Store-promoted product, if any (iOS 15+). OpenIAP consumes PurchaseIntent.intents on iOS 16.4+ and uses the StoreKit 1 observer only on iOS 15–16.3. When PurchaseIntent carries an externally redeemed win-back offer, OpenIAP preserves it for the next matching requestPurchase unless the caller supplies an explicit win-back or promotional offer. See: https://openiap.dev/docs/apis/ios/get-promoted-product-ios class getPromotedProductIOSField: const name = "getPromotedProductIOS" @@ -7049,6 +7247,24 @@ class Mutation: const return_type = "Purchase" const is_array = false + ## Show the store's required disclosure, open the external purchase link only after the user continues, and return the token to report from your server. The disclosure cannot be skipped. Unavailable and Cancelled are results, not errors. Available in Client Protocol 1.5.0 / openiap-apple 4.2.0 / openiap-google 4.2.0 (requires iOS 17.4+ or Play Billing 8.2.1+). See: https://openiap.dev/docs/apis/open-external-purchase-link + class openExternalPurchaseLinkField: + const name = "openExternalPurchaseLink" + const snake_name = "open_external_purchase_link" + class Args: + var request: _Types.ExternalPurchaseLinkRequest + + static func from_dict(data: Dictionary) -> _Types.Mutation.openExternalPurchaseLinkField.Args: + var obj = Args.new() + if data.has("request") and data["request"] != null: + obj.request = data["request"] + return obj + + func to_dict() -> Dictionary: + return _Types.open_external_purchase_link_args(request) + const return_type = "ExternalPurchaseLinkResult" + const is_array = false + ## Verify a purchase against your own backend. Every VerifyPurchaseResult variant exposes isValid, so entitlement can be gated without inspecting the concrete type. Variants add their own metadata on top: IOS carries receipt/JWS fields, Android carries Play Store receipt fields, and Horizon carries grantTime. See: https://openiap.dev/docs/features/validation#verify-purchase class verifyPurchaseField: const name = "verifyPurchase" @@ -7398,6 +7614,10 @@ static func has_active_subscriptions_args(subscription_ids: Variant = null) -> D static func get_storefront_args() -> Dictionary: return {} +## Whether this device and storefront may link out to an external purchase with openExternalPurchaseLink. Returns false instead of throwing when the store program is not enabled or the user is not eligible. Available in Client Protocol 1.5.0 / openiap-apple 4.2.0 / openiap-google 4.2.0 (requires iOS 17.4+ or Play Billing 8.2.1+). See: https://openiap.dev/docs/apis/is-external-purchase-link-available +static func is_external_purchase_link_available_args() -> Dictionary: + return {} + ## Read the App Store-promoted product, if any (iOS 15+). OpenIAP consumes PurchaseIntent.intents on iOS 16.4+ and uses the StoreKit 1 observer only on iOS 15–16.3. When PurchaseIntent carries an externally redeemed win-back offer, OpenIAP preserves it for the next matching requestPurchase unless the caller supplies an explicit win-back or promotional offer. See: https://openiap.dev/docs/apis/ios/get-promoted-product-ios static func get_promoted_product_ios_args() -> Dictionary: return {} @@ -7537,6 +7757,16 @@ static func deep_link_to_subscriptions_args(options: Variant = null) -> Dictiona static func open_redeem_offer_code_args() -> Dictionary: return {} +## Show the store's required disclosure, open the external purchase link only after the user continues, and return the token to report from your server. The disclosure cannot be skipped. Unavailable and Cancelled are results, not errors. Available in Client Protocol 1.5.0 / openiap-apple 4.2.0 / openiap-google 4.2.0 (requires iOS 17.4+ or Play Billing 8.2.1+). See: https://openiap.dev/docs/apis/open-external-purchase-link +static func open_external_purchase_link_args(request: _Types.ExternalPurchaseLinkRequest) -> Dictionary: + var args = {} + if request != null: + if request is Object and request.has_method("to_dict"): + args["request"] = request.to_dict() + else: + args["request"] = request + return args + ## Verify a purchase against your own backend. Every VerifyPurchaseResult variant exposes isValid, so entitlement can be gated without inspecting the concrete type. Variants add their own metadata on top: IOS carries receipt/JWS fields, Android carries Play Store receipt fields, and Horizon carries grantTime. See: https://openiap.dev/docs/features/validation#verify-purchase static func verify_purchase_args(options: _Types.VerifyPurchaseProps) -> Dictionary: var args = {} diff --git a/libraries/godot-iap/android/src/main/java/dev/hyo/godotiap/GodotIap.kt b/libraries/godot-iap/android/src/main/java/dev/hyo/godotiap/GodotIap.kt index 58604dee9..a181c7a0c 100644 --- a/libraries/godot-iap/android/src/main/java/dev/hyo/godotiap/GodotIap.kt +++ b/libraries/godot-iap/android/src/main/java/dev/hyo/godotiap/GodotIap.kt @@ -763,15 +763,36 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { } @UsedByGodot - fun verifyAppOwnership(optionsJson: String): String { + fun verifyAppOwnership(optionsJson: String): String = launchAsync("verifyAppOwnership") { envelope -> + val options = VerifyAppOwnershipProps.fromJson(GodotIapHelper.jsonObjectToMap(JSONObject(optionsJson))) + val result = openIap.verifyAppOwnership(options) + envelope.put("ownershipJson", JSONObject(result.toJson()).toString()) + } + + @UsedByGodot + fun isExternalPurchaseLinkAvailable(): String = launchAsync("isExternalPurchaseLinkAvailable") { envelope -> + envelope.put("available", openIap.isExternalPurchaseLinkAvailable()) + } + + @UsedByGodot + fun openExternalPurchaseLink(requestJson: String): String = launchAsync("openExternalPurchaseLink") { envelope -> + val activity = activity ?: throw OpenIapError.MissingCurrentActivity + val request = ExternalPurchaseLinkRequest.fromJson(GodotIapHelper.jsonObjectToMap(JSONObject(requestJson))) + ?: throw IllegalArgumentException("Invalid external purchase link request") + val result = openIap.openExternalPurchaseLink(activity, request) + GodotIapLog.result("openExternalPurchaseLink", result.status.toJson()) + envelope.put("resultJson", JSONObject(result.toJson()).toString()) + } + + /** Runs [block] off the call and completes the GDScript waiter on products_fetched by method and requestId. */ + private fun launchAsync(method: String, block: suspend (JSONObject) -> Unit): String { val requestId = java.util.UUID.randomUUID().toString() pluginScope.launch { - val envelope = JSONObject().put("method", "verifyAppOwnership").put("requestId", requestId) + val envelope = JSONObject().put("method", method).put("requestId", requestId) try { if (!isInitialized) throw OpenIapError.NotPrepared - val options = VerifyAppOwnershipProps.fromJson(GodotIapHelper.jsonObjectToMap(JSONObject(optionsJson))) - val result = openIap.verifyAppOwnership(options) - envelope.put("success", true).put("ownershipJson", JSONObject(result.toJson()).toString()) + block(envelope) + envelope.put("success", true) } catch (error: kotlinx.coroutines.CancellationException) { throw error } catch (error: Exception) { diff --git a/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift b/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift index 911aafd5d..a7054b064 100644 --- a/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift +++ b/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift @@ -1175,6 +1175,43 @@ public class GodotIap: RefCounted, @unchecked Sendable { return "{\"status\": \"pending\", \"requestId\": \"\(requestId)\"}" } + @Callable + public func isExternalPurchaseLinkAvailable() -> String { + let requestId = UUID().uuidString + Task { [weak self] in + guard let self else { return } + do { + let available = try await self.openIap.isExternalPurchaseLinkAvailable() + await self.emitAsyncResult(method: "isExternalPurchaseLinkAvailable", requestId: requestId, ["available": available]) + } catch { + await self.emitAsyncFailure(method: "isExternalPurchaseLinkAvailable", requestId: requestId, message: error.localizedDescription, code: GodotIapHelper.errorCode(error)) + } + } + return "{\"status\":\"pending\",\"requestId\":\"\(requestId)\"}" + } + + @Callable + public func openExternalPurchaseLink(requestJson: String) -> String { + let requestId = UUID().uuidString + Task { [weak self] in + guard let self else { return } + do { + let request = try JSONDecoder().decode(ExternalPurchaseLinkRequest.self, from: Data(requestJson.utf8)) + let result = try await self.openIap.openExternalPurchaseLink(request) + await self.emitAsyncResult( + method: "openExternalPurchaseLink", + requestId: requestId, + ["resultJson": GodotIapHelper.jsonString(OpenIapSerialization.encode(result))] + ) + } catch is DecodingError { + await self.emitAsyncFailure(method: "openExternalPurchaseLink", requestId: requestId, message: "Invalid external purchase link request", code: ErrorCode.developerError.rawValue) + } catch { + await self.emitAsyncFailure(method: "openExternalPurchaseLink", requestId: requestId, message: error.localizedDescription, code: GodotIapHelper.errorCode(error)) + } + } + return "{\"status\":\"pending\",\"requestId\":\"\(requestId)\"}" + } + @Callable public func deepLinkToSubscriptions(optionsJson: String) -> String { GodotIapLog.payload("Deep linking to subscriptions", payload: nil) diff --git a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/OpenIapDelegateInAppPurchaseAndroid.kt b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/OpenIapDelegateInAppPurchaseAndroid.kt index 595668660..f24681ef6 100644 --- a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/OpenIapDelegateInAppPurchaseAndroid.kt +++ b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/OpenIapDelegateInAppPurchaseAndroid.kt @@ -36,12 +36,16 @@ import io.github.hyochan.kmpiap.openiap.ExternalPurchaseCustomLinkNoticeResultIO import io.github.hyochan.kmpiap.openiap.ExternalPurchaseCustomLinkNoticeTypeIOS import io.github.hyochan.kmpiap.openiap.ExternalPurchaseCustomLinkTokenResultIOS import io.github.hyochan.kmpiap.openiap.ExternalPurchaseCustomLinkTokenTypeIOS +import io.github.hyochan.kmpiap.openiap.ExternalPurchaseLinkRequest +import io.github.hyochan.kmpiap.openiap.ExternalPurchaseLinkResult import io.github.hyochan.kmpiap.openiap.ExternalPurchaseLinkResultIOS +import io.github.hyochan.kmpiap.openiap.ExternalPurchaseLinkStatus import io.github.hyochan.kmpiap.openiap.ExternalPurchaseNoticeResultIOS import io.github.hyochan.kmpiap.openiap.FetchProductsResult import io.github.hyochan.kmpiap.openiap.FetchProductsResultAll import io.github.hyochan.kmpiap.openiap.FetchProductsResultProducts import io.github.hyochan.kmpiap.openiap.FetchProductsResultSubscriptions +import io.github.hyochan.kmpiap.openiap.IapPlatform import io.github.hyochan.kmpiap.openiap.InitConnectionConfig import io.github.hyochan.kmpiap.openiap.GetBillingChoiceInfoParamsAndroid import io.github.hyochan.kmpiap.openiap.InAppMessageParamsAndroid @@ -423,6 +427,16 @@ internal class OpenIapDelegateInAppPurchaseAndroid( ) } else false + override suspend fun isExternalPurchaseLinkAvailable(): Boolean = + hasPlayBillingApis && withMappedOpenIapError { requireModule().isExternalPurchaseLinkAvailable() } + + override suspend fun openExternalPurchaseLink(request: ExternalPurchaseLinkRequest): ExternalPurchaseLinkResult = + if (hasPlayBillingApis) withMappedOpenIapError { + ExternalPurchaseLinkResult.fromJson(requireModule().openExternalPurchaseLink( + requireActivity(), requireNotNull(dev.hyo.openiap.ExternalPurchaseLinkRequest.fromJson(request.toJson())) + ).toJson()) + } else ExternalPurchaseLinkResult(platform = IapPlatform.Android, status = ExternalPurchaseLinkStatus.Unavailable) + // Opens the store's redeem page, which needs no billing connection. override suspend fun openRedeemOfferCode(): Purchase? = when { !hasPlayBillingApis -> null diff --git a/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/CommunityOptionalOperationsTest.kt b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/CommunityOptionalOperationsTest.kt index fb3f6fa1a..ce56833ae 100644 --- a/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/CommunityOptionalOperationsTest.kt +++ b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/CommunityOptionalOperationsTest.kt @@ -123,6 +123,37 @@ class CommunityOptionalOperationsTest { assertEquals("opaque-community-token", purchase.purchaseToken) } + @Test + fun externalPurchaseLinkMapsRequestAndResult() = runBlocking { + val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + val provider = fakeModule { method, args -> + when (method) { + "isExternalPurchaseLinkAvailable" -> true + "openExternalPurchaseLink" -> { + assertEquals(activity, args?.get(0)) + val request = args?.get(1) as dev.hyo.openiap.ExternalPurchaseLinkRequest + assertEquals("https://example.test/pay", request.url) + assertEquals(dev.hyo.openiap.ExternalLinkTypeAndroid.LinkToAppDownload, request.google?.linkType) + dev.hyo.openiap.ExternalPurchaseLinkResult( + platform = dev.hyo.openiap.IapPlatform.Android, + reportToken = "external-token", + status = dev.hyo.openiap.ExternalPurchaseLinkStatus.Opened, + ) + } + else -> error("Unexpected call: $method") + } + } + val delegate = delegateFor(Store.UNKNOWN, provider, activity) + assertTrue(delegate.isExternalPurchaseLinkAvailable()) + val result = delegate.openExternalPurchaseLink(ExternalPurchaseLinkRequest( + url = "https://example.test/pay", + google = ExternalPurchaseLinkAndroidProps(linkType = ExternalLinkTypeAndroid.LinkToAppDownload), + )) + assertEquals(ExternalPurchaseLinkStatus.Opened, result.status) + assertEquals("external-token", result.reportToken) + assertEquals(IapPlatform.Android, result.platform) + } + @Test fun providerFailuresAndMissingActivityRemainTyped() = runBlocking { for (store in listOf(Store.UNKNOWN, Store.PLAY_STORE)) { @@ -141,6 +172,7 @@ class CommunityOptionalOperationsTest { { ui.showBillingProgramInformationDialogAndroid(BillingProgramInformationDialogParamsAndroid(externalTransactionToken = "token")) }, { ui.showAlternativeBillingOnlyInformationDialogAndroid() }, { ui.launchExternalLinkAndroid(linkParams()) }, + { ui.openExternalPurchaseLink(ExternalPurchaseLinkRequest(url = "https://example.test/pay")) }, ) + if (store == Store.UNKNOWN) listOf Any?>({ ui.openRedeemOfferCode() }) else emptyList() for (operation in operations) { assertEquals(ErrorCode.ActivityUnavailable, assertFailsWith { operation() }.error.code, "$store") @@ -196,6 +228,11 @@ class CommunityOptionalOperationsTest { assertFalse(delegate.isBillingProgramAvailableAndroid(BillingProgramAndroid.BillingChoice).isAvailable) assertFalse(delegate.launchExternalLinkAndroid(linkParams())) assertNull(delegate.openRedeemOfferCode()) + assertFalse(delegate.isExternalPurchaseLinkAvailable()) + assertEquals( + ExternalPurchaseLinkStatus.Unavailable, + delegate.openExternalPurchaseLink(ExternalPurchaseLinkRequest(url = "https://example.test/pay")).status, + ) for (operation in listOf Any?>( { delegate.verifyPurchase(VerifyPurchaseProps()) }, { delegate.getBillingChoiceInfoAndroid(GetBillingChoiceInfoParamsAndroid()) }, diff --git a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt index dcf47ebd3..66c65608e 100644 --- a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt +++ b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/openiap/Types.kt @@ -695,6 +695,47 @@ public enum class ExternalPurchaseCustomLinkTokenTypeIOS(val rawValue: String) { fun toJson(): String = rawValue } +/** + * Outcome of openExternalPurchaseLink. + * Available in Client Protocol 1.5.0 / openiap-apple 4.2.0 / openiap-google 4.2.0. + */ +public enum class ExternalPurchaseLinkStatus(val rawValue: String) { + /** + * The user continued past the store disclosure and the link was opened + */ + Opened("opened"), + /** + * The user dismissed the store disclosure; nothing was opened + */ + Cancelled("cancelled"), + /** + * The store program is not enabled or the user is not eligible; nothing was shown + */ + Unavailable("unavailable"); + + companion object { + private fun fromKnownJson(value: String): ExternalPurchaseLinkStatus? = when (value) { + "opened" -> ExternalPurchaseLinkStatus.Opened + "OPENED" -> ExternalPurchaseLinkStatus.Opened + "Opened" -> ExternalPurchaseLinkStatus.Opened + "cancelled" -> ExternalPurchaseLinkStatus.Cancelled + "CANCELLED" -> ExternalPurchaseLinkStatus.Cancelled + "Cancelled" -> ExternalPurchaseLinkStatus.Cancelled + "unavailable" -> ExternalPurchaseLinkStatus.Unavailable + "UNAVAILABLE" -> ExternalPurchaseLinkStatus.Unavailable + "Unavailable" -> ExternalPurchaseLinkStatus.Unavailable + else -> null + } + + fun fromJson(value: String): ExternalPurchaseLinkStatus = fromKnownJson(value) ?: throw IllegalArgumentException("Unknown ExternalPurchaseLinkStatus value: $value") + + fun fromJsonStrict(value: String): ExternalPurchaseLinkStatus = + fromKnownJson(value) ?: throw IllegalArgumentException("Unknown ExternalPurchaseLinkStatus input value: $value") + } + + fun toJson(): String = rawValue +} + /** * User actions on external purchase notice sheet (iOS 17.4+) */ @@ -2504,6 +2545,44 @@ public data class ExternalPurchaseCustomLinkTokenResultIOS( ) } +/** + * Result of openExternalPurchaseLink. + * Available in Client Protocol 1.5.0 / openiap-apple 4.2.0 / openiap-google 4.2.0. + */ +public data class ExternalPurchaseLinkResult( + /** + * Platform that produced reportToken + */ + val platform: IapPlatform, + /** + * Token to report from your server: Play's externalTransactionToken or Apple's + * external purchase token. Present only when status is Opened. + */ + val reportToken: String? = null, + /** + * What happened to the link + */ + val status: ExternalPurchaseLinkStatus +) { + + companion object { + fun fromJson(json: Map): ExternalPurchaseLinkResult { + return ExternalPurchaseLinkResult( + platform = (json["platform"] as? String)?.let { IapPlatform.fromJson(it) } ?: throw IllegalArgumentException("Missing required enum value for IapPlatform"), + reportToken = json["reportToken"] as? String, + status = (json["status"] as? String)?.let { ExternalPurchaseLinkStatus.fromJson(it) } ?: throw IllegalArgumentException("Missing required enum value for ExternalPurchaseLinkStatus"), + ) + } + } + + fun toJson(): Map = mapOf( + "__typename" to "ExternalPurchaseLinkResult", + "platform" to platform.toJson(), + "reportToken" to reportToken, + "status" to status.toJson(), + ) +} + /** * Result of presenting an external purchase link */ @@ -4767,6 +4846,94 @@ public data class DiscountOfferInputIOS( ) } +/** + * Google options for openExternalPurchaseLink. + * Available in Client Protocol 1.5.0 / openiap-google 4.2.0 (requires Play Billing 8.2.1+). + */ +public data class ExternalPurchaseLinkAndroidProps( + /** + * Link type passed to Play. Defaults to LINK_TO_DIGITAL_CONTENT_OFFER. + */ + val linkType: ExternalLinkTypeAndroid? = null +) { + companion object { + fun fromJson(json: Map): ExternalPurchaseLinkAndroidProps { + return ExternalPurchaseLinkAndroidProps( + linkType = json["linkType"]?.let { raw -> (raw as? String)?.let { ExternalLinkTypeAndroid.fromJsonStrict(it) } ?: throw IllegalArgumentException("Missing or invalid enum input value for ExternalLinkTypeAndroid") }, + ) + } + } + + fun toJson(): Map = mapOf( + "linkType" to linkType?.toJson(), + ) +} + +/** + * Apple options for openExternalPurchaseLink. + * Available in Client Protocol 1.5.0 / openiap-apple 4.2.0. + */ +public data class ExternalPurchaseLinkIosProps( + /** + * ExternalPurchaseCustomLink token type on iOS 18.1+. Defaults to Acquisition; + * use LinkOut in Japan and Brazil. Ignored on iOS 17.4 to 18.0, where the + * notice sheet returns the token. + */ + val tokenType: ExternalPurchaseCustomLinkTokenTypeIOS? = null +) { + companion object { + fun fromJson(json: Map): ExternalPurchaseLinkIosProps { + return ExternalPurchaseLinkIosProps( + tokenType = json["tokenType"]?.let { raw -> (raw as? String)?.let { ExternalPurchaseCustomLinkTokenTypeIOS.fromJsonStrict(it) } ?: throw IllegalArgumentException("Missing or invalid enum input value for ExternalPurchaseCustomLinkTokenTypeIOS") }, + ) + } + } + + fun toJson(): Map = mapOf( + "tokenType" to tokenType?.toJson(), + ) +} + +/** + * Request for openExternalPurchaseLink. + * Available in Client Protocol 1.5.0 / openiap-apple 4.2.0 / openiap-google 4.2.0. + */ +public data class ExternalPurchaseLinkRequest( + /** + * Apple-specific options + */ + val apple: ExternalPurchaseLinkIosProps? = null, + /** + * Google-specific options + */ + val google: ExternalPurchaseLinkAndroidProps? = null, + /** + * External purchase URL. On iOS 17.4 to 18.0 it must match the + * SKExternalPurchase link in Info.plist. + */ + val url: String +) { + companion object { + fun fromJson(json: Map): ExternalPurchaseLinkRequest? { + val apple = json["apple"]?.let { value -> (value as? Map)?.let { ExternalPurchaseLinkIosProps.fromJson(it) } ?: throw IllegalArgumentException("Invalid input object for ExternalPurchaseLinkIosProps") } + val google = json["google"]?.let { value -> (value as? Map)?.let { ExternalPurchaseLinkAndroidProps.fromJson(it) } ?: throw IllegalArgumentException("Invalid input object for ExternalPurchaseLinkAndroidProps") } + val url = json["url"] as? String + if (url == null) return null + return ExternalPurchaseLinkRequest( + apple = apple, + google = google, + url = url, + ) + } + } + + fun toJson(): Map = mapOf( + "apple" to apple?.toJson(), + "google" to google?.toJson(), + "url" to url, + ) +} + /** * Parameters for fetching Billing Choice display information (Android) * Available in OpenIAP 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). @@ -6210,6 +6377,16 @@ public interface MutationResolver { * See: https://openiap.dev/docs/apis/android/launch-external-link-android */ suspend fun launchExternalLinkAndroid(params: LaunchExternalLinkParamsAndroid): Boolean + /** + * Show the store's required disclosure, open the external purchase link only + * after the user continues, and return the token to report from your server. + * The disclosure cannot be skipped. Unavailable and Cancelled are results, not + * errors. + * Available in Client Protocol 1.5.0 / openiap-apple 4.2.0 / openiap-google 4.2.0 + * (requires iOS 17.4+ or Play Billing 8.2.1+). + * See: https://openiap.dev/docs/apis/open-external-purchase-link + */ + suspend fun openExternalPurchaseLink(request: ExternalPurchaseLinkRequest): ExternalPurchaseLinkResult /** * Open the platform's offer/promo code redemption flow. * Resolves the redeemed purchase only when the store reports it synchronously; @@ -6409,6 +6586,15 @@ public interface QueryResolver { * See: https://openiap.dev/docs/apis/ios/is-eligible-for-intro-offer-ios */ suspend fun isEligibleForIntroOfferIOS(groupID: String): Boolean + /** + * Whether this device and storefront may link out to an external purchase + * with openExternalPurchaseLink. Returns false instead of throwing when the + * store program is not enabled or the user is not eligible. + * Available in Client Protocol 1.5.0 / openiap-apple 4.2.0 / openiap-google 4.2.0 + * (requires iOS 17.4+ or Play Billing 8.2.1+). + * See: https://openiap.dev/docs/apis/is-external-purchase-link-available + */ + suspend fun isExternalPurchaseLinkAvailable(): Boolean /** * Check whether a transaction's JWS verification passed (StoreKit 2). * See: https://openiap.dev/docs/apis/ios/is-transaction-verified-ios @@ -6502,6 +6688,7 @@ public typealias MutationFinishTransactionHandler = suspend (purchase: PurchaseI public typealias MutationInitConnectionHandler = suspend (config: InitConnectionConfig?) -> Boolean public typealias MutationIsBillingProgramAvailableAndroidHandler = suspend (program: BillingProgramAndroid) -> BillingProgramAvailabilityResultAndroid public typealias MutationLaunchExternalLinkAndroidHandler = suspend (params: LaunchExternalLinkParamsAndroid) -> Boolean +public typealias MutationOpenExternalPurchaseLinkHandler = suspend (request: ExternalPurchaseLinkRequest) -> ExternalPurchaseLinkResult public typealias MutationOpenRedeemOfferCodeHandler = suspend () -> Purchase? public typealias MutationPresentExternalPurchaseLinkIOSHandler = suspend (url: String) -> ExternalPurchaseLinkResultIOS public typealias MutationPresentExternalPurchaseNoticeSheetIOSHandler = suspend () -> ExternalPurchaseNoticeResultIOS @@ -6597,6 +6784,16 @@ public class MutationHandlers( * See: https://openiap.dev/docs/apis/android/launch-external-link-android */ val launchExternalLinkAndroid: MutationLaunchExternalLinkAndroidHandler? = null, + /** + * Show the store's required disclosure, open the external purchase link only + * after the user continues, and return the token to report from your server. + * The disclosure cannot be skipped. Unavailable and Cancelled are results, not + * errors. + * Available in Client Protocol 1.5.0 / openiap-apple 4.2.0 / openiap-google 4.2.0 + * (requires iOS 17.4+ or Play Billing 8.2.1+). + * See: https://openiap.dev/docs/apis/open-external-purchase-link + */ + val openExternalPurchaseLink: MutationOpenExternalPurchaseLinkHandler? = null, /** * Open the platform's offer/promo code redemption flow. * Resolves the redeemed purchase only when the store reports it synchronously; @@ -6702,6 +6899,7 @@ public class MutationHandlers( initConnection: MutationInitConnectionHandler? = null, isBillingProgramAvailableAndroid: MutationIsBillingProgramAvailableAndroidHandler? = null, launchExternalLinkAndroid: MutationLaunchExternalLinkAndroidHandler? = null, + openExternalPurchaseLink: MutationOpenExternalPurchaseLinkHandler? = null, openRedeemOfferCode: MutationOpenRedeemOfferCodeHandler? = null, presentExternalPurchaseLinkIOS: MutationPresentExternalPurchaseLinkIOSHandler? = null, presentExternalPurchaseNoticeSheetIOS: MutationPresentExternalPurchaseNoticeSheetIOSHandler? = null, @@ -6715,7 +6913,7 @@ public class MutationHandlers( verifyPurchase: MutationVerifyPurchaseHandler? = null, verifyPurchaseWithProvider: MutationVerifyPurchaseWithProviderHandler? = null, showAlternativeBillingOnlyInformationDialogAndroid: MutationShowAlternativeBillingOnlyInformationDialogAndroidHandler?, - ) : this(acknowledgePurchaseAndroid, beginRefundRequestIOS, clearTransactionIOS, consumePurchaseAndroid, createBillingProgramReportingDetailsAndroid, deepLinkToSubscriptions, endConnection, finishTransaction, initConnection, isBillingProgramAvailableAndroid, launchExternalLinkAndroid, openRedeemOfferCode, presentExternalPurchaseLinkIOS, presentExternalPurchaseNoticeSheetIOS, requestPurchase, restorePurchases, showBillingProgramInformationDialogAndroid, showExternalPurchaseCustomLinkNoticeIOS, showInAppMessagesAndroid, showManageSubscriptionsIOS, syncIOS, verifyPurchase, verifyPurchaseWithProvider) { + ) : this(acknowledgePurchaseAndroid, beginRefundRequestIOS, clearTransactionIOS, consumePurchaseAndroid, createBillingProgramReportingDetailsAndroid, deepLinkToSubscriptions, endConnection, finishTransaction, initConnection, isBillingProgramAvailableAndroid, launchExternalLinkAndroid, openExternalPurchaseLink, openRedeemOfferCode, presentExternalPurchaseLinkIOS, presentExternalPurchaseNoticeSheetIOS, requestPurchase, restorePurchases, showBillingProgramInformationDialogAndroid, showExternalPurchaseCustomLinkNoticeIOS, showInAppMessagesAndroid, showManageSubscriptionsIOS, syncIOS, verifyPurchase, verifyPurchaseWithProvider) { this.showAlternativeBillingOnlyInformationDialogAndroid = showAlternativeBillingOnlyInformationDialogAndroid } @@ -6730,18 +6928,19 @@ public class MutationHandlers( operator fun component9(): MutationInitConnectionHandler? = initConnection operator fun component10(): MutationIsBillingProgramAvailableAndroidHandler? = isBillingProgramAvailableAndroid operator fun component11(): MutationLaunchExternalLinkAndroidHandler? = launchExternalLinkAndroid - operator fun component12(): MutationOpenRedeemOfferCodeHandler? = openRedeemOfferCode - operator fun component13(): MutationPresentExternalPurchaseLinkIOSHandler? = presentExternalPurchaseLinkIOS - operator fun component14(): MutationPresentExternalPurchaseNoticeSheetIOSHandler? = presentExternalPurchaseNoticeSheetIOS - operator fun component15(): MutationRequestPurchaseHandler? = requestPurchase - operator fun component16(): MutationRestorePurchasesHandler? = restorePurchases - operator fun component17(): MutationShowBillingProgramInformationDialogAndroidHandler? = showBillingProgramInformationDialogAndroid - operator fun component18(): MutationShowExternalPurchaseCustomLinkNoticeIOSHandler? = showExternalPurchaseCustomLinkNoticeIOS - operator fun component19(): MutationShowInAppMessagesAndroidHandler? = showInAppMessagesAndroid - operator fun component20(): MutationShowManageSubscriptionsIOSHandler? = showManageSubscriptionsIOS - operator fun component21(): MutationSyncIOSHandler? = syncIOS - operator fun component22(): MutationVerifyPurchaseHandler? = verifyPurchase - operator fun component23(): MutationVerifyPurchaseWithProviderHandler? = verifyPurchaseWithProvider + operator fun component12(): MutationOpenExternalPurchaseLinkHandler? = openExternalPurchaseLink + operator fun component13(): MutationOpenRedeemOfferCodeHandler? = openRedeemOfferCode + operator fun component14(): MutationPresentExternalPurchaseLinkIOSHandler? = presentExternalPurchaseLinkIOS + operator fun component15(): MutationPresentExternalPurchaseNoticeSheetIOSHandler? = presentExternalPurchaseNoticeSheetIOS + operator fun component16(): MutationRequestPurchaseHandler? = requestPurchase + operator fun component17(): MutationRestorePurchasesHandler? = restorePurchases + operator fun component18(): MutationShowBillingProgramInformationDialogAndroidHandler? = showBillingProgramInformationDialogAndroid + operator fun component19(): MutationShowExternalPurchaseCustomLinkNoticeIOSHandler? = showExternalPurchaseCustomLinkNoticeIOS + operator fun component20(): MutationShowInAppMessagesAndroidHandler? = showInAppMessagesAndroid + operator fun component21(): MutationShowManageSubscriptionsIOSHandler? = showManageSubscriptionsIOS + operator fun component22(): MutationSyncIOSHandler? = syncIOS + operator fun component23(): MutationVerifyPurchaseHandler? = verifyPurchase + operator fun component24(): MutationVerifyPurchaseWithProviderHandler? = verifyPurchaseWithProvider fun copy( acknowledgePurchaseAndroid: MutationAcknowledgePurchaseAndroidHandler? = this.acknowledgePurchaseAndroid, @@ -6755,6 +6954,7 @@ public class MutationHandlers( initConnection: MutationInitConnectionHandler? = this.initConnection, isBillingProgramAvailableAndroid: MutationIsBillingProgramAvailableAndroidHandler? = this.isBillingProgramAvailableAndroid, launchExternalLinkAndroid: MutationLaunchExternalLinkAndroidHandler? = this.launchExternalLinkAndroid, + openExternalPurchaseLink: MutationOpenExternalPurchaseLinkHandler? = this.openExternalPurchaseLink, openRedeemOfferCode: MutationOpenRedeemOfferCodeHandler? = this.openRedeemOfferCode, presentExternalPurchaseLinkIOS: MutationPresentExternalPurchaseLinkIOSHandler? = this.presentExternalPurchaseLinkIOS, presentExternalPurchaseNoticeSheetIOS: MutationPresentExternalPurchaseNoticeSheetIOSHandler? = this.presentExternalPurchaseNoticeSheetIOS, @@ -6779,6 +6979,7 @@ public class MutationHandlers( initConnection = initConnection, isBillingProgramAvailableAndroid = isBillingProgramAvailableAndroid, launchExternalLinkAndroid = launchExternalLinkAndroid, + openExternalPurchaseLink = openExternalPurchaseLink, openRedeemOfferCode = openRedeemOfferCode, presentExternalPurchaseLinkIOS = presentExternalPurchaseLinkIOS, presentExternalPurchaseNoticeSheetIOS = presentExternalPurchaseNoticeSheetIOS, @@ -6795,7 +6996,7 @@ public class MutationHandlers( ) override fun equals(other: Any?): Boolean = other is MutationHandlers && - acknowledgePurchaseAndroid == other.acknowledgePurchaseAndroid && beginRefundRequestIOS == other.beginRefundRequestIOS && clearTransactionIOS == other.clearTransactionIOS && consumePurchaseAndroid == other.consumePurchaseAndroid && createBillingProgramReportingDetailsAndroid == other.createBillingProgramReportingDetailsAndroid && deepLinkToSubscriptions == other.deepLinkToSubscriptions && endConnection == other.endConnection && finishTransaction == other.finishTransaction && initConnection == other.initConnection && isBillingProgramAvailableAndroid == other.isBillingProgramAvailableAndroid && launchExternalLinkAndroid == other.launchExternalLinkAndroid && openRedeemOfferCode == other.openRedeemOfferCode && presentExternalPurchaseLinkIOS == other.presentExternalPurchaseLinkIOS && presentExternalPurchaseNoticeSheetIOS == other.presentExternalPurchaseNoticeSheetIOS && requestPurchase == other.requestPurchase && restorePurchases == other.restorePurchases && showAlternativeBillingOnlyInformationDialogAndroid == other.showAlternativeBillingOnlyInformationDialogAndroid && showBillingProgramInformationDialogAndroid == other.showBillingProgramInformationDialogAndroid && showExternalPurchaseCustomLinkNoticeIOS == other.showExternalPurchaseCustomLinkNoticeIOS && showInAppMessagesAndroid == other.showInAppMessagesAndroid && showManageSubscriptionsIOS == other.showManageSubscriptionsIOS && syncIOS == other.syncIOS && verifyPurchase == other.verifyPurchase && verifyPurchaseWithProvider == other.verifyPurchaseWithProvider + acknowledgePurchaseAndroid == other.acknowledgePurchaseAndroid && beginRefundRequestIOS == other.beginRefundRequestIOS && clearTransactionIOS == other.clearTransactionIOS && consumePurchaseAndroid == other.consumePurchaseAndroid && createBillingProgramReportingDetailsAndroid == other.createBillingProgramReportingDetailsAndroid && deepLinkToSubscriptions == other.deepLinkToSubscriptions && endConnection == other.endConnection && finishTransaction == other.finishTransaction && initConnection == other.initConnection && isBillingProgramAvailableAndroid == other.isBillingProgramAvailableAndroid && launchExternalLinkAndroid == other.launchExternalLinkAndroid && openExternalPurchaseLink == other.openExternalPurchaseLink && openRedeemOfferCode == other.openRedeemOfferCode && presentExternalPurchaseLinkIOS == other.presentExternalPurchaseLinkIOS && presentExternalPurchaseNoticeSheetIOS == other.presentExternalPurchaseNoticeSheetIOS && requestPurchase == other.requestPurchase && restorePurchases == other.restorePurchases && showAlternativeBillingOnlyInformationDialogAndroid == other.showAlternativeBillingOnlyInformationDialogAndroid && showBillingProgramInformationDialogAndroid == other.showBillingProgramInformationDialogAndroid && showExternalPurchaseCustomLinkNoticeIOS == other.showExternalPurchaseCustomLinkNoticeIOS && showInAppMessagesAndroid == other.showInAppMessagesAndroid && showManageSubscriptionsIOS == other.showManageSubscriptionsIOS && syncIOS == other.syncIOS && verifyPurchase == other.verifyPurchase && verifyPurchaseWithProvider == other.verifyPurchaseWithProvider override fun hashCode(): Int { var result = 1 result = 31 * result + (acknowledgePurchaseAndroid?.hashCode() ?: 0) @@ -6809,6 +7010,7 @@ public class MutationHandlers( result = 31 * result + (initConnection?.hashCode() ?: 0) result = 31 * result + (isBillingProgramAvailableAndroid?.hashCode() ?: 0) result = 31 * result + (launchExternalLinkAndroid?.hashCode() ?: 0) + result = 31 * result + (openExternalPurchaseLink?.hashCode() ?: 0) result = 31 * result + (openRedeemOfferCode?.hashCode() ?: 0) result = 31 * result + (presentExternalPurchaseLinkIOS?.hashCode() ?: 0) result = 31 * result + (presentExternalPurchaseNoticeSheetIOS?.hashCode() ?: 0) @@ -6824,7 +7026,7 @@ public class MutationHandlers( result = 31 * result + (verifyPurchaseWithProvider?.hashCode() ?: 0) return result } - override fun toString(): String = "MutationHandlers(acknowledgePurchaseAndroid=$acknowledgePurchaseAndroid, beginRefundRequestIOS=$beginRefundRequestIOS, clearTransactionIOS=$clearTransactionIOS, consumePurchaseAndroid=$consumePurchaseAndroid, createBillingProgramReportingDetailsAndroid=$createBillingProgramReportingDetailsAndroid, deepLinkToSubscriptions=$deepLinkToSubscriptions, endConnection=$endConnection, finishTransaction=$finishTransaction, initConnection=$initConnection, isBillingProgramAvailableAndroid=$isBillingProgramAvailableAndroid, launchExternalLinkAndroid=$launchExternalLinkAndroid, openRedeemOfferCode=$openRedeemOfferCode, presentExternalPurchaseLinkIOS=$presentExternalPurchaseLinkIOS, presentExternalPurchaseNoticeSheetIOS=$presentExternalPurchaseNoticeSheetIOS, requestPurchase=$requestPurchase, restorePurchases=$restorePurchases, showAlternativeBillingOnlyInformationDialogAndroid=$showAlternativeBillingOnlyInformationDialogAndroid, showBillingProgramInformationDialogAndroid=$showBillingProgramInformationDialogAndroid, showExternalPurchaseCustomLinkNoticeIOS=$showExternalPurchaseCustomLinkNoticeIOS, showInAppMessagesAndroid=$showInAppMessagesAndroid, showManageSubscriptionsIOS=$showManageSubscriptionsIOS, syncIOS=$syncIOS, verifyPurchase=$verifyPurchase, verifyPurchaseWithProvider=$verifyPurchaseWithProvider)" + override fun toString(): String = "MutationHandlers(acknowledgePurchaseAndroid=$acknowledgePurchaseAndroid, beginRefundRequestIOS=$beginRefundRequestIOS, clearTransactionIOS=$clearTransactionIOS, consumePurchaseAndroid=$consumePurchaseAndroid, createBillingProgramReportingDetailsAndroid=$createBillingProgramReportingDetailsAndroid, deepLinkToSubscriptions=$deepLinkToSubscriptions, endConnection=$endConnection, finishTransaction=$finishTransaction, initConnection=$initConnection, isBillingProgramAvailableAndroid=$isBillingProgramAvailableAndroid, launchExternalLinkAndroid=$launchExternalLinkAndroid, openExternalPurchaseLink=$openExternalPurchaseLink, openRedeemOfferCode=$openRedeemOfferCode, presentExternalPurchaseLinkIOS=$presentExternalPurchaseLinkIOS, presentExternalPurchaseNoticeSheetIOS=$presentExternalPurchaseNoticeSheetIOS, requestPurchase=$requestPurchase, restorePurchases=$restorePurchases, showAlternativeBillingOnlyInformationDialogAndroid=$showAlternativeBillingOnlyInformationDialogAndroid, showBillingProgramInformationDialogAndroid=$showBillingProgramInformationDialogAndroid, showExternalPurchaseCustomLinkNoticeIOS=$showExternalPurchaseCustomLinkNoticeIOS, showInAppMessagesAndroid=$showInAppMessagesAndroid, showManageSubscriptionsIOS=$showManageSubscriptionsIOS, syncIOS=$syncIOS, verifyPurchase=$verifyPurchase, verifyPurchaseWithProvider=$verifyPurchaseWithProvider)" } // MARK: - Query Helpers @@ -6846,6 +7048,7 @@ public typealias QueryGetTransactionJwsIOSHandler = suspend (sku: String) -> Str public typealias QueryHasActiveSubscriptionsHandler = suspend (subscriptionIds: List?) -> Boolean public typealias QueryIsEligibleForExternalPurchaseCustomLinkIOSHandler = suspend () -> Boolean public typealias QueryIsEligibleForIntroOfferIOSHandler = suspend (groupID: String) -> Boolean +public typealias QueryIsExternalPurchaseLinkAvailableHandler = suspend () -> Boolean public typealias QueryIsTransactionVerifiedIOSHandler = suspend (sku: String) -> Boolean public typealias QueryLatestTransactionIOSHandler = suspend (sku: String) -> PurchaseIOS? public typealias QuerySubscriptionStatusIOSHandler = suspend (sku: String) -> List @@ -6955,6 +7158,15 @@ public class QueryHandlers( * See: https://openiap.dev/docs/apis/ios/is-eligible-for-intro-offer-ios */ val isEligibleForIntroOfferIOS: QueryIsEligibleForIntroOfferIOSHandler? = null, + /** + * Whether this device and storefront may link out to an external purchase + * with openExternalPurchaseLink. Returns false instead of throwing when the + * store program is not enabled or the user is not eligible. + * Available in Client Protocol 1.5.0 / openiap-apple 4.2.0 / openiap-google 4.2.0 + * (requires iOS 17.4+ or Play Billing 8.2.1+). + * See: https://openiap.dev/docs/apis/is-external-purchase-link-available + */ + val isExternalPurchaseLinkAvailable: QueryIsExternalPurchaseLinkAvailableHandler? = null, /** * Check whether a transaction's JWS verification passed (StoreKit 2). * See: https://openiap.dev/docs/apis/ios/is-transaction-verified-ios @@ -6998,11 +7210,12 @@ public class QueryHandlers( hasActiveSubscriptions: QueryHasActiveSubscriptionsHandler? = null, isEligibleForExternalPurchaseCustomLinkIOS: QueryIsEligibleForExternalPurchaseCustomLinkIOSHandler? = null, isEligibleForIntroOfferIOS: QueryIsEligibleForIntroOfferIOSHandler? = null, + isExternalPurchaseLinkAvailable: QueryIsExternalPurchaseLinkAvailableHandler? = null, isTransactionVerifiedIOS: QueryIsTransactionVerifiedIOSHandler? = null, latestTransactionIOS: QueryLatestTransactionIOSHandler? = null, subscriptionStatusIOS: QuerySubscriptionStatusIOSHandler? = null, verifyAppOwnership: QueryVerifyAppOwnershipHandler?, - ) : this(canPresentExternalPurchaseNoticeIOS, currentEntitlementIOS, fetchProducts, getActiveSubscriptions, getAllTransactionsIOS, getAppTransactionIOS, getAvailablePurchases, getBillingChoiceInfoAndroid, getExternalPurchaseCustomLinkTokenIOS, getPendingTransactionsIOS, getPromotedProductIOS, getReceiptDataIOS, getStorefront, getTransactionJwsIOS, hasActiveSubscriptions, isEligibleForExternalPurchaseCustomLinkIOS, isEligibleForIntroOfferIOS, isTransactionVerifiedIOS, latestTransactionIOS, subscriptionStatusIOS) { + ) : this(canPresentExternalPurchaseNoticeIOS, currentEntitlementIOS, fetchProducts, getActiveSubscriptions, getAllTransactionsIOS, getAppTransactionIOS, getAvailablePurchases, getBillingChoiceInfoAndroid, getExternalPurchaseCustomLinkTokenIOS, getPendingTransactionsIOS, getPromotedProductIOS, getReceiptDataIOS, getStorefront, getTransactionJwsIOS, hasActiveSubscriptions, isEligibleForExternalPurchaseCustomLinkIOS, isEligibleForIntroOfferIOS, isExternalPurchaseLinkAvailable, isTransactionVerifiedIOS, latestTransactionIOS, subscriptionStatusIOS) { this.verifyAppOwnership = verifyAppOwnership } @@ -7023,9 +7236,10 @@ public class QueryHandlers( operator fun component15(): QueryHasActiveSubscriptionsHandler? = hasActiveSubscriptions operator fun component16(): QueryIsEligibleForExternalPurchaseCustomLinkIOSHandler? = isEligibleForExternalPurchaseCustomLinkIOS operator fun component17(): QueryIsEligibleForIntroOfferIOSHandler? = isEligibleForIntroOfferIOS - operator fun component18(): QueryIsTransactionVerifiedIOSHandler? = isTransactionVerifiedIOS - operator fun component19(): QueryLatestTransactionIOSHandler? = latestTransactionIOS - operator fun component20(): QuerySubscriptionStatusIOSHandler? = subscriptionStatusIOS + operator fun component18(): QueryIsExternalPurchaseLinkAvailableHandler? = isExternalPurchaseLinkAvailable + operator fun component19(): QueryIsTransactionVerifiedIOSHandler? = isTransactionVerifiedIOS + operator fun component20(): QueryLatestTransactionIOSHandler? = latestTransactionIOS + operator fun component21(): QuerySubscriptionStatusIOSHandler? = subscriptionStatusIOS fun copy( canPresentExternalPurchaseNoticeIOS: QueryCanPresentExternalPurchaseNoticeIOSHandler? = this.canPresentExternalPurchaseNoticeIOS, @@ -7045,6 +7259,7 @@ public class QueryHandlers( hasActiveSubscriptions: QueryHasActiveSubscriptionsHandler? = this.hasActiveSubscriptions, isEligibleForExternalPurchaseCustomLinkIOS: QueryIsEligibleForExternalPurchaseCustomLinkIOSHandler? = this.isEligibleForExternalPurchaseCustomLinkIOS, isEligibleForIntroOfferIOS: QueryIsEligibleForIntroOfferIOSHandler? = this.isEligibleForIntroOfferIOS, + isExternalPurchaseLinkAvailable: QueryIsExternalPurchaseLinkAvailableHandler? = this.isExternalPurchaseLinkAvailable, isTransactionVerifiedIOS: QueryIsTransactionVerifiedIOSHandler? = this.isTransactionVerifiedIOS, latestTransactionIOS: QueryLatestTransactionIOSHandler? = this.latestTransactionIOS, subscriptionStatusIOS: QuerySubscriptionStatusIOSHandler? = this.subscriptionStatusIOS, @@ -7066,6 +7281,7 @@ public class QueryHandlers( hasActiveSubscriptions = hasActiveSubscriptions, isEligibleForExternalPurchaseCustomLinkIOS = isEligibleForExternalPurchaseCustomLinkIOS, isEligibleForIntroOfferIOS = isEligibleForIntroOfferIOS, + isExternalPurchaseLinkAvailable = isExternalPurchaseLinkAvailable, isTransactionVerifiedIOS = isTransactionVerifiedIOS, latestTransactionIOS = latestTransactionIOS, subscriptionStatusIOS = subscriptionStatusIOS, @@ -7073,7 +7289,7 @@ public class QueryHandlers( ) override fun equals(other: Any?): Boolean = other is QueryHandlers && - canPresentExternalPurchaseNoticeIOS == other.canPresentExternalPurchaseNoticeIOS && currentEntitlementIOS == other.currentEntitlementIOS && fetchProducts == other.fetchProducts && getActiveSubscriptions == other.getActiveSubscriptions && getAllTransactionsIOS == other.getAllTransactionsIOS && getAppTransactionIOS == other.getAppTransactionIOS && getAvailablePurchases == other.getAvailablePurchases && getBillingChoiceInfoAndroid == other.getBillingChoiceInfoAndroid && getExternalPurchaseCustomLinkTokenIOS == other.getExternalPurchaseCustomLinkTokenIOS && getPendingTransactionsIOS == other.getPendingTransactionsIOS && getPromotedProductIOS == other.getPromotedProductIOS && getReceiptDataIOS == other.getReceiptDataIOS && getStorefront == other.getStorefront && getTransactionJwsIOS == other.getTransactionJwsIOS && hasActiveSubscriptions == other.hasActiveSubscriptions && isEligibleForExternalPurchaseCustomLinkIOS == other.isEligibleForExternalPurchaseCustomLinkIOS && isEligibleForIntroOfferIOS == other.isEligibleForIntroOfferIOS && isTransactionVerifiedIOS == other.isTransactionVerifiedIOS && latestTransactionIOS == other.latestTransactionIOS && subscriptionStatusIOS == other.subscriptionStatusIOS && verifyAppOwnership == other.verifyAppOwnership + canPresentExternalPurchaseNoticeIOS == other.canPresentExternalPurchaseNoticeIOS && currentEntitlementIOS == other.currentEntitlementIOS && fetchProducts == other.fetchProducts && getActiveSubscriptions == other.getActiveSubscriptions && getAllTransactionsIOS == other.getAllTransactionsIOS && getAppTransactionIOS == other.getAppTransactionIOS && getAvailablePurchases == other.getAvailablePurchases && getBillingChoiceInfoAndroid == other.getBillingChoiceInfoAndroid && getExternalPurchaseCustomLinkTokenIOS == other.getExternalPurchaseCustomLinkTokenIOS && getPendingTransactionsIOS == other.getPendingTransactionsIOS && getPromotedProductIOS == other.getPromotedProductIOS && getReceiptDataIOS == other.getReceiptDataIOS && getStorefront == other.getStorefront && getTransactionJwsIOS == other.getTransactionJwsIOS && hasActiveSubscriptions == other.hasActiveSubscriptions && isEligibleForExternalPurchaseCustomLinkIOS == other.isEligibleForExternalPurchaseCustomLinkIOS && isEligibleForIntroOfferIOS == other.isEligibleForIntroOfferIOS && isExternalPurchaseLinkAvailable == other.isExternalPurchaseLinkAvailable && isTransactionVerifiedIOS == other.isTransactionVerifiedIOS && latestTransactionIOS == other.latestTransactionIOS && subscriptionStatusIOS == other.subscriptionStatusIOS && verifyAppOwnership == other.verifyAppOwnership override fun hashCode(): Int { var result = 1 result = 31 * result + (canPresentExternalPurchaseNoticeIOS?.hashCode() ?: 0) @@ -7093,13 +7309,14 @@ public class QueryHandlers( result = 31 * result + (hasActiveSubscriptions?.hashCode() ?: 0) result = 31 * result + (isEligibleForExternalPurchaseCustomLinkIOS?.hashCode() ?: 0) result = 31 * result + (isEligibleForIntroOfferIOS?.hashCode() ?: 0) + result = 31 * result + (isExternalPurchaseLinkAvailable?.hashCode() ?: 0) result = 31 * result + (isTransactionVerifiedIOS?.hashCode() ?: 0) result = 31 * result + (latestTransactionIOS?.hashCode() ?: 0) result = 31 * result + (subscriptionStatusIOS?.hashCode() ?: 0) result = 31 * result + (verifyAppOwnership?.hashCode() ?: 0) return result } - override fun toString(): String = "QueryHandlers(canPresentExternalPurchaseNoticeIOS=$canPresentExternalPurchaseNoticeIOS, currentEntitlementIOS=$currentEntitlementIOS, fetchProducts=$fetchProducts, getActiveSubscriptions=$getActiveSubscriptions, getAllTransactionsIOS=$getAllTransactionsIOS, getAppTransactionIOS=$getAppTransactionIOS, getAvailablePurchases=$getAvailablePurchases, getBillingChoiceInfoAndroid=$getBillingChoiceInfoAndroid, getExternalPurchaseCustomLinkTokenIOS=$getExternalPurchaseCustomLinkTokenIOS, getPendingTransactionsIOS=$getPendingTransactionsIOS, getPromotedProductIOS=$getPromotedProductIOS, getReceiptDataIOS=$getReceiptDataIOS, getStorefront=$getStorefront, getTransactionJwsIOS=$getTransactionJwsIOS, hasActiveSubscriptions=$hasActiveSubscriptions, isEligibleForExternalPurchaseCustomLinkIOS=$isEligibleForExternalPurchaseCustomLinkIOS, isEligibleForIntroOfferIOS=$isEligibleForIntroOfferIOS, isTransactionVerifiedIOS=$isTransactionVerifiedIOS, latestTransactionIOS=$latestTransactionIOS, subscriptionStatusIOS=$subscriptionStatusIOS, verifyAppOwnership=$verifyAppOwnership)" + override fun toString(): String = "QueryHandlers(canPresentExternalPurchaseNoticeIOS=$canPresentExternalPurchaseNoticeIOS, currentEntitlementIOS=$currentEntitlementIOS, fetchProducts=$fetchProducts, getActiveSubscriptions=$getActiveSubscriptions, getAllTransactionsIOS=$getAllTransactionsIOS, getAppTransactionIOS=$getAppTransactionIOS, getAvailablePurchases=$getAvailablePurchases, getBillingChoiceInfoAndroid=$getBillingChoiceInfoAndroid, getExternalPurchaseCustomLinkTokenIOS=$getExternalPurchaseCustomLinkTokenIOS, getPendingTransactionsIOS=$getPendingTransactionsIOS, getPromotedProductIOS=$getPromotedProductIOS, getReceiptDataIOS=$getReceiptDataIOS, getStorefront=$getStorefront, getTransactionJwsIOS=$getTransactionJwsIOS, hasActiveSubscriptions=$hasActiveSubscriptions, isEligibleForExternalPurchaseCustomLinkIOS=$isEligibleForExternalPurchaseCustomLinkIOS, isEligibleForIntroOfferIOS=$isEligibleForIntroOfferIOS, isExternalPurchaseLinkAvailable=$isExternalPurchaseLinkAvailable, isTransactionVerifiedIOS=$isTransactionVerifiedIOS, latestTransactionIOS=$latestTransactionIOS, subscriptionStatusIOS=$subscriptionStatusIOS, verifyAppOwnership=$verifyAppOwnership)" } // MARK: - Subscription Helpers diff --git a/libraries/kmp-iap/library/src/commonTest/kotlin/io/github/hyochan/kmpiap/InAppPurchaseTest.kt b/libraries/kmp-iap/library/src/commonTest/kotlin/io/github/hyochan/kmpiap/InAppPurchaseTest.kt index eae9cb038..6f0d9ac3e 100644 --- a/libraries/kmp-iap/library/src/commonTest/kotlin/io/github/hyochan/kmpiap/InAppPurchaseTest.kt +++ b/libraries/kmp-iap/library/src/commonTest/kotlin/io/github/hyochan/kmpiap/InAppPurchaseTest.kt @@ -1153,4 +1153,19 @@ class InAppPurchaseTest { assertNull(handlers.openRedeemOfferCode?.invoke()) assertNull(MutationHandlers().openRedeemOfferCode) } + + @Test + fun testExternalPurchaseLinkRequestAndResultJson() { + val request = ExternalPurchaseLinkRequest( + url = "https://example.com/buy", + apple = ExternalPurchaseLinkIosProps(tokenType = ExternalPurchaseCustomLinkTokenTypeIOS.Services), + google = ExternalPurchaseLinkAndroidProps(linkType = ExternalLinkTypeAndroid.LinkToDigitalContentOffer), + ) + assertEquals(request, ExternalPurchaseLinkRequest.fromJson(request.toJson())) + + val result = ExternalPurchaseLinkResult.fromJson(mapOf("status" to "opened", "reportToken" to "token", "platform" to "ios")) + assertEquals(ExternalPurchaseLinkStatus.Opened, result.status) + assertEquals("token", result.reportToken) + assertEquals(IapPlatform.Ios, result.platform) + } } 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 b92c9f993..d5d446ddc 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 @@ -389,6 +389,33 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { } } + /** Whether this device and storefront may link out with [openExternalPurchaseLink]. */ + override suspend fun isExternalPurchaseLinkAvailable(): Boolean = + suspendCancellableCoroutine { continuation -> + openIapModule.isExternalPurchaseLinkAvailableWithCompletion { isAvailable, error -> + if (error != null) { + continuation.resumeWithExceptionIfActive(error.toPurchaseException()) + } else { + continuation.resumeIfActive(isAvailable) + } + } + } + + /** Shows the App Store disclosure, opens the link once the user continues, and returns the report token. */ + override suspend fun openExternalPurchaseLink(request: ExternalPurchaseLinkRequest): ExternalPurchaseLinkResult = + suspendCancellableCoroutine { continuation -> + openIapModule.openExternalPurchaseLinkWithRequest(request.toJson().toBridgeMapIOS()) { result, error -> + if (error != null) { + continuation.resumeWithExceptionIfActive(error.toPurchaseException()) + return@openExternalPurchaseLinkWithRequest + } + val map = (result as? Map<*, *>)?.mapKeys { it.key.toString() } ?: emptyMap() + runCatching { ExternalPurchaseLinkResult.fromJson(map) } + .onSuccess { continuation.resumeIfActive(it) } + .onFailure { continuation.resumeWithExceptionIfActive(it) } + } + } + /** * Present the refund request sheet (iOS 15+). * diff --git a/libraries/maui-iap/android/openiap/src/main/java/dev/hyo/openiap/maui/OpenIapMauiModule.kt b/libraries/maui-iap/android/openiap/src/main/java/dev/hyo/openiap/maui/OpenIapMauiModule.kt index 4e82d26b4..9927e1e37 100644 --- a/libraries/maui-iap/android/openiap/src/main/java/dev/hyo/openiap/maui/OpenIapMauiModule.kt +++ b/libraries/maui-iap/android/openiap/src/main/java/dev/hyo/openiap/maui/OpenIapMauiModule.kt @@ -10,6 +10,7 @@ import dev.hyo.openiap.BillingProgramInformationDialogParamsAndroid import dev.hyo.openiap.BillingProgramAndroid import dev.hyo.openiap.DeepLinkOptions import dev.hyo.openiap.DeveloperBillingTypeAndroid +import dev.hyo.openiap.ExternalPurchaseLinkRequest import dev.hyo.openiap.FetchProductsResult import dev.hyo.openiap.FetchProductsResultAll import dev.hyo.openiap.FetchProductsResultProducts @@ -128,6 +129,10 @@ class OpenIapMauiModule(private val context: Context) { wrapString(module.getStorefront()) } + fun isExternalPurchaseLinkAvailable(callback: ResultCallback) = run(callback) { + wrapBool(module.isExternalPurchaseLinkAvailable()) + } + // ----------------------------------------------------------------- // Mutations — cross-platform // ----------------------------------------------------------------- @@ -236,6 +241,11 @@ class OpenIapMauiModule(private val context: Context) { gson.toJson(redeemOfferCode(module, currentActivity)?.toJson()) } + fun openExternalPurchaseLink(requestJson: String, callback: ResultCallback) = run(callback) { + val request = decode("ExternalPurchaseLinkRequest") { ExternalPurchaseLinkRequest.fromJson(parseMap(requestJson)) } + gson.toJson(module.openExternalPurchaseLink(currentActivityOrThrow(), request).toJson()) + } + // ----------------------------------------------------------------- // First-purchase notice (internal to OpenIap.Maui, not app API) // ----------------------------------------------------------------- diff --git a/libraries/maui-iap/src/OpenIap.Maui.Bindings.iOS/ApiDefinition.cs b/libraries/maui-iap/src/OpenIap.Maui.Bindings.iOS/ApiDefinition.cs index 660094e77..33427bf47 100644 --- a/libraries/maui-iap/src/OpenIap.Maui.Bindings.iOS/ApiDefinition.cs +++ b/libraries/maui-iap/src/OpenIap.Maui.Bindings.iOS/ApiDefinition.cs @@ -237,6 +237,14 @@ void VerifyPurchaseWithProviderPayload( [Introduced(PlatformName.MacCatalyst, 18, 1)] void ShowExternalPurchaseCustomLinkNoticeIOS(string noticeType, Action completion); + [Export("isExternalPurchaseLinkAvailableWithCompletion:")] + [Async] + void IsExternalPurchaseLinkAvailable(Action completion); + + [Export("openExternalPurchaseLinkWithRequest:completion:")] + [Async] + void OpenExternalPurchaseLink(NSDictionary request, Action completion); + // -- Listeners --------------------------------------------------------- [Export("addPurchaseUpdatedListener:")] diff --git a/libraries/maui-iap/src/OpenIap.Maui/Platforms/Android/OpenIapAndroid.Resolvers.cs b/libraries/maui-iap/src/OpenIap.Maui/Platforms/Android/OpenIapAndroid.Resolvers.cs index 9f7b05eb5..9e2de5281 100644 --- a/libraries/maui-iap/src/OpenIap.Maui/Platforms/Android/OpenIapAndroid.Resolvers.cs +++ b/libraries/maui-iap/src/OpenIap.Maui/Platforms/Android/OpenIapAndroid.Resolvers.cs @@ -106,6 +106,15 @@ public async Task VerifyPurchaseWithProviderAs return JsonSerializer.Deserialize(result, JsonOptions.Default); } + public async Task OpenExternalPurchaseLinkAsync(ExternalPurchaseLinkRequest request) + { + RefreshCurrentActivity(); + var json = JsonSerializer.Serialize(request, JsonOptions.Default); + var result = await Invoke(cb => _module.OpenExternalPurchaseLink(json, cb)); + return JsonSerializer.Deserialize(result, JsonOptions.Default) + ?? throw OpenIapErrorMapper.Wrap(ErrorCode.Unknown, "Empty openExternalPurchaseLink result"); + } + // ---- Android-only mutations ----------------------------------------- public Task AcknowledgePurchaseAndroidAsync(string purchaseToken) @@ -227,6 +236,9 @@ public async Task GetStorefrontAsync() return storefront; } + public Task IsExternalPurchaseLinkAvailableAsync() + => InvokeBool(cb => _module.IsExternalPurchaseLinkAvailable(cb)); + // ---- iOS-only queries (defaults / throw not-supported) -------------- public Task CanPresentExternalPurchaseNoticeIOSAsync() => Task.FromResult(false); diff --git a/libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs b/libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs index f327406c8..af728a8dd 100644 --- a/libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs +++ b/libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs @@ -379,6 +379,14 @@ public Task VerifyPurchaseWithProviderAsync(Ve public Task OpenRedeemOfferCodeAsync() => InvokeDict(cb => _module.OpenRedeemOfferCode(cb)); + public Task OpenExternalPurchaseLinkAsync(ExternalPurchaseLinkRequest request) + { + var node = JsonSerializer.SerializeToNode(request, JsonOptions.Default) as JsonObject + ?? throw OpenIapErrorMapper.Wrap(ErrorCode.DeveloperError, "Unable to serialize ExternalPurchaseLinkRequest"); + var payload = NSObjectJsonBridge.JsonObjectToDictionary(node); + return InvokeDict(cb => _module.OpenExternalPurchaseLink(payload, cb), required: true)!; + } + // ---- iOS-only mutations -------------------------------------------- public Task BeginRefundRequestIOSAsync(string sku) => InvokeNullableString(cb => _module.BeginRefundRequestIOS(sku, cb)); @@ -503,6 +511,8 @@ public async Task GetStorefrontAsync() return storefront; } + public Task IsExternalPurchaseLinkAvailableAsync() => InvokeBool(cb => _module.IsExternalPurchaseLinkAvailable(cb)); + public Task CanPresentExternalPurchaseNoticeIOSAsync() { if (!OperatingSystem.IsIOSVersionAtLeast(17, 4) && !OperatingSystem.IsMacCatalystVersionAtLeast(17, 4)) diff --git a/libraries/maui-iap/src/OpenIap.Maui/Types.cs b/libraries/maui-iap/src/OpenIap.Maui/Types.cs index f53a8a65e..875f13e9e 100644 --- a/libraries/maui-iap/src/OpenIap.Maui/Types.cs +++ b/libraries/maui-iap/src/OpenIap.Maui/Types.cs @@ -1202,6 +1202,71 @@ public static class ExternalPurchaseCustomLinkTokenTypeIOSExtensions public static ExternalPurchaseCustomLinkTokenTypeIOS FromJson(string value) => ExternalPurchaseCustomLinkTokenTypeIOSJsonConverter.FromRawString(value); } +/// +/// Outcome of openExternalPurchaseLink. +/// Available in Client Protocol 1.5.0 / openiap-apple 4.2.0 / openiap-google 4.2.0. +/// +[JsonConverter(typeof(ExternalPurchaseLinkStatusJsonConverter))] +public enum ExternalPurchaseLinkStatus +{ + /// The user continued past the store disclosure and the link was opened + Opened, + /// The user dismissed the store disclosure; nothing was opened + Cancelled, + /// The store program is not enabled or the user is not eligible; nothing was shown + Unavailable +} + +public sealed class ExternalPurchaseLinkStatusJsonConverter : JsonConverter, IOpenIapEnumJsonConverter +{ + private static readonly Dictionary _fromString = new() + { + ["opened"] = ExternalPurchaseLinkStatus.Opened, + ["OPENED"] = ExternalPurchaseLinkStatus.Opened, + ["Opened"] = ExternalPurchaseLinkStatus.Opened, + ["cancelled"] = ExternalPurchaseLinkStatus.Cancelled, + ["CANCELLED"] = ExternalPurchaseLinkStatus.Cancelled, + ["Cancelled"] = ExternalPurchaseLinkStatus.Cancelled, + ["unavailable"] = ExternalPurchaseLinkStatus.Unavailable, + ["UNAVAILABLE"] = ExternalPurchaseLinkStatus.Unavailable, + ["Unavailable"] = ExternalPurchaseLinkStatus.Unavailable, + }; + + private static readonly Dictionary _toString = new() + { + [ExternalPurchaseLinkStatus.Opened] = "opened", + [ExternalPurchaseLinkStatus.Cancelled] = "cancelled", + [ExternalPurchaseLinkStatus.Unavailable] = "unavailable", + }; + + public override ExternalPurchaseLinkStatus Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var raw = reader.GetString(); + if (raw is not null && _fromString.TryGetValue(raw, out var value)) return value; + throw new JsonException($"Unknown ExternalPurchaseLinkStatus value: {raw}"); + } + + public override void Write(Utf8JsonWriter writer, ExternalPurchaseLinkStatus value, JsonSerializerOptions options) + { + writer.WriteStringValue(_toString[value]); + } + + internal static string ToRawString(ExternalPurchaseLinkStatus value) => _toString[value]; + internal static ExternalPurchaseLinkStatus FromRawString(string value) => + _fromString.TryGetValue(value, out var v) ? v : throw new ArgumentException($"Unknown ExternalPurchaseLinkStatus value: {value}"); + + public bool TryReadRaw(string value, out ExternalPurchaseLinkStatus result) => + _fromString.TryGetValue(value, out result); + + public string WriteRaw(ExternalPurchaseLinkStatus value) => _toString[value]; +} + +public static class ExternalPurchaseLinkStatusExtensions +{ + public static string ToJson(this ExternalPurchaseLinkStatus value) => ExternalPurchaseLinkStatusJsonConverter.ToRawString(value); + public static ExternalPurchaseLinkStatus FromJson(string value) => ExternalPurchaseLinkStatusJsonConverter.FromRawString(value); +} + /// User actions on external purchase notice sheet (iOS 17.4+) [JsonConverter(typeof(ExternalPurchaseNoticeActionJsonConverter))] public enum ExternalPurchaseNoticeAction @@ -3267,6 +3332,26 @@ public sealed record ExternalPurchaseCustomLinkTokenResultIOS public string? Token { get; init; } } +/// +/// Result of openExternalPurchaseLink. +/// Available in Client Protocol 1.5.0 / openiap-apple 4.2.0 / openiap-google 4.2.0. +/// +public sealed record ExternalPurchaseLinkResult +{ + /// Platform that produced reportToken + [JsonPropertyName("platform")] + public required IapPlatform Platform { get; init; } + /// + /// Token to report from your server: Play's externalTransactionToken or Apple's + /// external purchase token. Present only when status is Opened. + /// + [JsonPropertyName("reportToken")] + public string? ReportToken { get; init; } + /// What happened to the link + [JsonPropertyName("status")] + public required ExternalPurchaseLinkStatus Status { get; init; } +} + /// Result of presenting an external purchase link public sealed record ExternalPurchaseLinkResultIOS { @@ -4676,6 +4761,54 @@ public sealed record DiscountOfferInputIOS public required double Timestamp { get; init; } } +/// +/// Google options for openExternalPurchaseLink. +/// Available in Client Protocol 1.5.0 / openiap-google 4.2.0 (requires Play Billing 8.2.1+). +/// +public sealed record ExternalPurchaseLinkAndroidProps +{ + /// Link type passed to Play. Defaults to LINK_TO_DIGITAL_CONTENT_OFFER. + [JsonPropertyName("linkType")] + [JsonConverter(typeof(StrictNullableEnumJsonConverter))] + public ExternalLinkTypeAndroid? LinkType { get; init; } +} + +/// +/// Apple options for openExternalPurchaseLink. +/// Available in Client Protocol 1.5.0 / openiap-apple 4.2.0. +/// +public sealed record ExternalPurchaseLinkIosProps +{ + /// + /// ExternalPurchaseCustomLink token type on iOS 18.1+. Defaults to Acquisition; + /// use LinkOut in Japan and Brazil. Ignored on iOS 17.4 to 18.0, where the + /// notice sheet returns the token. + /// + [JsonPropertyName("tokenType")] + [JsonConverter(typeof(StrictNullableEnumJsonConverter))] + public ExternalPurchaseCustomLinkTokenTypeIOS? TokenType { get; init; } +} + +/// +/// Request for openExternalPurchaseLink. +/// Available in Client Protocol 1.5.0 / openiap-apple 4.2.0 / openiap-google 4.2.0. +/// +public sealed record ExternalPurchaseLinkRequest +{ + /// + /// External purchase URL. On iOS 17.4 to 18.0 it must match the + /// SKExternalPurchase link in Info.plist. + /// + [JsonPropertyName("url")] + public required string Url { get; init; } + /// Apple-specific options + [JsonPropertyName("apple")] + public ExternalPurchaseLinkIosProps? Apple { get; init; } + /// Google-specific options + [JsonPropertyName("google")] + public ExternalPurchaseLinkAndroidProps? Google { get; init; } +} + /// /// Parameters for fetching Billing Choice display information (Android) /// Available in OpenIAP 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). @@ -5399,6 +5532,17 @@ public interface MutationResolver /// Task LaunchExternalLinkAndroidAsync(LaunchExternalLinkParamsAndroid @params); + /// + /// Show the store's required disclosure, open the external purchase link only + /// after the user continues, and return the token to report from your server. + /// The disclosure cannot be skipped. Unavailable and Cancelled are results, not + /// errors. + /// Available in Client Protocol 1.5.0 / openiap-apple 4.2.0 / openiap-google 4.2.0 + /// (requires iOS 17.4+ or Play Billing 8.2.1+). + /// See: https://openiap.dev/docs/apis/open-external-purchase-link + /// + Task OpenExternalPurchaseLinkAsync(ExternalPurchaseLinkRequest request); + /// /// Open the platform's offer/promo code redemption flow. /// Resolves the redeemed purchase only when the store reports it synchronously; @@ -5626,6 +5770,16 @@ public interface QueryResolver /// Task IsEligibleForIntroOfferIOSAsync(string groupId); + /// + /// Whether this device and storefront may link out to an external purchase + /// with openExternalPurchaseLink. Returns false instead of throwing when the + /// store program is not enabled or the user is not eligible. + /// Available in Client Protocol 1.5.0 / openiap-apple 4.2.0 / openiap-google 4.2.0 + /// (requires iOS 17.4+ or Play Billing 8.2.1+). + /// See: https://openiap.dev/docs/apis/is-external-purchase-link-available + /// + Task IsExternalPurchaseLinkAvailableAsync(); + /// /// Check whether a transaction's JWS verification passed (StoreKit 2). /// See: https://openiap.dev/docs/apis/ios/is-transaction-verified-ios diff --git a/libraries/maui-iap/src/OpenIap.Maui/UnsupportedOpenIap.cs b/libraries/maui-iap/src/OpenIap.Maui/UnsupportedOpenIap.cs index 429c119c7..27d09160c 100644 --- a/libraries/maui-iap/src/OpenIap.Maui/UnsupportedOpenIap.cs +++ b/libraries/maui-iap/src/OpenIap.Maui/UnsupportedOpenIap.cs @@ -39,6 +39,7 @@ private static Task Unsupported([CallerMemberName] string operation = "") public Task InitConnectionAsync(InitConnectionConfig? config = null) => Unsupported(); public Task IsBillingProgramAvailableAndroidAsync(BillingProgramAndroid program) => Unsupported(); public Task LaunchExternalLinkAndroidAsync(LaunchExternalLinkParamsAndroid @params) => Unsupported(); + public Task OpenExternalPurchaseLinkAsync(ExternalPurchaseLinkRequest request) => Unsupported(); public Task OpenRedeemOfferCodeAsync() => Unsupported(); public Task PresentExternalPurchaseLinkIOSAsync(string url) => Unsupported(); public Task PresentExternalPurchaseNoticeSheetIOSAsync() => Unsupported(); @@ -71,6 +72,7 @@ private static Task Unsupported([CallerMemberName] string operation = "") public Task HasActiveSubscriptionsAsync(IReadOnlyList? subscriptionIds = null) => Unsupported(); public Task IsEligibleForExternalPurchaseCustomLinkIOSAsync() => Unsupported(); public Task IsEligibleForIntroOfferIOSAsync(string groupId) => Unsupported(); + public Task IsExternalPurchaseLinkAvailableAsync() => Unsupported(); public Task IsTransactionVerifiedIOSAsync(string sku) => Unsupported(); public Task LatestTransactionIOSAsync(string sku) => Unsupported(); public Task> SubscriptionStatusIOSAsync(string sku) => Unsupported>(); diff --git a/libraries/maui-iap/tests/OpenIap.Maui.Tests/MutationResolverContractTests.cs b/libraries/maui-iap/tests/OpenIap.Maui.Tests/MutationResolverContractTests.cs index e3cded0d1..cce94787d 100644 --- a/libraries/maui-iap/tests/OpenIap.Maui.Tests/MutationResolverContractTests.cs +++ b/libraries/maui-iap/tests/OpenIap.Maui.Tests/MutationResolverContractTests.cs @@ -20,6 +20,26 @@ public void OpenRedeemOfferCode_DeclaresParameterlessNullablePurchaseTask() Assert.Equal(NullabilityState.Nullable, nullability.GenericTypeArguments[0].ReadState); } + [Fact] + public void OpenExternalPurchaseLink_TakesRequestAndReturnsResultTask() + { + var method = typeof(MutationResolver).GetMethod("OpenExternalPurchaseLinkAsync"); + + Assert.NotNull(method); + Assert.Equal(typeof(ExternalPurchaseLinkRequest), Assert.Single(method!.GetParameters()).ParameterType); + Assert.Equal(typeof(Task), method.ReturnType); + } + + [Fact] + public void IsExternalPurchaseLinkAvailable_IsParameterlessBoolQuery() + { + var method = typeof(QueryResolver).GetMethod("IsExternalPurchaseLinkAvailableAsync"); + + Assert.NotNull(method); + Assert.Empty(method!.GetParameters()); + Assert.Equal(typeof(Task), method.ReturnType); + } + [Fact] public void RemovedRedemptionAliases_AreAbsentFromTheStableContract() { diff --git a/libraries/maui-iap/tests/OpenIap.Maui.Tests/RecordJsonTests.cs b/libraries/maui-iap/tests/OpenIap.Maui.Tests/RecordJsonTests.cs index 099917c7b..df5494ff6 100644 --- a/libraries/maui-iap/tests/OpenIap.Maui.Tests/RecordJsonTests.cs +++ b/libraries/maui-iap/tests/OpenIap.Maui.Tests/RecordJsonTests.cs @@ -22,6 +22,33 @@ public void ExternalPurchaseTokenTypeUsesItsWireValue(ExternalPurchaseCustomLink Assert.Equal(tokenType, JsonSerializer.Deserialize($"\"{wireValue}\"", Options)); } + [Fact] + public void ExternalPurchaseLinkRequestUsesPlatformKeysAndWireValues() + { + var request = new ExternalPurchaseLinkRequest + { + Url = "https://example.com/buy", + Apple = new ExternalPurchaseLinkIosProps { TokenType = ExternalPurchaseCustomLinkTokenTypeIOS.LinkOut }, + Google = new ExternalPurchaseLinkAndroidProps { LinkType = ExternalLinkTypeAndroid.LinkToAppDownload }, + }; + var json = JsonNode.Parse(JsonSerializer.Serialize(request, Options))!; + Assert.Equal("https://example.com/buy", (string?)json["url"]); + Assert.Equal("link-out", (string?)json["apple"]!["tokenType"]); + Assert.Equal("link-to-app-download", (string?)json["google"]!["linkType"]); + } + + [Theory] + [InlineData("""{"status":"opened","reportToken":"token-1","platform":"android"}""", ExternalPurchaseLinkStatus.Opened, "token-1", IapPlatform.Android)] + [InlineData("""{"status":"cancelled","reportToken":null,"platform":"ios"}""", ExternalPurchaseLinkStatus.Cancelled, null, IapPlatform.IOS)] + [InlineData("""{"status":"unavailable","platform":"android"}""", ExternalPurchaseLinkStatus.Unavailable, null, IapPlatform.Android)] + public void ExternalPurchaseLinkResultDecodesNativePayload(string json, ExternalPurchaseLinkStatus status, string? reportToken, IapPlatform platform) + { + var result = JsonSerializer.Deserialize(json, Options)!; + Assert.Equal(status, result.Status); + Assert.Equal(reportToken, result.ReportToken); + Assert.Equal(platform, result.Platform); + } + [Fact] public void AlternativeBillingOnlyProgramUsesItsWireValue() { diff --git a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt index f724b9508..08856a18f 100644 --- a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt +++ b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt @@ -58,6 +58,10 @@ import dev.hyo.openiap.InAppMessageResponseCodeAndroid as OpenIapInAppMessageRes import dev.hyo.openiap.LaunchExternalLinkParamsAndroid as OpenIapLaunchExternalLinkParams import dev.hyo.openiap.ExternalLinkLaunchModeAndroid as OpenIapExternalLinkLaunchMode import dev.hyo.openiap.ExternalLinkTypeAndroid as OpenIapExternalLinkType +import dev.hyo.openiap.ExternalPurchaseLinkAndroidProps as OpenIapExternalPurchaseLinkAndroidProps +import dev.hyo.openiap.ExternalPurchaseLinkRequest as OpenIapExternalPurchaseLinkRequest +import dev.hyo.openiap.ExternalPurchaseLinkStatus as OpenIapExternalPurchaseLinkStatus +import dev.hyo.openiap.IapPlatform as OpenIapPlatform import dev.hyo.openiap.listener.OpenIapDeveloperProvidedBillingListener import dev.hyo.openiap.helpers.OpenIapFirstPurchaseNotice import dev.hyo.openiap.store.OpenIapStore @@ -2307,6 +2311,63 @@ class HybridRnIap : HybridRnIapSpec() { } } + override fun isExternalPurchaseLinkAvailable(): Promise { + return Promise.async { + RnIapLog.payload("isExternalPurchaseLinkAvailable", null) + try { + val available = openIap.isExternalPurchaseLinkAvailable() + RnIapLog.result("isExternalPurchaseLinkAvailable", available) + available + } catch (err: CancellationException) { + throw err + } catch (err: Exception) { + RnIapLog.failure("isExternalPurchaseLinkAvailable", err) + val errorType = parseOpenIapError(err) + throw OpenIapException(toPurchaseError(errorType, debugMessage = err.message), err) + } + } + } + + override fun openExternalPurchaseLink(request: NitroExternalPurchaseLinkRequest): Promise { + return Promise.async { + RnIapLog.payload("openExternalPurchaseLink", mapOf("url" to request.url)) + try { + val activity = withContext(Dispatchers.Main) { + runCatching { context.currentActivity }.getOrNull() + } ?: throw OpenIapException(toPurchaseError(OpenIapError.DeveloperError(), debugMessage = "Activity not available")) + + val linkType = (request.google as? Variant_NullType_NitroExternalPurchaseLinkAndroidProps.Second) + ?.value?.linkType + val openIapRequest = OpenIapExternalPurchaseLinkRequest( + google = OpenIapExternalPurchaseLinkAndroidProps(linkType = linkType?.let { mapExternalLinkType(it) }), + url = request.url + ) + val result = withContext(Dispatchers.Main) { + openIap.openExternalPurchaseLink(activity, openIapRequest) + } + RnIapLog.result("openExternalPurchaseLink", mapOf("status" to result.status.rawValue)) + NitroExternalPurchaseLinkResult( + platform = when (result.platform) { + OpenIapPlatform.Ios -> IapPlatform.IOS + OpenIapPlatform.Android -> IapPlatform.ANDROID + }, + reportToken = result.reportToken.wrapVariant(), + status = when (result.status) { + OpenIapExternalPurchaseLinkStatus.Opened -> ExternalPurchaseLinkStatus.OPENED + OpenIapExternalPurchaseLinkStatus.Cancelled -> ExternalPurchaseLinkStatus.CANCELLED + OpenIapExternalPurchaseLinkStatus.Unavailable -> ExternalPurchaseLinkStatus.UNAVAILABLE + } + ) + } catch (err: CancellationException) { + throw err + } catch (err: Exception) { + RnIapLog.failure("openExternalPurchaseLink", err) + val errorType = parseOpenIapError(err) + throw OpenIapException(toPurchaseError(errorType, debugMessage = err.message), err) + } + } + } + // Billing Programs helper functions private fun mapBillingProgram(program: BillingProgramAndroid): OpenIapBillingProgramAndroid { return when (program) { diff --git a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIapProtocol.kt b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIapProtocol.kt index 44f288340..2f2c9a5e7 100644 --- a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIapProtocol.kt +++ b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIapProtocol.kt @@ -74,6 +74,16 @@ class HybridRnIapProtocol: HybridRnIapProtocolSpec() { { NitroOpenRedeemOfferCodeOutcome(value = it, error = null) }, { NitroOpenRedeemOfferCodeOutcome(value = null, error = it) }) + override fun isExternalPurchaseLinkAvailable(): Promise = + lift({ implementation.isExternalPurchaseLinkAvailable() }, + { NitroBooleanOutcome(value = it, error = null) }, + { NitroBooleanOutcome(value = null, error = it) }) + + override fun openExternalPurchaseLink(request: NitroExternalPurchaseLinkRequest): Promise = + lift({ implementation.openExternalPurchaseLink(request) }, + { NitroExternalPurchaseLinkResultOutcome(value = it, error = null) }, + { NitroExternalPurchaseLinkResultOutcome(value = null, error = it) }) + override fun restorePurchases(): Promise = lift({ implementation.restorePurchases() }, { NitroBooleanOutcome(value = it, error = null) }, diff --git a/libraries/react-native-iap/example/ios/exampleTests/ConnectOnDemandTests.swift b/libraries/react-native-iap/example/ios/exampleTests/ConnectOnDemandTests.swift index f4fe70c0b..3664587c8 100644 --- a/libraries/react-native-iap/example/ios/exampleTests/ConnectOnDemandTests.swift +++ b/libraries/react-native-iap/example/ios/exampleTests/ConnectOnDemandTests.swift @@ -920,6 +920,8 @@ final class ConnectOnDemandTests: XCTestCase { "verifyPurchase", "verifyPurchaseWithProvider", "getStorefront", + "isExternalPurchaseLinkAvailable", + "openExternalPurchaseLink", "getAppTransactionIOS", "getPromotedProductIOS", "openRedeemOfferCode", diff --git a/libraries/react-native-iap/ios/HybridRnIap.swift b/libraries/react-native-iap/ios/HybridRnIap.swift index f1ec2ed47..6eeb522d1 100644 --- a/libraries/react-native-iap/ios/HybridRnIap.swift +++ b/libraries/react-native-iap/ios/HybridRnIap.swift @@ -763,6 +763,69 @@ class HybridRnIap: HybridRnIapSpec { } } + func isExternalPurchaseLinkAvailable() throws -> Promise { + return Promise.async { + RnIapLog.payload("isExternalPurchaseLinkAvailable", nil) + do { + let available = try await self.runConnectedOperation { + try await OpenIapModule.shared.isExternalPurchaseLinkAvailable() + } + RnIapLog.result("isExternalPurchaseLinkAvailable", available) + return available + } catch let purchaseError as PurchaseError { + RnIapLog.failure("isExternalPurchaseLinkAvailable", error: purchaseError) + throw OpenIapException.from(purchaseError) + } catch let connectionError as OpenIapException { + RnIapLog.failure("isExternalPurchaseLinkAvailable", error: connectionError) + throw connectionError + } catch { + RnIapLog.failure("isExternalPurchaseLinkAvailable", error: error) + throw OpenIapException.make(code: .serviceError, message: error.localizedDescription) + } + } + } + + func openExternalPurchaseLink(request: NitroExternalPurchaseLinkRequest) throws -> Promise { + return Promise.async { + RnIapLog.payload("openExternalPurchaseLink", ["url": request.url]) + do { + var tokenType: OpenIAP.ExternalPurchaseCustomLinkTokenTypeIOS? + if case .second(let apple) = request.apple, let nitroTokenType = apple.tokenType { + guard let converted = OpenIAP.ExternalPurchaseCustomLinkTokenTypeIOS(rawValue: nitroTokenType.stringValue) else { + throw OpenIapException.make(code: .developerError, message: "Invalid token type: \(nitroTokenType.stringValue)") + } + tokenType = converted + } + let openIapRequest = OpenIAP.ExternalPurchaseLinkRequest( + apple: OpenIAP.ExternalPurchaseLinkIosProps(tokenType: tokenType), + url: request.url + ) + let result = try await self.runConnectedOperation { + try await OpenIapModule.shared.openExternalPurchaseLink(openIapRequest) + } + guard let status = ExternalPurchaseLinkStatus(fromString: result.status.rawValue), + let platform = IapPlatform(fromString: result.platform.rawValue) else { + throw OpenIapException.make(code: .serviceError, message: "Invalid external purchase link result") + } + RnIapLog.result("openExternalPurchaseLink", ["status": result.status.rawValue]) + return NitroExternalPurchaseLinkResult( + platform: platform, + reportToken: RnIapHelper.wrapString(result.reportToken), + status: status + ) + } catch let purchaseError as PurchaseError { + RnIapLog.failure("openExternalPurchaseLink", error: purchaseError) + throw OpenIapException.from(purchaseError) + } catch let connectionError as OpenIapException { + RnIapLog.failure("openExternalPurchaseLink", error: connectionError) + throw connectionError + } catch { + RnIapLog.failure("openExternalPurchaseLink", error: error) + throw OpenIapException.make(code: .serviceError, message: error.localizedDescription) + } + } + } + // MARK: - iOS-specific Public Methods func getAppTransactionIOS() throws -> Promise { return Promise.async { diff --git a/libraries/react-native-iap/ios/HybridRnIapProtocol.swift b/libraries/react-native-iap/ios/HybridRnIapProtocol.swift index d29ac498d..73a60280c 100644 --- a/libraries/react-native-iap/ios/HybridRnIapProtocol.swift +++ b/libraries/react-native-iap/ios/HybridRnIapProtocol.swift @@ -82,6 +82,18 @@ final class HybridRnIapProtocol: HybridRnIapProtocolSpec { failure: { NitroOpenRedeemOfferCodeOutcome(value: nil, error: $0) }) } + func isExternalPurchaseLinkAvailable() -> Promise { + lift({ try implementation.isExternalPurchaseLinkAvailable() }, + success: { NitroBooleanOutcome(value: $0, error: nil) }, + failure: { NitroBooleanOutcome(value: nil, error: $0) }) + } + + func openExternalPurchaseLink(request: NitroExternalPurchaseLinkRequest) -> Promise { + lift({ try implementation.openExternalPurchaseLink(request: request) }, + success: { NitroExternalPurchaseLinkResultOutcome(value: $0, error: nil) }, + failure: { NitroExternalPurchaseLinkResultOutcome(value: nil, error: $0) }) + } + func restorePurchases() -> Promise { lift({ try implementation.restorePurchases() }, success: { NitroBooleanOutcome(value: $0, error: nil) }, diff --git a/libraries/react-native-iap/src/__tests__/hooks/useIAP.test.ts b/libraries/react-native-iap/src/__tests__/hooks/useIAP.test.ts index a18c4ea1e..0ba8d47e5 100644 --- a/libraries/react-native-iap/src/__tests__/hooks/useIAP.test.ts +++ b/libraries/react-native-iap/src/__tests__/hooks/useIAP.test.ts @@ -441,6 +441,24 @@ describe('hooks/useIAP (renderer)', () => { expect(api.openRedeemOfferCode).toBe(IAP.openRedeemOfferCode); }); + it('exposes the external purchase link APIs from the root', async () => { + let api: any; + const Harness = () => { + api = useIAP(); + return null; + }; + + await act(async () => { + TestRenderer.create(React.createElement(Harness)); + }); + await act(async () => {}); + + expect(api.isExternalPurchaseLinkAvailable).toBe( + IAP.isExternalPurchaseLinkAvailable, + ); + expect(api.openExternalPurchaseLink).toBe(IAP.openExternalPurchaseLink); + }); + it('does not log product offer tokens', async () => { Object.assign(globalThis, {RN_IAP_DEV_MODE: true}); const debug = jest.spyOn(console, 'debug').mockImplementation(); diff --git a/libraries/react-native-iap/src/__tests__/index.kepler.test.ts b/libraries/react-native-iap/src/__tests__/index.kepler.test.ts index e9b0b96e7..3b86d5b31 100644 --- a/libraries/react-native-iap/src/__tests__/index.kepler.test.ts +++ b/libraries/react-native-iap/src/__tests__/index.kepler.test.ts @@ -127,6 +127,13 @@ describe('Amazon Vega public API', () => { await expect(IAP.openRedeemOfferCode()).resolves.toBeNull(); }); + it('reports external purchase links as unavailable', async () => { + await expect(IAP.isExternalPurchaseLinkAvailable()).resolves.toBe(false); + await expect( + IAP.openExternalPurchaseLink({url: 'https://example.test/checkout'}), + ).resolves.toEqual({platform: 'android', status: 'unavailable'}); + }); + it('exposes inconclusive app ownership through the Vega entry', async () => { await expect(IAP.verifyAppOwnership()).resolves.toEqual({ status: 'unknown', diff --git a/libraries/react-native-iap/src/__tests__/index.test.ts b/libraries/react-native-iap/src/__tests__/index.test.ts index 2313bf884..9b2372b60 100644 --- a/libraries/react-native-iap/src/__tests__/index.test.ts +++ b/libraries/react-native-iap/src/__tests__/index.test.ts @@ -40,6 +40,12 @@ const mockIap: any = { getAppTransactionIOS: jest.fn(async () => null), getPromotedProductIOS: jest.fn(async () => null), openRedeemOfferCode: jest.fn(async () => null), + isExternalPurchaseLinkAvailable: jest.fn(async () => true), + openExternalPurchaseLink: jest.fn(async () => ({ + platform: 'ios', + status: 'opened', + reportToken: 'report-token', + })), getAllTransactionsIOS: jest.fn(async () => []), // Unified storefront @@ -3134,6 +3140,95 @@ describe('Public API (src/index.ts)', () => { 'Unsupported platform: web', ); }); + + it('isExternalPurchaseLinkAvailable forwards the native answer on iOS and Android', async () => { + Object.assign(Platform, {OS: 'ios'}); + mockIap.isExternalPurchaseLinkAvailable.mockResolvedValueOnce(true); + await expect(IAP.isExternalPurchaseLinkAvailable()).resolves.toBe(true); + + Object.assign(Platform, {OS: 'android'}); + mockIap.isExternalPurchaseLinkAvailable.mockResolvedValueOnce(false); + await expect(IAP.isExternalPurchaseLinkAvailable()).resolves.toBe(false); + expect(mockIap.isExternalPurchaseLinkAvailable).toHaveBeenCalledTimes(2); + }); + + it('openExternalPurchaseLink passes the request and returns the report token', async () => { + Object.assign(Platform, {OS: 'ios'}); + const request = { + url: 'https://example.test/checkout', + apple: {tokenType: 'link-out' as const}, + google: {linkType: 'link-to-digital-content-offer' as const}, + }; + mockIap.openExternalPurchaseLink.mockResolvedValueOnce({ + platform: 'ios', + status: 'opened', + reportToken: 'apple-token', + }); + await expect(IAP.openExternalPurchaseLink(request)).resolves.toEqual({ + platform: 'ios', + status: 'opened', + reportToken: 'apple-token', + }); + expect(mockIap.openExternalPurchaseLink).toHaveBeenCalledWith(request); + }); + + it('openExternalPurchaseLink resolves cancelled on Android without throwing', async () => { + Object.assign(Platform, {OS: 'android'}); + mockIap.openExternalPurchaseLink.mockResolvedValueOnce({ + platform: 'android', + status: 'cancelled', + }); + await expect( + IAP.openExternalPurchaseLink({url: 'https://example.test/checkout'}), + ).resolves.toEqual({platform: 'android', status: 'cancelled'}); + }); + + it('openExternalPurchaseLink surfaces native failures as PurchaseError', async () => { + Object.assign(Platform, {OS: 'android'}); + mockIap.openExternalPurchaseLink.mockRejectedValueOnce( + new Error('activity gone'), + ); + await expect( + IAP.openExternalPurchaseLink({url: 'https://example.test/checkout'}), + ).rejects.toMatchObject({ + code: ErrorCode.Unknown, + message: 'activity gone', + }); + }); + + it('external purchase link APIs report unavailable on Vega without calling native', async () => { + jest.resetModules(); + jest.doMock('react-native', () => ({ + Platform: {OS: 'kepler'}, + })); + jest.doMock('react-native-nitro-modules', () => ({ + NitroModules: { + createHybridObject: jest.fn(() => mockIap), + }, + })); + jest.doMock('../vega', () => ({ + getVegaIapModule: jest.fn(() => mockIap), + isVegaOS: jest.fn(() => true), + })); + IAP = require('../index'); + + await expect(IAP.isExternalPurchaseLinkAvailable()).resolves.toBe(false); + await expect( + IAP.openExternalPurchaseLink({url: 'https://example.test/checkout'}), + ).resolves.toEqual({platform: 'android', status: 'unavailable'}); + expect(mockIap.isExternalPurchaseLinkAvailable).not.toHaveBeenCalled(); + expect(mockIap.openExternalPurchaseLink).not.toHaveBeenCalled(); + }); + + it('external purchase link APIs throw on unsupported platform', async () => { + Object.assign(Platform, {OS: 'web'}); + await expect(IAP.isExternalPurchaseLinkAvailable()).rejects.toThrow( + 'Unsupported platform: web', + ); + await expect( + IAP.openExternalPurchaseLink({url: 'https://example.test/checkout'}), + ).rejects.toThrow('Unsupported platform: web'); + }); }); describe('subscription helpers', () => { diff --git a/libraries/react-native-iap/src/hooks/useIAP.ts b/libraries/react-native-iap/src/hooks/useIAP.ts index 4a92e9821..3a1618cdc 100644 --- a/libraries/react-native-iap/src/hooks/useIAP.ts +++ b/libraries/react-native-iap/src/hooks/useIAP.ts @@ -30,6 +30,8 @@ import { showAlternativeBillingOnlyInformationDialogAndroid, showInAppMessagesAndroid, openRedeemOfferCode, + isExternalPurchaseLinkAvailable, + openExternalPurchaseLink, userChoiceBillingListenerAndroid, developerProvidedBillingListenerAndroid, subscriptionBillingIssueListener, @@ -267,6 +269,19 @@ type UseIap = { * @see {@link https://openiap.dev/docs/apis/open-redeem-offer-code} */ openRedeemOfferCode: MutationField<'openRedeemOfferCode'>; + /** + * Whether the store allows linking out with `openExternalPurchaseLink`. + * + * @see {@link https://openiap.dev/docs/apis/is-external-purchase-link-available} + */ + isExternalPurchaseLinkAvailable: QueryField<'isExternalPurchaseLinkAvailable'>; + /** + * Show the store disclosure, open the external purchase link, and return the + * token to report from your server. + * + * @see {@link https://openiap.dev/docs/apis/open-external-purchase-link} + */ + openExternalPurchaseLink: MutationField<'openExternalPurchaseLink'>; /** Fetch assets and loyalty text for a developer-rendered Billing Choice screen. */ getBillingChoiceInfoAndroid?: QueryField<'getBillingChoiceInfoAndroid'>; /** Check Billing Program availability and the configured choice renderer. */ @@ -949,6 +964,8 @@ export function useIAP(options?: UseIapOptions): UseIap { hasActiveSubscriptions: hasActiveSubscriptionsInternal, reconnect, openRedeemOfferCode, + isExternalPurchaseLinkAvailable, + openExternalPurchaseLink, // Alternative billing (Android only) ...(Platform.OS === 'android' ? { diff --git a/libraries/react-native-iap/src/index.kepler.ts b/libraries/react-native-iap/src/index.kepler.ts index 78b8275ba..ade3c5e1c 100644 --- a/libraries/react-native-iap/src/index.kepler.ts +++ b/libraries/react-native-iap/src/index.kepler.ts @@ -339,6 +339,13 @@ export const showManageSubscriptionsIOS: MutationField< export const openRedeemOfferCode: MutationField< 'openRedeemOfferCode' > = async () => null; +// Amazon Vega has no external purchase link program. +export const isExternalPurchaseLinkAvailable: QueryField< + 'isExternalPurchaseLinkAvailable' +> = async () => false; +export const openExternalPurchaseLink: MutationField< + 'openExternalPurchaseLink' +> = async () => ({platform: 'android', status: 'unavailable'}); export const presentExternalPurchaseLinkIOS: MutationField< 'presentExternalPurchaseLinkIOS' > = async () => unsupported('presentExternalPurchaseLinkIOS'); diff --git a/libraries/react-native-iap/src/index.ts b/libraries/react-native-iap/src/index.ts index 36e5dd924..9e7142de4 100644 --- a/libraries/react-native-iap/src/index.ts +++ b/libraries/react-native-iap/src/index.ts @@ -2364,6 +2364,56 @@ export const openRedeemOfferCode: MutationField< } }; +/** + * Whether this device and storefront may link out with + * `openExternalPurchaseLink`. Resolves false instead of throwing when the + * store program is not enabled or the user is not eligible. + * + * @see {@link https://openiap.dev/docs/apis/is-external-purchase-link-available} + */ +export const isExternalPurchaseLinkAvailable: QueryField< + 'isExternalPurchaseLinkAvailable' +> = async () => { + if (isVegaOS()) return false; + if (Platform.OS !== 'ios' && Platform.OS !== 'android') { + throw unsupportedPlatformError(); + } + try { + return await IAP.instance.isExternalPurchaseLinkAvailable(); + } catch (error) { + const parsedError = parseErrorAndLogIfNeeded( + '[isExternalPurchaseLinkAvailable] Failed:', + error, + ); + throw createPurchaseError(parsedError); + } +}; + +/** + * Show the store's required disclosure, open the external purchase link only + * after the user continues, and return the token to report from your server. + * `cancelled` and `unavailable` are results, not errors. + * + * @see {@link https://openiap.dev/docs/apis/open-external-purchase-link} + */ +export const openExternalPurchaseLink: MutationField< + 'openExternalPurchaseLink' +> = async (request) => { + if (isVegaOS()) return {platform: 'android', status: 'unavailable'}; + if (Platform.OS !== 'ios' && Platform.OS !== 'android') { + throw unsupportedPlatformError(); + } + try { + return await IAP.instance.openExternalPurchaseLink(request); + } catch (error) { + const parsedError = parseErrorAndLogIfNeeded( + '[openExternalPurchaseLink] Failed:', + error, + ); + throw createPurchaseError(parsedError); + } +}; + /** * Clear unfinished transactions on iOS * @returns Promise diff --git a/libraries/react-native-iap/src/specs/RnIap.nitro.ts b/libraries/react-native-iap/src/specs/RnIap.nitro.ts index 356c65b40..cb60efbcf 100644 --- a/libraries/react-native-iap/src/specs/RnIap.nitro.ts +++ b/libraries/react-native-iap/src/specs/RnIap.nitro.ts @@ -23,7 +23,11 @@ import type { ExternalPurchaseCustomLinkNoticeResultIOS, ExternalPurchaseCustomLinkTokenResultIOS, ExternalPurchaseCustomLinkTokenTypeIOS, + ExternalPurchaseLinkIosProps, + ExternalPurchaseLinkRequest, + ExternalPurchaseLinkResult, ExternalPurchaseLinkResultIOS, + ExternalPurchaseLinkStatus, ExternalPurchaseNoticeResultIOS, DeveloperBillingOptionParamsAndroid, DeveloperProvidedBillingDetailsAndroid, @@ -323,6 +327,16 @@ export interface NitroLaunchExternalLinkParamsAndroid { linkUri: string; } +export interface NitroExternalPurchaseLinkAndroidProps { + linkType?: ExternalLinkTypeAndroid | null; +} + +export interface NitroExternalPurchaseLinkRequest { + url: ExternalPurchaseLinkRequest['url']; + apple?: ExternalPurchaseLinkIosProps | null; + google?: NitroExternalPurchaseLinkAndroidProps | null; +} + export interface NitroGetBillingChoiceInfoParamsAndroid { billingProgram: BillingProgramAndroid; playBillingChoiceImageLayout: BillingChoiceImageLayoutAndroid; @@ -384,6 +398,12 @@ export interface NitroInAppMessageResultAndroid { purchaseToken?: string | null; } +export interface NitroExternalPurchaseLinkResult { + platform: IapPlatform; + reportToken?: ExternalPurchaseLinkResult['reportToken']; + status: ExternalPurchaseLinkStatus; +} + export interface NitroPurchaseVerificationResultIOS { isValid: boolean; receiptData: string; @@ -848,6 +868,14 @@ export interface RnIap extends HybridObject<{ios: 'swift'; android: 'kotlin'}> { /** Open the store's offer-code flow; returns a purchase or null. */ openRedeemOfferCode(): Promise; + /** Whether the store allows linking out with openExternalPurchaseLink. */ + isExternalPurchaseLinkAvailable(): Promise; + + /** Show the store disclosure, then open the external purchase link. */ + openExternalPurchaseLink( + request: NitroExternalPurchaseLinkRequest, + ): Promise; + /** * Clear unfinished transactions (iOS only) * @returns Promise diff --git a/libraries/react-native-iap/src/specs/RnIapProtocol.nitro.ts b/libraries/react-native-iap/src/specs/RnIapProtocol.nitro.ts index 197f86b71..a76188096 100644 --- a/libraries/react-native-iap/src/specs/RnIapProtocol.nitro.ts +++ b/libraries/react-native-iap/src/specs/RnIapProtocol.nitro.ts @@ -12,6 +12,8 @@ import type { NitroBillingProgramReportingDetailsAndroid, NitroBillingResultAndroid, NitroDeepLinkOptionsAndroid, + NitroExternalPurchaseLinkRequest, + NitroExternalPurchaseLinkResult, NitroFinishTransactionParams, NitroGetBillingChoiceInfoParamsAndroid, NitroInAppMessageParamsAndroid, @@ -104,6 +106,11 @@ export interface NitroOpenRedeemOfferCodeOutcome { error?: NitroPurchaseError; } +export interface NitroExternalPurchaseLinkResultOutcome { + value?: NitroExternalPurchaseLinkResult; + error?: NitroPurchaseError; +} + export interface NitroVoidOutcome { error?: NitroPurchaseError; } @@ -198,6 +205,10 @@ export interface RnIapProtocol extends HybridObject<{ ): Promise; verifyAppOwnership(optionsJson: string): Promise; openRedeemOfferCode(): Promise; + isExternalPurchaseLinkAvailable(): Promise; + openExternalPurchaseLink( + request: NitroExternalPurchaseLinkRequest, + ): Promise; restorePurchases(): Promise; verifyPurchase( params: NitroPurchaseVerificationParams, diff --git a/libraries/react-native-iap/src/types.ts b/libraries/react-native-iap/src/types.ts index f6ed9d9e5..0b712af98 100644 --- a/libraries/react-native-iap/src/types.ts +++ b/libraries/react-native-iap/src/types.ts @@ -575,6 +575,60 @@ export interface ExternalPurchaseCustomLinkTokenResultIOS { */ export type ExternalPurchaseCustomLinkTokenTypeIOS = 'acquisition' | 'services' | 'in-app' | 'link-out'; +/** + * Google options for openExternalPurchaseLink. + * Available in Client Protocol 1.5.0 / openiap-google 4.2.0 (requires Play Billing 8.2.1+). + */ +export interface ExternalPurchaseLinkAndroidProps { + /** Link type passed to Play. Defaults to LINK_TO_DIGITAL_CONTENT_OFFER. */ + linkType?: (ExternalLinkTypeAndroid | null); +} + +/** + * Apple options for openExternalPurchaseLink. + * Available in Client Protocol 1.5.0 / openiap-apple 4.2.0. + */ +export interface ExternalPurchaseLinkIosProps { + /** + * ExternalPurchaseCustomLink token type on iOS 18.1+. Defaults to Acquisition; + * use LinkOut in Japan and Brazil. Ignored on iOS 17.4 to 18.0, where the + * notice sheet returns the token. + */ + tokenType?: (ExternalPurchaseCustomLinkTokenTypeIOS | null); +} + +/** + * Request for openExternalPurchaseLink. + * Available in Client Protocol 1.5.0 / openiap-apple 4.2.0 / openiap-google 4.2.0. + */ +export interface ExternalPurchaseLinkRequest { + /** Apple-specific options */ + apple?: (ExternalPurchaseLinkIosProps | null); + /** Google-specific options */ + google?: (ExternalPurchaseLinkAndroidProps | null); + /** + * External purchase URL. On iOS 17.4 to 18.0 it must match the + * SKExternalPurchase link in Info.plist. + */ + url: string; +} + +/** + * Result of openExternalPurchaseLink. + * Available in Client Protocol 1.5.0 / openiap-apple 4.2.0 / openiap-google 4.2.0. + */ +export interface ExternalPurchaseLinkResult { + /** Platform that produced reportToken */ + platform: IapPlatform; + /** + * Token to report from your server: Play's externalTransactionToken or Apple's + * external purchase token. Present only when status is Opened. + */ + reportToken?: (string | null); + /** What happened to the link */ + status: ExternalPurchaseLinkStatus; +} + /** Result of presenting an external purchase link */ export interface ExternalPurchaseLinkResultIOS { /** Optional error message if the presentation failed */ @@ -583,6 +637,12 @@ export interface ExternalPurchaseLinkResultIOS { success: boolean; } +/** + * Outcome of openExternalPurchaseLink. + * Available in Client Protocol 1.5.0 / openiap-apple 4.2.0 / openiap-google 4.2.0. + */ +export type ExternalPurchaseLinkStatus = 'opened' | 'cancelled' | 'unavailable'; + /** User actions on external purchase notice sheet (iOS 17.4+) */ export type ExternalPurchaseNoticeAction = 'continue' | 'dismissed'; @@ -840,6 +900,16 @@ export interface Mutation { * See: https://openiap.dev/docs/apis/android/launch-external-link-android */ launchExternalLinkAndroid: Promise; + /** + * Show the store's required disclosure, open the external purchase link only + * after the user continues, and return the token to report from your server. + * The disclosure cannot be skipped. Unavailable and Cancelled are results, not + * errors. + * Available in Client Protocol 1.5.0 / openiap-apple 4.2.0 / openiap-google 4.2.0 + * (requires iOS 17.4+ or Play Billing 8.2.1+). + * See: https://openiap.dev/docs/apis/open-external-purchase-link + */ + openExternalPurchaseLink: Promise; /** * Open the platform's offer/promo code redemption flow. * Resolves the redeemed purchase only when the store reports it synchronously; @@ -955,6 +1025,8 @@ export type MutationIsBillingProgramAvailableAndroidArgs = BillingProgramAndroid export type MutationLaunchExternalLinkAndroidArgs = LaunchExternalLinkParamsAndroid; +export type MutationOpenExternalPurchaseLinkArgs = ExternalPurchaseLinkRequest; + export type MutationPresentExternalPurchaseLinkIosArgs = string; export type MutationRequestPurchaseArgs = RequestPurchaseProps; @@ -1521,6 +1593,15 @@ export interface Query { * See: https://openiap.dev/docs/apis/ios/is-eligible-for-intro-offer-ios */ isEligibleForIntroOfferIOS: Promise; + /** + * Whether this device and storefront may link out to an external purchase + * with openExternalPurchaseLink. Returns false instead of throwing when the + * store program is not enabled or the user is not eligible. + * Available in Client Protocol 1.5.0 / openiap-apple 4.2.0 / openiap-google 4.2.0 + * (requires iOS 17.4+ or Play Billing 8.2.1+). + * See: https://openiap.dev/docs/apis/is-external-purchase-link-available + */ + isExternalPurchaseLinkAvailable: Promise; /** * Check whether a transaction's JWS verification passed (StoreKit 2). * See: https://openiap.dev/docs/apis/ios/is-transaction-verified-ios @@ -2370,6 +2451,7 @@ export type MutationArgsMap = { initConnection: MutationInitConnectionArgs; isBillingProgramAvailableAndroid: MutationIsBillingProgramAvailableAndroidArgs; launchExternalLinkAndroid: MutationLaunchExternalLinkAndroidArgs; + openExternalPurchaseLink: MutationOpenExternalPurchaseLinkArgs; openRedeemOfferCode: never; presentExternalPurchaseLinkIOS: MutationPresentExternalPurchaseLinkIosArgs; presentExternalPurchaseNoticeSheetIOS: never; @@ -2416,6 +2498,7 @@ export type QueryArgsMap = { hasActiveSubscriptions: QueryHasActiveSubscriptionsArgs; isEligibleForExternalPurchaseCustomLinkIOS: never; isEligibleForIntroOfferIOS: QueryIsEligibleForIntroOfferIosArgs; + isExternalPurchaseLinkAvailable: never; isTransactionVerifiedIOS: QueryIsTransactionVerifiedIosArgs; latestTransactionIOS: QueryLatestTransactionIosArgs; subscriptionStatusIOS: QuerySubscriptionStatusIosArgs; diff --git a/libraries/react-native-iap/src/utils/native-protocol.ts b/libraries/react-native-iap/src/utils/native-protocol.ts index 9cee1e942..a4ab7f523 100644 --- a/libraries/react-native-iap/src/utils/native-protocol.ts +++ b/libraries/react-native-iap/src/utils/native-protocol.ts @@ -18,6 +18,8 @@ const operations = { getPromotedProductIOS: false, verifyAppOwnership: false, openRedeemOfferCode: false, + isExternalPurchaseLinkAvailable: false, + openExternalPurchaseLink: false, clearTransactionIOS: true, beginRefundRequestIOS: false, subscriptionStatusIOS: false, diff --git a/libraries/react-native-iap/src/vega-adapter.ts b/libraries/react-native-iap/src/vega-adapter.ts index f0f7fb7e1..66770975f 100644 --- a/libraries/react-native-iap/src/vega-adapter.ts +++ b/libraries/react-native-iap/src/vega-adapter.ts @@ -1,6 +1,7 @@ import type { IapkitPurchaseState, NitroActiveSubscription, + NitroExternalPurchaseLinkResult, NitroProduct, NitroPurchase, NitroPurchaseResult, @@ -1739,6 +1740,12 @@ export function createVegaIapModule(service: VegaPurchasingService): RnIap { async openRedeemOfferCode(): Promise { return null; }, + async isExternalPurchaseLinkAvailable(): Promise { + return false; + }, + async openExternalPurchaseLink(): Promise { + return {platform: 'android', status: 'unavailable'}; + }, async canPresentExternalPurchaseNoticeIOS(): Promise { return throwUnsupportedFeature('canPresentExternalPurchaseNoticeIOS'); }, diff --git a/packages/apple/Sources/Models/Types.swift b/packages/apple/Sources/Models/Types.swift index e333be3ab..51391b30c 100644 --- a/packages/apple/Sources/Models/Types.swift +++ b/packages/apple/Sources/Models/Types.swift @@ -519,6 +519,30 @@ public enum ExternalPurchaseCustomLinkTokenTypeIOS: String, Codable, CaseIterabl } } +/// Outcome of openExternalPurchaseLink. +/// Available in Client Protocol 1.5.0 / openiap-apple 4.2.0 / openiap-google 4.2.0. +public enum ExternalPurchaseLinkStatus: String, Codable, CaseIterable { + /// The user continued past the store disclosure and the link was opened + case opened = "opened" + /// The user dismissed the store disclosure; nothing was opened + case cancelled = "cancelled" + /// The store program is not enabled or the user is not eligible; nothing was shown + case unavailable = "unavailable" + + public init?(rawValue: String) { + switch rawValue { + case "opened", "OPENED", "Opened": + self = .opened + case "cancelled", "CANCELLED", "Cancelled": + self = .cancelled + case "unavailable", "UNAVAILABLE", "Unavailable": + self = .unavailable + default: + return nil + } + } +} + /// User actions on external purchase notice sheet (iOS 17.4+) public enum ExternalPurchaseNoticeAction: String, Codable, CaseIterable { /// User chose to continue to external purchase @@ -1739,6 +1763,28 @@ public struct ExternalPurchaseCustomLinkTokenResultIOS: Codable { } } +/// Result of openExternalPurchaseLink. +/// Available in Client Protocol 1.5.0 / openiap-apple 4.2.0 / openiap-google 4.2.0. +public struct ExternalPurchaseLinkResult: Codable { + /// Platform that produced reportToken + public var platform: IapPlatform + /// Token to report from your server: Play's externalTransactionToken or Apple's + /// external purchase token. Present only when status is Opened. + public var reportToken: String? = nil + /// What happened to the link + public var status: ExternalPurchaseLinkStatus + + public init( + platform: IapPlatform, + reportToken: String? = nil, + status: ExternalPurchaseLinkStatus + ) { + self.platform = platform + self.reportToken = reportToken + self.status = status + } +} + /// Result of presenting an external purchase link public struct ExternalPurchaseLinkResultIOS: Codable { /// Optional error message if the presentation failed @@ -3361,6 +3407,56 @@ public struct DiscountOfferInputIOS: Codable { } } +/// Google options for openExternalPurchaseLink. +/// Available in Client Protocol 1.5.0 / openiap-google 4.2.0 (requires Play Billing 8.2.1+). +public struct ExternalPurchaseLinkAndroidProps: Codable { + /// Link type passed to Play. Defaults to LINK_TO_DIGITAL_CONTENT_OFFER. + public var linkType: ExternalLinkTypeAndroid? + + public init( + linkType: ExternalLinkTypeAndroid? = nil + ) { + self.linkType = linkType + } +} + +/// Apple options for openExternalPurchaseLink. +/// Available in Client Protocol 1.5.0 / openiap-apple 4.2.0. +public struct ExternalPurchaseLinkIosProps: Codable { + /// ExternalPurchaseCustomLink token type on iOS 18.1+. Defaults to Acquisition; + /// use LinkOut in Japan and Brazil. Ignored on iOS 17.4 to 18.0, where the + /// notice sheet returns the token. + public var tokenType: ExternalPurchaseCustomLinkTokenTypeIOS? + + public init( + tokenType: ExternalPurchaseCustomLinkTokenTypeIOS? = nil + ) { + self.tokenType = tokenType + } +} + +/// Request for openExternalPurchaseLink. +/// Available in Client Protocol 1.5.0 / openiap-apple 4.2.0 / openiap-google 4.2.0. +public struct ExternalPurchaseLinkRequest: Codable { + /// Apple-specific options + public var apple: ExternalPurchaseLinkIosProps? + /// Google-specific options + public var google: ExternalPurchaseLinkAndroidProps? + /// External purchase URL. On iOS 17.4 to 18.0 it must match the + /// SKExternalPurchase link in Info.plist. + public var url: String + + public init( + apple: ExternalPurchaseLinkIosProps? = nil, + google: ExternalPurchaseLinkAndroidProps? = nil, + url: String + ) { + self.apple = apple + self.google = google + self.url = url + } +} + /// Parameters for fetching Billing Choice display information (Android) /// Available in OpenIAP 2.1.0 / openiap-google 2.3.0 (requires Play Billing 9.1.0+). public struct GetBillingChoiceInfoParamsAndroid: Codable { @@ -4728,6 +4824,14 @@ public protocol MutationResolver { /// Throws OpenIapError.NotPrepared if billing client not ready. /// See: https://openiap.dev/docs/apis/android/launch-external-link-android func launchExternalLinkAndroid(_ params: LaunchExternalLinkParamsAndroid) async throws -> Bool + /// Show the store's required disclosure, open the external purchase link only + /// after the user continues, and return the token to report from your server. + /// The disclosure cannot be skipped. Unavailable and Cancelled are results, not + /// errors. + /// Available in Client Protocol 1.5.0 / openiap-apple 4.2.0 / openiap-google 4.2.0 + /// (requires iOS 17.4+ or Play Billing 8.2.1+). + /// See: https://openiap.dev/docs/apis/open-external-purchase-link + func openExternalPurchaseLink(_ request: ExternalPurchaseLinkRequest) async throws -> ExternalPurchaseLinkResult /// Open the platform's offer/promo code redemption flow. /// Resolves the redeemed purchase only when the store reports it synchronously; /// every other path resolves null, so reconcile through the purchase listeners. @@ -4871,6 +4975,13 @@ public protocol QueryResolver { /// Check intro-offer eligibility for a subscription group. /// See: https://openiap.dev/docs/apis/ios/is-eligible-for-intro-offer-ios func isEligibleForIntroOfferIOS(_ groupID: String) async throws -> Bool + /// Whether this device and storefront may link out to an external purchase + /// with openExternalPurchaseLink. Returns false instead of throwing when the + /// store program is not enabled or the user is not eligible. + /// Available in Client Protocol 1.5.0 / openiap-apple 4.2.0 / openiap-google 4.2.0 + /// (requires iOS 17.4+ or Play Billing 8.2.1+). + /// See: https://openiap.dev/docs/apis/is-external-purchase-link-available + func isExternalPurchaseLinkAvailable() async throws -> Bool /// Check whether a transaction's JWS verification passed (StoreKit 2). /// See: https://openiap.dev/docs/apis/ios/is-transaction-verified-ios func isTransactionVerifiedIOS(_ sku: String) async throws -> Bool @@ -4948,6 +5059,7 @@ public typealias MutationFinishTransactionHandler = (_ purchase: PurchaseInput, public typealias MutationInitConnectionHandler = (_ config: InitConnectionConfig?) async throws -> Bool public typealias MutationIsBillingProgramAvailableAndroidHandler = (_ program: BillingProgramAndroid) async throws -> BillingProgramAvailabilityResultAndroid public typealias MutationLaunchExternalLinkAndroidHandler = (_ params: LaunchExternalLinkParamsAndroid) async throws -> Bool +public typealias MutationOpenExternalPurchaseLinkHandler = (_ request: ExternalPurchaseLinkRequest) async throws -> ExternalPurchaseLinkResult public typealias MutationOpenRedeemOfferCodeHandler = () async throws -> Purchase? public typealias MutationPresentExternalPurchaseLinkIOSHandler = (_ url: String) async throws -> ExternalPurchaseLinkResultIOS public typealias MutationPresentExternalPurchaseNoticeSheetIOSHandler = () async throws -> ExternalPurchaseNoticeResultIOS @@ -4974,6 +5086,7 @@ public struct MutationHandlers { public var initConnection: MutationInitConnectionHandler? public var isBillingProgramAvailableAndroid: MutationIsBillingProgramAvailableAndroidHandler? public var launchExternalLinkAndroid: MutationLaunchExternalLinkAndroidHandler? + public var openExternalPurchaseLink: MutationOpenExternalPurchaseLinkHandler? public var openRedeemOfferCode: MutationOpenRedeemOfferCodeHandler? public var presentExternalPurchaseLinkIOS: MutationPresentExternalPurchaseLinkIOSHandler? public var presentExternalPurchaseNoticeSheetIOS: MutationPresentExternalPurchaseNoticeSheetIOSHandler? @@ -5000,6 +5113,7 @@ public struct MutationHandlers { initConnection: MutationInitConnectionHandler? = nil, isBillingProgramAvailableAndroid: MutationIsBillingProgramAvailableAndroidHandler? = nil, launchExternalLinkAndroid: MutationLaunchExternalLinkAndroidHandler? = nil, + openExternalPurchaseLink: MutationOpenExternalPurchaseLinkHandler? = nil, openRedeemOfferCode: MutationOpenRedeemOfferCodeHandler? = nil, presentExternalPurchaseLinkIOS: MutationPresentExternalPurchaseLinkIOSHandler? = nil, presentExternalPurchaseNoticeSheetIOS: MutationPresentExternalPurchaseNoticeSheetIOSHandler? = nil, @@ -5025,6 +5139,7 @@ public struct MutationHandlers { self.initConnection = initConnection self.isBillingProgramAvailableAndroid = isBillingProgramAvailableAndroid self.launchExternalLinkAndroid = launchExternalLinkAndroid + self.openExternalPurchaseLink = openExternalPurchaseLink self.openRedeemOfferCode = openRedeemOfferCode self.presentExternalPurchaseLinkIOS = presentExternalPurchaseLinkIOS self.presentExternalPurchaseNoticeSheetIOS = presentExternalPurchaseNoticeSheetIOS @@ -5060,6 +5175,7 @@ public typealias QueryGetTransactionJwsIOSHandler = (_ sku: String) async throws public typealias QueryHasActiveSubscriptionsHandler = (_ subscriptionIds: [String]?) async throws -> Bool public typealias QueryIsEligibleForExternalPurchaseCustomLinkIOSHandler = () async throws -> Bool public typealias QueryIsEligibleForIntroOfferIOSHandler = (_ groupID: String) async throws -> Bool +public typealias QueryIsExternalPurchaseLinkAvailableHandler = () async throws -> Bool public typealias QueryIsTransactionVerifiedIOSHandler = (_ sku: String) async throws -> Bool public typealias QueryLatestTransactionIOSHandler = (_ sku: String) async throws -> PurchaseIOS? public typealias QuerySubscriptionStatusIOSHandler = (_ sku: String) async throws -> [SubscriptionStatusIOS] @@ -5083,6 +5199,7 @@ public struct QueryHandlers { public var hasActiveSubscriptions: QueryHasActiveSubscriptionsHandler? public var isEligibleForExternalPurchaseCustomLinkIOS: QueryIsEligibleForExternalPurchaseCustomLinkIOSHandler? public var isEligibleForIntroOfferIOS: QueryIsEligibleForIntroOfferIOSHandler? + public var isExternalPurchaseLinkAvailable: QueryIsExternalPurchaseLinkAvailableHandler? public var isTransactionVerifiedIOS: QueryIsTransactionVerifiedIOSHandler? public var latestTransactionIOS: QueryLatestTransactionIOSHandler? public var subscriptionStatusIOS: QuerySubscriptionStatusIOSHandler? @@ -5106,6 +5223,7 @@ public struct QueryHandlers { hasActiveSubscriptions: QueryHasActiveSubscriptionsHandler? = nil, isEligibleForExternalPurchaseCustomLinkIOS: QueryIsEligibleForExternalPurchaseCustomLinkIOSHandler? = nil, isEligibleForIntroOfferIOS: QueryIsEligibleForIntroOfferIOSHandler? = nil, + isExternalPurchaseLinkAvailable: QueryIsExternalPurchaseLinkAvailableHandler? = nil, isTransactionVerifiedIOS: QueryIsTransactionVerifiedIOSHandler? = nil, latestTransactionIOS: QueryLatestTransactionIOSHandler? = nil, subscriptionStatusIOS: QuerySubscriptionStatusIOSHandler? = nil, @@ -5128,6 +5246,7 @@ public struct QueryHandlers { self.hasActiveSubscriptions = hasActiveSubscriptions self.isEligibleForExternalPurchaseCustomLinkIOS = isEligibleForExternalPurchaseCustomLinkIOS self.isEligibleForIntroOfferIOS = isEligibleForIntroOfferIOS + self.isExternalPurchaseLinkAvailable = isExternalPurchaseLinkAvailable self.isTransactionVerifiedIOS = isTransactionVerifiedIOS self.latestTransactionIOS = latestTransactionIOS self.subscriptionStatusIOS = subscriptionStatusIOS diff --git a/packages/apple/Sources/OpenIapExternalPurchaseLink.swift b/packages/apple/Sources/OpenIapExternalPurchaseLink.swift new file mode 100644 index 000000000..2da193139 --- /dev/null +++ b/packages/apple/Sources/OpenIapExternalPurchaseLink.swift @@ -0,0 +1,74 @@ +import Foundation + +// Shared external purchase link flow built on the platform primitives, so every +// provider gets the same disclosure-first behavior. +@available(iOS 15.0, macOS 14.0, tvOS 16.0, watchOS 8.0, *) +public extension OpenIapModuleProtocol { + /// See: https://openiap.dev/docs/apis/is-external-purchase-link-available + func isExternalPurchaseLinkAvailable() async throws -> Bool { + try await externalPurchaseLinkRoute() != nil + } + + /// The URL opens only after the store disclosure continues. + /// See: https://openiap.dev/docs/apis/open-external-purchase-link + func openExternalPurchaseLink(_ request: ExternalPurchaseLinkRequest) async throws -> ExternalPurchaseLinkResult { + let token: String + switch try await externalPurchaseLinkRoute() { + case nil: + return ExternalPurchaseLinkResult(platform: .ios, status: .unavailable) + case .customLink: + let notice = try await showExternalPurchaseCustomLinkNoticeIOS(.browser) + if let error = notice.error { throw externalPurchaseLinkError(error) } + guard notice.continued else { + return ExternalPurchaseLinkResult(platform: .ios, status: .cancelled) + } + let result = try await getExternalPurchaseCustomLinkTokenIOS(request.apple?.tokenType ?? .acquisition) + guard let value = result.token else { + throw externalPurchaseLinkError(result.error ?? "StoreKit returned no external purchase token") + } + token = value + case .noticeSheet: + let notice = try await presentExternalPurchaseNoticeSheetIOS() + if let error = notice.error { throw externalPurchaseLinkError(error) } + guard notice.result == .continue else { + return ExternalPurchaseLinkResult(platform: .ios, status: .cancelled) + } + guard let value = notice.externalPurchaseToken else { + throw externalPurchaseLinkError("StoreKit returned no external purchase token") + } + token = value + } + let link = try await presentExternalPurchaseLinkIOS(request.url) + guard link.success else { + throw externalPurchaseLinkError(link.error ?? "Cannot open URL") + } + return ExternalPurchaseLinkResult(platform: .ios, reportToken: token, status: .opened) + } +} + +private enum ExternalPurchaseLinkRoute { + case customLink + case noticeSheet +} + +@available(iOS 15.0, macOS 14.0, tvOS 16.0, watchOS 8.0, *) +private extension OpenIapModuleProtocol { + // Prefer the iOS 18.1+ custom link; fall back to the iOS 17.4+ notice sheet. + func externalPurchaseLinkRoute() async throws -> ExternalPurchaseLinkRoute? { + if try await unlessUnsupported(isEligibleForExternalPurchaseCustomLinkIOS) { return .customLink } + if try await unlessUnsupported(canPresentExternalPurchaseNoticeIOS) { return .noticeSheet } + return nil + } + + func unlessUnsupported(_ check: () async throws -> Bool) async throws -> Bool { + do { + return try await check() + } catch let error as PurchaseError where error.code == .featureNotSupported { + return false + } + } + + func externalPurchaseLinkError(_ message: String) -> PurchaseError { + PurchaseError.make(code: .serviceError, message: message) + } +} diff --git a/packages/apple/Sources/OpenIapModule+ObjC.swift b/packages/apple/Sources/OpenIapModule+ObjC.swift index f01a0a0aa..aedd6b6e8 100644 --- a/packages/apple/Sources/OpenIapModule+ObjC.swift +++ b/packages/apple/Sources/OpenIapModule+ObjC.swift @@ -902,6 +902,30 @@ import StoreKit } } + // MARK: - External Purchase Link + + @objc func isExternalPurchaseLinkAvailableWithCompletion(_ completion: @escaping (Bool, Error?) -> Void) { + Task { + do { + completion(try await isExternalPurchaseLinkAvailable(), nil) + } catch { + completion(false, error) + } + } + } + + @objc func openExternalPurchaseLinkWithRequest(_ request: NSDictionary, completion: @escaping (Any?, Error?) -> Void) { + Task { + do { + let props = try OpenIapSerialization.decode(object: request, as: ExternalPurchaseLinkRequest.self) + let result = try await openExternalPurchaseLink(props) + completion(OpenIapSerialization.encode(result), nil) + } catch { + completion(nil, error) + } + } + } + // MARK: - Event Listeners @objc func addPurchaseUpdatedListener(_ callback: @escaping (NSDictionary) -> Void) -> NSObject { diff --git a/packages/apple/Sources/OpenIapModule.swift b/packages/apple/Sources/OpenIapModule.swift index 990d0e1d3..d9d456bab 100644 --- a/packages/apple/Sources/OpenIapModule.swift +++ b/packages/apple/Sources/OpenIapModule.swift @@ -183,6 +183,14 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { try await provider.showExternalPurchaseCustomLinkNoticeIOS(noticeType) } + public func isExternalPurchaseLinkAvailable() async throws -> Bool { + try await provider.isExternalPurchaseLinkAvailable() + } + + public func openExternalPurchaseLink(_ request: ExternalPurchaseLinkRequest) async throws -> ExternalPurchaseLinkResult { + try await provider.openExternalPurchaseLink(request) + } + public func purchaseUpdatedListener(_ listener: @escaping PurchaseUpdatedListener, options: PurchaseUpdatedListenerOptions? = nil) -> Subscription { guard case .success(let selected) = selection else { return Subscription(eventType: .purchaseUpdated) } return selected.module.purchaseUpdatedListener(listener, options: options) diff --git a/packages/apple/Sources/OpenIapProtocol.swift b/packages/apple/Sources/OpenIapProtocol.swift index 4e7041efe..3c8d46524 100644 --- a/packages/apple/Sources/OpenIapProtocol.swift +++ b/packages/apple/Sources/OpenIapProtocol.swift @@ -95,6 +95,8 @@ public protocol OpenIapModuleProtocol { func showExternalPurchaseCustomLinkNoticeIOS( _ noticeType: ExternalPurchaseCustomLinkNoticeTypeIOS ) async throws -> ExternalPurchaseCustomLinkNoticeResultIOS + func isExternalPurchaseLinkAvailable() async throws -> Bool + func openExternalPurchaseLink(_ request: ExternalPurchaseLinkRequest) async throws -> ExternalPurchaseLinkResult // Event Listeners func purchaseUpdatedListener( diff --git a/packages/apple/Tests/OpenIapTests/ExternalPurchaseLinkTests.swift b/packages/apple/Tests/OpenIapTests/ExternalPurchaseLinkTests.swift new file mode 100644 index 000000000..6488c0551 --- /dev/null +++ b/packages/apple/Tests/OpenIapTests/ExternalPurchaseLinkTests.swift @@ -0,0 +1,179 @@ +import XCTest +@testable import OpenIAP + +@available(iOS 15.0, macOS 14.0, *) +final class ExternalPurchaseLinkTests: XCTestCase { + private let request = ExternalPurchaseLinkRequest(url: "https://example.com/checkout") + + func testUnavailableShowsNoDisclosure() async throws { + let module = FakeExternalLinkModule() + + let available = try await module.isExternalPurchaseLinkAvailable() + let result = try await module.openExternalPurchaseLink(request) + + XCTAssertFalse(available) + XCTAssertEqual(result.status, .unavailable) + XCTAssertNil(result.reportToken) + XCTAssertEqual(module.calls, []) + } + + func testUnsupportedProviderIsUnavailable() async throws { + let module = FakeExternalLinkModule(unsupported: true) + + let result = try await module.openExternalPurchaseLink(request) + + XCTAssertEqual(result.status, .unavailable) + XCTAssertEqual(module.calls, []) + } + + func testCancelledCustomLinkNoticeOpensNothing() async throws { + let module = FakeExternalLinkModule(customLinkEligible: true, noticeContinues: false) + + let result = try await module.openExternalPurchaseLink(request) + + XCTAssertEqual(result.status, .cancelled) + XCTAssertNil(result.reportToken) + XCTAssertEqual(module.calls, ["notice"]) + } + + func testCustomLinkReturnsTokenAfterNoticeContinues() async throws { + let module = FakeExternalLinkModule(customLinkEligible: true, noticeContinues: true) + let props = ExternalPurchaseLinkRequest( + apple: ExternalPurchaseLinkIosProps(tokenType: .linkOut), + url: "https://example.com/checkout" + ) + + let result = try await module.openExternalPurchaseLink(props) + + XCTAssertEqual(result.status, .opened) + XCTAssertEqual(result.platform, .ios) + XCTAssertEqual(result.reportToken, "custom-token") + XCTAssertEqual(module.calls, ["notice", "token:link-out", "open:https://example.com/checkout"]) + } + + func testCancelledNoticeSheetOpensNothing() async throws { + let module = FakeExternalLinkModule(noticeSheetAvailable: true, noticeContinues: false) + + let result = try await module.openExternalPurchaseLink(request) + + XCTAssertEqual(result.status, .cancelled) + XCTAssertEqual(module.calls, ["sheet"]) + } + + func testNoticeSheetReturnsTokenAfterContinue() async throws { + let module = FakeExternalLinkModule(noticeSheetAvailable: true, noticeContinues: true) + + let result = try await module.openExternalPurchaseLink(request) + + XCTAssertEqual(result.status, .opened) + XCTAssertEqual(result.reportToken, "sheet-token") + XCTAssertEqual(module.calls, ["sheet", "open:https://example.com/checkout"]) + } + + func testMissingTokenThrowsBeforeOpening() async throws { + let module = FakeExternalLinkModule(customLinkEligible: true, noticeContinues: true, tokenValue: nil) + + do { + _ = try await module.openExternalPurchaseLink(request) + XCTFail("Expected a service error") + } catch let error as PurchaseError { + XCTAssertEqual(error.code, .serviceError) + } + XCTAssertEqual(module.calls, ["notice", "token:acquisition"]) + } +} + +@available(iOS 15.0, macOS 14.0, *) +private final class FakeExternalLinkModule: OpenIapModuleProtocol { + private let unsupported: Bool + private let customLinkEligible: Bool + private let noticeSheetAvailable: Bool + private let noticeContinues: Bool + private let tokenValue: String? + private(set) var calls: [String] = [] + + init( + unsupported: Bool = false, + customLinkEligible: Bool = false, + noticeSheetAvailable: Bool = false, + noticeContinues: Bool = false, + tokenValue: String? = "custom-token" + ) { + self.unsupported = unsupported + self.customLinkEligible = customLinkEligible + self.noticeSheetAvailable = noticeSheetAvailable + self.noticeContinues = noticeContinues + self.tokenValue = tokenValue + } + + // MARK: - External purchase primitives + func isEligibleForExternalPurchaseCustomLinkIOS() async throws -> Bool { + if unsupported { throw PurchaseError(code: .featureNotSupported, message: "unsupported") } + return customLinkEligible + } + func canPresentExternalPurchaseNoticeIOS() async throws -> Bool { + if unsupported { throw PurchaseError(code: .featureNotSupported, message: "unsupported") } + return noticeSheetAvailable + } + func showExternalPurchaseCustomLinkNoticeIOS( + _ noticeType: ExternalPurchaseCustomLinkNoticeTypeIOS + ) async throws -> ExternalPurchaseCustomLinkNoticeResultIOS { + calls.append("notice") + return ExternalPurchaseCustomLinkNoticeResultIOS(continued: noticeContinues) + } + func getExternalPurchaseCustomLinkTokenIOS( + _ tokenType: ExternalPurchaseCustomLinkTokenTypeIOS + ) async throws -> ExternalPurchaseCustomLinkTokenResultIOS { + calls.append("token:\(tokenType.rawValue)") + return ExternalPurchaseCustomLinkTokenResultIOS(token: tokenValue) + } + func presentExternalPurchaseNoticeSheetIOS() async throws -> ExternalPurchaseNoticeResultIOS { + calls.append("sheet") + return noticeContinues + ? ExternalPurchaseNoticeResultIOS(externalPurchaseToken: "sheet-token", result: .continue) + : ExternalPurchaseNoticeResultIOS(result: .dismissed) + } + func presentExternalPurchaseLinkIOS(_ url: String) async throws -> ExternalPurchaseLinkResultIOS { + calls.append("open:\(url)") + return ExternalPurchaseLinkResultIOS(success: true) + } + + // MARK: - Unused + func initConnection() async throws -> Bool { true } + func endConnection() async throws -> Bool { true } + func fetchProducts(_ params: ProductRequest) async throws -> FetchProductsResult { .products(nil) } + func requestPurchase(_ params: RequestPurchaseProps) async throws -> RequestPurchaseResult? { nil } + func restorePurchases() async throws -> Void {} + func getAvailablePurchases(_ options: PurchaseOptions?) async throws -> [Purchase] { [] } + func finishTransaction(purchase: PurchaseInput, isConsumable: Bool?) async throws -> Void {} + func getReceiptDataIOS() async throws -> String? { nil } + func verifyPurchase(_ props: VerifyPurchaseProps) async throws -> VerifyPurchaseResult { + throw PurchaseError(code: .featureNotSupported, message: "unused") + } + func getStorefront() async throws -> String { "DEU" } + func getAppTransactionIOS() async throws -> AppTransaction? { nil } + func getActiveSubscriptions(_ subscriptionIds: [String]?) async throws -> [ActiveSubscription] { [] } + func hasActiveSubscriptions(_ subscriptionIds: [String]?) async throws -> Bool { false } + func subscriptionStatusIOS(sku: String) async throws -> [SubscriptionStatusIOS] { [] } + func isEligibleForIntroOfferIOS(groupID: String) async throws -> Bool { false } + func beginRefundRequestIOS(sku: String) async throws -> String? { nil } + func syncIOS() async throws -> Bool { true } + func openRedeemOfferCode() async throws -> PurchaseIOS? { nil } + func showManageSubscriptionsIOS() async throws -> [PurchaseIOS] { [] } + func deepLinkToSubscriptions(_ options: DeepLinkOptions?) async throws -> Void {} + func purchaseUpdatedListener( + _ listener: @escaping PurchaseUpdatedListener, + options: PurchaseUpdatedListenerOptions? + ) -> Subscription { Subscription(eventType: .purchaseUpdated) } + func purchaseErrorListener(_ listener: @escaping PurchaseErrorListener) -> Subscription { + Subscription(eventType: .purchaseError) + } + func promotedProductListenerIOS(_ listener: @escaping PromotedProductListener) -> Subscription { + Subscription(eventType: .promotedProductIos) + } + func subscriptionBillingIssueListener(_ listener: @escaping SubscriptionBillingIssueListener) -> Subscription { + Subscription(eventType: .subscriptionBillingIssue) + } + func removeListener(_ subscription: Subscription) {} + func removeAllListeners() {} +} diff --git a/packages/docs/src/lib/searchData.ts b/packages/docs/src/lib/searchData.ts index fb19d836e..a0474f0df 100644 --- a/packages/docs/src/lib/searchData.ts +++ b/packages/docs/src/lib/searchData.ts @@ -106,6 +106,26 @@ export const apiData: ApiItem[] = [ returns: 'Purchase', path: '/docs/apis/open-redeem-offer-code', }, + { + id: 'is-external-purchase-link-available', + title: 'isExternalPurchaseLinkAvailable', + category: 'Purchase', + description: + 'Check whether this device and storefront may link out to an external purchase', + parameters: '', + returns: 'Boolean!', + path: '/docs/apis/is-external-purchase-link-available', + }, + { + id: 'open-external-purchase-link', + title: 'openExternalPurchaseLink', + category: 'Purchase', + description: + 'Show the store disclosure, open the external purchase link after the user continues, and return the report token', + parameters: 'request: ExternalPurchaseLinkRequest', + returns: 'ExternalPurchaseLinkResult!', + path: '/docs/apis/open-external-purchase-link', + }, { id: 'get-storefront', title: 'getStorefront', diff --git a/packages/docs/src/pages/docs/apis/index.tsx b/packages/docs/src/pages/docs/apis/index.tsx index 1b0c5cb15..ff7095bbc 100644 --- a/packages/docs/src/pages/docs/apis/index.tsx +++ b/packages/docs/src/pages/docs/apis/index.tsx @@ -265,6 +265,28 @@ function APIsIndex() { every other flow resolves null. + + + + isExternalPurchaseLinkAvailable + + + + Whether this device and storefront may link out to an external + purchase. + + + + + + openExternalPurchaseLink + + + + Show the store disclosure, open the link after the user + continues, and return the token to report from your server. + + diff --git a/packages/docs/src/pages/docs/apis/is-external-purchase-link-available.tsx b/packages/docs/src/pages/docs/apis/is-external-purchase-link-available.tsx new file mode 100644 index 000000000..1e0102205 --- /dev/null +++ b/packages/docs/src/pages/docs/apis/is-external-purchase-link-available.tsx @@ -0,0 +1,102 @@ +import { Link } from 'react-router-dom'; +import AnchorLink from '../../../components/AnchorLink'; +import CodeBlock from '../../../components/CodeBlock'; +import LanguageTabs from '../../../components/LanguageTabs'; +import SEO from '../../../components/SEO'; +import { useScrollToHash } from '../../../hooks/useScrollToHash'; + +function IsExternalPurchaseLinkAvailable() { + useScrollToHash(); + + return ( +
+ +

isExternalPurchaseLinkAvailable

+

+ Check whether this device and storefront may link out to an external + purchase with{' '} + + openExternalPurchaseLink + + . +

+

+ Available in Client Protocol 1.5.0 / openiap-apple 4.2.0 /{' '} + openiap-google 4.2.0 (requires iOS 17.4+ or Play Billing + 8.2.1+). +

+
    +
  • + iOS: true when{' '} + ExternalPurchaseCustomLink.isEligible (iOS 18.1+) or{' '} + ExternalPurchase.canPresent (iOS 17.4+) is true. +
  • +
  • + Google Play: true when the external + offers program is enabled with{' '} + enableBillingProgramAndroid and available to this user. +
  • +
  • + Amazon, Meta Horizon, Vega OS: always{' '} + false. +
  • +
+ +

Signature

+ + {{ + typescript: ( + {`isExternalPurchaseLinkAvailable(): Promise`} + ), + swift: ( + {`func isExternalPurchaseLinkAvailable() async throws -> Bool`} + ), + kotlin: ( + {`suspend fun isExternalPurchaseLinkAvailable(): Boolean`} + ), + kmp: ( + {`suspend fun isExternalPurchaseLinkAvailable(): Boolean`} + ), + dart: ( + {`Future isExternalPurchaseLinkAvailable();`} + ), + csharp: ( + {`Task IsExternalPurchaseLinkAvailableAsync();`} + ), + gdscript: ( + {`func is_external_purchase_link_available() -> bool`} + ), + }} + + + + Returns + +

+ true when openExternalPurchaseLink can show + the store disclosure. It returns false instead of throwing + when the program is not enabled or the user is not eligible. A + connection failure still throws. +

+ +

Example

+ {`import { isExternalPurchaseLinkAvailable } from 'expo-iap'; // or 'react-native-iap' + +const showWebCheckout = await isExternalPurchaseLinkAvailable();`} + +

+ See:{' '} + + External Purchase + +

+
+ ); +} + +export default IsExternalPurchaseLinkAvailable; diff --git a/packages/docs/src/pages/docs/apis/open-external-purchase-link.tsx b/packages/docs/src/pages/docs/apis/open-external-purchase-link.tsx new file mode 100644 index 000000000..7bdbbd3a9 --- /dev/null +++ b/packages/docs/src/pages/docs/apis/open-external-purchase-link.tsx @@ -0,0 +1,204 @@ +import { Link } from 'react-router-dom'; +import AnchorLink from '../../../components/AnchorLink'; +import CodeBlock from '../../../components/CodeBlock'; +import LanguageTabs from '../../../components/LanguageTabs'; +import SEO from '../../../components/SEO'; +import { useScrollToHash } from '../../../hooks/useScrollToHash'; + +function OpenExternalPurchaseLink() { + useScrollToHash(); + + return ( +
+ +

openExternalPurchaseLink

+

+ Show the store's required disclosure, open the external purchase + link only after the user continues, and return the token to report from + your server. The disclosure cannot be skipped. +

+

+ Available in Client Protocol 1.5.0 / openiap-apple 4.2.0 /{' '} + openiap-google 4.2.0 (requires iOS 17.4+ or Play Billing + 8.2.1+). +

+
    +
  • + iOS 18.1+: shows the{' '} + ExternalPurchaseCustomLink notice, then requests a token + of apple.tokenType (default acquisition; use{' '} + link-out in Japan and Brazil) and opens the URL. +
  • +
  • + iOS 17.4 to 18.0: shows the{' '} + ExternalPurchase notice sheet, which returns the token, + then opens the URL. Use a link listed under{' '} + SKExternalPurchase in Info.plist. +
  • +
  • + Google Play: creates external offer reporting + details, then calls launchExternalLink, which shows + Play's dialog before opening the link. +
  • +
  • + Amazon, Meta Horizon, Vega OS: returns{' '} + unavailable without showing anything. +
  • +
+ +

Signature

+ + {{ + typescript: ( + {`openExternalPurchaseLink( + request: ExternalPurchaseLinkRequest, +): Promise`} + ), + swift: ( + {`func openExternalPurchaseLink( + _ request: ExternalPurchaseLinkRequest +) async throws -> ExternalPurchaseLinkResult`} + ), + kotlin: ( + {`suspend fun openExternalPurchaseLink( + activity: Activity, + request: ExternalPurchaseLinkRequest +): ExternalPurchaseLinkResult`} + ), + kmp: ( + {`suspend fun openExternalPurchaseLink( + request: ExternalPurchaseLinkRequest +): ExternalPurchaseLinkResult`} + ), + dart: ( + {`Future openExternalPurchaseLink( + ExternalPurchaseLinkRequest request, +);`} + ), + csharp: ( + {`Task OpenExternalPurchaseLinkAsync( + ExternalPurchaseLinkRequest request);`} + ), + gdscript: ( + {`func open_external_purchase_link(request) -> Variant # ExternalPurchaseLinkResult or null`} + ), + }} + + + + Parameters + +
    +
  • + url (required): the web checkout to open. +
  • +
  • + apple.tokenType: the iOS 18.1+ token type. Ignored on iOS + 17.4 to 18.0. +
  • +
  • + google.linkType: the Play link type. Defaults to{' '} + link-to-digital-content-offer. +
  • +
+ + + Returns + +
    +
  • + status: opened (the user continued and the + link opened), cancelled (the user dismissed the + disclosure; nothing opened), or unavailable (nothing was + shown). +
  • +
  • + reportToken: present only when opened. + Play's externalTransactionToken or Apple's + external purchase token. OpenIAP never sends it anywhere. +
  • +
  • + platform: the platform that produced the token. +
  • +
+ + + Throws + +

+ cancelled and unavailable are results, not + errors. A store failure, a missing token, or a link that cannot open + throws, for example service-error or{' '} + not-prepared. Android also needs a current Activity. Godot + emits purchase_error and returns null. +

+ +

Example

+ + {{ + typescript: ( + {`import { openExternalPurchaseLink } from 'expo-iap'; // or 'react-native-iap' + +const result = await openExternalPurchaseLink({ + url: 'https://example.com/checkout', + apple: { tokenType: 'acquisition' }, +}); +if (result.status === 'opened') { + // Report result.reportToken from your backend after the web purchase. +}`} + ), + swift: ( + {`let result = try await OpenIapModule.shared.openExternalPurchaseLink( + ExternalPurchaseLinkRequest(url: "https://example.com/checkout") +) +if result.status == .opened, let token = result.reportToken { + // Send token to your backend. +}`} + ), + kotlin: ( + {`val result = openIapStore.openExternalPurchaseLink( + activity, + ExternalPurchaseLinkRequest(url = "https://example.com/checkout"), +) +if (result.status == ExternalPurchaseLinkStatus.Opened) { + // Send result.reportToken to your backend. +}`} + ), + kmp: ( + {`val result = kmpIAP.openExternalPurchaseLink( + ExternalPurchaseLinkRequest(url = "https://example.com/checkout") +)`} + ), + dart: ( + {`final result = await FlutterInappPurchase.instance.openExternalPurchaseLink( + ExternalPurchaseLinkRequest(url: 'https://example.com/checkout'), +);`} + ), + csharp: ( + {`var result = await OpenIapClient.Mutation.OpenExternalPurchaseLinkAsync( + new ExternalPurchaseLinkRequest { Url = "https://example.com/checkout" });`} + ), + gdscript: ( + {`var request = Types.ExternalPurchaseLinkRequest.new() +request.url = "https://example.com/checkout" +var result = await iap.open_external_purchase_link(request)`} + ), + }} + + +

+ See:{' '} + + External Purchase + +

+
+ ); +} + +export default OpenExternalPurchaseLink; diff --git a/packages/docs/src/pages/docs/features/external-purchase.tsx b/packages/docs/src/pages/docs/features/external-purchase.tsx index 9c89b20f1..daf7b832b 100644 --- a/packages/docs/src/pages/docs/features/external-purchase.tsx +++ b/packages/docs/src/pages/docs/features/external-purchase.tsx @@ -88,6 +88,63 @@ function ExternalPurchase() { +
+ + One Call for Both Stores + +

+ To link out to a web checkout, call{' '} + + openExternalPurchaseLink + + . It shows the store's required disclosure, opens the URL only + after the user continues, and returns the token to report from your + server. The disclosure cannot be skipped, so the call cannot produce a + flow that App Review rejects for a missing notice. +

+
    +
  • + iOS 18.1+: ExternalPurchaseCustomLink{' '} + notice, then a token of apple.tokenType (default{' '} + acquisition; use link-out in Japan and + Brazil). +
  • +
  • + iOS 17.4 to 18.0: the ExternalPurchase{' '} + notice sheet, which returns the token. +
  • +
  • + Google Play: the external offers program (Play + Billing 8.2.1+). Play shows its own dialog before it opens the link. + Initialize with enableBillingProgramAndroid set to{' '} + external-offer. +
  • +
  • + Amazon, Meta Horizon, Vega OS: always{' '} + unavailable. +
  • +
+ {`import { + isExternalPurchaseLinkAvailable, + openExternalPurchaseLink, +} from 'expo-iap'; // or 'react-native-iap' + +if (await isExternalPurchaseLinkAvailable()) { + const result = await openExternalPurchaseLink({ + url: 'https://example.com/checkout', + }); + if (result.status === 'opened') { + // Report result.reportToken from your backend after the web purchase. + await api.saveExternalPurchaseToken(result.platform, result.reportToken); + } +}`} +

+ The store entitlement or program enrollment is still required. Use the + platform calls below when you need Android's in-app billing + choice or a step-by-step flow. +

+
+
Platform-Specific Implementation diff --git a/packages/docs/src/pages/docs/index.tsx b/packages/docs/src/pages/docs/index.tsx index 1aaee2188..25ccae81d 100644 --- a/packages/docs/src/pages/docs/index.tsx +++ b/packages/docs/src/pages/docs/index.tsx @@ -53,6 +53,8 @@ import APIsGetActiveSubscriptions from './apis/get-active-subscriptions'; import APIsHasActiveSubscriptions from './apis/has-active-subscriptions'; import APIsDeepLinkToSubscriptions from './apis/deep-link-to-subscriptions'; import APIsOpenRedeemOfferCode from './apis/open-redeem-offer-code'; +import APIsIsExternalPurchaseLinkAvailable from './apis/is-external-purchase-link-available'; +import APIsOpenExternalPurchaseLink from './apis/open-external-purchase-link'; import APIsVerifyAppOwnership from './apis/verify-app-ownership'; import APIsClearTransactionIOS from './apis/ios/clear-transaction-ios'; import APIsGetPendingTransactionsIOS from './apis/ios/get-pending-transactions-ios'; @@ -370,6 +372,14 @@ function Docs() { to: '/docs/apis/open-redeem-offer-code', label: 'openRedeemOfferCode', }, + { + to: '/docs/apis/is-external-purchase-link-available', + label: 'isExternalPurchaseLinkAvailable', + }, + { + to: '/docs/apis/open-external-purchase-link', + label: 'openExternalPurchaseLink', + }, { label: 'iOS Specific', items: [ @@ -988,6 +998,14 @@ function Docs() { path="apis/open-redeem-offer-code" element={} /> + } + /> + } + /> + {EXTERNAL_PURCHASE_LINK_RELEASES.map((release) => ( +
@@ -163,275 +164,25 @@ if (await isExternalPurchaseLinkAvailable()) {

Basic Usage

- Check that the notice sheet can be shown, present it, then - open your purchase link when the user continues: + Call{' '} + + openExternalPurchaseLink + + . It picks ExternalPurchaseCustomLink on iOS + 18.1+ and the notice sheet on iOS 17.4 to 18.0. The + step-by-step calls it replaces ( + canPresentExternalPurchaseNoticeIOS,{' '} + isEligibleForExternalPurchaseCustomLinkIOS,{' '} + presentExternalPurchaseNoticeSheetIOS, and{' '} + presentExternalPurchaseLinkIOS) are deprecated + and keep working until client protocol 2.0.0.

- - {{ - typescript: ( - {`import { - canPresentExternalPurchaseNoticeIOS, - presentExternalPurchaseNoticeSheetIOS, - presentExternalPurchaseLinkIOS -} from 'expo-iap'; - -async function handleExternalPurchaseFlow() { - const externalUrl = 'https://your-payment-site.com/checkout'; - - try { - // Step 1: Check if notice sheet can be presented - const canPresent = await canPresentExternalPurchaseNoticeIOS(); - - if (!canPresent) { - console.log('External purchase notice sheet not available'); - return; - } - - // Step 2: Present notice sheet (Apple's info sheet) - const noticeResult = await presentExternalPurchaseNoticeSheetIOS(); - - if (noticeResult.result === 'continue') { - // Step 3: Present external purchase link - const linkResult = await presentExternalPurchaseLinkIOS(externalUrl); - - if (linkResult.success) { - console.log('User acknowledged external purchase'); - // User approved external purchase - // Call your backend API to initiate purchase - // await yourBackend.createPurchase(productId, userId); - } else { - console.log(\`External purchase link failed: \${linkResult.error ?? ''}\`); - } - } else { - console.log('User dismissed notice sheet'); - } - } catch (error) { - console.error('External purchase error:', error); - } -}`} - ), - swift: ( - {`import OpenIAP - -@available(iOS 17.4, *) -func handleExternalPurchaseFlow() async { - let externalUrl = "https://your-payment-site.com/checkout" - - do { - // Step 1: Check if notice sheet can be presented - let canPresent = try await OpenIapModule.shared - .canPresentExternalPurchaseNoticeIOS() - - guard canPresent else { - print("External purchase notice sheet not available") - return - } - - // Step 2: Present notice sheet (Apple's info sheet) - let noticeResult = try await OpenIapModule.shared - .presentExternalPurchaseNoticeSheetIOS() - - if noticeResult.result == .continue { - // Step 3: Present external purchase link - let linkResult = try await OpenIapModule.shared - .presentExternalPurchaseLinkIOS(externalUrl) - - if linkResult.success { - print("User acknowledged external purchase") - // User approved external purchase - // Call your backend API to initiate purchase - // await yourBackend.createPurchase(productId, userId) - } else { - print("External purchase link failed: \\(linkResult.error ?? "")") - } - } else { - print("User dismissed notice sheet") - } - } catch { - print("External purchase error: \\(error)") - } -}`} - ), - kotlin: ( - {`import dev.openiap.OpenIap -import dev.openiap.ExternalPurchaseNoticeAction - -// iOS 17.4+ External Purchase Flow (from Kotlin Multiplatform) -suspend fun handleExternalPurchaseFlow() { - val externalUrl = "https://your-payment-site.com/checkout" - - try { - // Step 1: Check if notice sheet can be presented - val canPresent = OpenIap.canPresentExternalPurchaseNoticeIOS() - - if (!canPresent) { - println("External purchase notice sheet not available") - return - } - - // Step 2: Present notice sheet (Apple's info sheet) - val noticeResult = OpenIap.presentExternalPurchaseNoticeSheetIOS() - - if (noticeResult.result == ExternalPurchaseNoticeAction.Continue) { - // Step 3: Present external purchase link - val linkResult = OpenIap.presentExternalPurchaseLinkIOS(externalUrl) - - if (linkResult.success) { - println("User acknowledged external purchase") - // User approved external purchase - // Call your backend API to initiate purchase - // yourBackend.createPurchase(productId, userId) - } else { - println("External purchase link failed: \${linkResult.error ?: ""}") - } - } else { - println("User dismissed notice sheet") - } - } catch (e: Exception) { - println("External purchase error: \${e.message}") - } -}`} - ), - kmp: ( - {`import io.github.hyochan.kmpiap.KmpIAP -import io.github.hyochan.kmpiap.ExternalPurchaseNoticeAction - -// iOS 17.4+ External Purchase Flow (from Kotlin Multiplatform) -suspend fun handleExternalPurchaseFlow() { - val kmpIAP = KmpIAP() - val externalUrl = "https://your-payment-site.com/checkout" - - try { - // Step 1: Check if notice sheet can be presented - val canPresent = kmpIAP.canPresentExternalPurchaseNoticeIOS() - - if (!canPresent) { - println("External purchase notice sheet not available") - return - } - - // Step 2: Present notice sheet (Apple's info sheet) - val noticeResult = kmpIAP.presentExternalPurchaseNoticeSheetIOS() - - if (noticeResult.result == ExternalPurchaseNoticeAction.Continue) { - // Step 3: Present external purchase link - val linkResult = kmpIAP.presentExternalPurchaseLinkIOS(externalUrl) - - if (linkResult.success) { - println("User acknowledged external purchase") - // User approved external purchase - // Call your backend API to initiate purchase - // yourBackend.createPurchase(productId, userId) - } else { - println("External purchase link failed: \${linkResult.error ?: ""}") - } - } else { - println("User dismissed notice sheet") - } - } catch (e: Exception) { - println("External purchase error: \${e.message}") - } -}`} - ), - dart: ( - {`import 'package:flutter_inapp_purchase/flutter_inapp_purchase.dart'; - -// iOS 17.4+ External Purchase Flow -Future handleExternalPurchaseFlow() async { - const externalUrl = 'https://your-payment-site.com/checkout'; - - try { - // Step 1: Check if notice sheet can be presented - final canPresent = await FlutterInappPurchase.instance - .canPresentExternalPurchaseNoticeIOS(); - - if (!canPresent) { - print('External purchase notice sheet not available'); - return; - } - - // Step 2: Present notice sheet (Apple's info sheet) - final noticeResult = await FlutterInappPurchase.instance - .presentExternalPurchaseNoticeSheetIOS(); - - if (noticeResult.result == ExternalPurchaseNoticeAction.Continue) { - // Step 3: Present external purchase link - final linkResult = await FlutterInappPurchase.instance - .presentExternalPurchaseLinkIOS(externalUrl); - - if (linkResult.success) { - print('User acknowledged external purchase'); - // User approved external purchase - // Call your backend API to initiate purchase - // await yourBackend.createPurchase(productId, userId); - } else { - print('External purchase link failed: \${linkResult.error ?? ""}'); - } - } else { - print('User dismissed notice sheet'); - } - } catch (e) { - print('External purchase error: $e'); - } -}`} - ), - csharp: ( - {`using OpenIap; -using OpenIap.Maui; - -async Task HandleExternalPurchaseFlowAsync() -{ - if (!await OpenIapClient.Query.CanPresentExternalPurchaseNoticeIOSAsync()) return; - - var notice = await OpenIapClient.Mutation.PresentExternalPurchaseNoticeSheetIOSAsync(); - if (notice.Result != ExternalPurchaseNoticeAction.Continue) return; - - var link = await OpenIapClient.Mutation.PresentExternalPurchaseLinkIOSAsync( - "https://your-payment-site.com/checkout"); - if (!link.Success) - { - Console.WriteLine($"External purchase link failed: {link.Error}"); - } -}`} - ), - gdscript: ( - {`# iOS 17.4+ External Purchase Flow -func handle_external_purchase_flow() -> void: - var external_url = "https://your-payment-site.com/checkout" - - # Step 1: Check if notice sheet can be presented - var can_present = await iap.can_present_external_purchase_notice_ios() - - if not can_present: - print("External purchase notice sheet not available") - return - - # Step 2: Present notice sheet (Apple's info sheet) - var notice_result = await iap.present_external_purchase_notice_sheet_ios() - - if notice_result.result == ExternalPurchaseNoticeAction.CONTINUE: - # Step 3: Present external purchase link - var link_result = await iap.present_external_purchase_link_ios(external_url) - - if link_result.success: - print("User acknowledged external purchase") - # User approved external purchase - # Call your backend API to initiate purchase - # await your_backend.create_purchase(product_id, user_id) - else: - print("External purchase link failed: %s" % (link_result.error if link_result.error else "")) - else: - print("User dismissed notice sheet")`} - ), - }} -

Requirements

  • iOS 17.4+ - Required for{' '} - canPresentExternalPurchaseNoticeIOS and{' '} - presentExternalPurchaseNoticeSheetIOS + openExternalPurchaseLink
  • iOS 18.1+ - Required for the{' '} @@ -1853,45 +1604,26 @@ func _ready_user_choice() -> void: 1 - - canPresentExternalPurchaseNoticeIOS() + + isExternalPurchaseLinkAvailable() - - Check if device supports external purchase notice sheet - + Check that this app and device can link out 2 - - presentExternalPurchaseNoticeSheetIOS() + + openExternalPurchaseLink(request) - Show Apple's notice sheet informing user about external - purchase + Shows Apple's notice, opens the URL if the user + continues, and returns the report token 3 - User Action - - User taps "Continue" or dismisses the notice sheet - - - - 4 - - presentExternalPurchaseLinkIOS(url) - - - If user continued, present external purchase link (user - acknowledges external purchase) - - - - 5 Backend Payment Processing App calls backend API to process payment with payment @@ -1899,7 +1631,7 @@ func _ready_user_choice() -> void: - 6 + 4 Unlock Content App unlocks purchased content after backend confirmation @@ -2013,15 +1745,17 @@ func _ready_user_choice() -> void: > AlternativeBillingScreen.swift {' '} - - Complete implementation with notice sheet and external - purchase link presentation + - Complete implementation with{' '} + openExternalPurchaseLink

This example demonstrates:

  • Complete UI implementation with state management
  • -
  • Notice sheet presentation (iOS 17.4+)
  • +
  • + Apple's disclosure before the link opens (iOS 17.4+) +
  • Deep link handling and verification flow
  • Error handling and user feedback
  • Production-ready code patterns
  • diff --git a/packages/docs/src/pages/docs/types/external-purchase-link.tsx b/packages/docs/src/pages/docs/types/external-purchase-link.tsx index 0c72c728b..252d2bd94 100644 --- a/packages/docs/src/pages/docs/types/external-purchase-link.tsx +++ b/packages/docs/src/pages/docs/types/external-purchase-link.tsx @@ -27,9 +27,13 @@ function ExternalPurchaseLink() { Available from iOS 17.4+ (notice sheet) and iOS 18.1+ (custom links).

    - presentExternalPurchaseLinkIOS is{' '} - iOS only and opens the URL with{' '} - UIApplication.open, not a StoreKit API. + Link out with{' '} + + openExternalPurchaseLink + + . It shows Apple's disclosure, opens the URL only if the user + continues, and returns the report token. The step-by-step iOS calls + below are deprecated and keep working until client protocol 2.0.0.

    Native references:{' '} @@ -62,46 +66,6 @@ function ExternalPurchaseLink() {

    - - External Purchase APIs - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    APIDescriptionAvailability
    - canPresentExternalPurchaseNoticeIOS - Check if external purchase notice sheet can be presentediOS 17.4+
    - presentExternalPurchaseNoticeSheetIOS - - Present Apple's compliance notice sheet (required before - external purchase) - iOS 17.4+
    - presentExternalPurchaseLinkIOS - Open external purchase URL in SafariiOS 16+
    - Types @@ -402,221 +366,6 @@ enum ExternalPurchaseNoticeAction { }} - - External Purchase Flow - -

    The external purchase flow requires 3 steps for Apple compliance:

    -
      -
    1. - Check availability - Verify the device supports - external purchase -
    2. -
    3. - Present notice sheet - Show Apple's required - disclosure -
    4. -
    5. - Open external link - Redirect to your payment page -
    6. -
    - - - Complete Example - - - {{ - typescript: ( - {`import { - canPresentExternalPurchaseNoticeIOS, - presentExternalPurchaseNoticeSheetIOS, - presentExternalPurchaseLinkIOS, -} from 'expo-iap'; - -async function handleExternalPurchase(externalUrl: string) { - // Step 1: Check if external purchase is available - const canPresent = await canPresentExternalPurchaseNoticeIOS(); - if (!canPresent) { - console.log('External purchase not available on this device'); - return; - } - - // Step 2: Present Apple's compliance notice sheet - const noticeResult = await presentExternalPurchaseNoticeSheetIOS(); - if (noticeResult.result === 'dismissed') { - console.log('User dismissed the notice sheet'); - return; - } - - // Step 3: Open external purchase link - const linkResult = await presentExternalPurchaseLinkIOS(externalUrl); - if (linkResult.success) { - console.log('User redirected to external payment'); - // Implement deep linking to handle return from payment - } else { - console.error('Failed:', linkResult.error); - } -}`} - ), - swift: ( - {`import OpenIAP - -@available(iOS 17.4, *) -func handleExternalPurchase(externalUrl: String) async { - do { - // Step 1: Check if external purchase is available - let canPresent = try await OpenIapModule.shared.canPresentExternalPurchaseNoticeIOS() - guard canPresent else { - print("External purchase not available on this device") - return - } - - // Step 2: Present Apple's compliance notice sheet - let noticeResult = try await OpenIapModule.shared.presentExternalPurchaseNoticeSheetIOS() - guard noticeResult.result == .continue else { - print("User dismissed the notice sheet") - return - } - - // Step 3: Open external purchase link - let linkResult = try await OpenIapModule.shared.presentExternalPurchaseLinkIOS(externalUrl) - if linkResult.success { - print("User redirected to external payment") - // Implement deep linking to handle return from payment - } else if let error = linkResult.error { - print("Failed: \\(error)") - } - } catch { - print("External purchase error: \\(error)") - } -}`} - ), - kotlin: ( - {`import io.github.hyochan.kmpiap.kmpIapInstance -import io.github.hyochan.kmpiap.ExternalPurchaseNoticeAction - -// External purchase is iOS-only. For iOS targets in KMP: -suspend fun handleExternalPurchase(externalUrl: String) { - // Step 1: Check if external purchase is available - val canPresent = kmpIapInstance.canPresentExternalPurchaseNoticeIOS() - if (!canPresent) { - println("External purchase not available on this device") - return - } - - // Step 2: Present Apple's compliance notice sheet - val noticeResult = kmpIapInstance.presentExternalPurchaseNoticeSheetIOS() - if (noticeResult.result == ExternalPurchaseNoticeAction.Dismissed) { - println("User dismissed the notice sheet") - return - } - - // Step 3: Open external purchase link - val linkResult = kmpIapInstance.presentExternalPurchaseLinkIOS(externalUrl) - if (linkResult.success) { - println("User redirected to external payment") - // Implement deep linking to handle return from payment - } else { - println("Failed: \${linkResult.error}") - } -} - -// For Android, use the Billing Programs APIs instead.`} - ), - dart: ( - {`import 'package:flutter_inapp_purchase/flutter_inapp_purchase.dart'; - -Future handleExternalPurchase(String externalUrl) async { - final iap = FlutterInappPurchase.instance; - - // Step 1: Check if external purchase is available - final canPresent = await iap.canPresentExternalPurchaseNoticeIOS(); - if (!canPresent) { - print('External purchase not available on this device'); - return; - } - - // Step 2: Present Apple's compliance notice sheet - final noticeResult = await iap.presentExternalPurchaseNoticeSheetIOS(); - if (noticeResult.result == ExternalPurchaseNoticeAction.Dismissed) { - print('User dismissed the notice sheet'); - return; - } - - // Step 3: Open external purchase link - final linkResult = await iap.presentExternalPurchaseLinkIOS(externalUrl); - if (linkResult.success) { - print('User redirected to external payment'); - // Implement deep linking to handle return from payment - } else { - print('Failed: \${linkResult.error}'); - } -}`} - ), - csharp: ( - {`using OpenIap; -using OpenIap.Maui; - -async Task HandleExternalPurchaseAsync(string externalUrl) -{ - // Step 1: Check if external purchase is available. - var canPresent = - await OpenIapClient.Query.CanPresentExternalPurchaseNoticeIOSAsync(); - if (!canPresent) - { - Console.WriteLine("External purchase not available on this device"); - return; - } - - // Step 2: Present Apple's compliance notice sheet. - var noticeResult = - await OpenIapClient.Mutation.PresentExternalPurchaseNoticeSheetIOSAsync(); - if (noticeResult.Result == ExternalPurchaseNoticeAction.Dismissed) - { - Console.WriteLine("User dismissed the notice sheet"); - return; - } - - // Step 3: Open external purchase link. - var linkResult = - await OpenIapClient.Mutation.PresentExternalPurchaseLinkIOSAsync(externalUrl); - if (linkResult.Success) - { - Console.WriteLine("User redirected to external payment"); - // Implement deep linking to handle return from payment. - } - else - { - Console.WriteLine($"Failed: {linkResult.Error}"); - } -} - -// For Android, use the Billing Programs APIs instead.`} - ), - gdscript: ( - {`func handle_external_purchase(external_url: String): - # Step 1: Check if external purchase is available - var can_present = await iap.can_present_external_purchase_notice_ios() - if not can_present: - print("External purchase not available on this device") - return - - # Step 2: Present Apple's compliance notice sheet - var notice_result = await iap.present_external_purchase_notice_sheet_ios() - if notice_result.result == ExternalPurchaseNoticeAction.DISMISSED: - print("User dismissed the notice sheet") - return - - # Step 3: Open external purchase link - var link_result = await iap.present_external_purchase_link_ios(external_url) - if link_result.success: - print("User redirected to external payment") - # Implement deep linking to handle return from payment - else: - print("Failed: %s" % link_result.error)`} - ), - }} - - Requirements diff --git a/packages/docs/src/pages/docs/updates/releases.tsx b/packages/docs/src/pages/docs/updates/releases.tsx index 147db6196..19010363c 100644 --- a/packages/docs/src/pages/docs/updates/releases.tsx +++ b/packages/docs/src/pages/docs/updates/releases.tsx @@ -906,9 +906,14 @@ function Releases() { source="api.graphql" diff={`+ isExternalPurchaseLinkAvailable: Boolean! + openExternalPurchaseLink(request: ExternalPurchaseLinkRequest!): ExternalPurchaseLinkResult! -+ enum ExternalPurchaseLinkStatus { Opened, Cancelled, Unavailable }`} - > - Additive only; no existing member, value or default changes. ++ enum ExternalPurchaseLinkStatus { Opened, Cancelled, Unavailable } + canPresentExternalPurchaseNoticeIOS: Boolean! @deprecated + isEligibleForExternalPurchaseCustomLinkIOS: Boolean! @deprecated + presentExternalPurchaseNoticeSheetIOS: ExternalPurchaseNoticeResultIOS! @deprecated + presentExternalPurchaseLinkIOS(url: String!): ExternalPurchaseLinkResultIOS! @deprecated`} + > + Additive. Four iOS calls are deprecated and keep working until + client protocol 2.0.0; no value or default changes.
    Protocols and native packages @@ -949,14 +954,24 @@ function Releases() {
  • cancelled and unavailable are results, - not errors. Report reportToken only after your - backend confirms the web purchase. + not errors. Send reportToken to your backend. Apple + requires every token in the monthly report, even when no purchase + follows; Play reports it with the external transaction. +
  • +
  • + Deprecated, removed in client protocol 2.0.0:{' '} + canPresentExternalPurchaseNoticeIOS and{' '} + isEligibleForExternalPurchaseCustomLinkIOS (use{' '} + isExternalPurchaseLinkAvailable), and{' '} + presentExternalPurchaseNoticeSheetIOS and{' '} + presentExternalPurchaseLinkIOS (use{' '} + openExternalPurchaseLink). They still work.
  • - Platform calls such as{' '} - showExternalPurchaseCustomLinkNoticeIOS and{' '} - launchExternalLinkAndroid, and Play's in-app - billing choice, are unchanged. + showExternalPurchaseCustomLinkNoticeIOS,{' '} + getExternalPurchaseCustomLinkTokenIOS, the Android + billing program calls, and Play's in-app billing choice are + unchanged. The example apps now use the shared call.
Package Releases
diff --git a/packages/google/core/src/test/java/dev/hyo/openiap/ExternalPurchaseLinkTest.kt b/packages/google/core/src/test/java/dev/hyo/openiap/ExternalPurchaseLinkTest.kt index 1551d4344..65b0e9fa9 100644 --- a/packages/google/core/src/test/java/dev/hyo/openiap/ExternalPurchaseLinkTest.kt +++ b/packages/google/core/src/test/java/dev/hyo/openiap/ExternalPurchaseLinkTest.kt @@ -42,6 +42,17 @@ class ExternalPurchaseLinkTest { assertEquals(ExternalPurchaseLinkStatus.Unavailable, provider.openExternalPurchaseLinkFlow(activity, request).status) } + @Test + fun `unenrolled app is unavailable`() = runBlocking { + val provider = provider { method, _ -> + check(method == "isBillingProgramAvailable") + throw OpenIapError.BillingUnavailable("not enrolled") + } + + assertFalse(provider.externalPurchaseLinkAvailable()) + assertEquals(ExternalPurchaseLinkStatus.Unavailable, provider.openExternalPurchaseLinkFlow(activity, request).status) + } + @Test fun `cancelled disclosure returns no token`() = runBlocking { val provider = provider(available = true, launch = { throw OpenIapError.UserCancelled() }) diff --git a/packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapModule.kt index 53e5bd9cf..988794f16 100644 --- a/packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapModule.kt +++ b/packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapModule.kt @@ -973,8 +973,15 @@ class OpenIapModule( override val mutationHandlers: MutationHandlers = MutationHandlers( openExternalPurchaseLink = { request -> val activity = currentActivityRef?.get() - ?: throw OpenIapError.MissingCurrentActivity - openExternalPurchaseLink(activity, request) + when { + activity != null -> openExternalPurchaseLink(activity, request) + // Nothing will be shown, so no activity is needed to say so. + !isExternalPurchaseLinkAvailable() -> ExternalPurchaseLinkResult( + platform = IapPlatform.Android, + status = ExternalPurchaseLinkStatus.Unavailable, + ) + else -> throw OpenIapError.MissingCurrentActivity + } }, acknowledgePurchaseAndroid = acknowledgePurchaseAndroid, consumePurchaseAndroid = consumePurchaseAndroid, diff --git a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt index fd59aae55..f277fcee0 100644 --- a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt +++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt @@ -1523,8 +1523,15 @@ class OpenIapModule( override val mutationHandlers: MutationHandlers = MutationHandlers( openExternalPurchaseLink = { request -> val activity = currentActivityRef?.get() ?: fallbackActivity - ?: throw OpenIapError.MissingCurrentActivity - openExternalPurchaseLink(activity, request) + when { + activity != null -> openExternalPurchaseLink(activity, request) + // Nothing will be shown, so no activity is needed to say so. + !isExternalPurchaseLinkAvailable() -> ExternalPurchaseLinkResult( + platform = IapPlatform.Android, + status = ExternalPurchaseLinkStatus.Unavailable, + ) + else -> throw OpenIapError.MissingCurrentActivity + } }, acknowledgePurchaseAndroid = acknowledgePurchaseAndroid, consumePurchaseAndroid = consumePurchaseAndroid, diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapExternalPurchaseLink.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapExternalPurchaseLink.kt index 94bdd3bde..9d1456e1c 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapExternalPurchaseLink.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapExternalPurchaseLink.kt @@ -10,6 +10,8 @@ internal suspend fun OpenIapProtocol.externalPurchaseLinkAvailable(): Boolean = isBillingProgramAvailable(BillingProgramAndroid.ExternalOffer).isAvailable } catch (_: OpenIapError.FeatureNotSupported) { false + } catch (_: OpenIapError.BillingUnavailable) { + false // Play's answer for an unenrolled app or ineligible country. } internal suspend fun OpenIapProtocol.openExternalPurchaseLinkFlow( diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt index 4c6120cc9..66ac8e6e8 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/Types.kt @@ -6452,14 +6452,18 @@ public interface MutationResolver { /** * Present an external purchase link, StoreKit External (iOS 16+). * See: https://openiap.dev/docs/apis/ios/present-external-purchase-link-ios + * @deprecated Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0. */ + @Deprecated("Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0.") suspend fun presentExternalPurchaseLinkIOS(url: String): ExternalPurchaseLinkResultIOS /** * Present the external purchase notice sheet (iOS 17.4+). * Uses ExternalPurchase.presentNoticeSheet() which returns a token when the user continues. * Reference: https://developer.apple.com/documentation/storekit/externalpurchase/presentnoticesheet() * See: https://openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios + * @deprecated Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0. */ + @Deprecated("Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0.") suspend fun presentExternalPurchaseNoticeSheetIOS(): ExternalPurchaseNoticeResultIOS /** * Initiate a purchase or subscription flow; rely on events for final state. @@ -6540,7 +6544,9 @@ public interface QueryResolver { * Check eligibility for the external purchase notice sheet (iOS 17.4+). * Uses ExternalPurchase.canPresent. * See: https://openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios + * @deprecated Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0. */ + @Deprecated("Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0.") suspend fun canPresentExternalPurchaseNoticeIOS(): Boolean /** * Get the user's current entitlement for a product, using StoreKit 2 (iOS 15+). @@ -6632,7 +6638,9 @@ public interface QueryResolver { * Returns true if the app can use custom external purchase links. * Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/iseligible * See: https://openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios + * @deprecated Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0. */ + @Deprecated("Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0.") suspend fun isEligibleForExternalPurchaseCustomLinkIOS(): Boolean /** * Check intro-offer eligibility for a subscription group. @@ -6859,14 +6867,18 @@ public class MutationHandlers( /** * Present an external purchase link, StoreKit External (iOS 16+). * See: https://openiap.dev/docs/apis/ios/present-external-purchase-link-ios + * @deprecated Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0. */ + @Deprecated("Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0.") val presentExternalPurchaseLinkIOS: MutationPresentExternalPurchaseLinkIOSHandler? = null, /** * Present the external purchase notice sheet (iOS 17.4+). * Uses ExternalPurchase.presentNoticeSheet() which returns a token when the user continues. * Reference: https://developer.apple.com/documentation/storekit/externalpurchase/presentnoticesheet() * See: https://openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios + * @deprecated Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0. */ + @Deprecated("Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0.") val presentExternalPurchaseNoticeSheetIOS: MutationPresentExternalPurchaseNoticeSheetIOSHandler? = null, /** * Initiate a purchase or subscription flow; rely on events for final state. @@ -7112,7 +7124,9 @@ public class QueryHandlers( * Check eligibility for the external purchase notice sheet (iOS 17.4+). * Uses ExternalPurchase.canPresent. * See: https://openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios + * @deprecated Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0. */ + @Deprecated("Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0.") val canPresentExternalPurchaseNoticeIOS: QueryCanPresentExternalPurchaseNoticeIOSHandler? = null, /** * Get the user's current entitlement for a product, using StoreKit 2 (iOS 15+). @@ -7204,7 +7218,9 @@ public class QueryHandlers( * Returns true if the app can use custom external purchase links. * Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/iseligible * See: https://openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios + * @deprecated Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0. */ + @Deprecated("Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0.") val isEligibleForExternalPurchaseCustomLinkIOS: QueryIsEligibleForExternalPurchaseCustomLinkIOSHandler? = null, /** * Check intro-offer eligibility for a subscription group. diff --git a/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt index 3e8644bf4..21bf3ddeb 100644 --- a/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt +++ b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt @@ -2382,8 +2382,15 @@ class OpenIapModule( override val mutationHandlers: MutationHandlers = MutationHandlers( openExternalPurchaseLink = { request -> val activity = currentActivityRef?.get() ?: fallbackActivity - ?: throw OpenIapError.MissingCurrentActivity - openExternalPurchaseLink(activity, request) + when { + activity != null -> openExternalPurchaseLink(activity, request) + // Nothing will be shown, so no activity is needed to say so. + !isExternalPurchaseLinkAvailable() -> ExternalPurchaseLinkResult( + platform = IapPlatform.Android, + status = ExternalPurchaseLinkStatus.Unavailable, + ) + else -> throw OpenIapError.MissingCurrentActivity + } }, acknowledgePurchaseAndroid = acknowledgePurchaseAndroid, consumePurchaseAndroid = consumePurchaseAndroid, diff --git a/packages/google/openiap/src/testAmazon/java/dev/hyo/openiap/ExternalPurchaseLinkAmazonTest.kt b/packages/google/openiap/src/testAmazon/java/dev/hyo/openiap/ExternalPurchaseLinkAmazonTest.kt new file mode 100644 index 000000000..eb50bb155 --- /dev/null +++ b/packages/google/openiap/src/testAmazon/java/dev/hyo/openiap/ExternalPurchaseLinkAmazonTest.kt @@ -0,0 +1,19 @@ +package dev.hyo.openiap + +import android.content.ContextWrapper +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test + +class ExternalPurchaseLinkAmazonTest { + + @Test + fun `unified handler is unavailable without an activity`() { + val module = OpenIapModule(ContextWrapper(null)) + val request = ExternalPurchaseLinkRequest(url = "https://example.com/checkout") + + val result = runBlocking { module.mutationHandlers.openExternalPurchaseLink!!.invoke(request) } + + assertEquals(ExternalPurchaseLinkStatus.Unavailable, result.status) + } +} diff --git a/packages/google/openiap/src/testHorizon/java/dev/hyo/openiap/ExternalPurchaseLinkHorizonTest.kt b/packages/google/openiap/src/testHorizon/java/dev/hyo/openiap/ExternalPurchaseLinkHorizonTest.kt new file mode 100644 index 000000000..20891b930 --- /dev/null +++ b/packages/google/openiap/src/testHorizon/java/dev/hyo/openiap/ExternalPurchaseLinkHorizonTest.kt @@ -0,0 +1,19 @@ +package dev.hyo.openiap + +import android.content.ContextWrapper +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test + +class ExternalPurchaseLinkHorizonTest { + + @Test + fun `unified handler is unavailable without an activity`() { + val module = OpenIapModule(ContextWrapper(null)) + val request = ExternalPurchaseLinkRequest(url = "https://example.com/checkout") + + val result = runBlocking { module.mutationHandlers.openExternalPurchaseLink!!.invoke(request) } + + assertEquals(ExternalPurchaseLinkStatus.Unavailable, result.status) + } +} diff --git a/specs/client/src/api-ios.graphql b/specs/client/src/api-ios.graphql index 0ebc5d0f3..2579715e9 100644 --- a/specs/client/src/api-ios.graphql +++ b/specs/client/src/api-ios.graphql @@ -18,7 +18,7 @@ extend type Query { See: https://openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios """ # Future - canPresentExternalPurchaseNoticeIOS: Boolean! + canPresentExternalPurchaseNoticeIOS: Boolean! @deprecated(reason: "Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0.") """ Check eligibility for the custom-link variant of external purchase (iOS 18.1+). Returns true if the app can use custom external purchase links. @@ -26,7 +26,7 @@ extend type Query { See: https://openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios """ # Future - isEligibleForExternalPurchaseCustomLinkIOS: Boolean! + isEligibleForExternalPurchaseCustomLinkIOS: Boolean! @deprecated(reason: "Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0.") """ Fetch a token for Apple's External Purchase Server reporting API (iOS 18.1+). Use this token to report transactions made through ExternalPurchaseCustomLink. @@ -137,13 +137,13 @@ extend type Mutation { See: https://openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios """ # Future - presentExternalPurchaseNoticeSheetIOS: ExternalPurchaseNoticeResultIOS! + presentExternalPurchaseNoticeSheetIOS: ExternalPurchaseNoticeResultIOS! @deprecated(reason: "Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0.") """ Present an external purchase link, StoreKit External (iOS 16+). See: https://openiap.dev/docs/apis/ios/present-external-purchase-link-ios """ # Future - presentExternalPurchaseLinkIOS(url: String!): ExternalPurchaseLinkResultIOS! + presentExternalPurchaseLinkIOS(url: String!): ExternalPurchaseLinkResultIOS! @deprecated(reason: "Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0.") """ Present the disclosure sheet required before linking out via ExternalPurchaseCustomLink (iOS 18.1+). Call this after a deliberate customer interaction before linking out to external purchases. diff --git a/specs/client/src/generated-compatibility.test.ts b/specs/client/src/generated-compatibility.test.ts index 35a14bd12..c0f2edb39 100644 --- a/specs/client/src/generated-compatibility.test.ts +++ b/specs/client/src/generated-compatibility.test.ts @@ -321,7 +321,12 @@ describe('generated compatibility', () => { const implementors = interfaceImplementors(); const unionOwners = interfaceUnionOwners(); - expect(entries).toEqual([]); + expect(entries.map((entry) => entry.ownerPath)).toEqual([ + 'Query.canPresentExternalPurchaseNoticeIOS', + 'Query.isEligibleForExternalPurchaseCustomLinkIOS', + 'Mutation.presentExternalPurchaseNoticeSheetIOS', + 'Mutation.presentExternalPurchaseLinkIOS', + ]); for (const file of generatedFiles) { const source = generated(file); expect(source).not.toContain('openRedeemOfferCodeAndroid'); diff --git a/specs/client/src/generated/Types.cs b/specs/client/src/generated/Types.cs index 875f13e9e..93c414fcb 100644 --- a/specs/client/src/generated/Types.cs +++ b/specs/client/src/generated/Types.cs @@ -5556,7 +5556,9 @@ public interface MutationResolver /// /// Present an external purchase link, StoreKit External (iOS 16+). /// See: https://openiap.dev/docs/apis/ios/present-external-purchase-link-ios + /// @deprecated Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0. /// + [Obsolete("Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0.")] Task PresentExternalPurchaseLinkIOSAsync(string url); /// @@ -5564,7 +5566,9 @@ public interface MutationResolver /// Uses ExternalPurchase.presentNoticeSheet() which returns a token when the user continues. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchase/presentnoticesheet() /// See: https://openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios + /// @deprecated Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0. /// + [Obsolete("Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0.")] Task PresentExternalPurchaseNoticeSheetIOSAsync(); /// @@ -5654,7 +5658,9 @@ public interface QueryResolver /// Check eligibility for the external purchase notice sheet (iOS 17.4+). /// Uses ExternalPurchase.canPresent. /// See: https://openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios + /// @deprecated Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0. /// + [Obsolete("Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0.")] Task CanPresentExternalPurchaseNoticeIOSAsync(); /// @@ -5761,7 +5767,9 @@ public interface QueryResolver /// Returns true if the app can use custom external purchase links. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/iseligible /// See: https://openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios + /// @deprecated Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0. /// + [Obsolete("Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0.")] Task IsEligibleForExternalPurchaseCustomLinkIOSAsync(); /// diff --git a/specs/client/src/generated/Types.kt b/specs/client/src/generated/Types.kt index f89e09daf..ab09fede9 100644 --- a/specs/client/src/generated/Types.kt +++ b/specs/client/src/generated/Types.kt @@ -6397,14 +6397,18 @@ public interface MutationResolver { /** * Present an external purchase link, StoreKit External (iOS 16+). * See: https://openiap.dev/docs/apis/ios/present-external-purchase-link-ios + * @deprecated Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0. */ + @Deprecated("Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0.") suspend fun presentExternalPurchaseLinkIOS(url: String): ExternalPurchaseLinkResultIOS /** * Present the external purchase notice sheet (iOS 17.4+). * Uses ExternalPurchase.presentNoticeSheet() which returns a token when the user continues. * Reference: https://developer.apple.com/documentation/storekit/externalpurchase/presentnoticesheet() * See: https://openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios + * @deprecated Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0. */ + @Deprecated("Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0.") suspend fun presentExternalPurchaseNoticeSheetIOS(): ExternalPurchaseNoticeResultIOS /** * Initiate a purchase or subscription flow; rely on events for final state. @@ -6485,7 +6489,9 @@ public interface QueryResolver { * Check eligibility for the external purchase notice sheet (iOS 17.4+). * Uses ExternalPurchase.canPresent. * See: https://openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios + * @deprecated Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0. */ + @Deprecated("Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0.") suspend fun canPresentExternalPurchaseNoticeIOS(): Boolean /** * Get the user's current entitlement for a product, using StoreKit 2 (iOS 15+). @@ -6577,7 +6583,9 @@ public interface QueryResolver { * Returns true if the app can use custom external purchase links. * Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/iseligible * See: https://openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios + * @deprecated Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0. */ + @Deprecated("Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0.") suspend fun isEligibleForExternalPurchaseCustomLinkIOS(): Boolean /** * Check intro-offer eligibility for a subscription group. @@ -6804,14 +6812,18 @@ public class MutationHandlers( /** * Present an external purchase link, StoreKit External (iOS 16+). * See: https://openiap.dev/docs/apis/ios/present-external-purchase-link-ios + * @deprecated Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0. */ + @Deprecated("Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0.") val presentExternalPurchaseLinkIOS: MutationPresentExternalPurchaseLinkIOSHandler? = null, /** * Present the external purchase notice sheet (iOS 17.4+). * Uses ExternalPurchase.presentNoticeSheet() which returns a token when the user continues. * Reference: https://developer.apple.com/documentation/storekit/externalpurchase/presentnoticesheet() * See: https://openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios + * @deprecated Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0. */ + @Deprecated("Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0.") val presentExternalPurchaseNoticeSheetIOS: MutationPresentExternalPurchaseNoticeSheetIOSHandler? = null, /** * Initiate a purchase or subscription flow; rely on events for final state. @@ -7057,7 +7069,9 @@ public class QueryHandlers( * Check eligibility for the external purchase notice sheet (iOS 17.4+). * Uses ExternalPurchase.canPresent. * See: https://openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios + * @deprecated Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0. */ + @Deprecated("Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0.") val canPresentExternalPurchaseNoticeIOS: QueryCanPresentExternalPurchaseNoticeIOSHandler? = null, /** * Get the user's current entitlement for a product, using StoreKit 2 (iOS 15+). @@ -7149,7 +7163,9 @@ public class QueryHandlers( * Returns true if the app can use custom external purchase links. * Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/iseligible * See: https://openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios + * @deprecated Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0. */ + @Deprecated("Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0.") val isEligibleForExternalPurchaseCustomLinkIOS: QueryIsEligibleForExternalPurchaseCustomLinkIOSHandler? = null, /** * Check intro-offer eligibility for a subscription group. diff --git a/specs/client/src/generated/Types.swift b/specs/client/src/generated/Types.swift index 51391b30c..137697392 100644 --- a/specs/client/src/generated/Types.swift +++ b/specs/client/src/generated/Types.swift @@ -4841,11 +4841,15 @@ public protocol MutationResolver { func openRedeemOfferCode() async throws -> Purchase? /// Present an external purchase link, StoreKit External (iOS 16+). /// See: https://openiap.dev/docs/apis/ios/present-external-purchase-link-ios + /// @deprecated Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0. + @available(*, deprecated, message: "Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0.") func presentExternalPurchaseLinkIOS(_ url: String) async throws -> ExternalPurchaseLinkResultIOS /// Present the external purchase notice sheet (iOS 17.4+). /// Uses ExternalPurchase.presentNoticeSheet() which returns a token when the user continues. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchase/presentnoticesheet() /// See: https://openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios + /// @deprecated Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0. + @available(*, deprecated, message: "Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0.") func presentExternalPurchaseNoticeSheetIOS() async throws -> ExternalPurchaseNoticeResultIOS /// Initiate a purchase or subscription flow; rely on events for final state. /// Providers emit one canonical purchase-error event before returning or throwing a request failure. @@ -4909,6 +4913,8 @@ public protocol QueryResolver { /// Check eligibility for the external purchase notice sheet (iOS 17.4+). /// Uses ExternalPurchase.canPresent. /// See: https://openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios + /// @deprecated Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0. + @available(*, deprecated, message: "Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0.") func canPresentExternalPurchaseNoticeIOS() async throws -> Bool /// Get the user's current entitlement for a product, using StoreKit 2 (iOS 15+). /// See: https://openiap.dev/docs/apis/ios/current-entitlement-ios @@ -4971,6 +4977,8 @@ public protocol QueryResolver { /// Returns true if the app can use custom external purchase links. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/iseligible /// See: https://openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios + /// @deprecated Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0. + @available(*, deprecated, message: "Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0.") func isEligibleForExternalPurchaseCustomLinkIOS() async throws -> Bool /// Check intro-offer eligibility for a subscription group. /// See: https://openiap.dev/docs/apis/ios/is-eligible-for-intro-offer-ios @@ -5088,7 +5096,9 @@ public struct MutationHandlers { public var launchExternalLinkAndroid: MutationLaunchExternalLinkAndroidHandler? public var openExternalPurchaseLink: MutationOpenExternalPurchaseLinkHandler? public var openRedeemOfferCode: MutationOpenRedeemOfferCodeHandler? + @available(*, deprecated, message: "Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0.") public var presentExternalPurchaseLinkIOS: MutationPresentExternalPurchaseLinkIOSHandler? + @available(*, deprecated, message: "Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0.") public var presentExternalPurchaseNoticeSheetIOS: MutationPresentExternalPurchaseNoticeSheetIOSHandler? public var requestPurchase: MutationRequestPurchaseHandler? public var restorePurchases: MutationRestorePurchasesHandler? @@ -5182,6 +5192,7 @@ public typealias QuerySubscriptionStatusIOSHandler = (_ sku: String) async throw public typealias QueryVerifyAppOwnershipHandler = (_ options: VerifyAppOwnershipProps?) async throws -> AppOwnershipVerificationResult public struct QueryHandlers { + @available(*, deprecated, message: "Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0.") public var canPresentExternalPurchaseNoticeIOS: QueryCanPresentExternalPurchaseNoticeIOSHandler? public var currentEntitlementIOS: QueryCurrentEntitlementIOSHandler? public var fetchProducts: QueryFetchProductsHandler? @@ -5197,6 +5208,7 @@ public struct QueryHandlers { public var getStorefront: QueryGetStorefrontHandler? public var getTransactionJwsIOS: QueryGetTransactionJwsIOSHandler? public var hasActiveSubscriptions: QueryHasActiveSubscriptionsHandler? + @available(*, deprecated, message: "Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0.") public var isEligibleForExternalPurchaseCustomLinkIOS: QueryIsEligibleForExternalPurchaseCustomLinkIOSHandler? public var isEligibleForIntroOfferIOS: QueryIsEligibleForIntroOfferIOSHandler? public var isExternalPurchaseLinkAvailable: QueryIsExternalPurchaseLinkAvailableHandler? diff --git a/specs/client/src/generated/types.dart b/specs/client/src/generated/types.dart index 601b366f8..1d2dfa9bc 100644 --- a/specs/client/src/generated/types.dart +++ b/specs/client/src/generated/types.dart @@ -6398,11 +6398,15 @@ abstract class MutationResolver { Future openRedeemOfferCode(); /// Present an external purchase link, StoreKit External (iOS 16+). /// See: https://openiap.dev/docs/apis/ios/present-external-purchase-link-ios + /// @deprecated Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0. + @Deprecated('Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0.') Future presentExternalPurchaseLinkIOS(String url); /// Present the external purchase notice sheet (iOS 17.4+). /// Uses ExternalPurchase.presentNoticeSheet() which returns a token when the user continues. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchase/presentnoticesheet() /// See: https://openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios + /// @deprecated Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0. + @Deprecated('Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0.') Future presentExternalPurchaseNoticeSheetIOS(); /// Initiate a purchase or subscription flow; rely on events for final state. /// Providers emit one canonical purchase-error event before returning or throwing a request failure. @@ -6472,6 +6476,8 @@ abstract class QueryResolver { /// Check eligibility for the external purchase notice sheet (iOS 17.4+). /// Uses ExternalPurchase.canPresent. /// See: https://openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios + /// @deprecated Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0. + @Deprecated('Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0.') Future canPresentExternalPurchaseNoticeIOS(); /// Get the user's current entitlement for a product, using StoreKit 2 (iOS 15+). /// See: https://openiap.dev/docs/apis/ios/current-entitlement-ios @@ -6545,6 +6551,8 @@ abstract class QueryResolver { /// Returns true if the app can use custom external purchase links. /// Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/iseligible /// See: https://openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios + /// @deprecated Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0. + @Deprecated('Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0.') Future isEligibleForExternalPurchaseCustomLinkIOS(); /// Check intro-offer eligibility for a subscription group. /// See: https://openiap.dev/docs/apis/ios/is-eligible-for-intro-offer-ios @@ -6719,7 +6727,9 @@ class MutationHandlers { final MutationLaunchExternalLinkAndroidHandler? launchExternalLinkAndroid; final MutationOpenExternalPurchaseLinkHandler? openExternalPurchaseLink; final MutationOpenRedeemOfferCodeHandler? openRedeemOfferCode; + @Deprecated('Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0.') final MutationPresentExternalPurchaseLinkIOSHandler? presentExternalPurchaseLinkIOS; + @Deprecated('Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0.') final MutationPresentExternalPurchaseNoticeSheetIOSHandler? presentExternalPurchaseNoticeSheetIOS; final MutationRequestPurchaseHandler? requestPurchase; final MutationRestorePurchasesHandler? restorePurchases; @@ -6798,6 +6808,7 @@ class QueryHandlers { this.verifyAppOwnership, }); + @Deprecated('Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0.') final QueryCanPresentExternalPurchaseNoticeIOSHandler? canPresentExternalPurchaseNoticeIOS; final QueryCurrentEntitlementIOSHandler? currentEntitlementIOS; final QueryFetchProductsHandler? fetchProducts; @@ -6813,6 +6824,7 @@ class QueryHandlers { final QueryGetStorefrontHandler? getStorefront; final QueryGetTransactionJwsIOSHandler? getTransactionJwsIOS; final QueryHasActiveSubscriptionsHandler? hasActiveSubscriptions; + @Deprecated('Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0.') final QueryIsEligibleForExternalPurchaseCustomLinkIOSHandler? isEligibleForExternalPurchaseCustomLinkIOS; final QueryIsEligibleForIntroOfferIOSHandler? isEligibleForIntroOfferIOS; final QueryIsExternalPurchaseLinkAvailableHandler? isExternalPurchaseLinkAvailable; diff --git a/specs/client/src/generated/types.gd b/specs/client/src/generated/types.gd index 84cac8472..a7e548dfa 100644 --- a/specs/client/src/generated/types.gd +++ b/specs/client/src/generated/types.gd @@ -6925,7 +6925,7 @@ class Query: const return_type = "ProductIOS" const is_array = false - ## Check eligibility for the external purchase notice sheet (iOS 17.4+). Uses ExternalPurchase.canPresent. See: https://openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios + ## Check eligibility for the external purchase notice sheet (iOS 17.4+). Uses ExternalPurchase.canPresent. See: https://openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios @deprecated Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0. class canPresentExternalPurchaseNoticeIOSField: const name = "canPresentExternalPurchaseNoticeIOS" const snake_name = "can_present_external_purchase_notice_ios" @@ -6934,7 +6934,7 @@ class Query: const return_type = "Boolean" const is_array = false - ## Check eligibility for the custom-link variant of external purchase (iOS 18.1+). Returns true if the app can use custom external purchase links. Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/iseligible See: https://openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios + ## Check eligibility for the custom-link variant of external purchase (iOS 18.1+). Returns true if the app can use custom external purchase links. Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/iseligible See: https://openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios @deprecated Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0. class isEligibleForExternalPurchaseCustomLinkIOSField: const name = "isEligibleForExternalPurchaseCustomLinkIOS" const snake_name = "is_eligible_for_external_purchase_custom_link_ios" @@ -7346,7 +7346,7 @@ class Mutation: const return_type = "Boolean" const is_array = false - ## Present the external purchase notice sheet (iOS 17.4+). Uses ExternalPurchase.presentNoticeSheet() which returns a token when the user continues. Reference: https://developer.apple.com/documentation/storekit/externalpurchase/presentnoticesheet() See: https://openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios + ## Present the external purchase notice sheet (iOS 17.4+). Uses ExternalPurchase.presentNoticeSheet() which returns a token when the user continues. Reference: https://developer.apple.com/documentation/storekit/externalpurchase/presentnoticesheet() See: https://openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios @deprecated Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0. class presentExternalPurchaseNoticeSheetIOSField: const name = "presentExternalPurchaseNoticeSheetIOS" const snake_name = "present_external_purchase_notice_sheet_ios" @@ -7355,7 +7355,7 @@ class Mutation: const return_type = "ExternalPurchaseNoticeResultIOS" const is_array = false - ## Present an external purchase link, StoreKit External (iOS 16+). See: https://openiap.dev/docs/apis/ios/present-external-purchase-link-ios + ## Present an external purchase link, StoreKit External (iOS 16+). See: https://openiap.dev/docs/apis/ios/present-external-purchase-link-ios @deprecated Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0. class presentExternalPurchaseLinkIOSField: const name = "presentExternalPurchaseLinkIOS" const snake_name = "present_external_purchase_link_ios" @@ -7622,11 +7622,11 @@ static func is_external_purchase_link_available_args() -> Dictionary: static func get_promoted_product_ios_args() -> Dictionary: return {} -## Check eligibility for the external purchase notice sheet (iOS 17.4+). Uses ExternalPurchase.canPresent. See: https://openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios +## Check eligibility for the external purchase notice sheet (iOS 17.4+). Uses ExternalPurchase.canPresent. See: https://openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios @deprecated Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0. static func can_present_external_purchase_notice_ios_args() -> Dictionary: return {} -## Check eligibility for the custom-link variant of external purchase (iOS 18.1+). Returns true if the app can use custom external purchase links. Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/iseligible See: https://openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios +## Check eligibility for the custom-link variant of external purchase (iOS 18.1+). Returns true if the app can use custom external purchase links. Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/iseligible See: https://openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios @deprecated Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0. static func is_eligible_for_external_purchase_custom_link_ios_args() -> Dictionary: return {} @@ -7805,11 +7805,11 @@ static func begin_refund_request_ios_args(sku: String) -> Dictionary: static func sync_ios_args() -> Dictionary: return {} -## Present the external purchase notice sheet (iOS 17.4+). Uses ExternalPurchase.presentNoticeSheet() which returns a token when the user continues. Reference: https://developer.apple.com/documentation/storekit/externalpurchase/presentnoticesheet() See: https://openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios +## Present the external purchase notice sheet (iOS 17.4+). Uses ExternalPurchase.presentNoticeSheet() which returns a token when the user continues. Reference: https://developer.apple.com/documentation/storekit/externalpurchase/presentnoticesheet() See: https://openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios @deprecated Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0. static func present_external_purchase_notice_sheet_ios_args() -> Dictionary: return {} -## Present an external purchase link, StoreKit External (iOS 16+). See: https://openiap.dev/docs/apis/ios/present-external-purchase-link-ios +## Present an external purchase link, StoreKit External (iOS 16+). See: https://openiap.dev/docs/apis/ios/present-external-purchase-link-ios @deprecated Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0. static func present_external_purchase_link_ios_args(url: String) -> Dictionary: var args = {} args["url"] = url diff --git a/specs/client/src/generated/types.ts b/specs/client/src/generated/types.ts index 0b712af98..ea01521dd 100644 --- a/specs/client/src/generated/types.ts +++ b/specs/client/src/generated/types.ts @@ -922,6 +922,7 @@ export interface Mutation { /** * Present an external purchase link, StoreKit External (iOS 16+). * See: https://openiap.dev/docs/apis/ios/present-external-purchase-link-ios + * @deprecated Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0. */ presentExternalPurchaseLinkIOS: Promise; /** @@ -929,6 +930,7 @@ export interface Mutation { * Uses ExternalPurchase.presentNoticeSheet() which returns a token when the user continues. * Reference: https://developer.apple.com/documentation/storekit/externalpurchase/presentnoticesheet() * See: https://openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios + * @deprecated Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0. */ presentExternalPurchaseNoticeSheetIOS: Promise; /** @@ -1495,6 +1497,7 @@ export interface Query { * Check eligibility for the external purchase notice sheet (iOS 17.4+). * Uses ExternalPurchase.canPresent. * See: https://openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios + * @deprecated Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0. */ canPresentExternalPurchaseNoticeIOS: Promise; /** @@ -1586,6 +1589,7 @@ export interface Query { * Returns true if the app can use custom external purchase links. * Reference: https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/iseligible * See: https://openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios + * @deprecated Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0. */ isEligibleForExternalPurchaseCustomLinkIOS: Promise; /** diff --git a/specs/client/src/schema-deprecations.test.mjs b/specs/client/src/schema-deprecations.test.mjs index 681aa562a..bf75ceb75 100644 --- a/specs/client/src/schema-deprecations.test.mjs +++ b/specs/client/src/schema-deprecations.test.mjs @@ -111,11 +111,17 @@ type Query { expect(OPENIAP_REMOVAL_NOTICE_PATTERN.test(deprecations.entries[0].reason)).toBe(true); }); - it('contains no deprecations scheduled for the published 1.0.0 contract', () => { + it('contains only the deprecations scheduled for client protocol 2.0.0', () => { const deprecations = extractSchemaDeprecations(repositorySchemaSources()); expect(deprecations.issues).toEqual([]); - expect(deprecations.entries).toEqual([]); + expect(deprecations.entries.map((entry) => entry.ownerPath)).toEqual([ + 'Query.canPresentExternalPurchaseNoticeIOS', + 'Query.isEligibleForExternalPurchaseCustomLinkIOS', + 'Mutation.presentExternalPurchaseNoticeSheetIOS', + 'Mutation.presentExternalPurchaseLinkIOS', + ]); + expect(deprecations.entries.every((entry) => entry.reason.endsWith('client protocol 2.0.0.'))).toBe(true); expect(deprecations.typeReasons).toEqual(new Map()); expect(deprecations.operationArguments).toEqual([]); }); From e0ea8e61e521abda4c4bd900940b1921d1215179 Mon Sep 17 00:00:00 2001 From: Hyo Date: Sat, 14 Nov 2026 01:04:19 +0900 Subject: [PATCH 5/5] fix: keep deprecated iOS calls unchanged and expose the shared call on OpenIapStore - Revert the macOS NSWorkspace branch in presentExternalPurchaseLinkIOS so the deprecated call behaves as before. The StoreKit module now reports the shared call as unavailable on macOS and watchOS instead. - Add isExternalPurchaseLinkAvailable and openExternalPurchaseLink to OpenIapStore. - Docs: the availability query only checks; iOS 16 to 17.3 keeps the deprecated link call; macOS and watchOS are listed as unavailable. - Flutter example: check mounted before updating state after the link calls. Co-Authored-By: Claude Opus 5.5 --- .../screens/alternative_billing_screen.dart | 1 + .../lib/src/screens/builder_demo_screen.dart | 8 +++--- .../Sources/OpenIapExternalPurchaseLink.swift | 19 ++++++++------ packages/apple/Sources/OpenIapStore.swift | 8 ++++++ .../apple/Sources/OpenIapStoreKitModule.swift | 25 +++++++++++++------ .../ExternalPurchaseLinkTests.swift | 25 +++++++++++++++++++ ...n-present-external-purchase-notice-ios.tsx | 8 ++++-- ...-for-external-purchase-custom-link-ios.tsx | 8 ++++-- .../present-external-purchase-link-ios.tsx | 10 +++++--- .../is-external-purchase-link-available.tsx | 6 +++-- .../docs/apis/open-external-purchase-link.tsx | 9 +++++-- .../pages/docs/features/external-purchase.tsx | 6 +++-- .../docs/src/pages/docs/updates/releases.tsx | 5 ++-- 13 files changed, 103 insertions(+), 35 deletions(-) diff --git a/libraries/flutter_inapp_purchase/example/lib/src/screens/alternative_billing_screen.dart b/libraries/flutter_inapp_purchase/example/lib/src/screens/alternative_billing_screen.dart index 356943192..8afc437da 100644 --- a/libraries/flutter_inapp_purchase/example/lib/src/screens/alternative_billing_screen.dart +++ b/libraries/flutter_inapp_purchase/example/lib/src/screens/alternative_billing_screen.dart @@ -283,6 +283,7 @@ Report the token from your server. } } catch (e) { debugPrint('[iOS] Alternative billing error: $e'); + if (!mounted) return; setState(() => _purchaseResult = 'Error: $e'); _showAlert('Error', e.toString()); } finally { diff --git a/libraries/flutter_inapp_purchase/example/lib/src/screens/builder_demo_screen.dart b/libraries/flutter_inapp_purchase/example/lib/src/screens/builder_demo_screen.dart index d932a8583..0bc82fe6c 100644 --- a/libraries/flutter_inapp_purchase/example/lib/src/screens/builder_demo_screen.dart +++ b/libraries/flutter_inapp_purchase/example/lib/src/screens/builder_demo_screen.dart @@ -253,9 +253,9 @@ class _BuilderDemoScreenState extends State { setState(() => _status = 'Alternative billing not supported on web'); } } catch (e) { - setState(() => _status = 'Error: $e'); + if (mounted) setState(() => _status = 'Error: $e'); } finally { - setState(() => _isProcessing = false); + if (mounted) setState(() => _isProcessing = false); } } @@ -287,9 +287,9 @@ class _BuilderDemoScreenState extends State { setState(() => _status = 'Alternative billing not supported on web'); } } catch (e) { - setState(() => _status = 'Error: $e'); + if (mounted) setState(() => _status = 'Error: $e'); } finally { - setState(() => _isProcessing = false); + if (mounted) setState(() => _isProcessing = false); } } diff --git a/packages/apple/Sources/OpenIapExternalPurchaseLink.swift b/packages/apple/Sources/OpenIapExternalPurchaseLink.swift index dc90a9ae3..5041f8fa1 100644 --- a/packages/apple/Sources/OpenIapExternalPurchaseLink.swift +++ b/packages/apple/Sources/OpenIapExternalPurchaseLink.swift @@ -12,6 +12,13 @@ public extension OpenIapModuleProtocol { /// The URL opens only after the store disclosure continues. /// See: https://openiap.dev/docs/apis/open-external-purchase-link func openExternalPurchaseLink(_ request: ExternalPurchaseLinkRequest) async throws -> ExternalPurchaseLinkResult { + try await openExternalPurchaseLinkFlow(request) + } +} + +@available(iOS 15.0, macOS 14.0, tvOS 16.0, watchOS 8.0, *) +extension OpenIapModuleProtocol { + func openExternalPurchaseLinkFlow(_ request: ExternalPurchaseLinkRequest) async throws -> ExternalPurchaseLinkResult { let token: String switch try await externalPurchaseLinkRoute() { case nil: @@ -46,25 +53,21 @@ public extension OpenIapModuleProtocol { } } -private enum ExternalPurchaseLinkRoute { +enum ExternalPurchaseLinkRoute { case customLink case noticeSheet } @available(iOS 15.0, macOS 14.0, tvOS 16.0, watchOS 8.0, *) -private extension OpenIapModuleProtocol { +extension OpenIapModuleProtocol { // Prefer the iOS 18.1+ custom link; fall back to the iOS 17.4+ notice sheet. func externalPurchaseLinkRoute() async throws -> ExternalPurchaseLinkRoute? { - #if os(watchOS) - return nil // watchOS cannot open the link, so never show the disclosure. - #else if try await unlessUnsupported(isEligibleForExternalPurchaseCustomLinkIOS) { return .customLink } if try await unlessUnsupported(canPresentExternalPurchaseNoticeIOS) { return .noticeSheet } return nil - #endif } - func unlessUnsupported(_ check: () async throws -> Bool) async throws -> Bool { + private func unlessUnsupported(_ check: () async throws -> Bool) async throws -> Bool { do { return try await check() } catch let error as PurchaseError where error.code == .featureNotSupported { @@ -72,7 +75,7 @@ private extension OpenIapModuleProtocol { } } - func externalPurchaseLinkError(_ message: String) -> PurchaseError { + private func externalPurchaseLinkError(_ message: String) -> PurchaseError { PurchaseError.make(code: .serviceError, message: message) } } diff --git a/packages/apple/Sources/OpenIapStore.swift b/packages/apple/Sources/OpenIapStore.swift index c5303cc92..78699b92a 100644 --- a/packages/apple/Sources/OpenIapStore.swift +++ b/packages/apple/Sources/OpenIapStore.swift @@ -616,6 +616,14 @@ public final class OpenIapStore: ObservableObject { #endif // !os(tvOS) + public func isExternalPurchaseLinkAvailable() async throws -> Bool { + try await module.isExternalPurchaseLinkAvailable() + } + + public func openExternalPurchaseLink(_ request: ExternalPurchaseLinkRequest) async throws -> ExternalPurchaseLinkResult { + try await module.openExternalPurchaseLink(request) + } + @available(*, deprecated, message: "Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0.") public func canPresentExternalPurchaseNoticeIOS() async throws -> Bool { try await module.canPresentExternalPurchaseNoticeIOS() diff --git a/packages/apple/Sources/OpenIapStoreKitModule.swift b/packages/apple/Sources/OpenIapStoreKitModule.swift index 2d4e47721..aa35ef0cd 100644 --- a/packages/apple/Sources/OpenIapStoreKitModule.swift +++ b/packages/apple/Sources/OpenIapStoreKitModule.swift @@ -1631,7 +1631,7 @@ final class OpenIapStoreKitModule: NSObject, OpenIapModuleProtocol { /// See: https://openiap.dev/docs/apis/ios/present-external-purchase-link-ios public func presentExternalPurchaseLinkIOS(_ url: String) async throws -> ExternalPurchaseLinkResultIOS { try await ensureConnection() - // UIApplication.open on iOS/tvOS/visionOS, NSWorkspace on macOS; watchOS cannot open URLs. + // UIApplication.open is available on iOS/tvOS/visionOS but not watchOS/macOS // Reference: https://developer.apple.com/documentation/uikit/uiapplication/1648685-open #if os(iOS) || os(tvOS) || os(visionOS) guard let customLink = URL(string: url) else { @@ -1654,17 +1654,28 @@ final class OpenIapStoreKitModule: NSObject, OpenIapModuleProtocol { ) } } - #elseif os(macOS) - guard let customLink = URL(string: url) else { - return ExternalPurchaseLinkResultIOS(error: "Invalid URL", success: false) - } - let opened = await MainActor.run { NSWorkspace.shared.open(customLink) } - return ExternalPurchaseLinkResultIOS(error: opened ? nil : "Cannot open URL", success: opened) #else throw PurchaseError.make(code: .featureNotSupported) #endif // os(iOS) || os(tvOS) || os(visionOS) } + // The link opens with UIApplication, so other platforms never show the disclosure. + public func isExternalPurchaseLinkAvailable() async throws -> Bool { + #if os(iOS) || os(tvOS) || os(visionOS) + return try await externalPurchaseLinkRoute() != nil + #else + return false + #endif + } + + public func openExternalPurchaseLink(_ request: ExternalPurchaseLinkRequest) async throws -> ExternalPurchaseLinkResult { + #if os(iOS) || os(tvOS) || os(visionOS) + return try await openExternalPurchaseLinkFlow(request) + #else + return ExternalPurchaseLinkResult(platform: .ios, status: .unavailable) + #endif + } + // MARK: - ExternalPurchaseCustomLink (iOS 18.1+) /// Check eligibility for the custom-link variant of external purchase (iOS 18.1+). diff --git a/packages/apple/Tests/OpenIapTests/ExternalPurchaseLinkTests.swift b/packages/apple/Tests/OpenIapTests/ExternalPurchaseLinkTests.swift index 6488c0551..7d03f811a 100644 --- a/packages/apple/Tests/OpenIapTests/ExternalPurchaseLinkTests.swift +++ b/packages/apple/Tests/OpenIapTests/ExternalPurchaseLinkTests.swift @@ -17,6 +17,31 @@ final class ExternalPurchaseLinkTests: XCTestCase { XCTAssertEqual(module.calls, []) } + #if os(macOS) + func testStoreKitModuleIsUnavailableOnMacOS() async throws { + let module = OpenIapStoreKitModule() + + let available = try await module.isExternalPurchaseLinkAvailable() + let result = try await module.openExternalPurchaseLink(request) + + XCTAssertFalse(available) + XCTAssertEqual(result.status, .unavailable) + } + #endif + + @MainActor + func testStoreForwardsToItsModule() async throws { + let module = FakeExternalLinkModule(customLinkEligible: true, noticeContinues: true) + let store = OpenIapStore(module: module) + + let available = try await store.isExternalPurchaseLinkAvailable() + let result = try await store.openExternalPurchaseLink(request) + + XCTAssertTrue(available) + XCTAssertEqual(result.status, .opened) + XCTAssertEqual(module.calls, ["notice", "token:acquisition", "open:https://example.com/checkout"]) + } + func testUnsupportedProviderIsUnavailable() async throws { let module = FakeExternalLinkModule(unsupported: true) diff --git a/packages/docs/src/pages/docs/apis/ios/can-present-external-purchase-notice-ios.tsx b/packages/docs/src/pages/docs/apis/ios/can-present-external-purchase-notice-ios.tsx index 0f691dd6b..a8723c16a 100644 --- a/packages/docs/src/pages/docs/apis/ios/can-present-external-purchase-notice-ios.tsx +++ b/packages/docs/src/pages/docs/apis/ios/can-present-external-purchase-notice-ios.tsx @@ -26,8 +26,12 @@ function CanPresentExternalPurchaseNoticeIOS() { isExternalPurchaseLinkAvailable {' '} - instead. It runs the required store disclosure and opens the link in one - call. This API keeps working until client protocol 2.0.0. + instead. It only checks availability; then call{' '} + + openExternalPurchaseLink + + , which shows the disclosure and opens the link. This API keeps working + until client protocol 2.0.0.

Check if external purchase notice sheet can be presented (iOS 17.4+, diff --git a/packages/docs/src/pages/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios.tsx b/packages/docs/src/pages/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios.tsx index 803c6e9df..e5997ba3d 100644 --- a/packages/docs/src/pages/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios.tsx +++ b/packages/docs/src/pages/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios.tsx @@ -26,8 +26,12 @@ function IsEligibleForExternalPurchaseCustomLinkIOS() { isExternalPurchaseLinkAvailable {' '} - instead. It runs the required store disclosure and opens the link in one - call. This API keeps working until client protocol 2.0.0. + instead. It only checks availability; then call{' '} + + openExternalPurchaseLink + + , which shows the disclosure and opens the link. This API keeps working + until client protocol 2.0.0.

Check whether the app is eligible to use the{' '} diff --git a/packages/docs/src/pages/docs/apis/ios/present-external-purchase-link-ios.tsx b/packages/docs/src/pages/docs/apis/ios/present-external-purchase-link-ios.tsx index b7d0a9be6..a040be1d8 100644 --- a/packages/docs/src/pages/docs/apis/ios/present-external-purchase-link-ios.tsx +++ b/packages/docs/src/pages/docs/apis/ios/present-external-purchase-link-ios.tsx @@ -27,7 +27,8 @@ function PresentExternalPurchaseLinkIOS() { openExternalPurchaseLink {' '} instead. It runs the required store disclosure and opens the link in one - call. This API keeps working until client protocol 2.0.0. + call on iOS 17.4+. Keep this API for iOS 16 to 17.3; it works until + client protocol 2.0.0.

Open an external purchase URL outside the app (iOS 16+).

@@ -46,9 +47,10 @@ function PresentExternalPurchaseLinkIOS() { .

- - macOS opens the URL with NSWorkspace. watchOS returns a - feature-not-supported error. + + Not supported. The current OpenIAP Apple core implementation uses{' '} + UIApplication and returns a feature-not-supported error on + macOS.

Signature

diff --git a/packages/docs/src/pages/docs/apis/is-external-purchase-link-available.tsx b/packages/docs/src/pages/docs/apis/is-external-purchase-link-available.tsx index 45c17885f..26f3f2cff 100644 --- a/packages/docs/src/pages/docs/apis/is-external-purchase-link-available.tsx +++ b/packages/docs/src/pages/docs/apis/is-external-purchase-link-available.tsx @@ -42,8 +42,10 @@ function IsExternalPurchaseLinkAvailable() { enableBillingProgramAndroid and available to this user.
  • - Amazon, Meta Horizon, Vega OS: always{' '} - false. + + Amazon, Meta Horizon, Vega OS, macOS, watchOS, iOS before 17.4: + {' '} + always false.
  • diff --git a/packages/docs/src/pages/docs/apis/open-external-purchase-link.tsx b/packages/docs/src/pages/docs/apis/open-external-purchase-link.tsx index aff554c54..cbdb4d377 100644 --- a/packages/docs/src/pages/docs/apis/open-external-purchase-link.tsx +++ b/packages/docs/src/pages/docs/apis/open-external-purchase-link.tsx @@ -47,8 +47,13 @@ function OpenExternalPurchaseLink() { Play's dialog before opening the link.
  • - Amazon, Meta Horizon, Vega OS: returns{' '} - unavailable without showing anything. + + Amazon, Meta Horizon, Vega OS, macOS, watchOS, iOS before 17.4: + {' '} + returns unavailable without showing anything. An app that + still links out on iOS 16 to 17.3 keeps the deprecated{' '} + presentExternalPurchaseLinkIOS for those versions until + client protocol 2.0.0.
  • diff --git a/packages/docs/src/pages/docs/features/external-purchase.tsx b/packages/docs/src/pages/docs/features/external-purchase.tsx index 0754c1ec1..04d1dbb59 100644 --- a/packages/docs/src/pages/docs/features/external-purchase.tsx +++ b/packages/docs/src/pages/docs/features/external-purchase.tsx @@ -120,8 +120,10 @@ function ExternalPurchase() { external-offer.
  • - Amazon, Meta Horizon, Vega OS: always{' '} - unavailable. + + Amazon, Meta Horizon, Vega OS, macOS, watchOS, iOS before 17.4 + + : always unavailable.
  • {`import { diff --git a/packages/docs/src/pages/docs/updates/releases.tsx b/packages/docs/src/pages/docs/updates/releases.tsx index 19010363c..8cc8f5ba5 100644 --- a/packages/docs/src/pages/docs/updates/releases.tsx +++ b/packages/docs/src/pages/docs/updates/releases.tsx @@ -923,8 +923,9 @@ function Releases() { openiap-apple 4.2.0: uses{' '} ExternalPurchaseCustomLink on iOS 18.1 or later and the ExternalPurchase notice sheet on iOS 17.4 to - 18.0. A custom provider gets the same flow from its existing - external purchase methods. + 18.0. macOS and watchOS return unavailable. A custom + provider gets the same flow from its existing external purchase + methods.
  • openiap-google 4.2.0: uses Play's external