diff --git a/.github/workflows/ci-kmp-iap.yml b/.github/workflows/ci-kmp-iap.yml index bac74e3c0..8ce5871e3 100644 --- a/.github/workflows/ci-kmp-iap.yml +++ b/.github/workflows/ci-kmp-iap.yml @@ -118,4 +118,4 @@ jobs: uses: ./.github/actions/setup-xcode - name: Verify SwiftPM library, Xcode app, and Maven consumer without CocoaPods - run: bash scripts/verify-swiftpm-build.sh + run: bash scripts/verify-swiftpm-build.sh --allow-unreleased-apple 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/__tests__/alternative-billing.test.tsx b/libraries/expo-iap/example/__tests__/alternative-billing.test.tsx index 1f1ccea6d..2494befdb 100644 --- a/libraries/expo-iap/example/__tests__/alternative-billing.test.tsx +++ b/libraries/expo-iap/example/__tests__/alternative-billing.test.tsx @@ -82,6 +82,48 @@ describe('AlternativeBilling Component', () => { expect(getByText('Not supported on Amazon Vega')).toBeDefined(); }); + it('opens the iOS external purchase link through the shared API', async () => { + Object.defineProperty(Platform, 'OS', { + get: jest.fn(() => 'ios'), + configurable: true, + }); + jest + .mocked(ExpoIap.isExternalPurchaseLinkAvailable) + .mockResolvedValueOnce(true); + jest.mocked(ExpoIap.openExternalPurchaseLink).mockResolvedValueOnce({ + platform: 'ios', + status: 'opened', + reportToken: 'external-purchase-token', + }); + const {getByText} = await render(); + + await fireEvent.press(getByText('Test Consumable')); + await fireEvent.press(getByText('🛒 Buy (External URL)')); + + expect(ExpoIap.openExternalPurchaseLink).toHaveBeenCalledWith({ + url: 'https://openiap.dev', + }); + expect(getByText(/External purchase link opened/)).toBeTruthy(); + expect(getByText(/Report token: Present/)).toBeTruthy(); + }); + + it('skips the iOS link when the storefront is not eligible', async () => { + Object.defineProperty(Platform, 'OS', { + get: jest.fn(() => 'ios'), + configurable: true, + }); + jest + .mocked(ExpoIap.isExternalPurchaseLinkAvailable) + .mockResolvedValueOnce(false); + const {getByText} = await render(); + + await fireEvent.press(getByText('Test Consumable')); + await fireEvent.press(getByText('🛒 Buy (External URL)')); + + expect(ExpoIap.openExternalPurchaseLink).not.toHaveBeenCalled(); + expect(getByText(/not available for this app or storefront/)).toBeTruthy(); + }); + it('switches the billing flow only after native applies its program', async () => { Object.defineProperty(Platform, 'OS', { get: jest.fn(() => 'android'), diff --git a/libraries/expo-iap/example/app/alternative-billing.tsx b/libraries/expo-iap/example/app/alternative-billing.tsx index 500287a0c..f01663dc0 100644 --- a/libraries/expo-iap/example/app/alternative-billing.tsx +++ b/libraries/expo-iap/example/app/alternative-billing.tsx @@ -14,7 +14,8 @@ import { useIAP, requestPurchase, initConnection, - presentExternalPurchaseLinkIOS, + isExternalPurchaseLinkAvailable, + openExternalPurchaseLink, isBillingProgramAvailableAndroid, launchExternalLinkAndroid, createBillingProgramReportingDetailsAndroid, @@ -36,11 +37,12 @@ import { * * Demonstrates alternative billing flows for iOS and Android: * - * iOS (Alternative Billing): - * - Redirects users to external website configured in app.config.ts - * - No onPurchaseUpdated callback when using external URL - * - User completes purchase on external website - * - Must implement deep link to return to app + * iOS (External Purchase Link - 17.4+): + * - Enable the external purchase entitlement in app.config.ts + * - Check eligibility with isExternalPurchaseLinkAvailable() + * - openExternalPurchaseLink() shows Apple's disclosure, then opens the URL + * - No onPurchaseUpdated callback; the purchase completes on your website + * - Report the returned reportToken from your server * * Android (Billing Programs API - 8.2.0+): * - Step 1: Check availability with isBillingProgramAvailableAndroid() @@ -152,6 +154,7 @@ function AlternativeBillingScreen() { 'external-payments': 'External Payments', 'user-choice-billing': 'User Choice Billing', 'billing-choice': 'Billing Choice', + 'alternative-billing-only': 'Alternative Billing Only', }; setPurchaseResult( @@ -170,13 +173,9 @@ function AlternativeBillingScreen() { [fetchProducts], ); - // Handle iOS alternative billing purchase (external URL) + // Handle iOS external purchase link const handleIOSAlternativeBillingPurchase = useCallback( async (product: Product) => { - console.log('[iOS] Starting alternative billing purchase:', product.id); - console.log('[iOS] External URL:', externalUrl); - console.log('[iOS] Platform.Version:', Platform.Version); - if (!externalUrl || externalUrl.trim() === '') { Alert.alert('Error', 'Please enter a valid external purchase URL'); return; @@ -186,24 +185,32 @@ function AlternativeBillingScreen() { setPurchaseResult('🌐 Opening external purchase link...'); try { - // Use StoreKit External Purchase Link API - const result = await presentExternalPurchaseLinkIOS(externalUrl); - console.log('[iOS] External purchase link result:', result); - - if (result.error) { - setPurchaseResult(`❌ Error: ${result.error}`); - Alert.alert('Error', result.error); - } else if (result.success) { + if (!(await isExternalPurchaseLinkAvailable())) { setPurchaseResult( - `✅ External purchase link opened successfully\n\nProduct: ${product.id}\nURL: ${externalUrl}\n\nUser was redirected to external website.\n\nNote: Complete purchase on your website and implement server-side validation.`, + '❌ External purchase links are not available for this app or storefront', ); - Alert.alert( - 'Redirected', - 'User was redirected to your external purchase website. Complete the purchase there.', + return; + } + + // Shows Apple's disclosure sheet first, then opens the URL. + const result = await openExternalPurchaseLink({url: externalUrl}); + console.log('[iOS] External purchase link status:', result.status); + + if (result.status === 'opened') { + setPurchaseResult( + `✅ External purchase link opened\n\nProduct: ${ + product.id + }\nURL: ${externalUrl}\nReport token: ${ + result.reportToken ? 'Present' : 'Missing' + }\n\nNote: Complete purchase on your website and report the token from your server.`, ); + } else if (result.status === 'cancelled') { + setPurchaseResult('User cancelled the external purchase link'); + } else { + setPurchaseResult('❌ External purchase link unavailable'); } } catch (error: any) { - console.log('[iOS] Alternative billing error:', error); + console.log('[iOS] External purchase link error:', error); setPurchaseResult(`❌ Error: ${error.message}`); Alert.alert('Error', error.message); } finally { @@ -356,7 +363,7 @@ function AlternativeBillingScreen() { {isVega ? 'Not supported on Amazon Vega' : Platform.OS === 'ios' - ? 'External purchase links (iOS 16.0+)' + ? 'External purchase links (iOS 17.4+)' : 'Google Play alternative billing'} @@ -382,14 +389,15 @@ function AlternativeBillingScreen() { <> • Enter your external purchase URL{'\n'}• Tap Purchase on any - product{'\n'}• User will be redirected to the external URL{'\n'} - • Complete purchase on your website{'\n'}• No onPurchaseUpdated - callback{'\n'}• Implement deep link to return to app + product{'\n'}• The App Store disclosure sheet appears, then the + URL opens{'\n'}• Complete purchase on your website{'\n'}• No + onPurchaseUpdated callback{'\n'}• Report the returned token from + your server - ⚠️ iOS 16.0+ required{'\n'} + ⚠️ iOS 17.4+ required{'\n'} ⚠️ Valid external URL needed{'\n'} - ⚠️ Billing program mode is enabled + ⚠️ External purchase entitlement must be enabled ) : ( diff --git a/libraries/expo-iap/example/jest.setup.js b/libraries/expo-iap/example/jest.setup.js index b3643391a..72e0f0735 100644 --- a/libraries/expo-iap/example/jest.setup.js +++ b/libraries/expo-iap/example/jest.setup.js @@ -101,15 +101,16 @@ jest.mock('expo-iap', () => { getReceiptDataIOS: jest.fn(), isTransactionVerifiedIOS: jest.fn(), getTransactionJwsIOS: jest.fn(), - presentExternalPurchaseLinkIOS: jest.fn(() => - Promise.resolve({success: true}), - ), getAppTransactionIOS: jest.fn(), // Cross-platform storefront helper 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..26308c1a5 100644 --- a/libraries/expo-iap/src/index.kepler.ts +++ b/libraries/expo-iap/src/index.kepler.ts @@ -307,6 +307,7 @@ export const getPromotedProductIOS: QueryField< export const showManageSubscriptionsIOS: MutationField< 'showManageSubscriptionsIOS' > = async () => []; +/** @deprecated Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0. */ export const presentExternalPurchaseLinkIOS: MutationField< 'presentExternalPurchaseLinkIOS' > = async () => unsupported('presentExternalPurchaseLinkIOS'); @@ -318,6 +319,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/modules/ios.ts b/libraries/expo-iap/src/modules/ios.ts index 0257af073..8aa5d20d4 100644 --- a/libraries/expo-iap/src/modules/ios.ts +++ b/libraries/expo-iap/src/modules/ios.ts @@ -392,6 +392,7 @@ export const deepLinkToSubscriptionsIOS = async (): Promise => { * Check if the device can present an external purchase notice sheet (iOS 17.4+). * Wraps `ExternalPurchase.canPresent`. * + * @deprecated Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0. * @returns Promise resolving to true if the notice sheet can be presented * @platform iOS * @@ -411,6 +412,7 @@ export const canPresentExternalPurchaseNoticeIOS: QueryField< * This must be called before opening an external purchase link. * Returns the external purchase token when user continues. * + * @deprecated Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0. * @returns Promise resolving to the result with action, token, and error if any * @platform iOS * @@ -429,6 +431,7 @@ export const presentExternalPurchaseNoticeSheetIOS: MutationField< /** * Present an external purchase link to redirect users to your website (iOS 16.0+). * + * @deprecated Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0. * @param url - The external purchase URL to open * @returns Promise resolving to the result with success status and error if any * @platform iOS @@ -448,6 +451,7 @@ export const presentExternalPurchaseLinkIOS: MutationField< /** * Check if app is eligible for ExternalPurchaseCustomLink API (iOS 18.1+). * + * @deprecated Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0. * @returns Promise resolving to true if eligible * @platform iOS * @see https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/iseligible diff --git a/libraries/expo-iap/src/types.ts b/libraries/expo-iap/src/types.ts index f6ed9d9e5..ea01521dd 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; @@ -852,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; /** @@ -859,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; /** @@ -955,6 +1027,8 @@ export type MutationIsBillingProgramAvailableAndroidArgs = BillingProgramAndroid export type MutationLaunchExternalLinkAndroidArgs = LaunchExternalLinkParamsAndroid; +export type MutationOpenExternalPurchaseLinkArgs = ExternalPurchaseLinkRequest; + export type MutationPresentExternalPurchaseLinkIosArgs = string; export type MutationRequestPurchaseArgs = RequestPurchaseProps; @@ -1423,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; /** @@ -1514,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; /** @@ -1521,6 +1597,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 +2455,7 @@ export type MutationArgsMap = { initConnection: MutationInitConnectionArgs; isBillingProgramAvailableAndroid: MutationIsBillingProgramAvailableAndroidArgs; launchExternalLinkAndroid: MutationLaunchExternalLinkAndroidArgs; + openExternalPurchaseLink: MutationOpenExternalPurchaseLinkArgs; openRedeemOfferCode: never; presentExternalPurchaseLinkIOS: MutationPresentExternalPurchaseLinkIosArgs; presentExternalPurchaseNoticeSheetIOS: never; @@ -2416,6 +2502,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/example/lib/src/screens/alternative_billing_screen.dart b/libraries/flutter_inapp_purchase/example/lib/src/screens/alternative_billing_screen.dart index ce322ea35..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 @@ -10,10 +10,10 @@ import '../constants.dart'; /// /// Demonstrates alternative billing flows for iOS and Android: /// -/// iOS (Alternative Billing): -/// - Redirects users to external website -/// - No onPurchaseUpdated callback when using external URL -/// - User completes purchase on external website +/// iOS (External Purchase Link): +/// - Check isExternalPurchaseLinkAvailable(), then openExternalPurchaseLink() +/// - Apple's disclosure shows first; the URL opens only if the user continues +/// - No onPurchaseUpdated callback; report the returned token from your server /// - Must implement deep link to return to app /// /// Android (Billing Programs API - Recommended for 8.2.0+): @@ -239,34 +239,51 @@ Important: }); try { - final result = await FlutterInappPurchase.instance - .presentExternalPurchaseLinkIOS(url); + final iap = FlutterInappPurchase.instance; + final available = await iap.isExternalPurchaseLinkAvailable(); + if (!mounted) return; + if (!available) { + setState(() => _purchaseResult = 'External purchase link unavailable'); + _showAlert('Not Available', 'External purchase links are unavailable.'); + return; + } - debugPrint('[iOS] External purchase link result: $result'); + // Shows Apple's disclosure first; opens the URL only if the user continues. + final result = await iap.openExternalPurchaseLink( + ExternalPurchaseLinkRequest(url: url), + ); + + debugPrint('[iOS] External purchase link status: ${result.status.name}'); if (!mounted) return; - if (result.error != null) { - setState(() => _purchaseResult = 'Error: ${result.error}'); - _showAlert('Error', result.error!); - } else if (result.success) { - setState(() { - _purchaseResult = ''' + switch (result.status) { + case ExternalPurchaseLinkStatus.Opened: + setState(() { + _purchaseResult = ''' External purchase link opened Product: ${product.id} URL: $url +Token: ${result.reportToken != null ? 'Present' : 'Missing'} -User redirected to external website. +Report the token from your server. '''; - }); - _showAlert( - 'Redirected', - 'User was redirected to your external purchase website.', - ); + }); + _showAlert( + 'Redirected', + 'User was redirected to your external purchase website.', + ); + case ExternalPurchaseLinkStatus.Cancelled: + setState(() => _purchaseResult = 'User cancelled external purchase'); + case ExternalPurchaseLinkStatus.Unavailable: + setState( + () => _purchaseResult = 'External purchase link unavailable', + ); } } catch (e) { debugPrint('[iOS] Alternative billing error: $e'); + if (!mounted) return; setState(() => _purchaseResult = 'Error: $e'); _showAlert('Error', e.toString()); } finally { @@ -735,7 +752,7 @@ If user selects: String infoText; if (isIOS) { infoText = - '- Enter your external purchase URL\n- Tap Purchase on any product\n- User will be redirected to the external URL\n- Complete purchase on your website'; + '- Enter your external purchase URL\n- Tap Purchase on any product\n- Apple shows its disclosure, then opens the URL\n- Report the returned token from your server'; } else if (_androidBillingMode == 'billing-programs') { if (_billingProgram == BillingProgramAndroid.UserChoiceBilling) { infoText = @@ -779,7 +796,7 @@ If user selects: const SizedBox(height: 8), Text( isIOS - ? 'iOS 16.0+ required\nValid external URL needed' + ? 'iOS 17.4+ required\nValid external URL needed' : 'Requires approval from Google\nMust report tokens within 24 hours', style: TextStyle(fontSize: 12, color: Colors.deepOrange[700]), ), 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 77c0a449b..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 @@ -201,6 +201,30 @@ class _BuilderDemoScreenState extends State { } } + // Shows Apple's disclosure first; opens the URL only if the user continues. + Future _openExternalPurchaseLinkIOS() async { + final available = await _iap.isExternalPurchaseLinkAvailable(); + if (!mounted) return; + if (!available) { + setState(() => _status = 'External purchase link unavailable'); + return; + } + final result = await _iap.openExternalPurchaseLink( + const ExternalPurchaseLinkRequest(url: 'https://openiap.dev'), + ); + if (!mounted) return; + setState( + () => _status = switch (result.status) { + ExternalPurchaseLinkStatus.Opened => + 'External purchase link opened successfully', + ExternalPurchaseLinkStatus.Cancelled => + 'User cancelled external purchase', + ExternalPurchaseLinkStatus.Unavailable => + 'External purchase link unavailable', + }, + ); + } + Future _alternativeBillingPurchase() async { setState(() { _isProcessing = true; @@ -209,20 +233,7 @@ class _BuilderDemoScreenState extends State { try { if (!kIsWeb && defaultTargetPlatform == TargetPlatform.iOS) { - // iOS: Use external purchase link - final result = await _iap.presentExternalPurchaseLinkIOS( - 'https://openiap.dev', - ); - - if (result.error != null) { - setState(() => _status = 'Error: ${result.error}'); - } else if (result.success) { - setState( - () => _status = 'External purchase link opened successfully', - ); - } else { - setState(() => _status = 'User cancelled external purchase'); - } + await _openExternalPurchaseLinkIOS(); } else if (!kIsWeb) { // Android: use the current Billing Programs request shape. await _iap.requestPurchaseWithBuilder( @@ -242,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); } } @@ -256,20 +267,7 @@ class _BuilderDemoScreenState extends State { try { if (!kIsWeb && defaultTargetPlatform == TargetPlatform.iOS) { - // iOS: Use external purchase link - final result = await _iap.presentExternalPurchaseLinkIOS( - 'https://openiap.dev', - ); - - if (result.error != null) { - setState(() => _status = 'Error: ${result.error}'); - } else if (result.success) { - setState( - () => _status = 'External purchase link opened successfully', - ); - } else { - setState(() => _status = 'User cancelled external purchase'); - } + await _openExternalPurchaseLinkIOS(); } else if (!kIsWeb) { // Android: use the current Billing Programs request shape. await _iap.requestPurchaseWithBuilder( @@ -289,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/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..4ef447812 100644 --- a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart +++ b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart @@ -1592,6 +1592,8 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { /// Check eligibility for the external purchase notice sheet (iOS 17.4+, macOS 14.4+). /// /// 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.') gentype.QueryCanPresentExternalPurchaseNoticeIOSHandler get canPresentExternalPurchaseNoticeIOS => () async { if (!isIOS) { @@ -2837,9 +2839,71 @@ 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 + @Deprecated( + 'Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0.') gentype.MutationPresentExternalPurchaseNoticeSheetIOSHandler get presentExternalPurchaseNoticeSheetIOS => () async { if (!isIOS) { @@ -2878,6 +2942,8 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { /// Present an external purchase link (iOS 16+; unsupported on macOS). /// /// 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.') gentype.MutationPresentExternalPurchaseLinkIOSHandler get presentExternalPurchaseLinkIOS => (String url) async { if (!_platform.isIOS || _platform.isMacOS) { @@ -2919,6 +2985,8 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { /// Check eligibility for ExternalPurchaseCustomLink (iOS 18.1+, macOS 15.1+). /// /// 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.') gentype.QueryIsEligibleForExternalPurchaseCustomLinkIOSHandler get isEligibleForExternalPurchaseCustomLinkIOS => () async { if (!_platform.isIOS && !_platform.isMacOS) { @@ -3087,6 +3155,7 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { isEligibleForExternalPurchaseCustomLinkIOS: isEligibleForExternalPurchaseCustomLinkIOS, isEligibleForIntroOfferIOS: isEligibleForIntroOfferIOS, + isExternalPurchaseLinkAvailable: isExternalPurchaseLinkAvailable, isTransactionVerifiedIOS: isTransactionVerifiedIOS, latestTransactionIOS: latestTransactionIOS, subscriptionStatusIOS: subscriptionStatusIOS, @@ -3167,6 +3236,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..1d2dfa9bc 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. @@ -6232,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. @@ -6306,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 @@ -6379,10 +6551,19 @@ 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 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 +6658,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 +6698,7 @@ class MutationHandlers { this.initConnection, this.isBillingProgramAvailableAndroid, this.launchExternalLinkAndroid, + this.openExternalPurchaseLink, this.openRedeemOfferCode, this.presentExternalPurchaseLinkIOS, this.presentExternalPurchaseNoticeSheetIOS, @@ -6542,8 +6725,11 @@ class MutationHandlers { final MutationInitConnectionHandler? initConnection; final MutationIsBillingProgramAvailableAndroidHandler? isBillingProgramAvailableAndroid; 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; @@ -6587,6 +6773,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,12 +6801,14 @@ class QueryHandlers { this.hasActiveSubscriptions, this.isEligibleForExternalPurchaseCustomLinkIOS, this.isEligibleForIntroOfferIOS, + this.isExternalPurchaseLinkAvailable, this.isTransactionVerifiedIOS, this.latestTransactionIOS, this.subscriptionStatusIOS, this.verifyAppOwnership, }); + @Deprecated('Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0.') final QueryCanPresentExternalPurchaseNoticeIOSHandler? canPresentExternalPurchaseNoticeIOS; final QueryCurrentEntitlementIOSHandler? currentEntitlementIOS; final QueryFetchProductsHandler? fetchProducts; @@ -6635,8 +6824,10 @@ 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; 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..54c1520d6 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,183 @@ 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( + 'isExternalPurchaseLinkAvailable 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: 'ios'), + ); + + await expectLater( + iap.isExternalPurchaseLinkAvailable(), + throwsA( + isA().having( + (error) => error.code, + 'code', + types.ErrorCode.ServiceError, + ), + ), + ); + }); + + test('openExternalPurchaseLink rejects an empty native result', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall call) async => null); + + 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..69bd379d0 100644 --- a/libraries/godot-iap/addons/godot-iap/godot_iap.gd +++ b/libraries/godot-iap/addons/godot-iap/godot_iap.gd @@ -1878,6 +1878,7 @@ func get_promoted_product_ios() -> Variant: ## @return bool - true if external purchase notice can be presented ## ## See: https://openiap.dev/docs/apis/ios/can-present-external-purchase-notice-ios +## @deprecated: Use is_external_purchase_link_available instead. Scheduled for removal in client protocol 2.0.0. func can_present_external_purchase_notice_ios() -> bool: if _native_plugin and _platform == "iOS": var payload = await _call_native_async("canPresentExternalPurchaseNoticeIOS") @@ -1888,6 +1889,7 @@ func can_present_external_purchase_notice_ios() -> bool: ## @return Types.ExternalPurchaseNoticeResultIOS ## ## See: https://openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios +## @deprecated: Use open_external_purchase_link instead. Scheduled for removal in client protocol 2.0.0. func present_external_purchase_notice_sheet_ios() -> Variant: # A failure must never read as CONTINUE, the enum's zero value. var failed = Types.ExternalPurchaseNoticeResultIOS.new() @@ -1915,6 +1917,7 @@ func present_external_purchase_notice_sheet_ios() -> Variant: ## @return Types.ExternalPurchaseLinkResultIOS ## ## See: https://openiap.dev/docs/apis/ios/present-external-purchase-link-ios +## @deprecated: Use open_external_purchase_link instead. Scheduled for removal in client protocol 2.0.0. func present_external_purchase_link_ios(url: String) -> Variant: if _native_plugin and _platform == "iOS": var payload = await _call_native_async( @@ -2144,6 +2147,7 @@ func _parse_request_id(pending_json) -> String: ## @return bool true if the current context can show external purchase custom link ## ## See: https://openiap.dev/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios +## @deprecated: Use is_external_purchase_link_available instead. Scheduled for removal in client protocol 2.0.0. func is_eligible_for_external_purchase_custom_link_ios() -> bool: if not (_native_plugin and _platform == "iOS"): return false @@ -2489,6 +2493,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..a7e548dfa 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" @@ -6727,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" @@ -6736,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" @@ -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" @@ -7130,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" @@ -7139,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" @@ -7398,15 +7614,19 @@ 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 {} -## 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 {} @@ -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 = {} @@ -7575,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/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/example/composeApp/src/commonMain/kotlin/dev/hyo/martie/screens/AlternativeBillingScreen.kt b/libraries/kmp-iap/example/composeApp/src/commonMain/kotlin/dev/hyo/martie/screens/AlternativeBillingScreen.kt index 443787efb..24984083c 100644 --- a/libraries/kmp-iap/example/composeApp/src/commonMain/kotlin/dev/hyo/martie/screens/AlternativeBillingScreen.kt +++ b/libraries/kmp-iap/example/composeApp/src/commonMain/kotlin/dev/hyo/martie/screens/AlternativeBillingScreen.kt @@ -243,47 +243,33 @@ fun AlternativeBillingScreen(navController: NavController) { isProcessing = true try { - // Present the notice sheet first when available (iOS 17.4+) - if (kmpIapInstance.canPresentExternalPurchaseNoticeIOS()) { - purchaseResult = "📋 Presenting external purchase notice..." - - val noticeResult = kmpIapInstance.presentExternalPurchaseNoticeSheetIOS() - - if (noticeResult.error != null) { - purchaseResult = "❌ Notice error: ${noticeResult.error}" - isProcessing = false - return@launch - } - - if (noticeResult.result == ExternalPurchaseNoticeAction.Dismissed) { - purchaseResult = "ℹ️ User dismissed external purchase notice" - isProcessing = false - return@launch - } - - // User chose to continue + if (!kmpIapInstance.isExternalPurchaseLinkAvailable()) { + purchaseResult = "❌ External purchase links are not available" + return@launch } purchaseResult = "🌐 Opening external purchase link..." - val result = kmpIapInstance.presentExternalPurchaseLinkIOS(externalUrl) + // Shows the App Store disclosure first, then opens the URL + val result = kmpIapInstance.openExternalPurchaseLink( + ExternalPurchaseLinkRequest(url = externalUrl) + ) - purchaseResult = if (result.error?.contains("opened in Safari") == true) { - """ - 🌐 External purchase link opened in Safari + purchaseResult = when (result.status) { + ExternalPurchaseLinkStatus.Opened -> """ + 🌐 External purchase link opened Product: ${product.id} URL: $externalUrl + Report token: ${result.reportToken ?: "none"} ⚠️ Important: - - User was redirected to external website - - Complete purchase on your website - - Implement server-side validation - - Use deep linking to return user to app - - Purchase completion must be verified via backend + - Complete the purchase on your website + - Report the token from your server + - Use deep linking to return the user to the app """.trimIndent() - } else { - "❌ Error: ${result.error}" + ExternalPurchaseLinkStatus.Cancelled -> "ℹ️ User dismissed the external purchase notice" + ExternalPurchaseLinkStatus.Unavailable -> "❌ External purchase links are not available" } } catch (e: Exception) { purchaseResult = "❌ Error: ${e.message}" 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..849b6d634 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 @@ -469,10 +483,14 @@ internal class OpenIapDelegateInAppPurchaseAndroid( override suspend fun showManageSubscriptionsIOS(): List = emptyList() override suspend fun syncIOS(): Boolean = false override suspend fun clearTransactionIOS(): Boolean = false + @Deprecated("Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0.") override suspend fun presentExternalPurchaseLinkIOS(url: String): ExternalPurchaseLinkResultIOS = failUnsupported("External purchase links are iOS only.") + @Deprecated("Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0.") override suspend fun presentExternalPurchaseNoticeSheetIOS(): ExternalPurchaseNoticeResultIOS = failUnsupported("External purchase notice sheet is iOS only.") override suspend fun showExternalPurchaseCustomLinkNoticeIOS(noticeType: ExternalPurchaseCustomLinkNoticeTypeIOS): ExternalPurchaseCustomLinkNoticeResultIOS = failUnsupported("External purchase custom-link notice is iOS only.") + @Deprecated("Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0.") override suspend fun canPresentExternalPurchaseNoticeIOS(): Boolean = false + @Deprecated("Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0.") override suspend fun isEligibleForExternalPurchaseCustomLinkIOS(): Boolean = false override suspend fun getExternalPurchaseCustomLinkTokenIOS(tokenType: ExternalPurchaseCustomLinkTokenTypeIOS): ExternalPurchaseCustomLinkTokenResultIOS = failUnsupported("External purchase custom-link token is iOS only.") override suspend fun isEligibleForIntroOfferIOS(groupID: String): Boolean = false 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..fb22d17c9 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; @@ -6222,14 +6399,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. @@ -6310,7 +6491,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+). @@ -6402,13 +6585,24 @@ 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. * 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 +6696,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 +6792,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; @@ -6609,14 +6814,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. @@ -6702,6 +6911,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 +6925,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 +6940,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 +6966,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 +6991,7 @@ public class MutationHandlers( initConnection = initConnection, isBillingProgramAvailableAndroid = isBillingProgramAvailableAndroid, launchExternalLinkAndroid = launchExternalLinkAndroid, + openExternalPurchaseLink = openExternalPurchaseLink, openRedeemOfferCode = openRedeemOfferCode, presentExternalPurchaseLinkIOS = presentExternalPurchaseLinkIOS, presentExternalPurchaseNoticeSheetIOS = presentExternalPurchaseNoticeSheetIOS, @@ -6795,7 +7008,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 +7022,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 +7038,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 +7060,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 @@ -6856,7 +7071,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+). @@ -6948,13 +7165,24 @@ 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. * 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 +7226,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 +7252,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 +7275,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 +7297,7 @@ public class QueryHandlers( hasActiveSubscriptions = hasActiveSubscriptions, isEligibleForExternalPurchaseCustomLinkIOS = isEligibleForExternalPurchaseCustomLinkIOS, isEligibleForIntroOfferIOS = isEligibleForIntroOfferIOS, + isExternalPurchaseLinkAvailable = isExternalPurchaseLinkAvailable, isTransactionVerifiedIOS = isTransactionVerifiedIOS, latestTransactionIOS = latestTransactionIOS, subscriptionStatusIOS = subscriptionStatusIOS, @@ -7073,7 +7305,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 +7325,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..8c910eeb7 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+). * @@ -768,6 +795,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { * * @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.") override suspend fun isEligibleForExternalPurchaseCustomLinkIOS(): Boolean = isAtLeastIOS(18, 1) && suspendCancellableCoroutine { continuation -> openIapModule.isEligibleForExternalPurchaseCustomLinkIOSWithCompletion { isEligible, error -> @@ -1419,6 +1447,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { * * @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.") override suspend fun presentExternalPurchaseLinkIOS(url: String): ExternalPurchaseLinkResultIOS = if (!isAtLeastIOS(16)) { ExternalPurchaseLinkResultIOS(success = false, error = "Requires iOS 16.0 or later") @@ -1449,6 +1478,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { * * @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.") override suspend fun presentExternalPurchaseNoticeSheetIOS(): ExternalPurchaseNoticeResultIOS = if (!isAtLeastIOS(17, 4)) { ExternalPurchaseNoticeResultIOS(result = ExternalPurchaseNoticeAction.Dismissed, error = "Requires iOS 17.4 or later") @@ -1484,6 +1514,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { * * @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.") override suspend fun canPresentExternalPurchaseNoticeIOS(): Boolean = isAtLeastIOS(17, 4) && suspendCancellableCoroutine { continuation -> openIapModule.canPresentExternalPurchaseNoticeIOSWithCompletion { canPresent, error -> diff --git a/libraries/kmp-iap/scripts/verify-swiftpm-build.sh b/libraries/kmp-iap/scripts/verify-swiftpm-build.sh index 048e70181..0cb0b27b5 100755 --- a/libraries/kmp-iap/scripts/verify-swiftpm-build.sh +++ b/libraries/kmp-iap/scripts/verify-swiftpm-build.sh @@ -29,6 +29,19 @@ xcodebuild -project example/iosApp/iosApp.xcodeproj -scheme iosApp \ -derivedDataPath "$verification_dir/DerivedData" \ CODE_SIGNING_ALLOWED=NO ARCHS=arm64 build +# PR CI passes --allow-unreleased-apple: when packages/apple differs from the +# pinned release, the published consumer cannot build yet. The release +# workflow omits the flag, so it always runs the published check. +apple_version="$(python3 -c 'import json; print(json.load(open("openiap-versions.json"))["apple"])')" +if [ "${1:-}" = "--allow-unreleased-apple" ]; then + git fetch --quiet --no-tags --depth=1 origin "refs/tags/${apple_version}:refs/tags/${apple_version}" + if ! git diff --quiet "refs/tags/$apple_version" HEAD -- ../../packages/apple/Sources ../../packages/apple/Package.swift; then + test ! -e "$OPENIAP_COCOAPODS_CALLED" + echo "::warning::Skipped the published consumer check: packages/apple differs from openiap-apple $apple_version. The KMP release runs it after the Apple release." + exit 0 + fi +fi + verification_version=0.0.0-swiftpm-check ./gradlew --no-parallel -PopeniapAppleRemote=true \ "-PlibraryVersion=$verification_version" "-Dmaven.repo.local=$verification_dir/maven" \ 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/example/OpenIap.Maui.Example/Pages/AlternativeBillingPage.xaml.cs b/libraries/maui-iap/example/OpenIap.Maui.Example/Pages/AlternativeBillingPage.xaml.cs index 573c69d11..eb2c5e99e 100644 --- a/libraries/maui-iap/example/OpenIap.Maui.Example/Pages/AlternativeBillingPage.xaml.cs +++ b/libraries/maui-iap/example/OpenIap.Maui.Example/Pages/AlternativeBillingPage.xaml.cs @@ -312,19 +312,30 @@ private async Task HandleIOSAlternativeBillingPurchaseAsync(Product product) } SetProcessing(true); - ShowResult("🌐 Opening external purchase link..."); try { - var result = await OpenIapClient.Mutation.PresentExternalPurchaseLinkIOSAsync(externalUrl); - if (!string.IsNullOrEmpty(result.Error)) + if (!await OpenIapClient.Query.IsExternalPurchaseLinkAvailableAsync()) { - ShowResult($"❌ Error: {result.Error}"); - await DisplayAlertAsync("Error", result.Error, "OK"); + ShowResult("❌ External purchase links are not available"); + return; } - else if (result.Success) + + ShowResult("🌐 Opening external purchase link..."); + // Shows the App Store disclosure first, then opens the URL + var result = await OpenIapClient.Mutation.OpenExternalPurchaseLinkAsync( + new ExternalPurchaseLinkRequest { Url = externalUrl }); + switch (result.Status) { - ShowResult($"✅ External purchase link opened successfully\n\nProduct: {product.Id}\nURL: {externalUrl}\n\nUser was redirected to external website.\n\nNote: Complete purchase on your website and implement server-side validation."); - await DisplayAlertAsync("Redirected", "User was redirected to your external purchase website. Complete the purchase there.", "OK"); + case ExternalPurchaseLinkStatus.Opened: + ShowResult($"✅ External purchase link opened\n\nProduct: {product.Id}\nURL: {externalUrl}\nReport token: {result.ReportToken ?? "none"}\n\nNote: Complete purchase on your website and report the token from your server."); + await DisplayAlertAsync("Redirected", "User was redirected to your external purchase website. Complete the purchase there.", "OK"); + break; + case ExternalPurchaseLinkStatus.Cancelled: + ShowResult("ℹ️ User dismissed the external purchase notice"); + break; + case ExternalPurchaseLinkStatus.Unavailable: + ShowResult("❌ External purchase links are not available"); + break; } } catch (Exception ex) 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..6b55883a2 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) @@ -179,7 +188,9 @@ public Task LaunchExternalLinkAndroidAsync(LaunchExternalLinkParamsAndroid public Task BeginRefundRequestIOSAsync(string sku) => NotSupportedIOS("beginRefundRequestIOS"); public Task ClearTransactionIOSAsync() => NotSupportedIOS("clearTransactionIOS"); + [Obsolete("Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0.")] public Task PresentExternalPurchaseLinkIOSAsync(string url) => NotSupportedIOS("presentExternalPurchaseLinkIOS"); + [Obsolete("Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0.")] public Task PresentExternalPurchaseNoticeSheetIOSAsync() => NotSupportedIOS("presentExternalPurchaseNoticeSheetIOS"); public Task ShowExternalPurchaseCustomLinkNoticeIOSAsync(ExternalPurchaseCustomLinkNoticeTypeIOS noticeType) => NotSupportedIOS("showExternalPurchaseCustomLinkNoticeIOS"); public Task> ShowManageSubscriptionsIOSAsync() => NotSupportedIOS>("showManageSubscriptionsIOS"); @@ -227,8 +238,12 @@ public async Task GetStorefrontAsync() return storefront; } + public Task IsExternalPurchaseLinkAvailableAsync() + => InvokeBool(cb => _module.IsExternalPurchaseLinkAvailable(cb)); + // ---- iOS-only queries (defaults / throw not-supported) -------------- + [Obsolete("Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0.")] public Task CanPresentExternalPurchaseNoticeIOSAsync() => Task.FromResult(false); public Task CurrentEntitlementIOSAsync(string sku) => Task.FromResult(null); public Task> GetAllTransactionsIOSAsync() => Task.FromResult>(Array.Empty()); @@ -238,6 +253,7 @@ public async Task GetStorefrontAsync() public Task GetPromotedProductIOSAsync() => Task.FromResult(null); public Task GetReceiptDataIOSAsync() => Task.FromResult(null); public Task GetTransactionJwsIOSAsync(string sku) => Task.FromResult(null); + [Obsolete("Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0.")] public Task IsEligibleForExternalPurchaseCustomLinkIOSAsync() => Task.FromResult(false); public Task IsEligibleForIntroOfferIOSAsync(string groupId) => Task.FromResult(false); public Task IsTransactionVerifiedIOSAsync(string sku) => 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..768a7070e 100644 --- a/libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs +++ b/libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs @@ -379,11 +379,20 @@ 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)); public Task ClearTransactionIOSAsync() => InvokeBool(cb => _module.ClearTransactionIOS(cb)); + [Obsolete("Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0.")] public Task PresentExternalPurchaseLinkIOSAsync(string url) { if (!OperatingSystem.IsIOSVersionAtLeast(16, 0) && !OperatingSystem.IsMacCatalystVersionAtLeast(16, 0)) @@ -391,6 +400,7 @@ public Task PresentExternalPurchaseLinkIOSAsync(s return InvokeDict(cb => _module.PresentExternalPurchaseLinkIOS(url, cb), required: true)!; } + [Obsolete("Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0.")] public Task PresentExternalPurchaseNoticeSheetIOSAsync() { if (!OperatingSystem.IsIOSVersionAtLeast(17, 4) && !OperatingSystem.IsMacCatalystVersionAtLeast(17, 4)) @@ -503,6 +513,9 @@ public async Task GetStorefrontAsync() return storefront; } + public Task IsExternalPurchaseLinkAvailableAsync() => InvokeBool(cb => _module.IsExternalPurchaseLinkAvailable(cb)); + + [Obsolete("Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0.")] public Task CanPresentExternalPurchaseNoticeIOSAsync() { if (!OperatingSystem.IsIOSVersionAtLeast(17, 4) && !OperatingSystem.IsMacCatalystVersionAtLeast(17, 4)) @@ -540,6 +553,7 @@ public Task GetExternalPurchaseCustomL public Task GetReceiptDataIOSAsync() => InvokeNullableString(cb => _module.GetReceiptDataIOS(cb)); public Task GetTransactionJwsIOSAsync(string sku) => InvokeNullableString(cb => _module.GetTransactionJwsIOS(sku, cb)); + [Obsolete("Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0.")] public Task IsEligibleForExternalPurchaseCustomLinkIOSAsync() { if (!OperatingSystem.IsIOSVersionAtLeast(18, 1) && !OperatingSystem.IsMacCatalystVersionAtLeast(18, 1)) diff --git a/libraries/maui-iap/src/OpenIap.Maui/Types.cs b/libraries/maui-iap/src/OpenIap.Maui/Types.cs index f53a8a65e..93c414fcb 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; @@ -5412,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); /// @@ -5420,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(); /// @@ -5510,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(); /// @@ -5617,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(); /// @@ -5626,6 +5778,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..5683a3c2d 100644 --- a/libraries/maui-iap/src/OpenIap.Maui/UnsupportedOpenIap.cs +++ b/libraries/maui-iap/src/OpenIap.Maui/UnsupportedOpenIap.cs @@ -39,8 +39,11 @@ 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(); + [Obsolete("Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0.")] public Task PresentExternalPurchaseLinkIOSAsync(string url) => Unsupported(); + [Obsolete("Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0.")] public Task PresentExternalPurchaseNoticeSheetIOSAsync() => Unsupported(); public Task RequestPurchaseAsync(RequestPurchaseProps @params) => Unsupported(); public Task RestorePurchasesAsync() => Unsupported(); @@ -54,6 +57,7 @@ private static Task Unsupported([CallerMemberName] string operation = "") public Task VerifyPurchaseWithProviderAsync(VerifyPurchaseWithProviderProps options) => Unsupported(); // QueryResolver + [Obsolete("Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0.")] public Task CanPresentExternalPurchaseNoticeIOSAsync() => Unsupported(); public Task CurrentEntitlementIOSAsync(string sku) => Unsupported(); public Task FetchProductsAsync(ProductRequest @params) => Unsupported(); @@ -69,8 +73,10 @@ private static Task Unsupported([CallerMemberName] string operation = "") public Task GetStorefrontAsync() => Unsupported(); public Task GetTransactionJwsIOSAsync(string sku) => Unsupported(); public Task HasActiveSubscriptionsAsync(IReadOnlyList? subscriptionIds = null) => Unsupported(); + [Obsolete("Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0.")] 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/__tests__/screens/AlternativeBilling.test.tsx b/libraries/react-native-iap/example/__tests__/screens/AlternativeBilling.test.tsx index ab8dd6656..6fbebd41c 100644 --- a/libraries/react-native-iap/example/__tests__/screens/AlternativeBilling.test.tsx +++ b/libraries/react-native-iap/example/__tests__/screens/AlternativeBilling.test.tsx @@ -84,6 +84,35 @@ describe('AlternativeBilling Screen', () => { expect(getByText(/Current mode: EXTERNAL_PAYMENTS/)).toBeTruthy(); }); + it('opens the iOS external purchase link through the shared API', async () => { + Object.assign(Platform, {OS: 'ios'}); + const {getByText} = await render(); + + await fireEvent.press(getByText('Test Consumable')); + await fireEvent.press(getByText('🛒 Buy (External URL)')); + + expect(RNIap.isExternalPurchaseLinkAvailable).toHaveBeenCalled(); + expect(RNIap.openExternalPurchaseLink).toHaveBeenCalledWith({ + url: 'https://openiap.dev', + }); + expect(getByText(/External purchase link opened/)).toBeTruthy(); + expect(getByText(/Report token: Present/)).toBeTruthy(); + }); + + it('skips the iOS link when the storefront is not eligible', async () => { + Object.assign(Platform, {OS: 'ios'}); + jest + .mocked(RNIap.isExternalPurchaseLinkAvailable) + .mockResolvedValueOnce(false); + const {getByText} = await render(); + + await fireEvent.press(getByText('Test Consumable')); + await fireEvent.press(getByText('🛒 Buy (External URL)')); + + expect(RNIap.openExternalPurchaseLink).not.toHaveBeenCalled(); + expect(getByText(/not available for this app or storefront/)).toBeTruthy(); + }); + it('renders Amazon Vega as unsupported for alternative billing', async () => { Object.assign(Platform, {OS: 'kepler'}); 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/example/jest.setup.js b/libraries/react-native-iap/example/jest.setup.js index 8b8449740..e78875c4f 100644 --- a/libraries/react-native-iap/example/jest.setup.js +++ b/libraries/react-native-iap/example/jest.setup.js @@ -114,6 +114,14 @@ jest.mock('../src/index', () => { verifyPurchase: mockVerifyPurchase, verifyPurchaseWithProvider: mockVerifyPurchaseWithProvider, openRedeemOfferCode: jest.fn(() => Promise.resolve(null)), + isExternalPurchaseLinkAvailable: jest.fn(() => Promise.resolve(true)), + openExternalPurchaseLink: jest.fn(() => + Promise.resolve({ + platform: 'ios', + status: 'opened', + reportToken: 'external-purchase-token', + }), + ), verifyAppOwnership: jest.fn(), // Android specific diff --git a/libraries/react-native-iap/example/screens/AlternativeBilling.tsx b/libraries/react-native-iap/example/screens/AlternativeBilling.tsx index 74adc248a..c353a8e99 100644 --- a/libraries/react-native-iap/example/screens/AlternativeBilling.tsx +++ b/libraries/react-native-iap/example/screens/AlternativeBilling.tsx @@ -14,7 +14,8 @@ import { useIAP, requestPurchase, initConnection, - presentExternalPurchaseLinkIOS, + isExternalPurchaseLinkAvailable, + openExternalPurchaseLink, // Billing Programs API (Android 8.2.0+); enable the program in InitConnectionConfig isBillingProgramAvailableAndroid, createBillingProgramReportingDetailsAndroid, @@ -40,11 +41,11 @@ import { * * Demonstrates alternative billing flows for iOS and Android: * - * iOS (Alternative Billing): - * - Redirects users to external website - * - No onPurchaseUpdated callback when using external URL - * - User completes purchase on external website - * - Must implement deep link to return to app + * iOS (External Purchase Link - 17.4+): + * - Check eligibility with isExternalPurchaseLinkAvailable() + * - openExternalPurchaseLink() shows Apple's disclosure, then opens the URL + * - No onPurchaseUpdated callback; the purchase completes on your website + * - Report the returned reportToken from your server * * Android (Billing Programs API - Recommended for 8.2.0+): * - Step 1: Set enableBillingProgramAndroid in InitConnectionConfig @@ -202,13 +203,9 @@ function AlternativeBillingScreen() { [fetchProducts], ); - // Handle iOS alternative billing purchase (external URL) + // Handle iOS external purchase link const handleIosAlternativeBillingPurchase = useCallback( async (product: Product) => { - console.log('[iOS] Starting alternative billing purchase:', product.id); - console.log('[iOS] External URL:', externalUrl); - console.log('[iOS] Platform.Version:', Platform.Version); - if (!externalUrl || externalUrl.trim() === '') { Alert.alert('Error', 'Please enter a valid external purchase URL'); return; @@ -218,24 +215,28 @@ function AlternativeBillingScreen() { setPurchaseResult('🌐 Opening external purchase link...'); try { - // Use StoreKit External Purchase Link API - const result = await presentExternalPurchaseLinkIOS(externalUrl); - console.log('[iOS] External purchase link result:', result); - - if (result.error) { - setPurchaseResult(`❌ Error: ${result.error}`); - Alert.alert('Error', result.error); - } else if (result.success) { + if (!(await isExternalPurchaseLinkAvailable())) { setPurchaseResult( - `✅ External purchase link opened successfully\n\nProduct: ${product.id}\nURL: ${externalUrl}\n\nUser was redirected to external website.\n\nNote: Complete purchase on your website and implement server-side validation.`, + '❌ External purchase links are not available for this app or storefront', ); - Alert.alert( - 'Redirected', - 'User was redirected to your external purchase website. Complete the purchase there.', + return; + } + + // Shows Apple's disclosure sheet first, then opens the URL. + const result = await openExternalPurchaseLink({url: externalUrl}); + console.log('[iOS] External purchase link status:', result.status); + + if (result.status === 'opened') { + setPurchaseResult( + `✅ External purchase link opened\n\nProduct: ${product.id}\nURL: ${externalUrl}\nReport token: ${result.reportToken ? 'Present' : 'Missing'}\n\nNote: Complete purchase on your website and report the token from your server.`, ); + } else if (result.status === 'cancelled') { + setPurchaseResult('User cancelled the external purchase link'); + } else { + setPurchaseResult('❌ External purchase link unavailable'); } } catch (error: any) { - console.log('[iOS] Alternative billing error:', error); + console.log('[iOS] External purchase link error:', error); setPurchaseResult(`❌ Error: ${error.message}`); Alert.alert('Error', error.message); } finally { @@ -398,7 +399,7 @@ function AlternativeBillingScreen() { {isVega ? 'Not supported on Amazon Vega' : Platform.OS === 'ios' - ? 'External purchase links (iOS 16.0+)' + ? 'External purchase links (iOS 17.4+)' : 'Google Play alternative billing'} @@ -424,14 +425,15 @@ function AlternativeBillingScreen() { <> • Enter your external purchase URL{'\n'}• Tap Purchase on any - product{'\n'}• User will be redirected to the external URL{'\n'} - • Complete purchase on your website{'\n'}• No onPurchaseUpdated - callback{'\n'}• Implement deep link to return to app + product{'\n'}• The App Store disclosure sheet appears, then the + URL opens{'\n'}• Complete purchase on your website{'\n'}• No + onPurchaseUpdated callback{'\n'}• Report the returned token from + your server - ⚠️ iOS 16.0+ required{'\n'} + ⚠️ iOS 17.4+ required{'\n'} ⚠️ Valid external URL needed{'\n'} - ⚠️ Billing program mode is enabled + ⚠️ External purchase entitlement must be enabled ) : ( 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..e2db4e724 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,108 @@ 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('isExternalPurchaseLinkAvailable surfaces native failures as PurchaseError', async () => { + Object.assign(Platform, {OS: 'ios'}); + mockIap.isExternalPurchaseLinkAvailable.mockRejectedValueOnce( + new Error('storekit down'), + ); + await expect(IAP.isExternalPurchaseLinkAvailable()).rejects.toMatchObject( + { + code: ErrorCode.Unknown, + message: 'storekit down', + }, + ); + }); + + 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/__tests__/vega-adapter.test.ts b/libraries/react-native-iap/src/__tests__/vega-adapter.test.ts index 3f66d4c53..1db721ae7 100644 --- a/libraries/react-native-iap/src/__tests__/vega-adapter.test.ts +++ b/libraries/react-native-iap/src/__tests__/vega-adapter.test.ts @@ -122,6 +122,18 @@ describe('Amazon Vega adapter', () => { } }); + it('external purchase links are unavailable without calling the Amazon service', async () => { + const service = createService(); + const module = createVegaIapModule(service); + await expect(module.isExternalPurchaseLinkAvailable()).resolves.toBe(false); + await expect( + module.openExternalPurchaseLink({url: 'https://example.test/checkout'}), + ).resolves.toEqual({platform: 'android', status: 'unavailable'}); + for (const method of Object.values(service)) { + expect(method).not.toHaveBeenCalled(); + } + }); + it('initializes without fetching Amazon user data', async () => { const service = createService(); const module = createVegaIapModule(service); 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..8a3817eb7 100644 --- a/libraries/react-native-iap/src/index.kepler.ts +++ b/libraries/react-native-iap/src/index.kepler.ts @@ -339,6 +339,14 @@ 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'}); +/** @deprecated Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0. */ 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..e2838f2dd 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 @@ -2930,6 +2980,7 @@ export const launchExternalLinkAndroid: MutationField< * Check if the device can present an external purchase notice sheet (iOS 17.4+). * Wraps `ExternalPurchase.canPresent`. * + * @deprecated Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0. * @returns Promise - true if notice sheet can be presented * @platform iOS * @@ -2965,6 +3016,7 @@ export const canPresentExternalPurchaseNoticeIOS: QueryField< * Present an external purchase notice sheet to inform users about external purchases (iOS 17.4+). * This must be called before opening an external purchase link. * + * @deprecated Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0. * @returns Promise - Result with action and error if any * @platform iOS * @@ -2999,6 +3051,7 @@ export const presentExternalPurchaseNoticeSheetIOS: MutationField< /** * Present an external purchase link to redirect users to your website (iOS 16.0+). * + * @deprecated Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0. * @param url - The external purchase URL to open * @returns Promise - Result with success status and error if any * @platform iOS @@ -3034,6 +3087,7 @@ export const presentExternalPurchaseLinkIOS: MutationField< /** * Check if app is eligible for ExternalPurchaseCustomLink API (iOS 18.1+). * + * @deprecated Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0. * @returns Promise - true if eligible * @platform iOS * @see https://developer.apple.com/documentation/storekit/externalpurchasecustomlink/iseligible 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..ea01521dd 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; @@ -852,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; /** @@ -859,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; /** @@ -955,6 +1027,8 @@ export type MutationIsBillingProgramAvailableAndroidArgs = BillingProgramAndroid export type MutationLaunchExternalLinkAndroidArgs = LaunchExternalLinkParamsAndroid; +export type MutationOpenExternalPurchaseLinkArgs = ExternalPurchaseLinkRequest; + export type MutationPresentExternalPurchaseLinkIosArgs = string; export type MutationRequestPurchaseArgs = RequestPurchaseProps; @@ -1423,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; /** @@ -1514,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; /** @@ -1521,6 +1597,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 +2455,7 @@ export type MutationArgsMap = { initConnection: MutationInitConnectionArgs; isBillingProgramAvailableAndroid: MutationIsBillingProgramAvailableAndroidArgs; launchExternalLinkAndroid: MutationLaunchExternalLinkAndroidArgs; + openExternalPurchaseLink: MutationOpenExternalPurchaseLinkArgs; openRedeemOfferCode: never; presentExternalPurchaseLinkIOS: MutationPresentExternalPurchaseLinkIosArgs; presentExternalPurchaseNoticeSheetIOS: never; @@ -2416,6 +2502,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/Example/OpenIapExample/Screens/AlternativeBillingScreen.swift b/packages/apple/Example/OpenIapExample/Screens/AlternativeBillingScreen.swift index 6e1a94ea1..073a581e1 100644 --- a/packages/apple/Example/OpenIapExample/Screens/AlternativeBillingScreen.swift +++ b/packages/apple/Example/OpenIapExample/Screens/AlternativeBillingScreen.swift @@ -327,64 +327,42 @@ struct AlternativeBillingScreen: View { private func purchaseProduct(_ product: OpenIapProduct) { print("🛒 [AlternativeBilling] Starting alternative billing purchase for: \(product.id)") print("🌐 [AlternativeBilling] External URL: \(externalUrl)") - - if #available(iOS 17.4, *) { - Task { await testExternalPurchaseFlow() } - } else { - errorMessage = "The external purchase notice sheet requires iOS 17.4 or later" - showError = true - } + Task { await openExternalPurchase() } } // MARK: - External Purchase Flow - @available(iOS 17.4, *) - private func testExternalPurchaseFlow() async { - print("🔷 [AlternativeBilling] Testing external purchase flow...") - + private func openExternalPurchase() async { do { - // Step 1: Check if notice sheet can be presented - let canPresent = try await OpenIapModule.shared.canPresentExternalPurchaseNoticeIOS() - print("✅ [AlternativeBilling] Can present notice sheet: \(canPresent)") - - guard canPresent else { + guard try await OpenIapModule.shared.isExternalPurchaseLinkAvailable() else { await MainActor.run { - errorMessage = "External purchase notice sheet is not available on this device" + errorMessage = "External purchase links are not available for this app or device" showError = true } return } - // Step 2: Present notice sheet - let noticeResult = try await OpenIapModule.shared.presentExternalPurchaseNoticeSheetIOS() - print("✅ [AlternativeBilling] Notice sheet result: \(noticeResult.result)") - - if noticeResult.result == .continue { - // Step 3: Present external purchase link - let linkResult = try await OpenIapModule.shared.presentExternalPurchaseLinkIOS(externalUrl) - print("✅ [AlternativeBilling] Link result: \(linkResult.success)") + // Shows Apple's disclosure first; the URL opens only if the user continues. + let result = try await OpenIapModule.shared.openExternalPurchaseLink( + ExternalPurchaseLinkRequest(url: externalUrl) + ) + print("✅ [AlternativeBilling] External purchase link: \(result.status)") - await MainActor.run { - if linkResult.success { - purchaseResultMessage = """ - 🌐 External purchase flow completed - User was redirected to: \(externalUrl) - """ - } else { - purchaseResultMessage = """ - ❌ External purchase link failed - Error: \(linkResult.error ?? "Unknown error") - """ - } - showPurchaseResult = true - } - } else { - await MainActor.run { - purchaseResultMessage = "User dismissed the notice sheet" - showPurchaseResult = true + await MainActor.run { + switch result.status { + case .opened: + purchaseResultMessage = """ + 🌐 External purchase flow completed + User was redirected to: \(externalUrl) + Report token: \(result.reportToken ?? "-") + """ + case .cancelled: + purchaseResultMessage = "User dismissed the notice" + case .unavailable: + purchaseResultMessage = "External purchase links are not available" } + showPurchaseResult = true } - } catch { print("❌ [AlternativeBilling] External purchase flow error: \(error)") await MainActor.run { diff --git a/packages/apple/Sources/Models/Types.swift b/packages/apple/Sources/Models/Types.swift index e333be3ab..137697392 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. @@ -4737,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. @@ -4805,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 @@ -4867,10 +4977,19 @@ 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 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 +5067,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,8 +5094,11 @@ public struct MutationHandlers { public var initConnection: MutationInitConnectionHandler? public var isBillingProgramAvailableAndroid: MutationIsBillingProgramAvailableAndroidHandler? 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? @@ -5000,6 +5123,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 +5149,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,12 +5185,14 @@ 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] 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? @@ -5081,8 +5208,10 @@ 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? public var isTransactionVerifiedIOS: QueryIsTransactionVerifiedIOSHandler? public var latestTransactionIOS: QueryLatestTransactionIOSHandler? public var subscriptionStatusIOS: QuerySubscriptionStatusIOSHandler? @@ -5106,6 +5235,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 +5258,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..5041f8fa1 --- /dev/null +++ b/packages/apple/Sources/OpenIapExternalPurchaseLink.swift @@ -0,0 +1,81 @@ +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 { + 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: + 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) + } +} + +enum ExternalPurchaseLinkRoute { + case customLink + case noticeSheet +} + +@available(iOS 15.0, macOS 14.0, tvOS 16.0, watchOS 8.0, *) +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 + } + + private 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 + } + } + + private 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..7e4da24da 100644 --- a/packages/apple/Sources/OpenIapModule+ObjC.swift +++ b/packages/apple/Sources/OpenIapModule+ObjC.swift @@ -805,6 +805,7 @@ import StoreKit #endif // !os(tvOS) @available(iOS 16.0, macOS 14.0, *) + @available(*, deprecated, message: "Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0.") @objc func presentExternalPurchaseLinkIOSWithUrl(_ url: String, completion: @escaping (Any?, Error?) -> Void) { Task { do { @@ -818,6 +819,7 @@ import StoreKit } @available(iOS 17.4, macOS 14.4, *) + @available(*, deprecated, message: "Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0.") @objc func presentExternalPurchaseNoticeSheetIOSWithCompletion(_ completion: @escaping (Any?, Error?) -> Void) { Task { do { @@ -831,6 +833,7 @@ import StoreKit } @available(iOS 17.4, macOS 14.4, *) + @available(*, deprecated, message: "Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0.") @objc func canPresentExternalPurchaseNoticeIOSWithCompletion(_ completion: @escaping (Bool, Error?) -> Void) { Task { do { @@ -845,6 +848,7 @@ import StoreKit // MARK: - ExternalPurchaseCustomLink (iOS 18.1+) @available(iOS 18.1, macOS 15.1, tvOS 18.1, watchOS 11.1, visionOS 2.1, *) + @available(*, deprecated, message: "Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0.") @objc func isEligibleForExternalPurchaseCustomLinkIOSWithCompletion(_ completion: @escaping (Bool, Error?) -> Void) { Task { do { @@ -902,6 +906,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..5f71a10da 100644 --- a/packages/apple/Sources/OpenIapModule.swift +++ b/packages/apple/Sources/OpenIapModule.swift @@ -159,18 +159,22 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { try await provider.deepLinkToSubscriptions(options) } + @available(*, deprecated, message: "Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0.") public func canPresentExternalPurchaseNoticeIOS() async throws -> Bool { try await provider.canPresentExternalPurchaseNoticeIOS() } + @available(*, deprecated, message: "Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0.") public func presentExternalPurchaseNoticeSheetIOS() async throws -> ExternalPurchaseNoticeResultIOS { try await provider.presentExternalPurchaseNoticeSheetIOS() } + @available(*, deprecated, message: "Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0.") public func presentExternalPurchaseLinkIOS(_ url: String) async throws -> ExternalPurchaseLinkResultIOS { try await provider.presentExternalPurchaseLinkIOS(url) } + @available(*, deprecated, message: "Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0.") public func isEligibleForExternalPurchaseCustomLinkIOS() async throws -> Bool { try await provider.isEligibleForExternalPurchaseCustomLinkIOS() } @@ -183,6 +187,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/Sources/OpenIapStore.swift b/packages/apple/Sources/OpenIapStore.swift index 15ae32ed8..78699b92a 100644 --- a/packages/apple/Sources/OpenIapStore.swift +++ b/packages/apple/Sources/OpenIapStore.swift @@ -616,18 +616,30 @@ 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() } + @available(*, deprecated, message: "Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0.") public func presentExternalPurchaseNoticeSheetIOS() async throws -> ExternalPurchaseNoticeResultIOS { try await module.presentExternalPurchaseNoticeSheetIOS() } + @available(*, deprecated, message: "Use openExternalPurchaseLink instead. Scheduled for removal in client protocol 2.0.0.") public func presentExternalPurchaseLinkIOS(_ url: String) async throws -> ExternalPurchaseLinkResultIOS { try await module.presentExternalPurchaseLinkIOS(url) } + @available(*, deprecated, message: "Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0.") public func isEligibleForExternalPurchaseCustomLinkIOS() async throws -> Bool { try await module.isEligibleForExternalPurchaseCustomLinkIOS() } diff --git a/packages/apple/Sources/OpenIapStoreKitModule.swift b/packages/apple/Sources/OpenIapStoreKitModule.swift index 8974a530e..aa35ef0cd 100644 --- a/packages/apple/Sources/OpenIapStoreKitModule.swift +++ b/packages/apple/Sources/OpenIapStoreKitModule.swift @@ -1659,6 +1659,23 @@ final class OpenIapStoreKitModule: NSObject, OpenIapModuleProtocol { #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 new file mode 100644 index 000000000..7d03f811a --- /dev/null +++ b/packages/apple/Tests/OpenIapTests/ExternalPurchaseLinkTests.swift @@ -0,0 +1,204 @@ +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, []) + } + + #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) + + 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..b3d19c936 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', @@ -317,7 +337,7 @@ export const apiData: ApiItem[] = [ title: 'canPresentExternalPurchaseNoticeIOS', category: 'iOS Specific', description: - 'Check if the external purchase notice sheet can be presented (iOS 17.4+)', + 'Deprecated: use isExternalPurchaseLinkAvailable. Check if the external purchase notice sheet can be presented (iOS 17.4+)', parameters: '', returns: 'Boolean!', path: '/docs/apis/ios/can-present-external-purchase-notice-ios', @@ -326,7 +346,8 @@ export const apiData: ApiItem[] = [ id: 'present-external-purchase-notice-sheet-ios', title: 'presentExternalPurchaseNoticeSheetIOS', category: 'iOS Specific', - description: "Present Apple's compliance notice sheet (iOS 17.4+)", + description: + "Deprecated: use openExternalPurchaseLink. Present Apple's compliance notice sheet (iOS 17.4+)", parameters: '', returns: 'ExternalPurchaseNoticeResultIOS!', path: '/docs/apis/ios/present-external-purchase-notice-sheet-ios', @@ -335,7 +356,8 @@ export const apiData: ApiItem[] = [ id: 'present-external-purchase-link-ios', title: 'presentExternalPurchaseLinkIOS', category: 'iOS Specific', - description: 'Open the external purchase URL in Safari (iOS 16+)', + description: + 'Deprecated: use openExternalPurchaseLink. Open the external purchase URL in Safari (iOS 16+)', parameters: 'url: String!', returns: 'ExternalPurchaseLinkResultIOS!', path: '/docs/apis/ios/present-external-purchase-link-ios', @@ -345,7 +367,7 @@ export const apiData: ApiItem[] = [ title: 'isEligibleForExternalPurchaseCustomLinkIOS', category: 'iOS Specific', description: - 'Check whether the app can use the iOS 18.1+ ExternalPurchaseCustomLink API', + 'Deprecated: use isExternalPurchaseLinkAvailable. Check whether the app can use the iOS 18.1+ ExternalPurchaseCustomLink API', parameters: '', returns: 'Boolean!', path: '/docs/apis/ios/is-eligible-for-external-purchase-custom-link-ios', diff --git a/packages/docs/src/pages/docs/apis/index.tsx b/packages/docs/src/pages/docs/apis/index.tsx index 1b0c5cb15..1f3217d62 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. + + @@ -525,6 +547,7 @@ function APIsIndex() { + Deprecated: use isExternalPurchaseLinkAvailable. Check eligibility for the external purchase notice sheet (iOS 17.4+). @@ -535,7 +558,10 @@ function APIsIndex() { presentExternalPurchaseNoticeSheetIOS - Present the external purchase notice sheet (iOS 17.4+). + + Deprecated: use openExternalPurchaseLink. Present + the external purchase notice sheet (iOS 17.4+). + @@ -544,7 +570,8 @@ function APIsIndex() { - Present an external purchase link, StoreKit External (iOS 16+). + Deprecated: use openExternalPurchaseLink. Present + an external purchase link, StoreKit External (iOS 16+). @@ -554,6 +581,7 @@ function APIsIndex() { + Deprecated: use isExternalPurchaseLinkAvailable. Check eligibility for the custom-link variant of external purchase (iOS 18.1+). 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 fbd01145c..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 @@ -1,4 +1,6 @@ +import { Link } from 'react-router-dom'; import AnchorLink from '../../../../components/AnchorLink'; +import Callout from '../../../../components/Callout'; import CodeBlock from '../../../../components/CodeBlock'; import LanguageTabs from '../../../../components/LanguageTabs'; import SEO from '../../../../components/SEO'; @@ -19,6 +21,18 @@ function CanPresentExternalPurchaseNoticeIOS() { iOS{' '} canPresentExternalPurchaseNoticeIOS + + Use{' '} + + isExternalPurchaseLinkAvailable + {' '} + 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+, macOS 14.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 6941cdd87..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 @@ -1,4 +1,6 @@ +import { Link } from 'react-router-dom'; import AnchorLink from '../../../../components/AnchorLink'; +import Callout from '../../../../components/Callout'; import CodeBlock from '../../../../components/CodeBlock'; import LanguageTabs from '../../../../components/LanguageTabs'; import SEO from '../../../../components/SEO'; @@ -19,6 +21,18 @@ function IsEligibleForExternalPurchaseCustomLinkIOS() { iOS{' '} isEligibleForExternalPurchaseCustomLinkIOS + + Use{' '} + + isExternalPurchaseLinkAvailable + {' '} + 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{' '} iOS{' '} presentExternalPurchaseLinkIOS + + Use{' '} + + openExternalPurchaseLink + {' '} + instead. It runs the required store disclosure and opens the link in one + 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+).

Opens the URL via{' '} diff --git a/packages/docs/src/pages/docs/apis/ios/present-external-purchase-notice-sheet-ios.tsx b/packages/docs/src/pages/docs/apis/ios/present-external-purchase-notice-sheet-ios.tsx index 4933fb3c9..22085906b 100644 --- a/packages/docs/src/pages/docs/apis/ios/present-external-purchase-notice-sheet-ios.tsx +++ b/packages/docs/src/pages/docs/apis/ios/present-external-purchase-notice-sheet-ios.tsx @@ -1,5 +1,6 @@ import { Link } from 'react-router-dom'; import AnchorLink from '../../../../components/AnchorLink'; +import Callout from '../../../../components/Callout'; import CodeBlock from '../../../../components/CodeBlock'; import LanguageTabs from '../../../../components/LanguageTabs'; import SEO from '../../../../components/SEO'; @@ -20,6 +21,14 @@ function PresentExternalPurchaseNoticeSheetIOS() { iOS{' '} presentExternalPurchaseNoticeSheetIOS + + Use{' '} + + 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. +

Present Apple's compliance notice sheet (iOS 17.4+, macOS 14.4+).

Wraps ExternalPurchase.presentNoticeSheet() — returns a 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..26f3f2cff --- /dev/null +++ b/packages/docs/src/pages/docs/apis/is-external-purchase-link-available.tsx @@ -0,0 +1,105 @@ +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, macOS, watchOS, iOS before 17.4: + {' '} + 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, except in Godot, which has no + exceptions and returns false for any failure. +

+ +

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..cbdb4d377 --- /dev/null +++ b/packages/docs/src/pages/docs/apis/open-external-purchase-link.tsx @@ -0,0 +1,212 @@ +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{' '} + SKExternalPurchaseLink (or{' '} + SKExternalPurchaseMultiLink) 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, 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. +
  • +
+ +

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. Apple + requires every token in your monthly report, even when no purchase + follows. +
  • +
  • + 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') { + // Send result.reportToken to your backend and report it as the store requires. +}`} + ), + 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..04d1dbb59 100644 --- a/packages/docs/src/pages/docs/features/external-purchase.tsx +++ b/packages/docs/src/pages/docs/features/external-purchase.tsx @@ -88,6 +88,66 @@ 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, macOS, watchOS, iOS before 17.4 + + : 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') { + // Send result.reportToken to your backend and report it as the store requires. + await api.saveExternalPurchaseToken(result.platform, result.reportToken); + } +}`} +

+ The store entitlement or program enrollment is still required. On + Android, use the platform calls below for alternative billing and + billing choice. The step-by-step iOS calls are deprecated and are + removed in client protocol 2.0.0. +

+
+
Platform-Specific Implementation @@ -106,275 +166,25 @@ function ExternalPurchase() {

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{' '} @@ -1796,45 +1606,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 @@ -1842,7 +1633,7 @@ func _ready_user_choice() -> void: - 6 + 4 Unlock Content App unlocks purchased content after backend confirmation @@ -1956,15 +1747,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/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={} /> + } + /> + } + />

    - 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 e12ab263b..8cc8f5ba5 100644 --- a/packages/docs/src/pages/docs/updates/releases.tsx +++ b/packages/docs/src/pages/docs/updates/releases.tsx @@ -180,6 +180,30 @@ const REACT_NATIVE_NITRO_CAUSE_RELEASES: readonly ReleaseMetadata[] = [ }, ]; +const EXTERNAL_PURCHASE_LINK_RELEASES: readonly ReleaseMetadata[] = [ + { + name: '@hyodotdev/openiap-client-protocol', + version: '1.5.0', + tag: 'openiap-client-protocol-1.5.0', + }, + { name: 'openiap-apple', version: '4.2.0', tag: '4.2.0' }, + { name: 'openiap-google', version: '4.2.0', tag: 'google-4.2.0' }, + { + name: 'react-native-iap', + version: '17.4.0', + tag: 'react-native-iap-17.4.0', + }, + { name: 'expo-iap', version: '6.5.0', tag: 'expo-iap-6.5.0' }, + { + name: 'flutter_inapp_purchase', + version: '11.4.0', + tag: 'flutter-iap-11.4.0', + }, + { name: 'godot-iap', version: '4.4.0', tag: 'godot-iap-4.4.0' }, + { name: 'kmp-iap', version: '4.3.0', tag: 'kmp-iap-4.3.0' }, + { name: 'OpenIap.Maui', version: '3.3.0', tag: 'maui-iap-3.3.0' }, +]; + const ALTERNATIVE_BILLING_ONLY_RELEASES: readonly ReleaseMetadata[] = [ { name: '@hyodotdev/openiap-client-protocol', @@ -854,6 +878,126 @@ function Releases() { } const allNotes: Note[] = [ + { + id: 'external-purchase-link-2026-11-13', + date: new Date('2026-11-13'), + element: ( +
    + {EXTERNAL_PURCHASE_LINK_RELEASES.map((release) => ( +
    + ), + }, { id: 'alternative-billing-only-2026-11-11', date: new Date('2026-11-11'), diff --git a/packages/google/core/api/openiap-core.api b/packages/google/core/api/openiap-core.api index 3666fbe6b..206d38d72 100644 --- a/packages/google/core/api/openiap-core.api +++ b/packages/google/core/api/openiap-core.api @@ -916,6 +916,92 @@ public final class dev/hyo/openiap/ExternalPurchaseCustomLinkTokenTypeIOS$Compan public final fun fromJsonStrict (Ljava/lang/String;)Ldev/hyo/openiap/ExternalPurchaseCustomLinkTokenTypeIOS; } +public final class dev/hyo/openiap/ExternalPurchaseLinkAndroidProps { + public static final field $stable I + public static final field Companion Ldev/hyo/openiap/ExternalPurchaseLinkAndroidProps$Companion; + public fun ()V + public fun (Ldev/hyo/openiap/ExternalLinkTypeAndroid;)V + public synthetic fun (Ldev/hyo/openiap/ExternalLinkTypeAndroid;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ldev/hyo/openiap/ExternalLinkTypeAndroid; + public final fun copy (Ldev/hyo/openiap/ExternalLinkTypeAndroid;)Ldev/hyo/openiap/ExternalPurchaseLinkAndroidProps; + public static synthetic fun copy$default (Ldev/hyo/openiap/ExternalPurchaseLinkAndroidProps;Ldev/hyo/openiap/ExternalLinkTypeAndroid;ILjava/lang/Object;)Ldev/hyo/openiap/ExternalPurchaseLinkAndroidProps; + public fun equals (Ljava/lang/Object;)Z + public final fun getLinkType ()Ldev/hyo/openiap/ExternalLinkTypeAndroid; + public fun hashCode ()I + public final fun toJson ()Ljava/util/Map; + public fun toString ()Ljava/lang/String; +} + +public final class dev/hyo/openiap/ExternalPurchaseLinkAndroidProps$Companion { + public final fun fromJson (Ljava/util/Map;)Ldev/hyo/openiap/ExternalPurchaseLinkAndroidProps; +} + +public final class dev/hyo/openiap/ExternalPurchaseLinkIosProps { + public static final field $stable I + public static final field Companion Ldev/hyo/openiap/ExternalPurchaseLinkIosProps$Companion; + public fun ()V + public fun (Ldev/hyo/openiap/ExternalPurchaseCustomLinkTokenTypeIOS;)V + public synthetic fun (Ldev/hyo/openiap/ExternalPurchaseCustomLinkTokenTypeIOS;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ldev/hyo/openiap/ExternalPurchaseCustomLinkTokenTypeIOS; + public final fun copy (Ldev/hyo/openiap/ExternalPurchaseCustomLinkTokenTypeIOS;)Ldev/hyo/openiap/ExternalPurchaseLinkIosProps; + public static synthetic fun copy$default (Ldev/hyo/openiap/ExternalPurchaseLinkIosProps;Ldev/hyo/openiap/ExternalPurchaseCustomLinkTokenTypeIOS;ILjava/lang/Object;)Ldev/hyo/openiap/ExternalPurchaseLinkIosProps; + public fun equals (Ljava/lang/Object;)Z + public final fun getTokenType ()Ldev/hyo/openiap/ExternalPurchaseCustomLinkTokenTypeIOS; + public fun hashCode ()I + public final fun toJson ()Ljava/util/Map; + public fun toString ()Ljava/lang/String; +} + +public final class dev/hyo/openiap/ExternalPurchaseLinkIosProps$Companion { + public final fun fromJson (Ljava/util/Map;)Ldev/hyo/openiap/ExternalPurchaseLinkIosProps; +} + +public final class dev/hyo/openiap/ExternalPurchaseLinkRequest { + public static final field $stable I + public static final field Companion Ldev/hyo/openiap/ExternalPurchaseLinkRequest$Companion; + public fun (Ldev/hyo/openiap/ExternalPurchaseLinkIosProps;Ldev/hyo/openiap/ExternalPurchaseLinkAndroidProps;Ljava/lang/String;)V + public synthetic fun (Ldev/hyo/openiap/ExternalPurchaseLinkIosProps;Ldev/hyo/openiap/ExternalPurchaseLinkAndroidProps;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ldev/hyo/openiap/ExternalPurchaseLinkIosProps; + public final fun component2 ()Ldev/hyo/openiap/ExternalPurchaseLinkAndroidProps; + public final fun component3 ()Ljava/lang/String; + public final fun copy (Ldev/hyo/openiap/ExternalPurchaseLinkIosProps;Ldev/hyo/openiap/ExternalPurchaseLinkAndroidProps;Ljava/lang/String;)Ldev/hyo/openiap/ExternalPurchaseLinkRequest; + public static synthetic fun copy$default (Ldev/hyo/openiap/ExternalPurchaseLinkRequest;Ldev/hyo/openiap/ExternalPurchaseLinkIosProps;Ldev/hyo/openiap/ExternalPurchaseLinkAndroidProps;Ljava/lang/String;ILjava/lang/Object;)Ldev/hyo/openiap/ExternalPurchaseLinkRequest; + public fun equals (Ljava/lang/Object;)Z + public final fun getApple ()Ldev/hyo/openiap/ExternalPurchaseLinkIosProps; + public final fun getGoogle ()Ldev/hyo/openiap/ExternalPurchaseLinkAndroidProps; + public final fun getUrl ()Ljava/lang/String; + public fun hashCode ()I + public final fun toJson ()Ljava/util/Map; + public fun toString ()Ljava/lang/String; +} + +public final class dev/hyo/openiap/ExternalPurchaseLinkRequest$Companion { + public final fun fromJson (Ljava/util/Map;)Ldev/hyo/openiap/ExternalPurchaseLinkRequest; +} + +public final class dev/hyo/openiap/ExternalPurchaseLinkResult { + public static final field $stable I + public static final field Companion Ldev/hyo/openiap/ExternalPurchaseLinkResult$Companion; + public fun (Ldev/hyo/openiap/IapPlatform;Ljava/lang/String;Ldev/hyo/openiap/ExternalPurchaseLinkStatus;)V + public synthetic fun (Ldev/hyo/openiap/IapPlatform;Ljava/lang/String;Ldev/hyo/openiap/ExternalPurchaseLinkStatus;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ldev/hyo/openiap/IapPlatform; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ldev/hyo/openiap/ExternalPurchaseLinkStatus; + public final fun copy (Ldev/hyo/openiap/IapPlatform;Ljava/lang/String;Ldev/hyo/openiap/ExternalPurchaseLinkStatus;)Ldev/hyo/openiap/ExternalPurchaseLinkResult; + public static synthetic fun copy$default (Ldev/hyo/openiap/ExternalPurchaseLinkResult;Ldev/hyo/openiap/IapPlatform;Ljava/lang/String;Ldev/hyo/openiap/ExternalPurchaseLinkStatus;ILjava/lang/Object;)Ldev/hyo/openiap/ExternalPurchaseLinkResult; + public fun equals (Ljava/lang/Object;)Z + public final fun getPlatform ()Ldev/hyo/openiap/IapPlatform; + public final fun getReportToken ()Ljava/lang/String; + public final fun getStatus ()Ldev/hyo/openiap/ExternalPurchaseLinkStatus; + public fun hashCode ()I + public final fun toJson ()Ljava/util/Map; + public fun toString ()Ljava/lang/String; +} + +public final class dev/hyo/openiap/ExternalPurchaseLinkResult$Companion { + public final fun fromJson (Ljava/util/Map;)Ldev/hyo/openiap/ExternalPurchaseLinkResult; +} + public final class dev/hyo/openiap/ExternalPurchaseLinkResultIOS { public static final field $stable I public static final field Companion Ldev/hyo/openiap/ExternalPurchaseLinkResultIOS$Companion; @@ -937,6 +1023,23 @@ public final class dev/hyo/openiap/ExternalPurchaseLinkResultIOS$Companion { public final fun fromJson (Ljava/util/Map;)Ldev/hyo/openiap/ExternalPurchaseLinkResultIOS; } +public final class dev/hyo/openiap/ExternalPurchaseLinkStatus : java/lang/Enum { + public static final field Cancelled Ldev/hyo/openiap/ExternalPurchaseLinkStatus; + public static final field Companion Ldev/hyo/openiap/ExternalPurchaseLinkStatus$Companion; + public static final field Opened Ldev/hyo/openiap/ExternalPurchaseLinkStatus; + public static final field Unavailable Ldev/hyo/openiap/ExternalPurchaseLinkStatus; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public final fun getRawValue ()Ljava/lang/String; + public final fun toJson ()Ljava/lang/String; + public static fun valueOf (Ljava/lang/String;)Ldev/hyo/openiap/ExternalPurchaseLinkStatus; + public static fun values ()[Ldev/hyo/openiap/ExternalPurchaseLinkStatus; +} + +public final class dev/hyo/openiap/ExternalPurchaseLinkStatus$Companion { + public final fun fromJson (Ljava/lang/String;)Ldev/hyo/openiap/ExternalPurchaseLinkStatus; + public final fun fromJsonStrict (Ljava/lang/String;)Ldev/hyo/openiap/ExternalPurchaseLinkStatus; +} + public final class dev/hyo/openiap/ExternalPurchaseNoticeAction : java/lang/Enum { public static final field Companion Ldev/hyo/openiap/ExternalPurchaseNoticeAction$Companion; public static final field Continue Ldev/hyo/openiap/ExternalPurchaseNoticeAction; @@ -1331,26 +1434,27 @@ public final class dev/hyo/openiap/LimitedQuantityInfoAndroid$Companion { public final class dev/hyo/openiap/MutationHandlers { public static final field $stable I public fun ()V - public fun (Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;)V - public synthetic fun (Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public fun (Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)V - public synthetic fun (Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;)V + public synthetic fun (Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)V + public synthetic fun (Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lkotlin/jvm/functions/Function2; public final fun component10 ()Lkotlin/jvm/functions/Function2; public final fun component11 ()Lkotlin/jvm/functions/Function2; - public final fun component12 ()Lkotlin/jvm/functions/Function1; - public final fun component13 ()Lkotlin/jvm/functions/Function2; - public final fun component14 ()Lkotlin/jvm/functions/Function1; - public final fun component15 ()Lkotlin/jvm/functions/Function2; - public final fun component16 ()Lkotlin/jvm/functions/Function1; - public final fun component17 ()Lkotlin/jvm/functions/Function2; + public final fun component12 ()Lkotlin/jvm/functions/Function2; + public final fun component13 ()Lkotlin/jvm/functions/Function1; + public final fun component14 ()Lkotlin/jvm/functions/Function2; + public final fun component15 ()Lkotlin/jvm/functions/Function1; + public final fun component16 ()Lkotlin/jvm/functions/Function2; + public final fun component17 ()Lkotlin/jvm/functions/Function1; public final fun component18 ()Lkotlin/jvm/functions/Function2; public final fun component19 ()Lkotlin/jvm/functions/Function2; public final fun component2 ()Lkotlin/jvm/functions/Function2; - public final fun component20 ()Lkotlin/jvm/functions/Function1; + public final fun component20 ()Lkotlin/jvm/functions/Function2; public final fun component21 ()Lkotlin/jvm/functions/Function1; - public final fun component22 ()Lkotlin/jvm/functions/Function2; + public final fun component22 ()Lkotlin/jvm/functions/Function1; public final fun component23 ()Lkotlin/jvm/functions/Function2; + public final fun component24 ()Lkotlin/jvm/functions/Function2; public final fun component3 ()Lkotlin/jvm/functions/Function1; public final fun component4 ()Lkotlin/jvm/functions/Function2; public final fun component5 ()Lkotlin/jvm/functions/Function3; @@ -1358,8 +1462,8 @@ public final class dev/hyo/openiap/MutationHandlers { public final fun component7 ()Lkotlin/jvm/functions/Function1; public final fun component8 ()Lkotlin/jvm/functions/Function3; public final fun component9 ()Lkotlin/jvm/functions/Function2; - public final fun copy (Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;)Ldev/hyo/openiap/MutationHandlers; - public static synthetic fun copy$default (Ldev/hyo/openiap/MutationHandlers;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Ldev/hyo/openiap/MutationHandlers; + public final fun copy (Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;)Ldev/hyo/openiap/MutationHandlers; + public static synthetic fun copy$default (Ldev/hyo/openiap/MutationHandlers;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Ldev/hyo/openiap/MutationHandlers; public fun equals (Ljava/lang/Object;)Z public final fun getAcknowledgePurchaseAndroid ()Lkotlin/jvm/functions/Function2; public final fun getBeginRefundRequestIOS ()Lkotlin/jvm/functions/Function2; @@ -1371,6 +1475,7 @@ public final class dev/hyo/openiap/MutationHandlers { public final fun getFinishTransaction ()Lkotlin/jvm/functions/Function3; public final fun getInitConnection ()Lkotlin/jvm/functions/Function2; public final fun getLaunchExternalLinkAndroid ()Lkotlin/jvm/functions/Function2; + public final fun getOpenExternalPurchaseLink ()Lkotlin/jvm/functions/Function2; public final fun getOpenRedeemOfferCode ()Lkotlin/jvm/functions/Function1; public final fun getPresentExternalPurchaseLinkIOS ()Lkotlin/jvm/functions/Function2; public final fun getPresentExternalPurchaseNoticeSheetIOS ()Lkotlin/jvm/functions/Function1; @@ -1405,6 +1510,7 @@ public abstract interface class dev/hyo/openiap/MutationResolver { public static synthetic fun initConnection$default (Ldev/hyo/openiap/MutationResolver;Ldev/hyo/openiap/InitConnectionConfig;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public abstract fun isBillingProgramAvailableAndroid (Ldev/hyo/openiap/BillingProgramAndroid;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun launchExternalLinkAndroid (Ldev/hyo/openiap/LaunchExternalLinkParamsAndroid;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun openExternalPurchaseLink (Ldev/hyo/openiap/ExternalPurchaseLinkRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun openRedeemOfferCode (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun presentExternalPurchaseLinkIOS (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun presentExternalPurchaseNoticeSheetIOS (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -2041,7 +2147,9 @@ public abstract interface class dev/hyo/openiap/OpenIapProtocol { public abstract fun getVerifyPurchase ()Lkotlin/jvm/functions/Function2; public abstract fun getVerifyPurchaseWithProvider ()Lkotlin/jvm/functions/Function2; public abstract fun isBillingProgramAvailable (Ldev/hyo/openiap/BillingProgramAndroid;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun isExternalPurchaseLinkAvailable (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun launchExternalLink (Landroid/app/Activity;Ldev/hyo/openiap/LaunchExternalLinkParamsAndroid;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun openExternalPurchaseLink (Landroid/app/Activity;Ldev/hyo/openiap/ExternalPurchaseLinkRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun openRedeemOfferCode (Landroid/app/Activity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun removeConnectionStateListener (Ldev/hyo/openiap/listener/OpenIapConnectionStateListener;)V public abstract fun removeDeveloperProvidedBillingListener (Ldev/hyo/openiap/listener/OpenIapDeveloperProvidedBillingListener;)V @@ -2062,6 +2170,8 @@ public final class dev/hyo/openiap/OpenIapProtocol$DefaultImpls { public static fun getAvailableItems (Ldev/hyo/openiap/OpenIapProtocol;Ldev/hyo/openiap/ProductQueryType;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static fun getStorefront (Ldev/hyo/openiap/OpenIapProtocol;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static fun getVerifyAppOwnership (Ldev/hyo/openiap/OpenIapProtocol;)Lkotlin/jvm/functions/Function2; + public static fun isExternalPurchaseLinkAvailable (Ldev/hyo/openiap/OpenIapProtocol;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static fun openExternalPurchaseLink (Ldev/hyo/openiap/OpenIapProtocol;Landroid/app/Activity;Ldev/hyo/openiap/ExternalPurchaseLinkRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static fun showAlternativeBillingOnlyInformationDialog (Ldev/hyo/openiap/OpenIapProtocol;Landroid/app/Activity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun showInAppMessages$default (Ldev/hyo/openiap/OpenIapProtocol;Landroid/app/Activity;Ldev/hyo/openiap/InAppMessageParamsAndroid;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; } @@ -2965,10 +3075,10 @@ public final class dev/hyo/openiap/PurchaseVerificationProvider$Companion { public final class dev/hyo/openiap/QueryHandlers { public static final field $stable I public fun ()V - public fun (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;)V - public synthetic fun (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public fun (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;)V - public synthetic fun (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;)V + public synthetic fun (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;)V + public synthetic fun (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lkotlin/jvm/functions/Function1; public final fun component10 ()Lkotlin/jvm/functions/Function1; public final fun component11 ()Lkotlin/jvm/functions/Function1; @@ -2978,10 +3088,11 @@ public final class dev/hyo/openiap/QueryHandlers { public final fun component15 ()Lkotlin/jvm/functions/Function2; public final fun component16 ()Lkotlin/jvm/functions/Function1; public final fun component17 ()Lkotlin/jvm/functions/Function2; - public final fun component18 ()Lkotlin/jvm/functions/Function2; + public final fun component18 ()Lkotlin/jvm/functions/Function1; public final fun component19 ()Lkotlin/jvm/functions/Function2; public final fun component2 ()Lkotlin/jvm/functions/Function2; public final fun component20 ()Lkotlin/jvm/functions/Function2; + public final fun component21 ()Lkotlin/jvm/functions/Function2; public final fun component3 ()Lkotlin/jvm/functions/Function2; public final fun component4 ()Lkotlin/jvm/functions/Function2; public final fun component5 ()Lkotlin/jvm/functions/Function1; @@ -2989,8 +3100,8 @@ public final class dev/hyo/openiap/QueryHandlers { public final fun component7 ()Lkotlin/jvm/functions/Function2; public final fun component8 ()Lkotlin/jvm/functions/Function2; public final fun component9 ()Lkotlin/jvm/functions/Function2; - public final fun copy (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;)Ldev/hyo/openiap/QueryHandlers; - public static synthetic fun copy$default (Ldev/hyo/openiap/QueryHandlers;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Ldev/hyo/openiap/QueryHandlers; + public final fun copy (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;)Ldev/hyo/openiap/QueryHandlers; + public static synthetic fun copy$default (Ldev/hyo/openiap/QueryHandlers;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Ldev/hyo/openiap/QueryHandlers; public fun equals (Ljava/lang/Object;)Z public final fun getCanPresentExternalPurchaseNoticeIOS ()Lkotlin/jvm/functions/Function1; public final fun getCurrentEntitlementIOS ()Lkotlin/jvm/functions/Function2; @@ -3013,6 +3124,7 @@ public final class dev/hyo/openiap/QueryHandlers { public fun hashCode ()I public final fun isEligibleForExternalPurchaseCustomLinkIOS ()Lkotlin/jvm/functions/Function1; public final fun isEligibleForIntroOfferIOS ()Lkotlin/jvm/functions/Function2; + public final fun isExternalPurchaseLinkAvailable ()Lkotlin/jvm/functions/Function1; public final fun isTransactionVerifiedIOS ()Lkotlin/jvm/functions/Function2; public fun toString ()Ljava/lang/String; } @@ -3038,6 +3150,7 @@ public abstract interface class dev/hyo/openiap/QueryResolver { public static synthetic fun hasActiveSubscriptions$default (Ldev/hyo/openiap/QueryResolver;Ljava/util/List;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public abstract fun isEligibleForExternalPurchaseCustomLinkIOS (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun isEligibleForIntroOfferIOS (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun isExternalPurchaseLinkAvailable (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun isTransactionVerifiedIOS (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun latestTransactionIOS (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun subscriptionStatusIOS (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; @@ -4684,7 +4797,9 @@ public final class dev/hyo/openiap/store/OpenIapStore { public final fun initConnection (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun isBillingProgramAvailable (Ldev/hyo/openiap/BillingProgramAndroid;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun isConnected ()Lkotlinx/coroutines/flow/StateFlow; + public final fun isExternalPurchaseLinkAvailable (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun launchExternalLink (Landroid/app/Activity;Ldev/hyo/openiap/LaunchExternalLinkParamsAndroid;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun openExternalPurchaseLink (Landroid/app/Activity;Ldev/hyo/openiap/ExternalPurchaseLinkRequest;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun openRedeemOfferCode (Landroid/app/Activity;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun postStatusMessage (Ljava/lang/String;Ldev/hyo/openiap/store/PurchaseResultStatus;Ljava/lang/String;)V public static synthetic fun postStatusMessage$default (Ldev/hyo/openiap/store/OpenIapStore;Ljava/lang/String;Ldev/hyo/openiap/store/PurchaseResultStatus;Ljava/lang/String;ILjava/lang/Object;)V 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 new file mode 100644 index 000000000..65b0e9fa9 --- /dev/null +++ b/packages/google/core/src/test/java/dev/hyo/openiap/ExternalPurchaseLinkTest.kt @@ -0,0 +1,125 @@ +package dev.hyo.openiap + +import android.app.Activity +import java.lang.reflect.Proxy +import kotlin.coroutines.Continuation +import kotlin.coroutines.intrinsics.COROUTINE_SUSPENDED +import kotlin.coroutines.resumeWithException +import kotlinx.coroutines.runBlocking +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class ExternalPurchaseLinkTest { + private val request = ExternalPurchaseLinkRequest(url = "https://example.com/checkout") + private val activity: Activity get() = Robolectric.buildActivity(Activity::class.java).setup().get() + + @Test + fun `unavailable program shows no disclosure`() = runBlocking { + val calls = mutableListOf() + val provider = provider(available = false, calls = calls) + + assertFalse(provider.externalPurchaseLinkAvailable()) + val result = provider.openExternalPurchaseLinkFlow(activity, request) + + assertEquals(ExternalPurchaseLinkStatus.Unavailable, result.status) + assertNull(result.reportToken) + assertEquals(listOf("isBillingProgramAvailable", "isBillingProgramAvailable"), calls) + } + + @Test + fun `unsupported provider is unavailable`() = runBlocking { + val provider = provider { method, _ -> + check(method == "isBillingProgramAvailable") + throw OpenIapError.FeatureNotSupported("unsupported") + } + + 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() }) + + val result = provider.openExternalPurchaseLinkFlow(activity, request) + + assertEquals(ExternalPurchaseLinkStatus.Cancelled, result.status) + assertNull(result.reportToken) + } + + @Test + fun `opened link returns the external offer token`() = runBlocking { + var launched: LaunchExternalLinkParamsAndroid? = null + val provider = provider(available = true, launch = { launched = it; true }) + + val result = provider.openExternalPurchaseLinkFlow( + activity, + request.copy(google = ExternalPurchaseLinkAndroidProps(linkType = ExternalLinkTypeAndroid.LinkToAppDownload)), + ) + + assertEquals(ExternalPurchaseLinkStatus.Opened, result.status) + assertEquals(IapPlatform.Android, result.platform) + assertEquals("play-token", result.reportToken) + assertEquals(BillingProgramAndroid.ExternalOffer, launched?.billingProgram) + assertEquals(ExternalLinkLaunchModeAndroid.LaunchInExternalBrowserOrApp, launched?.launchMode) + assertEquals(ExternalLinkTypeAndroid.LinkToAppDownload, launched?.linkType) + assertEquals("https://example.com/checkout", launched?.linkUri) + } + + @Test + fun `launch failure propagates`() { + val provider = provider(available = true, launch = { throw OpenIapError.ServiceUnavailable() }) + + assertThrows(OpenIapError.ServiceUnavailable::class.java) { + runBlocking { provider.openExternalPurchaseLinkFlow(activity, request) } + } + } + + private fun provider( + available: Boolean, + calls: MutableList = mutableListOf(), + launch: (LaunchExternalLinkParamsAndroid) -> Boolean = { error("Unexpected launch") }, + ): OpenIapProtocol = provider { method, args -> + calls.add(method) + when (method) { + "isBillingProgramAvailable" -> BillingProgramAvailabilityResultAndroid( + billingProgram = args?.first() as BillingProgramAndroid, + isAvailable = available, + ) + "createBillingProgramReportingDetails" -> BillingProgramReportingDetailsAndroid( + billingProgram = args?.first() as BillingProgramAndroid, + externalTransactionToken = "play-token", + ) + "launchExternalLink" -> launch(args?.get(1) as LaunchExternalLinkParamsAndroid) + else -> error("Unexpected call: $method") + } + } + + private fun provider(invoke: (String, Array?) -> Any?): OpenIapProtocol = + Proxy.newProxyInstance(OpenIapProtocol::class.java.classLoader, arrayOf(OpenIapProtocol::class.java)) { _, method, args -> + try { + invoke(method.name, args) + } catch (error: OpenIapError) { + // A proxy cannot throw a checked exception; resume the suspend call with it instead. + @Suppress("UNCHECKED_CAST") + (args!!.last() as Continuation).resumeWithException(error) + COROUTINE_SUSPENDED + } + } as OpenIapProtocol +} 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 8b91ff3c2..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 @@ -958,6 +958,7 @@ class OpenIapModule( } override val queryHandlers: QueryHandlers = QueryHandlers( + isExternalPurchaseLinkAvailable = { isExternalPurchaseLinkAvailable() }, verifyAppOwnership = verifyAppOwnership, fetchProducts = fetchProducts, getActiveSubscriptions = getActiveSubscriptions, @@ -970,6 +971,18 @@ class OpenIapModule( ) override val mutationHandlers: MutationHandlers = MutationHandlers( + openExternalPurchaseLink = { request -> + val activity = currentActivityRef?.get() + 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, createBillingProgramReportingDetailsAndroid = { program, developerBillingType -> 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 5806abdb3..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 @@ -1508,6 +1508,7 @@ class OpenIapModule( } override val queryHandlers: QueryHandlers = QueryHandlers( + isExternalPurchaseLinkAvailable = { isExternalPurchaseLinkAvailable() }, verifyAppOwnership = verifyAppOwnership, fetchProducts = fetchProducts, getActiveSubscriptions = getActiveSubscriptions, @@ -1520,6 +1521,18 @@ class OpenIapModule( ) override val mutationHandlers: MutationHandlers = MutationHandlers( + openExternalPurchaseLink = { request -> + val activity = currentActivityRef?.get() ?: fallbackActivity + 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, createBillingProgramReportingDetailsAndroid = { program, developerBillingType -> 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 new file mode 100644 index 000000000..9d1456e1c --- /dev/null +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapExternalPurchaseLink.kt @@ -0,0 +1,41 @@ +package dev.hyo.openiap + +import android.app.Activity + +// Shared external purchase link flow on Play's external offers program. Play shows +// its own disclosure inside launchExternalLink, so the link cannot open without it. + +internal suspend fun OpenIapProtocol.externalPurchaseLinkAvailable(): Boolean = + try { + 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( + activity: Activity, + request: ExternalPurchaseLinkRequest, +): ExternalPurchaseLinkResult { + if (!externalPurchaseLinkAvailable()) { + return ExternalPurchaseLinkResult(platform = IapPlatform.Android, status = ExternalPurchaseLinkStatus.Unavailable) + } + val token = createBillingProgramReportingDetails(BillingProgramAndroid.ExternalOffer).externalTransactionToken + val params = LaunchExternalLinkParamsAndroid( + billingProgram = BillingProgramAndroid.ExternalOffer, + launchMode = ExternalLinkLaunchModeAndroid.LaunchInExternalBrowserOrApp, + linkType = request.google?.linkType ?: ExternalLinkTypeAndroid.LinkToDigitalContentOffer, + linkUri = request.url, + ) + val opened = try { + launchExternalLink(activity, params) + } catch (_: OpenIapError.UserCancelled) { + false + } + return if (opened) { + ExternalPurchaseLinkResult(platform = IapPlatform.Android, reportToken = token, status = ExternalPurchaseLinkStatus.Opened) + } else { + ExternalPurchaseLinkResult(platform = IapPlatform.Android, status = ExternalPurchaseLinkStatus.Cancelled) + } +} diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapProtocol.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapProtocol.kt index 6f568f7e1..d8db69c9d 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapProtocol.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapProtocol.kt @@ -99,6 +99,18 @@ interface OpenIapProtocol { */ suspend fun launchExternalLink(activity: Activity, params: LaunchExternalLinkParamsAndroid): Boolean + /** Whether this device and storefront may link out with [openExternalPurchaseLink]. */ + suspend fun isExternalPurchaseLinkAvailable(): Boolean = externalPurchaseLinkAvailable() + + /** + * Show the store disclosure, open the link only after the user continues, + * and return the token to report from your server. + */ + suspend fun openExternalPurchaseLink( + activity: Activity, + request: ExternalPurchaseLinkRequest + ): ExternalPurchaseLinkResult = openExternalPurchaseLinkFlow(activity, request) + /** * Fetch Play Billing choice display assets for developer-rendered Billing Choice. */ 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 dbd92609b..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 @@ -719,6 +719,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+) */ @@ -2557,6 +2598,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 */ @@ -4820,6 +4899,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+). @@ -6263,6 +6430,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; @@ -6275,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. @@ -6363,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+). @@ -6455,13 +6638,24 @@ 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. * 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 @@ -6555,6 +6749,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 @@ -6650,6 +6845,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; @@ -6662,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. @@ -6755,6 +6964,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, @@ -6768,7 +6978,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 } @@ -6783,18 +6993,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, @@ -6808,6 +7019,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, @@ -6832,6 +7044,7 @@ public class MutationHandlers( initConnection = initConnection, isBillingProgramAvailableAndroid = isBillingProgramAvailableAndroid, launchExternalLinkAndroid = launchExternalLinkAndroid, + openExternalPurchaseLink = openExternalPurchaseLink, openRedeemOfferCode = openRedeemOfferCode, presentExternalPurchaseLinkIOS = presentExternalPurchaseLinkIOS, presentExternalPurchaseNoticeSheetIOS = presentExternalPurchaseNoticeSheetIOS, @@ -6848,7 +7061,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) @@ -6862,6 +7075,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) @@ -6877,7 +7091,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 @@ -6899,6 +7113,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 @@ -6909,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+). @@ -7001,13 +7218,24 @@ 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. * 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 @@ -7051,11 +7279,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 } @@ -7076,9 +7305,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, @@ -7098,6 +7328,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, @@ -7119,6 +7350,7 @@ public class QueryHandlers( hasActiveSubscriptions = hasActiveSubscriptions, isEligibleForExternalPurchaseCustomLinkIOS = isEligibleForExternalPurchaseCustomLinkIOS, isEligibleForIntroOfferIOS = isEligibleForIntroOfferIOS, + isExternalPurchaseLinkAvailable = isExternalPurchaseLinkAvailable, isTransactionVerifiedIOS = isTransactionVerifiedIOS, latestTransactionIOS = latestTransactionIOS, subscriptionStatusIOS = subscriptionStatusIOS, @@ -7126,7 +7358,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) @@ -7146,13 +7378,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/packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt index ec854ed39..cef1f72b8 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt @@ -45,6 +45,8 @@ import dev.hyo.openiap.DeveloperBillingTypeAndroid import dev.hyo.openiap.GetBillingChoiceInfoParamsAndroid import dev.hyo.openiap.InAppMessageParamsAndroid import dev.hyo.openiap.InAppMessageResultAndroid +import dev.hyo.openiap.ExternalPurchaseLinkRequest +import dev.hyo.openiap.ExternalPurchaseLinkResult import dev.hyo.openiap.LaunchExternalLinkParamsAndroid import android.app.Activity import android.content.Context @@ -570,6 +572,23 @@ class OpenIapStore(private val module: OpenIapProtocol) { suspend fun launchExternalLink(activity: Activity, params: LaunchExternalLinkParamsAndroid): Boolean = module.launchExternalLink(activity, params) + /** + * Whether this device and storefront may link out to an external purchase. + * + * @see https://openiap.dev/docs/apis/is-external-purchase-link-available + */ + suspend fun isExternalPurchaseLinkAvailable(): Boolean = module.isExternalPurchaseLinkAvailable() + + /** + * Show Play's disclosure, open the link only after the user continues, and return the report token. + * + * @see https://openiap.dev/docs/apis/open-external-purchase-link + */ + suspend fun openExternalPurchaseLink( + activity: Activity, + request: ExternalPurchaseLinkRequest + ): ExternalPurchaseLinkResult = module.openExternalPurchaseLink(activity, request) + /** * Open the Google Play offer/promo code redemption page (https://play.google.com/redeem) * so the user can enter a code. A listener can receive the redeemed purchase while the 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 b3fe8b63d..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 @@ -2367,6 +2367,7 @@ class OpenIapModule( } override val queryHandlers: QueryHandlers = QueryHandlers( + isExternalPurchaseLinkAvailable = { isExternalPurchaseLinkAvailable() }, verifyAppOwnership = verifyAppOwnership, fetchProducts = fetchProducts, getActiveSubscriptions = getActiveSubscriptions, @@ -2379,6 +2380,18 @@ class OpenIapModule( ) override val mutationHandlers: MutationHandlers = MutationHandlers( + openExternalPurchaseLink = { request -> + val activity = currentActivityRef?.get() ?: fallbackActivity + 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, createBillingProgramReportingDetailsAndroid = { program, developerBillingType -> 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/scripts/audit-non-godot-parity.mjs b/scripts/audit-non-godot-parity.mjs index 01b5d5e2f..3be01c21f 100644 --- a/scripts/audit-non-godot-parity.mjs +++ b/scripts/audit-non-godot-parity.mjs @@ -148,6 +148,7 @@ const operationParityRegistry = { "initConnection", "isBillingProgramAvailableAndroid", "launchExternalLinkAndroid", + "openExternalPurchaseLink", "openRedeemOfferCode", "presentExternalPurchaseLinkIOS", "presentExternalPurchaseNoticeSheetIOS", @@ -179,6 +180,7 @@ const operationParityRegistry = { "getTransactionJwsIOS", "hasActiveSubscriptions", "isEligibleForExternalPurchaseCustomLinkIOS", + "isExternalPurchaseLinkAvailable", "isEligibleForIntroOfferIOS", "isTransactionVerifiedIOS", "latestTransactionIOS", @@ -2298,6 +2300,7 @@ function checkFlutter() { "canPresentExternalPurchaseNoticeIOS", "presentExternalPurchaseNoticeSheetIOS", "isEligibleForExternalPurchaseCustomLinkIOS", + "isExternalPurchaseLinkAvailable", "getExternalPurchaseCustomLinkTokenIOS", "showExternalPurchaseCustomLinkNoticeIOS", ]; 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/api.graphql b/specs/client/src/api.graphql index 1bb0334a9..c5367d175 100644 --- a/specs/client/src/api.graphql +++ b/specs/client/src/api.graphql @@ -42,6 +42,16 @@ extend type Query { """ # Future getStorefront: String! + """ + 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: Boolean! } # Request APIs (event driven) @@ -97,6 +107,19 @@ extend type Mutation { # Future openRedeemOfferCode: Purchase """ + 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( + request: ExternalPurchaseLinkRequest! + ): ExternalPurchaseLinkResult! + """ 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 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 f53a8a65e..93c414fcb 100644 --- a/specs/client/src/generated/Types.cs +++ b/specs/client/src/generated/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; @@ -5412,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); /// @@ -5420,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(); /// @@ -5510,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(); /// @@ -5617,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(); /// @@ -5626,6 +5778,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/specs/client/src/generated/Types.kt b/specs/client/src/generated/Types.kt index c940b5944..ab09fede9 100644 --- a/specs/client/src/generated/Types.kt +++ b/specs/client/src/generated/Types.kt @@ -693,6 +693,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+) */ @@ -2502,6 +2543,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 */ @@ -4765,6 +4844,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+). @@ -6208,6 +6375,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; @@ -6220,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. @@ -6308,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+). @@ -6400,13 +6583,24 @@ 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. * 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 @@ -6500,6 +6694,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 @@ -6595,6 +6790,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; @@ -6607,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. @@ -6700,6 +6909,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, @@ -6713,7 +6923,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 } @@ -6728,18 +6938,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, @@ -6753,6 +6964,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, @@ -6777,6 +6989,7 @@ public class MutationHandlers( initConnection = initConnection, isBillingProgramAvailableAndroid = isBillingProgramAvailableAndroid, launchExternalLinkAndroid = launchExternalLinkAndroid, + openExternalPurchaseLink = openExternalPurchaseLink, openRedeemOfferCode = openRedeemOfferCode, presentExternalPurchaseLinkIOS = presentExternalPurchaseLinkIOS, presentExternalPurchaseNoticeSheetIOS = presentExternalPurchaseNoticeSheetIOS, @@ -6793,7 +7006,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) @@ -6807,6 +7020,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) @@ -6822,7 +7036,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 @@ -6844,6 +7058,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 @@ -6854,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+). @@ -6946,13 +7163,24 @@ 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. * 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 @@ -6996,11 +7224,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 } @@ -7021,9 +7250,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, @@ -7043,6 +7273,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, @@ -7064,6 +7295,7 @@ public class QueryHandlers( hasActiveSubscriptions = hasActiveSubscriptions, isEligibleForExternalPurchaseCustomLinkIOS = isEligibleForExternalPurchaseCustomLinkIOS, isEligibleForIntroOfferIOS = isEligibleForIntroOfferIOS, + isExternalPurchaseLinkAvailable = isExternalPurchaseLinkAvailable, isTransactionVerifiedIOS = isTransactionVerifiedIOS, latestTransactionIOS = latestTransactionIOS, subscriptionStatusIOS = subscriptionStatusIOS, @@ -7071,7 +7303,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) @@ -7091,13 +7323,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/specs/client/src/generated/Types.swift b/specs/client/src/generated/Types.swift index e333be3ab..137697392 100644 --- a/specs/client/src/generated/Types.swift +++ b/specs/client/src/generated/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. @@ -4737,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. @@ -4805,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 @@ -4867,10 +4977,19 @@ 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 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 +5067,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,8 +5094,11 @@ public struct MutationHandlers { public var initConnection: MutationInitConnectionHandler? public var isBillingProgramAvailableAndroid: MutationIsBillingProgramAvailableAndroidHandler? 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? @@ -5000,6 +5123,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 +5149,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,12 +5185,14 @@ 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] 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? @@ -5081,8 +5208,10 @@ 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? public var isTransactionVerifiedIOS: QueryIsTransactionVerifiedIOSHandler? public var latestTransactionIOS: QueryLatestTransactionIOSHandler? public var subscriptionStatusIOS: QuerySubscriptionStatusIOSHandler? @@ -5106,6 +5235,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 +5258,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/specs/client/src/generated/types.dart b/specs/client/src/generated/types.dart index 6feb0e916..1d2dfa9bc 100644 --- a/specs/client/src/generated/types.dart +++ b/specs/client/src/generated/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. @@ -6232,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. @@ -6306,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 @@ -6379,10 +6551,19 @@ 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 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 +6658,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 +6698,7 @@ class MutationHandlers { this.initConnection, this.isBillingProgramAvailableAndroid, this.launchExternalLinkAndroid, + this.openExternalPurchaseLink, this.openRedeemOfferCode, this.presentExternalPurchaseLinkIOS, this.presentExternalPurchaseNoticeSheetIOS, @@ -6542,8 +6725,11 @@ class MutationHandlers { final MutationInitConnectionHandler? initConnection; final MutationIsBillingProgramAvailableAndroidHandler? isBillingProgramAvailableAndroid; 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; @@ -6587,6 +6773,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,12 +6801,14 @@ class QueryHandlers { this.hasActiveSubscriptions, this.isEligibleForExternalPurchaseCustomLinkIOS, this.isEligibleForIntroOfferIOS, + this.isExternalPurchaseLinkAvailable, this.isTransactionVerifiedIOS, this.latestTransactionIOS, this.subscriptionStatusIOS, this.verifyAppOwnership, }); + @Deprecated('Use isExternalPurchaseLinkAvailable instead. Scheduled for removal in client protocol 2.0.0.') final QueryCanPresentExternalPurchaseNoticeIOSHandler? canPresentExternalPurchaseNoticeIOS; final QueryCurrentEntitlementIOSHandler? currentEntitlementIOS; final QueryFetchProductsHandler? fetchProducts; @@ -6635,8 +6824,10 @@ 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; final QueryIsTransactionVerifiedIOSHandler? isTransactionVerifiedIOS; final QueryLatestTransactionIOSHandler? latestTransactionIOS; final QuerySubscriptionStatusIOSHandler? subscriptionStatusIOS; diff --git a/specs/client/src/generated/types.gd b/specs/client/src/generated/types.gd index e2d90fe26..a7e548dfa 100644 --- a/specs/client/src/generated/types.gd +++ b/specs/client/src/generated/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" @@ -6727,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" @@ -6736,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" @@ -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" @@ -7130,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" @@ -7139,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" @@ -7398,15 +7614,19 @@ 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 {} -## 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 {} @@ -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 = {} @@ -7575,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 f6ed9d9e5..ea01521dd 100644 --- a/specs/client/src/generated/types.ts +++ b/specs/client/src/generated/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; @@ -852,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; /** @@ -859,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; /** @@ -955,6 +1027,8 @@ export type MutationIsBillingProgramAvailableAndroidArgs = BillingProgramAndroid export type MutationLaunchExternalLinkAndroidArgs = LaunchExternalLinkParamsAndroid; +export type MutationOpenExternalPurchaseLinkArgs = ExternalPurchaseLinkRequest; + export type MutationPresentExternalPurchaseLinkIosArgs = string; export type MutationRequestPurchaseArgs = RequestPurchaseProps; @@ -1423,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; /** @@ -1514,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; /** @@ -1521,6 +1597,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 +2455,7 @@ export type MutationArgsMap = { initConnection: MutationInitConnectionArgs; isBillingProgramAvailableAndroid: MutationIsBillingProgramAvailableAndroidArgs; launchExternalLinkAndroid: MutationLaunchExternalLinkAndroidArgs; + openExternalPurchaseLink: MutationOpenExternalPurchaseLinkArgs; openRedeemOfferCode: never; presentExternalPurchaseLinkIOS: MutationPresentExternalPurchaseLinkIosArgs; presentExternalPurchaseNoticeSheetIOS: never; @@ -2416,6 +2502,7 @@ export type QueryArgsMap = { hasActiveSubscriptions: QueryHasActiveSubscriptionsArgs; isEligibleForExternalPurchaseCustomLinkIOS: never; isEligibleForIntroOfferIOS: QueryIsEligibleForIntroOfferIosArgs; + isExternalPurchaseLinkAvailable: never; isTransactionVerifiedIOS: QueryIsTransactionVerifiedIosArgs; latestTransactionIOS: QueryLatestTransactionIosArgs; subscriptionStatusIOS: QuerySubscriptionStatusIosArgs; 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([]); }); diff --git a/specs/client/src/type-android.graphql b/specs/client/src/type-android.graphql index df31879e9..8ffb45a68 100644 --- a/specs/client/src/type-android.graphql +++ b/specs/client/src/type-android.graphql @@ -1066,3 +1066,14 @@ input PlayIntegrityParamsAndroid { """Hash of the server challenge and request data; at most 500 UTF-8 bytes.""" requestHash: String! } + +""" +Google options for openExternalPurchaseLink. +Available in Client Protocol 1.5.0 / openiap-google 4.2.0 (requires Play Billing 8.2.1+). +""" +input ExternalPurchaseLinkAndroidProps { + """ + Link type passed to Play. Defaults to LINK_TO_DIGITAL_CONTENT_OFFER. + """ + linkType: ExternalLinkTypeAndroid +} diff --git a/specs/client/src/type-ios.graphql b/specs/client/src/type-ios.graphql index a3ca86fc0..8b34e8176 100644 --- a/specs/client/src/type-ios.graphql +++ b/specs/client/src/type-ios.graphql @@ -819,3 +819,16 @@ type AdvancedCommerceRefundIOS { """ jsonRepresentation: String } + +""" +Apple options for openExternalPurchaseLink. +Available in Client Protocol 1.5.0 / openiap-apple 4.2.0. +""" +input 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 +} diff --git a/specs/client/src/type.graphql b/specs/client/src/type.graphql index d366f089b..a47cca9a9 100644 --- a/specs/client/src/type.graphql +++ b/specs/client/src/type.graphql @@ -253,6 +253,65 @@ input PurchaseInput { isAutoRenewing: Boolean! } +""" +Request for openExternalPurchaseLink. +Available in Client Protocol 1.5.0 / openiap-apple 4.2.0 / openiap-google 4.2.0. +""" +input ExternalPurchaseLinkRequest { + """ + External purchase URL. On iOS 17.4 to 18.0 it must match the + SKExternalPurchase link in Info.plist. + """ + url: String! + """ + Apple-specific options + """ + apple: ExternalPurchaseLinkIosProps + """ + Google-specific options + """ + google: ExternalPurchaseLinkAndroidProps +} + +""" +Result of openExternalPurchaseLink. +Available in Client Protocol 1.5.0 / openiap-apple 4.2.0 / openiap-google 4.2.0. +""" +type ExternalPurchaseLinkResult { + """ + What happened to the link + """ + status: ExternalPurchaseLinkStatus! + """ + Token to report from your server: Play's externalTransactionToken or Apple's + external purchase token. Present only when status is Opened. + """ + reportToken: String + """ + Platform that produced reportToken + """ + platform: IapPlatform! +} + +""" +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 + """ + 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 +} + # Options for deep linking into subscription management input DeepLinkOptions { """