From 66319166d34f769527c2e3fe6148fbf6f99948b4 Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 11 Dec 2025 11:57:59 +0900 Subject: [PATCH 1/8] feat(android): add Google Play Billing 8.1.0/8.2.0 APIs - Add SubscriptionProductReplacementParams API (8.1.0+) - Add Billing Programs APIs: enableBillingProgram, isBillingProgramAvailable, createBillingProgramReportingDetails, launchExternalLink (8.2.0+) - Deprecate old External Offer APIs in favor of Billing Programs - Fix ReplacementMode constants in Example app - Update documentation with new APIs and examples - Generate types for all platforms (TypeScript, Kotlin, Swift, Dart) --- packages/apple/Sources/Models/Types.swift | 145 ++++++- .../subscription-upgrade-downgrade.tsx | 140 ++++++- .../martie/screens/SubscriptionFlowScreen.kt | 25 +- .../java/dev/hyo/openiap/OpenIapModule.kt | 22 ++ .../dev/hyo/openiap/helpers/SharedHelpers.kt | 4 + .../java/dev/hyo/openiap/OpenIapProtocol.kt | 32 ++ .../src/main/java/dev/hyo/openiap/Types.kt | 366 +++++++++++++++++- .../dev/hyo/openiap/store/OpenIapStore.kt | 40 ++ .../java/dev/hyo/openiap/OpenIapModule.kt | 360 +++++++++++++++++ .../java/dev/hyo/openiap/helpers/Helpers.kt | 4 + .../hyo/openiap/utils/BillingConverters.kt | 8 +- packages/gql/src/generated/Types.kt | 359 ++++++++++++++++- packages/gql/src/generated/Types.swift | 145 ++++++- packages/gql/src/generated/types.dart | 350 +++++++++++++++++ packages/gql/src/generated/types.ts | 111 +++++- packages/gql/src/type-android.graphql | 210 ++++++++++ 16 files changed, 2292 insertions(+), 29 deletions(-) diff --git a/packages/apple/Sources/Models/Types.swift b/packages/apple/Sources/Models/Types.swift index 1d17f520..e4681362 100644 --- a/packages/apple/Sources/Models/Types.swift +++ b/packages/apple/Sources/Models/Types.swift @@ -20,6 +20,19 @@ public enum AlternativeBillingModeAndroid: String, Codable, CaseIterable { case alternativeOnly = "alternative-only" } +/// Billing program types for external content links and external offers (Android) +/// Available in Google Play Billing Library 8.2.0+ +public enum BillingProgramAndroid: String, Codable, CaseIterable { + /// Unspecified billing program. Do not use. + case unspecified = "unspecified" + /// External Content Links program. + /// Allows linking to external content outside the app. + case externalContentLink = "external-content-link" + /// External Offers program. + /// Allows offering digital content purchases outside the app. + case externalOffer = "external-offer" +} + public enum ErrorCode: String, Codable, CaseIterable { case unknown = "unknown" case userCancelled = "user-cancelled" @@ -144,6 +157,30 @@ public enum ErrorCode: String, Codable, CaseIterable { } } +/// Launch mode for external link flow (Android) +/// Determines how the external URL is launched +/// Available in Google Play Billing Library 8.2.0+ +public enum ExternalLinkLaunchModeAndroid: String, Codable, CaseIterable { + /// Unspecified launch mode. Do not use. + case unspecified = "unspecified" + /// Play will launch the URL in an external browser or eligible app + case launchInExternalBrowserOrApp = "launch-in-external-browser-or-app" + /// Play will not launch the URL. The app handles launching the URL after Play returns control. + case callerWillLaunchLink = "caller-will-launch-link" +} + +/// Link type for external link flow (Android) +/// Specifies the type of external link destination +/// Available in Google Play Billing Library 8.2.0+ +public enum ExternalLinkTypeAndroid: String, Codable, CaseIterable { + /// Unspecified link type. Do not use. + case unspecified = "unspecified" + /// The link will direct users to a digital content offer + case linkToDigitalContentOffer = "link-to-digital-content-offer" + /// The link will direct users to download an app + case linkToAppDownload = "link-to-app-download" +} + /// User actions on external purchase notice sheet (iOS 18.2+) public enum ExternalPurchaseNoticeAction: String, Codable, CaseIterable { /// User chose to continue to external purchase @@ -244,6 +281,26 @@ public enum SubscriptionPeriodIOS: String, Codable, CaseIterable { case empty = "empty" } +/// Replacement mode for subscription changes (Android) +/// These modes determine how the subscription replacement affects billing. +/// Available in Google Play Billing Library 8.1.0+ +public enum SubscriptionReplacementModeAndroid: String, Codable, CaseIterable { + /// Unknown replacement mode. Do not use. + case unknownReplacementMode = "unknown-replacement-mode" + /// Replacement takes effect immediately, and the new expiration time will be prorated. + case withTimeProration = "with-time-proration" + /// Replacement takes effect immediately, and the billing cycle remains the same. + case chargeProratedPrice = "charge-prorated-price" + /// Replacement takes effect immediately, and the user is charged full price immediately. + case chargeFullPrice = "charge-full-price" + /// Replacement takes effect when the old plan expires. + case withoutProration = "without-proration" + /// Replacement takes effect when the old plan expires, and the user is not charged. + case deferred = "deferred" + /// Keep the existing payment schedule unchanged for the item (8.1.0+) + case keepExisting = "keep-existing" +} + // MARK: - Interfaces public protocol ProductCommon: Codable { @@ -324,6 +381,26 @@ public struct AppTransaction: Codable { public var signedDate: Double } +/// Result of checking billing program availability (Android) +/// Available in Google Play Billing Library 8.2.0+ +public struct BillingProgramAvailabilityResultAndroid: Codable { + /// The billing program that was checked + public var billingProgram: BillingProgramAndroid + /// Whether the billing program is available for the user + public var isAvailable: Bool +} + +/// Reporting details for transactions made outside of Google Play Billing (Android) +/// Contains the external transaction token needed for reporting +/// Available in Google Play Billing Library 8.2.0+ +public struct BillingProgramReportingDetailsAndroid: Codable { + /// The billing program that the reporting details are associated with + public var billingProgram: BillingProgramAndroid + /// External transaction token used to report transactions made outside of Google Play Billing. + /// This token must be used when reporting the external transaction to Google. + public var externalTransactionToken: String +} + /// Discount amount details for one-time purchase offers (Android) /// Available in Google Play Billing Library 7.0+ public struct DiscountAmountAndroid: Codable { @@ -374,6 +451,22 @@ public struct EntitlementIOS: Codable { public var transactionId: String } +/// External offer availability result (Android) +/// @deprecated Use BillingProgramAvailabilityResultAndroid with isBillingProgramAvailableAsync instead +/// Available in Google Play Billing Library 6.2.0+, deprecated in 8.2.0 +public struct ExternalOfferAvailabilityResultAndroid: Codable { + /// Whether external offers are available for the user + public var isAvailable: Bool +} + +/// External offer reporting details (Android) +/// @deprecated Use BillingProgramReportingDetailsAndroid with createBillingProgramReportingDetailsAsync instead +/// Available in Google Play Billing Library 6.2.0+, deprecated in 8.2.0 +public struct ExternalOfferReportingDetailsAndroid: Codable { + /// External transaction token for reporting external offer transactions + public var externalTransactionToken: String +} + /// Result of presenting an external purchase link (iOS 18.2+) public struct ExternalPurchaseLinkResultIOS: Codable { /// Optional error message if the presentation failed @@ -873,6 +966,32 @@ public struct InitConnectionConfig: Codable { } } +/// Parameters for launching an external link (Android) +/// Used with launchExternalLink to initiate external offer or app install flows +/// Available in Google Play Billing Library 8.2.0+ +public struct LaunchExternalLinkParamsAndroid: Codable { + /// The billing program (EXTERNAL_CONTENT_LINK or EXTERNAL_OFFER) + public var billingProgram: BillingProgramAndroid + /// The external link launch mode + public var launchMode: ExternalLinkLaunchModeAndroid + /// The type of the external link + public var linkType: ExternalLinkTypeAndroid + /// The URI where the content will be accessed from + public var linkUri: String + + public init( + billingProgram: BillingProgramAndroid, + launchMode: ExternalLinkLaunchModeAndroid, + linkType: ExternalLinkTypeAndroid, + linkUri: String + ) { + self.billingProgram = billingProgram + self.launchMode = launchMode + self.linkType = linkType + self.linkUri = linkUri + } +} + public struct ProductRequest: Codable { public var skus: [String] public var type: ProductQueryType? @@ -1056,11 +1175,15 @@ public struct RequestSubscriptionAndroidProps: Codable { /// Purchase token for upgrades/downgrades public var purchaseTokenAndroid: String? /// Replacement mode for subscription changes + /// @deprecated Use subscriptionProductReplacementParams instead for item-level replacement (8.1.0+) public var replacementModeAndroid: Int? /// List of subscription SKUs public var skus: [String] /// Subscription offers public var subscriptionOffers: [AndroidSubscriptionOfferInput]? + /// Product-level replacement parameters (8.1.0+) + /// Use this instead of replacementModeAndroid for item-level replacement + public var subscriptionProductReplacementParams: SubscriptionProductReplacementParamsAndroid? public init( isOfferPersonalized: Bool? = nil, @@ -1069,7 +1192,8 @@ public struct RequestSubscriptionAndroidProps: Codable { purchaseTokenAndroid: String? = nil, replacementModeAndroid: Int? = nil, skus: [String], - subscriptionOffers: [AndroidSubscriptionOfferInput]? = nil + subscriptionOffers: [AndroidSubscriptionOfferInput]? = nil, + subscriptionProductReplacementParams: SubscriptionProductReplacementParamsAndroid? = nil ) { self.isOfferPersonalized = isOfferPersonalized self.obfuscatedAccountIdAndroid = obfuscatedAccountIdAndroid @@ -1078,6 +1202,7 @@ public struct RequestSubscriptionAndroidProps: Codable { self.replacementModeAndroid = replacementModeAndroid self.skus = skus self.subscriptionOffers = subscriptionOffers + self.subscriptionProductReplacementParams = subscriptionProductReplacementParams } } @@ -1167,6 +1292,24 @@ public struct RequestVerifyPurchaseWithIapkitProps: Codable { } } +/// Product-level subscription replacement parameters (Android) +/// Used with setSubscriptionProductReplacementParams in BillingFlowParams.ProductDetailsParams +/// Available in Google Play Billing Library 8.1.0+ +public struct SubscriptionProductReplacementParamsAndroid: Codable { + /// The old product ID that needs to be replaced + public var oldProductId: String + /// The replacement mode for this product change + public var replacementMode: SubscriptionReplacementModeAndroid + + public init( + oldProductId: String, + replacementMode: SubscriptionReplacementModeAndroid + ) { + self.oldProductId = oldProductId + self.replacementMode = replacementMode + } +} + public struct VerifyPurchaseAndroidOptions: Codable { public var accessToken: String public var isSub: Bool? diff --git a/packages/docs/src/pages/docs/features/subscription-upgrade-downgrade.tsx b/packages/docs/src/pages/docs/features/subscription-upgrade-downgrade.tsx index e8da24ac..38598f9c 100644 --- a/packages/docs/src/pages/docs/features/subscription-upgrade-downgrade.tsx +++ b/packages/docs/src/pages/docs/features/subscription-upgrade-downgrade.tsx @@ -975,23 +975,26 @@ class _SubscriptionStatusState extends State {

@@ -1001,6 +1004,39 @@ class _SubscriptionStatusState extends State { Play Console subscription settings.

+ + šŸ†• Billing Library 8.1.0+: Per-Product Replacement Params} + variant="tip" + > +

+ Starting with Google Play Billing Library 8.1.0, you can use{' '} + subscriptionProductReplacementParams for more granular + control over subscription replacements at the product level: +

+ +
    +
  • + + oldProductId + + : The product ID being replaced +
  • +
  • + + replacementMode + + : The replacement mode enum value +
  • +
+ +

+ This API is useful when you need different replacement behaviors + for different products in a multi-product purchase scenario. + The new KEEP_EXISTING mode is only available through + this API. +

+
@@ -1260,6 +1296,100 @@ if (premiumPurchase != null) { print('āœ… Downgrade scheduled for next billing cycle'); // Note: Purchase callback will complete with empty list - this is expected! +}`} + ), + }} + + + + šŸ“ Code Example: Using subscriptionProductReplacementParams (8.1.0+)} + > +

+ For more granular control, use the new per-product replacement params API: +

+ + {{ + typescript: ( + {`// Android subscription replacement with 8.1.0+ API +import { requestSubscription, getAvailablePurchases } from 'expo-iap'; + +// Get current subscription +const purchases = await getAvailablePurchases(); +const currentSub = purchases.find(p => p.productId === 'premium_monthly'); + +if (currentSub) { + // Upgrade using the new per-product replacement params + await requestSubscription({ + skus: ['premium_yearly'], + subscriptionProductReplacementParams: { + oldProductId: currentSub.productId, + replacementMode: 'WITH_TIME_PRORATION', // or 'KEEP_EXISTING' (8.1.0+ only) + }, + // subscriptionOffers if needed for base plan selection + }); + + console.log('āœ… Upgrade initiated with per-product replacement'); +}`} + ), + kotlin: ( + {`// Android subscription replacement with 8.1.0+ API +import dev.hyo.openiap.OpenIapModule +import dev.hyo.openiap.SubscriptionReplacementModeAndroid + +// Get current subscription +val purchases = openIapModule.getAvailablePurchases() +val currentSub = purchases.find { it.productId == "premium_monthly" } + +currentSub?.let { sub -> + // Upgrade using the new per-product replacement params + openIapModule.requestSubscription( + RequestPurchaseProps( + type = ProductQueryType.Subs, + request = RequestPurchaseProps.Request.Subscription( + RequestSubscriptionProps( + android = RequestSubscriptionAndroidProps( + skus = listOf("premium_yearly"), + subscriptionProductReplacementParams = SubscriptionProductReplacementParamsAndroid( + oldProductId = sub.productId, + replacementMode = SubscriptionReplacementModeAndroid.WithTimeProration + // or SubscriptionReplacementModeAndroid.KeepExisting (8.1.0+ only) + ) + ) + ) + ) + ) + ) + + println("āœ… Upgrade initiated with per-product replacement") +}`} + ), + dart: ( + {`// Android subscription replacement with 8.1.0+ API +import 'package:flutter_inapp_purchase/flutter_inapp_purchase.dart'; + +// Get current subscription +final purchases = await FlutterInappPurchase.instance.getAvailablePurchases(); +final currentSub = purchases.firstWhere((p) => p.productId == 'premium_monthly'); + +if (currentSub != null) { + // Upgrade using the new per-product replacement params + await FlutterInappPurchase.instance.requestSubscription( + RequestPurchaseProps( + request: RequestPurchasePropsByPlatforms( + google: RequestSubscriptionAndroidProps( + skus: ['premium_yearly'], + subscriptionProductReplacementParams: SubscriptionProductReplacementParamsAndroid( + oldProductId: currentSub.productId, + replacementMode: SubscriptionReplacementModeAndroid.withTimeProration, + // or SubscriptionReplacementModeAndroid.keepExisting (8.1.0+ only) + ), + ), + ), + ), + ); + + print('āœ… Upgrade initiated with per-product replacement'); }`} ), }} diff --git a/packages/google/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt b/packages/google/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt index 9ba088bd..011bcb34 100644 --- a/packages/google/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt +++ b/packages/google/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt @@ -43,6 +43,8 @@ import dev.hyo.openiap.RequestSubscriptionPropsByPlatforms import dev.hyo.openiap.AndroidSubscriptionOfferInput import dev.hyo.openiap.RequestVerifyPurchaseWithIapkitGoogleProps import dev.hyo.openiap.RequestVerifyPurchaseWithIapkitProps +import dev.hyo.openiap.SubscriptionProductReplacementParamsAndroid +import dev.hyo.openiap.SubscriptionReplacementModeAndroid import dev.hyo.openiap.utils.verifyPurchaseWithIapkit import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -57,12 +59,15 @@ import dev.hyo.martie.util.resolvePremiumOfferInfo import dev.hyo.martie.util.savePremiumOffer // Google Play Billing SubscriptionReplacementMode values +// See: https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.SubscriptionUpdateParams.ReplacementMode private object ReplacementMode { - const val WITHOUT_PRORATION = 1 // No proration - const val CHARGE_PRORATED_PRICE = 2 // Charge prorated amount immediately - const val DEFERRED = 3 // Change takes effect at next billing cycle - const val WITH_TIME_PRORATION = 4 // Time-based proration - const val CHARGE_FULL_PRICE = 5 // Charge full price immediately + const val UNKNOWN_REPLACEMENT_MODE = 0 + const val WITH_TIME_PRORATION = 1 // Immediate change with prorated credit + const val CHARGE_PRORATED_PRICE = 2 // Immediate change, charge difference (upgrade only) + const val WITHOUT_PRORATION = 3 // Immediate change, no proration + const val CHARGE_FULL_PRICE = 5 // Immediate change, charge full price + const val DEFERRED = 6 // Change at next billing cycle + const val KEEP_EXISTING = 7 // Keep existing payment schedule (8.1.0+) } // Helper to format remaining time like "3d 4h" / "2h 12m" / "35m" @@ -768,10 +773,8 @@ fun SubscriptionFlowScreen( println("SubscriptionFlow [Horizon/Play]: Changing from ${currentOffer.basePlanId} to ${targetOffer.basePlanId} with token: ${purchaseToken.take(10)}...") - // Use CHARGE_FULL_PRICE for plan changes - val replacementMode = ReplacementMode.CHARGE_FULL_PRICE - // Request subscription offer change (same product, different offer) + // Using new subscriptionProductReplacementParams API (8.1.0+) val offerInputs = listOf( AndroidSubscriptionOfferInput( sku = IapConstants.PREMIUM_PRODUCT_ID, @@ -786,7 +789,11 @@ fun SubscriptionFlowScreen( obfuscatedAccountIdAndroid = null, obfuscatedProfileIdAndroid = null, purchaseTokenAndroid = purchaseToken, - replacementModeAndroid = replacementMode, + // New 8.1.0+ API: per-product replacement params + subscriptionProductReplacementParams = SubscriptionProductReplacementParamsAndroid( + oldProductId = IapConstants.PREMIUM_PRODUCT_ID, + replacementMode = SubscriptionReplacementModeAndroid.ChargeFullPrice + ), skus = listOf(IapConstants.PREMIUM_PRODUCT_ID), subscriptionOffers = offerInputs ) 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 f4e87503..13e1d488 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 @@ -901,6 +901,7 @@ class OpenIapModule( } // Alternative Billing - Testing if supported by Horizon Billing Compatibility Library + @Deprecated("Use isBillingProgramAvailable with BillingProgramAndroid.ExternalOffer instead") override suspend fun checkAlternativeBillingAvailability(): Boolean = withContext(Dispatchers.IO) { try { val client = billingClient ?: throw Exception("Not connected") @@ -931,6 +932,7 @@ class OpenIapModule( } } + @Deprecated("Use launchExternalLink instead") override suspend fun showAlternativeBillingInformationDialog(activity: Activity): Boolean = withContext(Dispatchers.IO) { try { val client = billingClient ?: throw Exception("Not connected") @@ -968,6 +970,7 @@ class OpenIapModule( } } + @Deprecated("Use createBillingProgramReportingDetails with BillingProgramAndroid.ExternalOffer instead") override suspend fun createAlternativeBillingReportingToken(): String? = withContext(Dispatchers.IO) { try { val client = billingClient ?: throw Exception("Not connected") @@ -1011,4 +1014,23 @@ class OpenIapModule( override fun removeUserChoiceBillingListener(listener: OpenIapUserChoiceBillingListener) { // Not supported on Horizon } + + // Billing Programs (8.2.0+) - Not supported on Horizon + override suspend fun isBillingProgramAvailable(program: BillingProgramAndroid): BillingProgramAvailabilityResultAndroid { + OpenIapLog.w("isBillingProgramAvailable not supported on Horizon", TAG) + return BillingProgramAvailabilityResultAndroid( + billingProgram = program, + isAvailable = false + ) + } + + override suspend fun createBillingProgramReportingDetails(program: BillingProgramAndroid): BillingProgramReportingDetailsAndroid { + OpenIapLog.w("createBillingProgramReportingDetails not supported on Horizon", TAG) + throw OpenIapError.FeatureNotSupported + } + + override suspend fun launchExternalLink(activity: Activity, params: LaunchExternalLinkParamsAndroid): Boolean { + OpenIapLog.w("launchExternalLink not supported on Horizon", TAG) + return false + } } diff --git a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/helpers/SharedHelpers.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/helpers/SharedHelpers.kt index 93b4cdd5..b6af70ff 100644 --- a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/helpers/SharedHelpers.kt +++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/helpers/SharedHelpers.kt @@ -7,6 +7,7 @@ import dev.hyo.openiap.ProductQueryType import dev.hyo.openiap.Purchase import dev.hyo.openiap.PurchaseError import dev.hyo.openiap.RequestPurchaseProps +import dev.hyo.openiap.SubscriptionProductReplacementParamsAndroid import dev.hyo.openiap.listener.OpenIapPurchaseErrorListener import dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener import kotlinx.coroutines.suspendCancellableCoroutine @@ -57,6 +58,7 @@ internal data class AndroidPurchaseArgs( val purchaseTokenAndroid: String?, val replacementModeAndroid: Int?, val subscriptionOffers: List?, + val subscriptionProductReplacementParams: SubscriptionProductReplacementParamsAndroid?, val type: ProductQueryType, val useAlternativeBilling: Boolean? ) @@ -77,6 +79,7 @@ internal fun RequestPurchaseProps.toAndroidPurchaseArgs(): AndroidPurchaseArgs { purchaseTokenAndroid = null, replacementModeAndroid = null, subscriptionOffers = null, + subscriptionProductReplacementParams = null, type = type, useAlternativeBilling = useAlternativeBilling ) @@ -97,6 +100,7 @@ internal fun RequestPurchaseProps.toAndroidPurchaseArgs(): AndroidPurchaseArgs { purchaseTokenAndroid = android.purchaseTokenAndroid, replacementModeAndroid = android.replacementModeAndroid, subscriptionOffers = android.subscriptionOffers, + subscriptionProductReplacementParams = android.subscriptionProductReplacementParams, type = type, useAlternativeBilling = useAlternativeBilling ) 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 2579cd91..36c16344 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 @@ -41,10 +41,42 @@ interface OpenIapProtocol { fun removePurchaseErrorListener(listener: OpenIapPurchaseErrorListener) // Alternative Billing (Google Play only) + @Deprecated("Use isBillingProgramAvailable with BillingProgramAndroid.ExternalOffer instead") suspend fun checkAlternativeBillingAvailability(): Boolean + @Deprecated("Use launchExternalLink instead") suspend fun showAlternativeBillingInformationDialog(activity: Activity): Boolean + @Deprecated("Use createBillingProgramReportingDetails with BillingProgramAndroid.ExternalOffer instead") suspend fun createAlternativeBillingReportingToken(): String? fun setUserChoiceBillingListener(listener: dev.hyo.openiap.listener.UserChoiceBillingListener?) fun addUserChoiceBillingListener(listener: OpenIapUserChoiceBillingListener) fun removeUserChoiceBillingListener(listener: OpenIapUserChoiceBillingListener) + + // Billing Programs (Google Play Billing Library 8.2.0+) + /** + * Check if a billing program is available for this user/device. + * Replaces checkAlternativeBillingAvailability() for external offers. + * + * @param program The billing program to check (EXTERNAL_CONTENT_LINK or EXTERNAL_OFFER) + * @return Result containing availability information + */ + suspend fun isBillingProgramAvailable(program: BillingProgramAndroid): BillingProgramAvailabilityResultAndroid + + /** + * Create reporting details for transactions made outside of Google Play Billing. + * Replaces createAlternativeBillingReportingToken() for external offers. + * + * @param program The billing program (EXTERNAL_CONTENT_LINK or EXTERNAL_OFFER) + * @return Reporting details containing the external transaction token + */ + suspend fun createBillingProgramReportingDetails(program: BillingProgramAndroid): BillingProgramReportingDetailsAndroid + + /** + * Launch an external link for external offer or app download. + * Replaces showAlternativeBillingInformationDialog() for external offers. + * + * @param activity Current activity context + * @param params Parameters for the external link + * @return true if launch was successful, false otherwise + */ + suspend fun launchExternalLink(activity: Activity, params: LaunchExternalLinkParamsAndroid): Boolean } 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 3139eae7..f193f38e 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 @@ -43,6 +43,41 @@ public enum class AlternativeBillingModeAndroid(val rawValue: String) { fun toJson(): String = rawValue } +/** + * Billing program types for external content links and external offers (Android) + * Available in Google Play Billing Library 8.2.0+ + */ +public enum class BillingProgramAndroid(val rawValue: String) { + /** + * Unspecified billing program. Do not use. + */ + Unspecified("unspecified"), + /** + * External Content Links program. + * Allows linking to external content outside the app. + */ + ExternalContentLink("external-content-link"), + /** + * External Offers program. + * Allows offering digital content purchases outside the app. + */ + ExternalOffer("external-offer"); + + companion object { + fun fromJson(value: String): BillingProgramAndroid = when (value) { + "unspecified" -> BillingProgramAndroid.Unspecified + "Unspecified" -> BillingProgramAndroid.Unspecified + "external-content-link" -> BillingProgramAndroid.ExternalContentLink + "ExternalContentLink" -> BillingProgramAndroid.ExternalContentLink + "external-offer" -> BillingProgramAndroid.ExternalOffer + "ExternalOffer" -> BillingProgramAndroid.ExternalOffer + else -> throw IllegalArgumentException("Unknown BillingProgramAndroid value: $value") + } + } + + fun toJson(): String = rawValue +} + public enum class ErrorCode(val rawValue: String) { Unknown("unknown"), UserCancelled("user-cancelled"), @@ -165,6 +200,74 @@ public enum class ErrorCode(val rawValue: String) { fun toJson(): String = rawValue } +/** + * Launch mode for external link flow (Android) + * Determines how the external URL is launched + * Available in Google Play Billing Library 8.2.0+ + */ +public enum class ExternalLinkLaunchModeAndroid(val rawValue: String) { + /** + * Unspecified launch mode. Do not use. + */ + Unspecified("unspecified"), + /** + * Play will launch the URL in an external browser or eligible app + */ + LaunchInExternalBrowserOrApp("launch-in-external-browser-or-app"), + /** + * Play will not launch the URL. The app handles launching the URL after Play returns control. + */ + CallerWillLaunchLink("caller-will-launch-link"); + + companion object { + fun fromJson(value: String): ExternalLinkLaunchModeAndroid = when (value) { + "unspecified" -> ExternalLinkLaunchModeAndroid.Unspecified + "UNSPECIFIED" -> ExternalLinkLaunchModeAndroid.Unspecified + "launch-in-external-browser-or-app" -> ExternalLinkLaunchModeAndroid.LaunchInExternalBrowserOrApp + "LAUNCH_IN_EXTERNAL_BROWSER_OR_APP" -> ExternalLinkLaunchModeAndroid.LaunchInExternalBrowserOrApp + "caller-will-launch-link" -> ExternalLinkLaunchModeAndroid.CallerWillLaunchLink + "CALLER_WILL_LAUNCH_LINK" -> ExternalLinkLaunchModeAndroid.CallerWillLaunchLink + else -> throw IllegalArgumentException("Unknown ExternalLinkLaunchModeAndroid value: $value") + } + } + + fun toJson(): String = rawValue +} + +/** + * Link type for external link flow (Android) + * Specifies the type of external link destination + * Available in Google Play Billing Library 8.2.0+ + */ +public enum class ExternalLinkTypeAndroid(val rawValue: String) { + /** + * Unspecified link type. Do not use. + */ + Unspecified("unspecified"), + /** + * The link will direct users to a digital content offer + */ + LinkToDigitalContentOffer("link-to-digital-content-offer"), + /** + * The link will direct users to download an app + */ + LinkToAppDownload("link-to-app-download"); + + companion object { + fun fromJson(value: String): ExternalLinkTypeAndroid = when (value) { + "unspecified" -> ExternalLinkTypeAndroid.Unspecified + "Unspecified" -> ExternalLinkTypeAndroid.Unspecified + "link-to-digital-content-offer" -> ExternalLinkTypeAndroid.LinkToDigitalContentOffer + "LinkToDigitalContentOffer" -> ExternalLinkTypeAndroid.LinkToDigitalContentOffer + "link-to-app-download" -> ExternalLinkTypeAndroid.LinkToAppDownload + "LinkToAppDownload" -> ExternalLinkTypeAndroid.LinkToAppDownload + else -> throw IllegalArgumentException("Unknown ExternalLinkTypeAndroid value: $value") + } + } + + fun toJson(): String = rawValue +} + /** * User actions on external purchase notice sheet (iOS 18.2+) */ @@ -181,10 +284,8 @@ public enum class ExternalPurchaseNoticeAction(val rawValue: String) { companion object { fun fromJson(value: String): ExternalPurchaseNoticeAction = when (value) { "continue" -> ExternalPurchaseNoticeAction.Continue - "CONTINUE" -> ExternalPurchaseNoticeAction.Continue "Continue" -> ExternalPurchaseNoticeAction.Continue "dismissed" -> ExternalPurchaseNoticeAction.Dismissed - "DISMISSED" -> ExternalPurchaseNoticeAction.Dismissed "Dismissed" -> ExternalPurchaseNoticeAction.Dismissed else -> throw IllegalArgumentException("Unknown ExternalPurchaseNoticeAction value: $value") } @@ -202,16 +303,13 @@ public enum class IapEvent(val rawValue: String) { companion object { fun fromJson(value: String): IapEvent = when (value) { "purchase-updated" -> IapEvent.PurchaseUpdated - "PURCHASE_UPDATED" -> IapEvent.PurchaseUpdated "PurchaseUpdated" -> IapEvent.PurchaseUpdated "purchase-error" -> IapEvent.PurchaseError - "PURCHASE_ERROR" -> IapEvent.PurchaseError "PurchaseError" -> IapEvent.PurchaseError "promoted-product-ios" -> IapEvent.PromotedProductIos - "PROMOTED_PRODUCT_IOS" -> IapEvent.PromotedProductIos + "PromotedProductIos" -> IapEvent.PromotedProductIos "PromotedProductIOS" -> IapEvent.PromotedProductIos "user-choice-billing-android" -> IapEvent.UserChoiceBillingAndroid - "USER_CHOICE_BILLING_ANDROID" -> IapEvent.UserChoiceBillingAndroid "UserChoiceBillingAndroid" -> IapEvent.UserChoiceBillingAndroid else -> throw IllegalArgumentException("Unknown IapEvent value: $value") } @@ -498,6 +596,64 @@ public enum class SubscriptionPeriodIOS(val rawValue: String) { fun toJson(): String = rawValue } +/** + * Replacement mode for subscription changes (Android) + * These modes determine how the subscription replacement affects billing. + * Available in Google Play Billing Library 8.1.0+ + */ +public enum class SubscriptionReplacementModeAndroid(val rawValue: String) { + /** + * Unknown replacement mode. Do not use. + */ + UnknownReplacementMode("unknown-replacement-mode"), + /** + * Replacement takes effect immediately, and the new expiration time will be prorated. + */ + WithTimeProration("with-time-proration"), + /** + * Replacement takes effect immediately, and the billing cycle remains the same. + */ + ChargeProratedPrice("charge-prorated-price"), + /** + * Replacement takes effect immediately, and the user is charged full price immediately. + */ + ChargeFullPrice("charge-full-price"), + /** + * Replacement takes effect when the old plan expires. + */ + WithoutProration("without-proration"), + /** + * Replacement takes effect when the old plan expires, and the user is not charged. + */ + Deferred("deferred"), + /** + * Keep the existing payment schedule unchanged for the item (8.1.0+) + */ + KeepExisting("keep-existing"); + + companion object { + fun fromJson(value: String): SubscriptionReplacementModeAndroid = when (value) { + "unknown-replacement-mode" -> SubscriptionReplacementModeAndroid.UnknownReplacementMode + "UnknownReplacementMode" -> SubscriptionReplacementModeAndroid.UnknownReplacementMode + "with-time-proration" -> SubscriptionReplacementModeAndroid.WithTimeProration + "WithTimeProration" -> SubscriptionReplacementModeAndroid.WithTimeProration + "charge-prorated-price" -> SubscriptionReplacementModeAndroid.ChargeProratedPrice + "ChargeProratedPrice" -> SubscriptionReplacementModeAndroid.ChargeProratedPrice + "charge-full-price" -> SubscriptionReplacementModeAndroid.ChargeFullPrice + "ChargeFullPrice" -> SubscriptionReplacementModeAndroid.ChargeFullPrice + "without-proration" -> SubscriptionReplacementModeAndroid.WithoutProration + "WithoutProration" -> SubscriptionReplacementModeAndroid.WithoutProration + "deferred" -> SubscriptionReplacementModeAndroid.Deferred + "Deferred" -> SubscriptionReplacementModeAndroid.Deferred + "keep-existing" -> SubscriptionReplacementModeAndroid.KeepExisting + "KeepExisting" -> SubscriptionReplacementModeAndroid.KeepExisting + else -> throw IllegalArgumentException("Unknown SubscriptionReplacementModeAndroid value: $value") + } + } + + fun toJson(): String = rawValue +} + // MARK: - Interfaces public interface ProductCommon { @@ -670,6 +826,70 @@ public data class AppTransaction( ) } +/** + * Result of checking billing program availability (Android) + * Available in Google Play Billing Library 8.2.0+ + */ +public data class BillingProgramAvailabilityResultAndroid( + /** + * The billing program that was checked + */ + val billingProgram: BillingProgramAndroid, + /** + * Whether the billing program is available for the user + */ + val isAvailable: Boolean +) { + + companion object { + fun fromJson(json: Map): BillingProgramAvailabilityResultAndroid { + return BillingProgramAvailabilityResultAndroid( + billingProgram = BillingProgramAndroid.fromJson(json["billingProgram"] as String), + isAvailable = json["isAvailable"] as Boolean, + ) + } + } + + fun toJson(): Map = mapOf( + "__typename" to "BillingProgramAvailabilityResultAndroid", + "billingProgram" to billingProgram.toJson(), + "isAvailable" to isAvailable, + ) +} + +/** + * Reporting details for transactions made outside of Google Play Billing (Android) + * Contains the external transaction token needed for reporting + * Available in Google Play Billing Library 8.2.0+ + */ +public data class BillingProgramReportingDetailsAndroid( + /** + * The billing program that the reporting details are associated with + */ + val billingProgram: BillingProgramAndroid, + /** + * External transaction token used to report transactions made outside of Google Play Billing. + * This token must be used when reporting the external transaction to Google. + */ + val externalTransactionToken: String +) { + + companion object { + fun fromJson(json: Map): BillingProgramReportingDetailsAndroid { + return BillingProgramReportingDetailsAndroid( + billingProgram = BillingProgramAndroid.fromJson(json["billingProgram"] as String), + externalTransactionToken = json["externalTransactionToken"] as String, + ) + } + } + + fun toJson(): Map = mapOf( + "__typename" to "BillingProgramReportingDetailsAndroid", + "billingProgram" to billingProgram.toJson(), + "externalTransactionToken" to externalTransactionToken, + ) +} + /** * Discount amount details for one-time purchase offers (Android) * Available in Google Play Billing Library 7.0+ @@ -842,6 +1062,58 @@ public data class EntitlementIOS( ) } +/** + * External offer availability result (Android) + * @deprecated Use BillingProgramAvailabilityResultAndroid with isBillingProgramAvailableAsync instead + * Available in Google Play Billing Library 6.2.0+, deprecated in 8.2.0 + */ +public data class ExternalOfferAvailabilityResultAndroid( + /** + * Whether external offers are available for the user + */ + val isAvailable: Boolean +) { + + companion object { + fun fromJson(json: Map): ExternalOfferAvailabilityResultAndroid { + return ExternalOfferAvailabilityResultAndroid( + isAvailable = json["isAvailable"] as Boolean, + ) + } + } + + fun toJson(): Map = mapOf( + "__typename" to "ExternalOfferAvailabilityResultAndroid", + "isAvailable" to isAvailable, + ) +} + +/** + * External offer reporting details (Android) + * @deprecated Use BillingProgramReportingDetailsAndroid with createBillingProgramReportingDetailsAsync instead + * Available in Google Play Billing Library 6.2.0+, deprecated in 8.2.0 + */ +public data class ExternalOfferReportingDetailsAndroid( + /** + * External transaction token for reporting external offer transactions + */ + val externalTransactionToken: String +) { + + companion object { + fun fromJson(json: Map): ExternalOfferReportingDetailsAndroid { + return ExternalOfferReportingDetailsAndroid( + externalTransactionToken = json["externalTransactionToken"] as String, + ) + } + } + + fun toJson(): Map = mapOf( + "__typename" to "ExternalOfferReportingDetailsAndroid", + "externalTransactionToken" to externalTransactionToken, + ) +} + /** * Result of presenting an external purchase link (iOS 18.2+) */ @@ -2274,6 +2546,48 @@ public data class InitConnectionConfig( ) } +/** + * Parameters for launching an external link (Android) + * Used with launchExternalLink to initiate external offer or app install flows + * Available in Google Play Billing Library 8.2.0+ + */ +public data class LaunchExternalLinkParamsAndroid( + /** + * The billing program (EXTERNAL_CONTENT_LINK or EXTERNAL_OFFER) + */ + val billingProgram: BillingProgramAndroid, + /** + * The external link launch mode + */ + val launchMode: ExternalLinkLaunchModeAndroid, + /** + * The type of the external link + */ + val linkType: ExternalLinkTypeAndroid, + /** + * The URI where the content will be accessed from + */ + val linkUri: String +) { + companion object { + fun fromJson(json: Map): LaunchExternalLinkParamsAndroid { + return LaunchExternalLinkParamsAndroid( + billingProgram = BillingProgramAndroid.fromJson(json["billingProgram"] as String), + launchMode = ExternalLinkLaunchModeAndroid.fromJson(json["launchMode"] as String), + linkType = ExternalLinkTypeAndroid.fromJson(json["linkType"] as String), + linkUri = json["linkUri"] as String, + ) + } + } + + fun toJson(): Map = mapOf( + "billingProgram" to billingProgram.toJson(), + "launchMode" to launchMode.toJson(), + "linkType" to linkType.toJson(), + "linkUri" to linkUri, + ) +} + public data class ProductRequest( val skus: List, val type: ProductQueryType? = null @@ -2509,6 +2823,7 @@ public data class RequestSubscriptionAndroidProps( val purchaseTokenAndroid: String? = null, /** * Replacement mode for subscription changes + * @deprecated Use subscriptionProductReplacementParams instead for item-level replacement (8.1.0+) */ val replacementModeAndroid: Int? = null, /** @@ -2518,7 +2833,12 @@ public data class RequestSubscriptionAndroidProps( /** * Subscription offers */ - val subscriptionOffers: List? = null + val subscriptionOffers: List? = null, + /** + * Product-level replacement parameters (8.1.0+) + * Use this instead of replacementModeAndroid for item-level replacement + */ + val subscriptionProductReplacementParams: SubscriptionProductReplacementParamsAndroid? = null ) { companion object { fun fromJson(json: Map): RequestSubscriptionAndroidProps { @@ -2530,6 +2850,7 @@ public data class RequestSubscriptionAndroidProps( replacementModeAndroid = (json["replacementModeAndroid"] as Number?)?.toInt(), skus = (json["skus"] as List<*>).map { it as String }, subscriptionOffers = (json["subscriptionOffers"] as List<*>?)?.map { AndroidSubscriptionOfferInput.fromJson((it as Map)) }, + subscriptionProductReplacementParams = (json["subscriptionProductReplacementParams"] as Map?)?.let { SubscriptionProductReplacementParamsAndroid.fromJson(it) }, ) } } @@ -2542,6 +2863,7 @@ public data class RequestSubscriptionAndroidProps( "replacementModeAndroid" to replacementModeAndroid, "skus" to skus.map { it }, "subscriptionOffers" to subscriptionOffers?.map { it.toJson() }, + "subscriptionProductReplacementParams" to subscriptionProductReplacementParams?.toJson(), ) } @@ -2679,6 +3001,36 @@ public data class RequestVerifyPurchaseWithIapkitProps( ) } +/** + * Product-level subscription replacement parameters (Android) + * Used with setSubscriptionProductReplacementParams in BillingFlowParams.ProductDetailsParams + * Available in Google Play Billing Library 8.1.0+ + */ +public data class SubscriptionProductReplacementParamsAndroid( + /** + * The old product ID that needs to be replaced + */ + val oldProductId: String, + /** + * The replacement mode for this product change + */ + val replacementMode: SubscriptionReplacementModeAndroid +) { + companion object { + fun fromJson(json: Map): SubscriptionProductReplacementParamsAndroid { + return SubscriptionProductReplacementParamsAndroid( + oldProductId = json["oldProductId"] as String, + replacementMode = SubscriptionReplacementModeAndroid.fromJson(json["replacementMode"] as String), + ) + } + } + + fun toJson(): Map = mapOf( + "oldProductId" to oldProductId, + "replacementMode" to replacementMode.toJson(), + ) +} + public data class VerifyPurchaseAndroidOptions( val accessToken: String, val isSub: Boolean? = null, 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 807eb11d..dba32c51 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 @@ -33,6 +33,10 @@ import dev.hyo.openiap.QueryGetAvailablePurchasesHandler import dev.hyo.openiap.MutationFinishTransactionHandler import dev.hyo.openiap.MutationInitConnectionHandler import dev.hyo.openiap.MutationEndConnectionHandler +import dev.hyo.openiap.BillingProgramAndroid +import dev.hyo.openiap.BillingProgramAvailabilityResultAndroid +import dev.hyo.openiap.BillingProgramReportingDetailsAndroid +import dev.hyo.openiap.LaunchExternalLinkParamsAndroid import android.app.Activity import android.content.Context import dev.hyo.openiap.OpenIapError @@ -419,10 +423,46 @@ class OpenIapStore(private val module: OpenIapProtocol) { * Must be called AFTER successful payment in your payment system * Token must be reported to Google Play backend within 24 hours * @return External transaction token, or null if failed + * @deprecated Use createBillingProgramReportingDetails with BillingProgramAndroid.ExternalOffer instead */ + @Deprecated("Use createBillingProgramReportingDetails with BillingProgramAndroid.ExternalOffer instead") suspend fun createAlternativeBillingReportingToken(): String? = module.createAlternativeBillingReportingToken() + // ------------------------------------------------------------------------- + // Billing Programs (Google Play Billing Library 8.2.0+) + // ------------------------------------------------------------------------- + /** + * Check if a billing program is available for this user/device. + * This is the new API that replaces checkAlternativeBillingAvailability for external offers. + * + * @param program The billing program to check (EXTERNAL_CONTENT_LINK or EXTERNAL_OFFER) + * @return Result containing availability information + */ + suspend fun isBillingProgramAvailable(program: BillingProgramAndroid): BillingProgramAvailabilityResultAndroid = + module.isBillingProgramAvailable(program) + + /** + * Create reporting details for transactions made outside of Google Play Billing. + * This is the new API that replaces createAlternativeBillingReportingToken for external offers. + * + * @param program The billing program (EXTERNAL_CONTENT_LINK or EXTERNAL_OFFER) + * @return Reporting details containing the external transaction token + */ + suspend fun createBillingProgramReportingDetails(program: BillingProgramAndroid): BillingProgramReportingDetailsAndroid = + module.createBillingProgramReportingDetails(program) + + /** + * Launch an external link for external offer or app download. + * This is the new API that replaces showAlternativeBillingInformationDialog for external offers. + * + * @param activity Current activity context + * @param params Parameters for the external link + * @return true if launch was successful, false otherwise + */ + suspend fun launchExternalLink(activity: Activity, params: LaunchExternalLinkParamsAndroid): Boolean = + module.launchExternalLink(activity, params) + // ------------------------------------------------------------------------- // Event listeners passthrough // ------------------------------------------------------------------------- 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 3b07638a..3a3a54f9 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 @@ -107,6 +107,9 @@ class OpenIapModule( private val userChoiceBillingListeners = mutableSetOf() private var currentPurchaseCallback: ((Result>) -> Unit)? = null + // Billing programs enabled via enableBillingProgram (8.2.0+) + private val enabledBillingPrograms = mutableSetOf() + override val initConnection: MutationInitConnectionHandler = { config -> // Update alternativeBillingMode if provided in config config?.alternativeBillingModeAndroid?.let { modeAndroid -> @@ -266,7 +269,9 @@ class OpenIapModule( /** * Check if alternative billing is available for this user/device * Step 1 of alternative billing flow + * @deprecated Use isBillingProgramAvailable with BillingProgramAndroid.ExternalOffer instead */ + @Deprecated("Use isBillingProgramAvailable with BillingProgramAndroid.ExternalOffer instead") override suspend fun checkAlternativeBillingAvailability(): Boolean = withContext(Dispatchers.IO) { val client = billingClient ?: throw OpenIapError.NotPrepared if (!client.isReady) throw OpenIapError.NotPrepared @@ -305,7 +310,9 @@ class OpenIapModule( * Show alternative billing information dialog to user * Step 2 of alternative billing flow * Must be called BEFORE processing payment + * @deprecated Use launchExternalLink instead */ + @Deprecated("Use launchExternalLink instead") override suspend fun showAlternativeBillingInformationDialog(activity: Activity): Boolean = withContext(Dispatchers.IO) { val client = billingClient ?: throw OpenIapError.NotPrepared if (!client.isReady) throw OpenIapError.NotPrepared @@ -353,7 +360,9 @@ class OpenIapModule( * Step 3 of alternative billing flow * Must be called AFTER successful payment in your payment system * Token must be reported to Google Play backend within 24 hours + * @deprecated Use createBillingProgramReportingDetails with BillingProgramAndroid.ExternalOffer instead */ + @Deprecated("Use createBillingProgramReportingDetails with BillingProgramAndroid.ExternalOffer instead") override suspend fun createAlternativeBillingReportingToken(): String? = withContext(Dispatchers.IO) { val client = billingClient ?: throw OpenIapError.NotPrepared if (!client.isReady) throw OpenIapError.NotPrepared @@ -395,6 +404,256 @@ class OpenIapModule( } } + /** + * Check if a billing program is available for this user/device (8.2.0+) + * This is the new API that replaces checkAlternativeBillingAvailability for external offers. + * + * @param program The billing program to check (EXTERNAL_CONTENT_LINK or EXTERNAL_OFFER) + * @return Result containing availability information + */ + override suspend fun isBillingProgramAvailable(program: BillingProgramAndroid): BillingProgramAvailabilityResultAndroid = withContext(Dispatchers.IO) { + val client = billingClient ?: throw OpenIapError.NotPrepared + if (!client.isReady) throw OpenIapError.NotPrepared + + OpenIapLog.d("Checking billing program availability for: $program", TAG) + + // Convert our enum to BillingClient.BillingProgram constant + val billingProgramConstant = when (program) { + BillingProgramAndroid.ExternalContentLink -> 1 // EXTERNAL_CONTENT_LINK + BillingProgramAndroid.ExternalOffer -> 3 // EXTERNAL_OFFER + BillingProgramAndroid.Unspecified -> throw IllegalArgumentException("Cannot check availability for UNSPECIFIED program") + } + + suspendCancellableCoroutine { continuation -> + try { + // Use reflection to call isBillingProgramAvailableAsync (8.2.0+) + val listenerClass = Class.forName("com.android.billingclient.api.BillingProgramAvailabilityListener") + val listener = java.lang.reflect.Proxy.newProxyInstance( + listenerClass.classLoader, + arrayOf(listenerClass) + ) { _, method, args -> + if (method.name == "onBillingProgramAvailabilityResponse") { + val result = args?.get(0) as? BillingResult + OpenIapLog.d("Billing program availability result: ${result?.responseCode} - ${result?.debugMessage}", TAG) + + val isAvailable = result?.responseCode == BillingClient.BillingResponseCode.OK + if (continuation.isActive) { + continuation.resume(BillingProgramAvailabilityResultAndroid( + billingProgram = program, + isAvailable = isAvailable + )) + } + } + null + } + + val method = client.javaClass.getMethod( + "isBillingProgramAvailableAsync", + Int::class.javaPrimitiveType, + listenerClass + ) + method.invoke(client, billingProgramConstant, listener) + } catch (e: NoSuchMethodException) { + OpenIapLog.e("isBillingProgramAvailableAsync not found. Requires Billing Library 8.2.0+", e, TAG) + if (continuation.isActive) { + continuation.resume(BillingProgramAvailabilityResultAndroid( + billingProgram = program, + isAvailable = false + )) + } + } catch (e: Exception) { + OpenIapLog.e("Failed to check billing program availability: ${e.message}", e, TAG) + if (continuation.isActive) { + continuation.resume(BillingProgramAvailabilityResultAndroid( + billingProgram = program, + isAvailable = false + )) + } + } + } + } + + /** + * Create reporting details for transactions made outside of Google Play Billing (8.2.0+) + * This is the new API that replaces createAlternativeBillingReportingToken for external offers. + * + * @param program The billing program (EXTERNAL_CONTENT_LINK or EXTERNAL_OFFER) + * @return Reporting details containing the external transaction token + */ + override suspend fun createBillingProgramReportingDetails(program: BillingProgramAndroid): BillingProgramReportingDetailsAndroid = withContext(Dispatchers.IO) { + val client = billingClient ?: throw OpenIapError.NotPrepared + if (!client.isReady) throw OpenIapError.NotPrepared + + OpenIapLog.d("Creating billing program reporting details for: $program", TAG) + + val billingProgramConstant = when (program) { + BillingProgramAndroid.ExternalContentLink -> 1 + BillingProgramAndroid.ExternalOffer -> 3 + BillingProgramAndroid.Unspecified -> throw IllegalArgumentException("Cannot create reporting details for UNSPECIFIED program") + } + + suspendCancellableCoroutine { continuation -> + try { + val listenerClass = Class.forName("com.android.billingclient.api.BillingProgramReportingDetailsListener") + val listener = java.lang.reflect.Proxy.newProxyInstance( + listenerClass.classLoader, + arrayOf(listenerClass) + ) { _, method, args -> + if (method.name == "onBillingProgramReportingDetailsResponse") { + val result = args?.get(0) as? BillingResult + val details = args?.getOrNull(1) + + if (result?.responseCode == BillingClient.BillingResponseCode.OK && details != null) { + try { + val tokenMethod = details.javaClass.getMethod("getExternalTransactionToken") + val token = tokenMethod.invoke(details) as? String + OpenIapLog.d("Billing program reporting token created: $token", TAG) + + if (continuation.isActive && token != null) { + continuation.resume(BillingProgramReportingDetailsAndroid( + billingProgram = program, + externalTransactionToken = token + )) + } else if (continuation.isActive) { + throw OpenIapError.PurchaseFailed + } + } catch (e: Exception) { + OpenIapLog.e("Failed to extract token: ${e.message}", e, TAG) + if (continuation.isActive) throw OpenIapError.PurchaseFailed + } + } else { + OpenIapLog.e("Reporting details creation failed: ${result?.debugMessage}", tag = TAG) + if (continuation.isActive) throw OpenIapError.PurchaseFailed + } + } + null + } + + val method = client.javaClass.getMethod( + "createBillingProgramReportingDetailsAsync", + Int::class.javaPrimitiveType, + listenerClass + ) + method.invoke(client, billingProgramConstant, listener) + } catch (e: NoSuchMethodException) { + OpenIapLog.e("createBillingProgramReportingDetailsAsync not found. Requires Billing Library 8.2.0+", e, TAG) + throw OpenIapError.FeatureNotSupported + } catch (e: Exception) { + OpenIapLog.e("Failed to create billing program reporting details: ${e.message}", e, TAG) + throw OpenIapError.PurchaseFailed + } + } + } + + /** + * Launch an external link for external offer or app download (8.2.0+) + * This is the new API that replaces showExternalOfferInformationDialog. + * + * @param activity Current activity context + * @param params Parameters for the external link + * @return true if launch was successful, false otherwise + */ + override suspend fun launchExternalLink(activity: Activity, params: LaunchExternalLinkParamsAndroid): Boolean = withContext(Dispatchers.IO) { + val client = billingClient ?: throw OpenIapError.NotPrepared + if (!client.isReady) throw OpenIapError.NotPrepared + + OpenIapLog.d("Launching external link: program=${params.billingProgram}, launchMode=${params.launchMode}, linkType=${params.linkType}", TAG) + + // Convert enums to BillingClient constants + val billingProgramConstant = when (params.billingProgram) { + BillingProgramAndroid.ExternalContentLink -> 1 + BillingProgramAndroid.ExternalOffer -> 3 + BillingProgramAndroid.Unspecified -> throw IllegalArgumentException("Cannot launch with UNSPECIFIED program") + } + + val launchModeConstant = when (params.launchMode) { + ExternalLinkLaunchModeAndroid.LaunchInExternalBrowserOrApp -> 1 + ExternalLinkLaunchModeAndroid.CallerWillLaunchLink -> 2 + ExternalLinkLaunchModeAndroid.Unspecified -> throw IllegalArgumentException("Cannot launch with UNSPECIFIED launch mode") + } + + val linkTypeConstant = when (params.linkType) { + ExternalLinkTypeAndroid.LinkToDigitalContentOffer -> 1 + ExternalLinkTypeAndroid.LinkToAppDownload -> 2 + ExternalLinkTypeAndroid.Unspecified -> throw IllegalArgumentException("Cannot launch with UNSPECIFIED link type") + } + + suspendCancellableCoroutine { continuation -> + try { + // Build LaunchExternalLinkParams using reflection + val paramsClass = Class.forName("com.android.billingclient.api.LaunchExternalLinkParams") + val builderClass = Class.forName("com.android.billingclient.api.LaunchExternalLinkParams\$Builder") + + val newBuilderMethod = paramsClass.getMethod("newBuilder") + val builder = newBuilderMethod.invoke(null) + + // Set billing program + val setBillingProgramMethod = builderClass.getMethod("setBillingProgram", Int::class.javaPrimitiveType) + setBillingProgramMethod.invoke(builder, billingProgramConstant) + + // Set launch mode + val setLaunchModeMethod = builderClass.getMethod("setLaunchMode", Int::class.javaPrimitiveType) + setLaunchModeMethod.invoke(builder, launchModeConstant) + + // Set link type + val setLinkTypeMethod = builderClass.getMethod("setLinkType", Int::class.javaPrimitiveType) + setLinkTypeMethod.invoke(builder, linkTypeConstant) + + // Set link URI + val setLinkUriMethod = builderClass.getMethod("setLinkUri", android.net.Uri::class.java) + setLinkUriMethod.invoke(builder, android.net.Uri.parse(params.linkUri)) + + // Build the params + val buildMethod = builderClass.getMethod("build") + val launchParams = buildMethod.invoke(builder) + + // Create the response listener + val listenerClass = Class.forName("com.android.billingclient.api.LaunchExternalLinkResponseListener") + val listener = java.lang.reflect.Proxy.newProxyInstance( + listenerClass.classLoader, + arrayOf(listenerClass) + ) { _, method, args -> + if (method.name == "onLaunchExternalLinkResponse") { + val result = args?.get(0) as? BillingResult + OpenIapLog.d("External link launch result: ${result?.responseCode} - ${result?.debugMessage}", TAG) + + val success = result?.responseCode == BillingClient.BillingResponseCode.OK + if (continuation.isActive) continuation.resume(success) + } + null + } + + // Call launchExternalLink + val launchMethod = client.javaClass.getMethod( + "launchExternalLink", + android.app.Activity::class.java, + paramsClass, + listenerClass + ) + launchMethod.invoke(client, activity, launchParams, listener) + } catch (e: NoSuchMethodException) { + OpenIapLog.e("launchExternalLink not found. Requires Billing Library 8.2.0+", e, TAG) + if (continuation.isActive) continuation.resume(false) + } catch (e: Exception) { + OpenIapLog.e("Failed to launch external link: ${e.message}", e, TAG) + if (continuation.isActive) continuation.resume(false) + } + } + } + + /** + * Enable a billing program for external content links or external offers (8.2.0+) + * This should be called before initConnection to configure the BillingClient. + * + * @param program The billing program to enable + */ + fun enableBillingProgram(program: BillingProgramAndroid) { + if (program != BillingProgramAndroid.Unspecified) { + enabledBillingPrograms.add(program) + OpenIapLog.d("Billing program enabled: $program", TAG) + } + } + override val requestPurchase: MutationRequestPurchaseHandler = { props -> val purchases = withContext(Dispatchers.IO) { // ALTERNATIVE_ONLY mode: Show information dialog and create token @@ -417,6 +676,8 @@ class OpenIapModule( try { // Step 1: Check if alternative billing is available + // Using deprecated API for backward compatibility in ALTERNATIVE_ONLY mode + @Suppress("DEPRECATION") val isAvailable = checkAlternativeBillingAvailability() if (!isAvailable) { OpenIapLog.e("Alternative billing is not available for this user/app", tag = TAG) @@ -441,6 +702,8 @@ class OpenIapModule( } // Step 2: Show alternative billing information dialog + // Using deprecated API for backward compatibility in ALTERNATIVE_ONLY mode + @Suppress("DEPRECATION") val dialogSuccess = showAlternativeBillingInformationDialog(activity) if (!dialogSuccess) { val err = OpenIapError.UserCancelled @@ -464,6 +727,8 @@ class OpenIapModule( // - YOUR_PAYMENT_SYSTEM.processPayment() // - createAlternativeBillingReportingToken() // ============================================================ + // Using deprecated API for backward compatibility in ALTERNATIVE_ONLY mode + @Suppress("DEPRECATION") val tokenResult = createAlternativeBillingReportingToken() if (tokenResult != null) { @@ -589,6 +854,15 @@ class OpenIapModule( } builder.setOfferToken(resolved) + + // Apply per-product subscription replacement params (8.1.0+) + androidArgs.subscriptionProductReplacementParams?.let { replacementParams -> + if (replacementParams.oldProductId == productDetails.productId || + androidArgs.skus.size == 1) { + // Apply to this product if it matches or if it's a single-product upgrade + applySubscriptionProductReplacementParams(builder, replacementParams) + } + } } paramsList += builder.build() @@ -1099,6 +1373,28 @@ class OpenIapModule( } } + // Enable billing programs (8.2.0+) for external content links and external offers + if (enabledBillingPrograms.isNotEmpty()) { + OpenIapLog.d("=== BILLING PROGRAMS INITIALIZATION (8.2.0+) ===", TAG) + for (program in enabledBillingPrograms) { + val programConstant = when (program) { + BillingProgramAndroid.ExternalContentLink -> 1 + BillingProgramAndroid.ExternalOffer -> 3 + BillingProgramAndroid.Unspecified -> continue + } + try { + val method = builder.javaClass.getMethod("enableBillingProgram", Int::class.javaPrimitiveType) + method.invoke(builder, programConstant) + OpenIapLog.d("āœ“ Billing program enabled: $program (constant=$programConstant)", TAG) + } catch (e: NoSuchMethodException) { + OpenIapLog.w("āœ— enableBillingProgram not found. Requires Billing Library 8.2.0+", TAG) + } catch (e: Exception) { + OpenIapLog.w("āœ— Failed to enable billing program $program: ${e.message}", TAG) + } + } + OpenIapLog.d("=== END BILLING PROGRAMS INITIALIZATION ===", TAG) + } + billingClient = builder.build() OpenIapLog.d("=== buildBillingClient END ===", TAG) } @@ -1147,4 +1443,68 @@ class OpenIapModule( override fun setUserChoiceBillingListener(listener: dev.hyo.openiap.listener.UserChoiceBillingListener?) { userChoiceBillingListener = listener } + + /** + * Apply SubscriptionProductReplacementParams to ProductDetailsParams builder using reflection. + * This enables per-product replacement mode configuration (Billing Library 8.1.0+). + * + * @param builder The ProductDetailsParams.Builder to configure + * @param params The replacement parameters containing oldProductId and replacementMode + */ + private fun applySubscriptionProductReplacementParams( + builder: BillingFlowParams.ProductDetailsParams.Builder, + params: SubscriptionProductReplacementParamsAndroid + ) { + try { + // Convert our enum to BillingClient replacement mode constant + val replacementModeConstant = when (params.replacementMode) { + SubscriptionReplacementModeAndroid.UnknownReplacementMode -> 0 + SubscriptionReplacementModeAndroid.WithTimeProration -> 1 + SubscriptionReplacementModeAndroid.ChargeProratedPrice -> 2 + SubscriptionReplacementModeAndroid.WithoutProration -> 3 + SubscriptionReplacementModeAndroid.Deferred -> 6 + SubscriptionReplacementModeAndroid.ChargeFullPrice -> 5 + SubscriptionReplacementModeAndroid.KeepExisting -> 7 // New in 8.1.0 + } + + // Build SubscriptionProductReplacementParams using reflection + val replacementParamsClass = Class.forName( + "com.android.billingclient.api.BillingFlowParams\$SubscriptionProductReplacementParams" + ) + val replacementBuilderClass = Class.forName( + "com.android.billingclient.api.BillingFlowParams\$SubscriptionProductReplacementParams\$Builder" + ) + + // Create new builder + val newBuilderMethod = replacementParamsClass.getMethod("newBuilder") + val replacementBuilder = newBuilderMethod.invoke(null) + + // Set old product ID + val setOldProductIdMethod = replacementBuilderClass.getMethod("setOldProductId", String::class.java) + setOldProductIdMethod.invoke(replacementBuilder, params.oldProductId) + + // Set replacement mode + val setReplacementModeMethod = replacementBuilderClass.getMethod("setReplacementMode", Int::class.javaPrimitiveType) + setReplacementModeMethod.invoke(replacementBuilder, replacementModeConstant) + + // Build the params + val buildMethod = replacementBuilderClass.getMethod("build") + val subscriptionReplacementParams = buildMethod.invoke(replacementBuilder) + + // Apply to ProductDetailsParams builder + val setSubsReplacementParamsMethod = builder.javaClass.getMethod( + "setSubscriptionProductReplacementParams", + replacementParamsClass + ) + setSubsReplacementParamsMethod.invoke(builder, subscriptionReplacementParams) + + OpenIapLog.d("Applied SubscriptionProductReplacementParams: oldProductId=${params.oldProductId}, mode=${params.replacementMode} (constant=$replacementModeConstant)", TAG) + } catch (e: NoSuchMethodException) { + OpenIapLog.w("setSubscriptionProductReplacementParams not found. Requires Billing Library 8.1.0+. Falling back to legacy replacement mode.", TAG) + } catch (e: ClassNotFoundException) { + OpenIapLog.w("SubscriptionProductReplacementParams class not found. Requires Billing Library 8.1.0+.", TAG) + } catch (e: Exception) { + OpenIapLog.e("Failed to apply SubscriptionProductReplacementParams: ${e.message}", e, TAG) + } + } } diff --git a/packages/google/openiap/src/play/java/dev/hyo/openiap/helpers/Helpers.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/helpers/Helpers.kt index 5f117ccb..ad8af070 100644 --- a/packages/google/openiap/src/play/java/dev/hyo/openiap/helpers/Helpers.kt +++ b/packages/google/openiap/src/play/java/dev/hyo/openiap/helpers/Helpers.kt @@ -10,6 +10,7 @@ import dev.hyo.openiap.ProductQueryType import dev.hyo.openiap.Purchase import dev.hyo.openiap.PurchaseError import dev.hyo.openiap.RequestPurchaseProps +import dev.hyo.openiap.SubscriptionProductReplacementParamsAndroid import dev.hyo.openiap.listener.OpenIapPurchaseErrorListener import dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener import dev.hyo.openiap.utils.BillingConverters.toPurchase @@ -99,6 +100,7 @@ internal data class AndroidPurchaseArgs( val purchaseTokenAndroid: String?, val replacementModeAndroid: Int?, val subscriptionOffers: List?, + val subscriptionProductReplacementParams: SubscriptionProductReplacementParamsAndroid?, val type: ProductQueryType, val useAlternativeBilling: Boolean? ) @@ -116,6 +118,7 @@ internal fun RequestPurchaseProps.toAndroidPurchaseArgs(): AndroidPurchaseArgs { purchaseTokenAndroid = null, replacementModeAndroid = null, subscriptionOffers = null, + subscriptionProductReplacementParams = null, type = type, useAlternativeBilling = useAlternativeBilling ) @@ -136,6 +139,7 @@ internal fun RequestPurchaseProps.toAndroidPurchaseArgs(): AndroidPurchaseArgs { purchaseTokenAndroid = android.purchaseTokenAndroid, replacementModeAndroid = android.replacementModeAndroid, subscriptionOffers = android.subscriptionOffers, + subscriptionProductReplacementParams = android.subscriptionProductReplacementParams, type = type, useAlternativeBilling = useAlternativeBilling ) diff --git a/packages/google/openiap/src/play/java/dev/hyo/openiap/utils/BillingConverters.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/utils/BillingConverters.kt index 43b5872b..6da3ea39 100644 --- a/packages/google/openiap/src/play/java/dev/hyo/openiap/utils/BillingConverters.kt +++ b/packages/google/openiap/src/play/java/dev/hyo/openiap/utils/BillingConverters.kt @@ -62,16 +62,16 @@ internal object BillingConverters { ) } - // Extract preorder details if available - val preorder = preorderDetails?.let { details -> + // Extract preorder details if available (Billing Library 8.1.0+) + val preorder = runCatching { preorderDetails }?.getOrNull()?.let { details -> PreorderDetailsAndroid( preorderPresaleEndTimeMillis = details.preorderPresaleEndTimeMillis.toString(), preorderReleaseTimeMillis = details.preorderReleaseTimeMillis.toString() ) } - // Extract rental details if available - val rental = rentalDetails?.let { details -> + // Extract rental details if available (Billing Library 7.0+) + val rental = runCatching { rentalDetails }?.getOrNull()?.let { details -> RentalDetailsAndroid( rentalPeriod = details.rentalPeriod, rentalExpirationPeriod = runCatching { details.rentalExpirationPeriod }.getOrNull() diff --git a/packages/gql/src/generated/Types.kt b/packages/gql/src/generated/Types.kt index ce956aa6..cd1b9968 100644 --- a/packages/gql/src/generated/Types.kt +++ b/packages/gql/src/generated/Types.kt @@ -42,6 +42,41 @@ public enum class AlternativeBillingModeAndroid(val rawValue: String) { fun toJson(): String = rawValue } +/** + * Billing program types for external content links and external offers (Android) + * Available in Google Play Billing Library 8.2.0+ + */ +public enum class BillingProgramAndroid(val rawValue: String) { + /** + * Unspecified billing program. Do not use. + */ + Unspecified("unspecified"), + /** + * External Content Links program. + * Allows linking to external content outside the app. + */ + ExternalContentLink("external-content-link"), + /** + * External Offers program. + * Allows offering digital content purchases outside the app. + */ + ExternalOffer("external-offer") + + companion object { + fun fromJson(value: String): BillingProgramAndroid = when (value) { + "unspecified" -> BillingProgramAndroid.Unspecified + "UNSPECIFIED" -> BillingProgramAndroid.Unspecified + "external-content-link" -> BillingProgramAndroid.ExternalContentLink + "EXTERNAL_CONTENT_LINK" -> BillingProgramAndroid.ExternalContentLink + "external-offer" -> BillingProgramAndroid.ExternalOffer + "EXTERNAL_OFFER" -> BillingProgramAndroid.ExternalOffer + else -> throw IllegalArgumentException("Unknown BillingProgramAndroid value: $value") + } + } + + fun toJson(): String = rawValue +} + public enum class ErrorCode(val rawValue: String) { Unknown("unknown"), UserCancelled("user-cancelled"), @@ -201,6 +236,74 @@ public enum class ErrorCode(val rawValue: String) { fun toJson(): String = rawValue } +/** + * Launch mode for external link flow (Android) + * Determines how the external URL is launched + * Available in Google Play Billing Library 8.2.0+ + */ +public enum class ExternalLinkLaunchModeAndroid(val rawValue: String) { + /** + * Unspecified launch mode. Do not use. + */ + Unspecified("unspecified"), + /** + * Play will launch the URL in an external browser or eligible app + */ + LaunchInExternalBrowserOrApp("launch-in-external-browser-or-app"), + /** + * Play will not launch the URL. The app handles launching the URL after Play returns control. + */ + CallerWillLaunchLink("caller-will-launch-link") + + companion object { + fun fromJson(value: String): ExternalLinkLaunchModeAndroid = when (value) { + "unspecified" -> ExternalLinkLaunchModeAndroid.Unspecified + "UNSPECIFIED" -> ExternalLinkLaunchModeAndroid.Unspecified + "launch-in-external-browser-or-app" -> ExternalLinkLaunchModeAndroid.LaunchInExternalBrowserOrApp + "LAUNCH_IN_EXTERNAL_BROWSER_OR_APP" -> ExternalLinkLaunchModeAndroid.LaunchInExternalBrowserOrApp + "caller-will-launch-link" -> ExternalLinkLaunchModeAndroid.CallerWillLaunchLink + "CALLER_WILL_LAUNCH_LINK" -> ExternalLinkLaunchModeAndroid.CallerWillLaunchLink + else -> throw IllegalArgumentException("Unknown ExternalLinkLaunchModeAndroid value: $value") + } + } + + fun toJson(): String = rawValue +} + +/** + * Link type for external link flow (Android) + * Specifies the type of external link destination + * Available in Google Play Billing Library 8.2.0+ + */ +public enum class ExternalLinkTypeAndroid(val rawValue: String) { + /** + * Unspecified link type. Do not use. + */ + Unspecified("unspecified"), + /** + * The link will direct users to a digital content offer + */ + LinkToDigitalContentOffer("link-to-digital-content-offer"), + /** + * The link will direct users to download an app + */ + LinkToAppDownload("link-to-app-download") + + companion object { + fun fromJson(value: String): ExternalLinkTypeAndroid = when (value) { + "unspecified" -> ExternalLinkTypeAndroid.Unspecified + "UNSPECIFIED" -> ExternalLinkTypeAndroid.Unspecified + "link-to-digital-content-offer" -> ExternalLinkTypeAndroid.LinkToDigitalContentOffer + "LINK_TO_DIGITAL_CONTENT_OFFER" -> ExternalLinkTypeAndroid.LinkToDigitalContentOffer + "link-to-app-download" -> ExternalLinkTypeAndroid.LinkToAppDownload + "LINK_TO_APP_DOWNLOAD" -> ExternalLinkTypeAndroid.LinkToAppDownload + else -> throw IllegalArgumentException("Unknown ExternalLinkTypeAndroid value: $value") + } + } + + fun toJson(): String = rawValue +} + /** * User actions on external purchase notice sheet (iOS 18.2+) */ @@ -565,6 +668,64 @@ public enum class SubscriptionPeriodIOS(val rawValue: String) { fun toJson(): String = rawValue } +/** + * Replacement mode for subscription changes (Android) + * These modes determine how the subscription replacement affects billing. + * Available in Google Play Billing Library 8.1.0+ + */ +public enum class SubscriptionReplacementModeAndroid(val rawValue: String) { + /** + * Unknown replacement mode. Do not use. + */ + UnknownReplacementMode("unknown-replacement-mode"), + /** + * Replacement takes effect immediately, and the new expiration time will be prorated. + */ + WithTimeProration("with-time-proration"), + /** + * Replacement takes effect immediately, and the billing cycle remains the same. + */ + ChargeProratedPrice("charge-prorated-price"), + /** + * Replacement takes effect immediately, and the user is charged full price immediately. + */ + ChargeFullPrice("charge-full-price"), + /** + * Replacement takes effect when the old plan expires. + */ + WithoutProration("without-proration"), + /** + * Replacement takes effect when the old plan expires, and the user is not charged. + */ + Deferred("deferred"), + /** + * Keep the existing payment schedule unchanged for the item (8.1.0+) + */ + KeepExisting("keep-existing") + + companion object { + fun fromJson(value: String): SubscriptionReplacementModeAndroid = when (value) { + "unknown-replacement-mode" -> SubscriptionReplacementModeAndroid.UnknownReplacementMode + "UNKNOWN_REPLACEMENT_MODE" -> SubscriptionReplacementModeAndroid.UnknownReplacementMode + "with-time-proration" -> SubscriptionReplacementModeAndroid.WithTimeProration + "WITH_TIME_PRORATION" -> SubscriptionReplacementModeAndroid.WithTimeProration + "charge-prorated-price" -> SubscriptionReplacementModeAndroid.ChargeProratedPrice + "CHARGE_PRORATED_PRICE" -> SubscriptionReplacementModeAndroid.ChargeProratedPrice + "charge-full-price" -> SubscriptionReplacementModeAndroid.ChargeFullPrice + "CHARGE_FULL_PRICE" -> SubscriptionReplacementModeAndroid.ChargeFullPrice + "without-proration" -> SubscriptionReplacementModeAndroid.WithoutProration + "WITHOUT_PRORATION" -> SubscriptionReplacementModeAndroid.WithoutProration + "deferred" -> SubscriptionReplacementModeAndroid.Deferred + "DEFERRED" -> SubscriptionReplacementModeAndroid.Deferred + "keep-existing" -> SubscriptionReplacementModeAndroid.KeepExisting + "KEEP_EXISTING" -> SubscriptionReplacementModeAndroid.KeepExisting + else -> throw IllegalArgumentException("Unknown SubscriptionReplacementModeAndroid value: $value") + } + } + + fun toJson(): String = rawValue +} + // MARK: - Interfaces public interface ProductCommon { @@ -737,6 +898,70 @@ public data class AppTransaction( ) } +/** + * Result of checking billing program availability (Android) + * Available in Google Play Billing Library 8.2.0+ + */ +public data class BillingProgramAvailabilityResultAndroid( + /** + * The billing program that was checked + */ + val billingProgram: BillingProgramAndroid, + /** + * Whether the billing program is available for the user + */ + val isAvailable: Boolean +) { + + companion object { + fun fromJson(json: Map): BillingProgramAvailabilityResultAndroid { + return BillingProgramAvailabilityResultAndroid( + billingProgram = BillingProgramAndroid.fromJson(json["billingProgram"] as String), + isAvailable = json["isAvailable"] as Boolean, + ) + } + } + + fun toJson(): Map = mapOf( + "__typename" to "BillingProgramAvailabilityResultAndroid", + "billingProgram" to billingProgram.toJson(), + "isAvailable" to isAvailable, + ) +} + +/** + * Reporting details for transactions made outside of Google Play Billing (Android) + * Contains the external transaction token needed for reporting + * Available in Google Play Billing Library 8.2.0+ + */ +public data class BillingProgramReportingDetailsAndroid( + /** + * The billing program that the reporting details are associated with + */ + val billingProgram: BillingProgramAndroid, + /** + * External transaction token used to report transactions made outside of Google Play Billing. + * This token must be used when reporting the external transaction to Google. + */ + val externalTransactionToken: String +) { + + companion object { + fun fromJson(json: Map): BillingProgramReportingDetailsAndroid { + return BillingProgramReportingDetailsAndroid( + billingProgram = BillingProgramAndroid.fromJson(json["billingProgram"] as String), + externalTransactionToken = json["externalTransactionToken"] as String, + ) + } + } + + fun toJson(): Map = mapOf( + "__typename" to "BillingProgramReportingDetailsAndroid", + "billingProgram" to billingProgram.toJson(), + "externalTransactionToken" to externalTransactionToken, + ) +} + /** * Discount amount details for one-time purchase offers (Android) * Available in Google Play Billing Library 7.0+ @@ -909,6 +1134,58 @@ public data class EntitlementIOS( ) } +/** + * External offer availability result (Android) + * @deprecated Use BillingProgramAvailabilityResultAndroid with isBillingProgramAvailableAsync instead + * Available in Google Play Billing Library 6.2.0+, deprecated in 8.2.0 + */ +public data class ExternalOfferAvailabilityResultAndroid( + /** + * Whether external offers are available for the user + */ + val isAvailable: Boolean +) { + + companion object { + fun fromJson(json: Map): ExternalOfferAvailabilityResultAndroid { + return ExternalOfferAvailabilityResultAndroid( + isAvailable = json["isAvailable"] as Boolean, + ) + } + } + + fun toJson(): Map = mapOf( + "__typename" to "ExternalOfferAvailabilityResultAndroid", + "isAvailable" to isAvailable, + ) +} + +/** + * External offer reporting details (Android) + * @deprecated Use BillingProgramReportingDetailsAndroid with createBillingProgramReportingDetailsAsync instead + * Available in Google Play Billing Library 6.2.0+, deprecated in 8.2.0 + */ +public data class ExternalOfferReportingDetailsAndroid( + /** + * External transaction token for reporting external offer transactions + */ + val externalTransactionToken: String +) { + + companion object { + fun fromJson(json: Map): ExternalOfferReportingDetailsAndroid { + return ExternalOfferReportingDetailsAndroid( + externalTransactionToken = json["externalTransactionToken"] as String, + ) + } + } + + fun toJson(): Map = mapOf( + "__typename" to "ExternalOfferReportingDetailsAndroid", + "externalTransactionToken" to externalTransactionToken, + ) +} + /** * Result of presenting an external purchase link (iOS 18.2+) */ @@ -2341,6 +2618,48 @@ public data class InitConnectionConfig( ) } +/** + * Parameters for launching an external link (Android) + * Used with launchExternalLink to initiate external offer or app install flows + * Available in Google Play Billing Library 8.2.0+ + */ +public data class LaunchExternalLinkParamsAndroid( + /** + * The billing program (EXTERNAL_CONTENT_LINK or EXTERNAL_OFFER) + */ + val billingProgram: BillingProgramAndroid, + /** + * The external link launch mode + */ + val launchMode: ExternalLinkLaunchModeAndroid, + /** + * The type of the external link + */ + val linkType: ExternalLinkTypeAndroid, + /** + * The URI where the content will be accessed from + */ + val linkUri: String +) { + companion object { + fun fromJson(json: Map): LaunchExternalLinkParamsAndroid { + return LaunchExternalLinkParamsAndroid( + billingProgram = BillingProgramAndroid.fromJson(json["billingProgram"] as String), + launchMode = ExternalLinkLaunchModeAndroid.fromJson(json["launchMode"] as String), + linkType = ExternalLinkTypeAndroid.fromJson(json["linkType"] as String), + linkUri = json["linkUri"] as String, + ) + } + } + + fun toJson(): Map = mapOf( + "billingProgram" to billingProgram.toJson(), + "launchMode" to launchMode.toJson(), + "linkType" to linkType.toJson(), + "linkUri" to linkUri, + ) +} + public data class ProductRequest( val skus: List, val type: ProductQueryType? = null @@ -2576,6 +2895,7 @@ public data class RequestSubscriptionAndroidProps( val purchaseTokenAndroid: String? = null, /** * Replacement mode for subscription changes + * @deprecated Use subscriptionProductReplacementParams instead for item-level replacement (8.1.0+) */ val replacementModeAndroid: Int? = null, /** @@ -2585,7 +2905,12 @@ public data class RequestSubscriptionAndroidProps( /** * Subscription offers */ - val subscriptionOffers: List? = null + val subscriptionOffers: List? = null, + /** + * Product-level replacement parameters (8.1.0+) + * Use this instead of replacementModeAndroid for item-level replacement + */ + val subscriptionProductReplacementParams: SubscriptionProductReplacementParamsAndroid? = null ) { companion object { fun fromJson(json: Map): RequestSubscriptionAndroidProps { @@ -2597,6 +2922,7 @@ public data class RequestSubscriptionAndroidProps( replacementModeAndroid = (json["replacementModeAndroid"] as Number?)?.toInt(), skus = (json["skus"] as List<*>).map { it as String }, subscriptionOffers = (json["subscriptionOffers"] as List<*>?)?.map { AndroidSubscriptionOfferInput.fromJson((it as Map)) }, + subscriptionProductReplacementParams = (json["subscriptionProductReplacementParams"] as Map?)?.let { SubscriptionProductReplacementParamsAndroid.fromJson(it) }, ) } } @@ -2609,6 +2935,7 @@ public data class RequestSubscriptionAndroidProps( "replacementModeAndroid" to replacementModeAndroid, "skus" to skus.map { it }, "subscriptionOffers" to subscriptionOffers?.map { it.toJson() }, + "subscriptionProductReplacementParams" to subscriptionProductReplacementParams?.toJson(), ) } @@ -2746,6 +3073,36 @@ public data class RequestVerifyPurchaseWithIapkitProps( ) } +/** + * Product-level subscription replacement parameters (Android) + * Used with setSubscriptionProductReplacementParams in BillingFlowParams.ProductDetailsParams + * Available in Google Play Billing Library 8.1.0+ + */ +public data class SubscriptionProductReplacementParamsAndroid( + /** + * The old product ID that needs to be replaced + */ + val oldProductId: String, + /** + * The replacement mode for this product change + */ + val replacementMode: SubscriptionReplacementModeAndroid +) { + companion object { + fun fromJson(json: Map): SubscriptionProductReplacementParamsAndroid { + return SubscriptionProductReplacementParamsAndroid( + oldProductId = json["oldProductId"] as String, + replacementMode = SubscriptionReplacementModeAndroid.fromJson(json["replacementMode"] as String), + ) + } + } + + fun toJson(): Map = mapOf( + "oldProductId" to oldProductId, + "replacementMode" to replacementMode.toJson(), + ) +} + public data class VerifyPurchaseAndroidOptions( val accessToken: String, val isSub: Boolean? = null, diff --git a/packages/gql/src/generated/Types.swift b/packages/gql/src/generated/Types.swift index 1d17f520..e4681362 100644 --- a/packages/gql/src/generated/Types.swift +++ b/packages/gql/src/generated/Types.swift @@ -20,6 +20,19 @@ public enum AlternativeBillingModeAndroid: String, Codable, CaseIterable { case alternativeOnly = "alternative-only" } +/// Billing program types for external content links and external offers (Android) +/// Available in Google Play Billing Library 8.2.0+ +public enum BillingProgramAndroid: String, Codable, CaseIterable { + /// Unspecified billing program. Do not use. + case unspecified = "unspecified" + /// External Content Links program. + /// Allows linking to external content outside the app. + case externalContentLink = "external-content-link" + /// External Offers program. + /// Allows offering digital content purchases outside the app. + case externalOffer = "external-offer" +} + public enum ErrorCode: String, Codable, CaseIterable { case unknown = "unknown" case userCancelled = "user-cancelled" @@ -144,6 +157,30 @@ public enum ErrorCode: String, Codable, CaseIterable { } } +/// Launch mode for external link flow (Android) +/// Determines how the external URL is launched +/// Available in Google Play Billing Library 8.2.0+ +public enum ExternalLinkLaunchModeAndroid: String, Codable, CaseIterable { + /// Unspecified launch mode. Do not use. + case unspecified = "unspecified" + /// Play will launch the URL in an external browser or eligible app + case launchInExternalBrowserOrApp = "launch-in-external-browser-or-app" + /// Play will not launch the URL. The app handles launching the URL after Play returns control. + case callerWillLaunchLink = "caller-will-launch-link" +} + +/// Link type for external link flow (Android) +/// Specifies the type of external link destination +/// Available in Google Play Billing Library 8.2.0+ +public enum ExternalLinkTypeAndroid: String, Codable, CaseIterable { + /// Unspecified link type. Do not use. + case unspecified = "unspecified" + /// The link will direct users to a digital content offer + case linkToDigitalContentOffer = "link-to-digital-content-offer" + /// The link will direct users to download an app + case linkToAppDownload = "link-to-app-download" +} + /// User actions on external purchase notice sheet (iOS 18.2+) public enum ExternalPurchaseNoticeAction: String, Codable, CaseIterable { /// User chose to continue to external purchase @@ -244,6 +281,26 @@ public enum SubscriptionPeriodIOS: String, Codable, CaseIterable { case empty = "empty" } +/// Replacement mode for subscription changes (Android) +/// These modes determine how the subscription replacement affects billing. +/// Available in Google Play Billing Library 8.1.0+ +public enum SubscriptionReplacementModeAndroid: String, Codable, CaseIterable { + /// Unknown replacement mode. Do not use. + case unknownReplacementMode = "unknown-replacement-mode" + /// Replacement takes effect immediately, and the new expiration time will be prorated. + case withTimeProration = "with-time-proration" + /// Replacement takes effect immediately, and the billing cycle remains the same. + case chargeProratedPrice = "charge-prorated-price" + /// Replacement takes effect immediately, and the user is charged full price immediately. + case chargeFullPrice = "charge-full-price" + /// Replacement takes effect when the old plan expires. + case withoutProration = "without-proration" + /// Replacement takes effect when the old plan expires, and the user is not charged. + case deferred = "deferred" + /// Keep the existing payment schedule unchanged for the item (8.1.0+) + case keepExisting = "keep-existing" +} + // MARK: - Interfaces public protocol ProductCommon: Codable { @@ -324,6 +381,26 @@ public struct AppTransaction: Codable { public var signedDate: Double } +/// Result of checking billing program availability (Android) +/// Available in Google Play Billing Library 8.2.0+ +public struct BillingProgramAvailabilityResultAndroid: Codable { + /// The billing program that was checked + public var billingProgram: BillingProgramAndroid + /// Whether the billing program is available for the user + public var isAvailable: Bool +} + +/// Reporting details for transactions made outside of Google Play Billing (Android) +/// Contains the external transaction token needed for reporting +/// Available in Google Play Billing Library 8.2.0+ +public struct BillingProgramReportingDetailsAndroid: Codable { + /// The billing program that the reporting details are associated with + public var billingProgram: BillingProgramAndroid + /// External transaction token used to report transactions made outside of Google Play Billing. + /// This token must be used when reporting the external transaction to Google. + public var externalTransactionToken: String +} + /// Discount amount details for one-time purchase offers (Android) /// Available in Google Play Billing Library 7.0+ public struct DiscountAmountAndroid: Codable { @@ -374,6 +451,22 @@ public struct EntitlementIOS: Codable { public var transactionId: String } +/// External offer availability result (Android) +/// @deprecated Use BillingProgramAvailabilityResultAndroid with isBillingProgramAvailableAsync instead +/// Available in Google Play Billing Library 6.2.0+, deprecated in 8.2.0 +public struct ExternalOfferAvailabilityResultAndroid: Codable { + /// Whether external offers are available for the user + public var isAvailable: Bool +} + +/// External offer reporting details (Android) +/// @deprecated Use BillingProgramReportingDetailsAndroid with createBillingProgramReportingDetailsAsync instead +/// Available in Google Play Billing Library 6.2.0+, deprecated in 8.2.0 +public struct ExternalOfferReportingDetailsAndroid: Codable { + /// External transaction token for reporting external offer transactions + public var externalTransactionToken: String +} + /// Result of presenting an external purchase link (iOS 18.2+) public struct ExternalPurchaseLinkResultIOS: Codable { /// Optional error message if the presentation failed @@ -873,6 +966,32 @@ public struct InitConnectionConfig: Codable { } } +/// Parameters for launching an external link (Android) +/// Used with launchExternalLink to initiate external offer or app install flows +/// Available in Google Play Billing Library 8.2.0+ +public struct LaunchExternalLinkParamsAndroid: Codable { + /// The billing program (EXTERNAL_CONTENT_LINK or EXTERNAL_OFFER) + public var billingProgram: BillingProgramAndroid + /// The external link launch mode + public var launchMode: ExternalLinkLaunchModeAndroid + /// The type of the external link + public var linkType: ExternalLinkTypeAndroid + /// The URI where the content will be accessed from + public var linkUri: String + + public init( + billingProgram: BillingProgramAndroid, + launchMode: ExternalLinkLaunchModeAndroid, + linkType: ExternalLinkTypeAndroid, + linkUri: String + ) { + self.billingProgram = billingProgram + self.launchMode = launchMode + self.linkType = linkType + self.linkUri = linkUri + } +} + public struct ProductRequest: Codable { public var skus: [String] public var type: ProductQueryType? @@ -1056,11 +1175,15 @@ public struct RequestSubscriptionAndroidProps: Codable { /// Purchase token for upgrades/downgrades public var purchaseTokenAndroid: String? /// Replacement mode for subscription changes + /// @deprecated Use subscriptionProductReplacementParams instead for item-level replacement (8.1.0+) public var replacementModeAndroid: Int? /// List of subscription SKUs public var skus: [String] /// Subscription offers public var subscriptionOffers: [AndroidSubscriptionOfferInput]? + /// Product-level replacement parameters (8.1.0+) + /// Use this instead of replacementModeAndroid for item-level replacement + public var subscriptionProductReplacementParams: SubscriptionProductReplacementParamsAndroid? public init( isOfferPersonalized: Bool? = nil, @@ -1069,7 +1192,8 @@ public struct RequestSubscriptionAndroidProps: Codable { purchaseTokenAndroid: String? = nil, replacementModeAndroid: Int? = nil, skus: [String], - subscriptionOffers: [AndroidSubscriptionOfferInput]? = nil + subscriptionOffers: [AndroidSubscriptionOfferInput]? = nil, + subscriptionProductReplacementParams: SubscriptionProductReplacementParamsAndroid? = nil ) { self.isOfferPersonalized = isOfferPersonalized self.obfuscatedAccountIdAndroid = obfuscatedAccountIdAndroid @@ -1078,6 +1202,7 @@ public struct RequestSubscriptionAndroidProps: Codable { self.replacementModeAndroid = replacementModeAndroid self.skus = skus self.subscriptionOffers = subscriptionOffers + self.subscriptionProductReplacementParams = subscriptionProductReplacementParams } } @@ -1167,6 +1292,24 @@ public struct RequestVerifyPurchaseWithIapkitProps: Codable { } } +/// Product-level subscription replacement parameters (Android) +/// Used with setSubscriptionProductReplacementParams in BillingFlowParams.ProductDetailsParams +/// Available in Google Play Billing Library 8.1.0+ +public struct SubscriptionProductReplacementParamsAndroid: Codable { + /// The old product ID that needs to be replaced + public var oldProductId: String + /// The replacement mode for this product change + public var replacementMode: SubscriptionReplacementModeAndroid + + public init( + oldProductId: String, + replacementMode: SubscriptionReplacementModeAndroid + ) { + self.oldProductId = oldProductId + self.replacementMode = replacementMode + } +} + public struct VerifyPurchaseAndroidOptions: Codable { public var accessToken: String public var isSub: Bool? diff --git a/packages/gql/src/generated/types.dart b/packages/gql/src/generated/types.dart index e8417cdd..b79e0451 100644 --- a/packages/gql/src/generated/types.dart +++ b/packages/gql/src/generated/types.dart @@ -42,6 +42,39 @@ enum AlternativeBillingModeAndroid { String toJson() => value; } +/// Billing program types for external content links and external offers (Android) +/// Available in Google Play Billing Library 8.2.0+ +enum BillingProgramAndroid { + /// Unspecified billing program. Do not use. + Unspecified('unspecified'), + /// External Content Links program. + /// Allows linking to external content outside the app. + ExternalContentLink('external-content-link'), + /// External Offers program. + /// Allows offering digital content purchases outside the app. + ExternalOffer('external-offer'); + + const BillingProgramAndroid(this.value); + final String value; + + factory BillingProgramAndroid.fromJson(String value) { + switch (value) { + case 'unspecified': + case 'UNSPECIFIED': + return BillingProgramAndroid.Unspecified; + case 'external-content-link': + case 'EXTERNAL_CONTENT_LINK': + return BillingProgramAndroid.ExternalContentLink; + case 'external-offer': + case 'EXTERNAL_OFFER': + return BillingProgramAndroid.ExternalOffer; + } + throw ArgumentError('Unknown BillingProgramAndroid value: $value'); + } + + String toJson() => value; +} + enum ErrorCode { Unknown('unknown'), UserCancelled('user-cancelled'), @@ -241,6 +274,70 @@ enum ErrorCode { String toJson() => value; } +/// Launch mode for external link flow (Android) +/// Determines how the external URL is launched +/// Available in Google Play Billing Library 8.2.0+ +enum ExternalLinkLaunchModeAndroid { + /// Unspecified launch mode. Do not use. + Unspecified('unspecified'), + /// Play will launch the URL in an external browser or eligible app + LaunchInExternalBrowserOrApp('launch-in-external-browser-or-app'), + /// Play will not launch the URL. The app handles launching the URL after Play returns control. + CallerWillLaunchLink('caller-will-launch-link'); + + const ExternalLinkLaunchModeAndroid(this.value); + final String value; + + factory ExternalLinkLaunchModeAndroid.fromJson(String value) { + switch (value) { + case 'unspecified': + case 'UNSPECIFIED': + return ExternalLinkLaunchModeAndroid.Unspecified; + case 'launch-in-external-browser-or-app': + case 'LAUNCH_IN_EXTERNAL_BROWSER_OR_APP': + return ExternalLinkLaunchModeAndroid.LaunchInExternalBrowserOrApp; + case 'caller-will-launch-link': + case 'CALLER_WILL_LAUNCH_LINK': + return ExternalLinkLaunchModeAndroid.CallerWillLaunchLink; + } + throw ArgumentError('Unknown ExternalLinkLaunchModeAndroid value: $value'); + } + + String toJson() => value; +} + +/// Link type for external link flow (Android) +/// Specifies the type of external link destination +/// Available in Google Play Billing Library 8.2.0+ +enum ExternalLinkTypeAndroid { + /// Unspecified link type. Do not use. + Unspecified('unspecified'), + /// The link will direct users to a digital content offer + LinkToDigitalContentOffer('link-to-digital-content-offer'), + /// The link will direct users to download an app + LinkToAppDownload('link-to-app-download'); + + const ExternalLinkTypeAndroid(this.value); + final String value; + + factory ExternalLinkTypeAndroid.fromJson(String value) { + switch (value) { + case 'unspecified': + case 'UNSPECIFIED': + return ExternalLinkTypeAndroid.Unspecified; + case 'link-to-digital-content-offer': + case 'LINK_TO_DIGITAL_CONTENT_OFFER': + return ExternalLinkTypeAndroid.LinkToDigitalContentOffer; + case 'link-to-app-download': + case 'LINK_TO_APP_DOWNLOAD': + return ExternalLinkTypeAndroid.LinkToAppDownload; + } + throw ArgumentError('Unknown ExternalLinkTypeAndroid value: $value'); + } + + String toJson() => value; +} + /// User actions on external purchase notice sheet (iOS 18.2+) enum ExternalPurchaseNoticeAction { /// User chose to continue to external purchase @@ -666,6 +763,58 @@ enum SubscriptionPeriodIOS { String toJson() => value; } +/// Replacement mode for subscription changes (Android) +/// These modes determine how the subscription replacement affects billing. +/// Available in Google Play Billing Library 8.1.0+ +enum SubscriptionReplacementModeAndroid { + /// Unknown replacement mode. Do not use. + UnknownReplacementMode('unknown-replacement-mode'), + /// Replacement takes effect immediately, and the new expiration time will be prorated. + WithTimeProration('with-time-proration'), + /// Replacement takes effect immediately, and the billing cycle remains the same. + ChargeProratedPrice('charge-prorated-price'), + /// Replacement takes effect immediately, and the user is charged full price immediately. + ChargeFullPrice('charge-full-price'), + /// Replacement takes effect when the old plan expires. + WithoutProration('without-proration'), + /// Replacement takes effect when the old plan expires, and the user is not charged. + Deferred('deferred'), + /// Keep the existing payment schedule unchanged for the item (8.1.0+) + KeepExisting('keep-existing'); + + const SubscriptionReplacementModeAndroid(this.value); + final String value; + + factory SubscriptionReplacementModeAndroid.fromJson(String value) { + switch (value) { + case 'unknown-replacement-mode': + case 'UNKNOWN_REPLACEMENT_MODE': + return SubscriptionReplacementModeAndroid.UnknownReplacementMode; + case 'with-time-proration': + case 'WITH_TIME_PRORATION': + return SubscriptionReplacementModeAndroid.WithTimeProration; + case 'charge-prorated-price': + case 'CHARGE_PRORATED_PRICE': + return SubscriptionReplacementModeAndroid.ChargeProratedPrice; + case 'charge-full-price': + case 'CHARGE_FULL_PRICE': + return SubscriptionReplacementModeAndroid.ChargeFullPrice; + case 'without-proration': + case 'WITHOUT_PRORATION': + return SubscriptionReplacementModeAndroid.WithoutProration; + case 'deferred': + case 'DEFERRED': + return SubscriptionReplacementModeAndroid.Deferred; + case 'keep-existing': + case 'KEEP_EXISTING': + return SubscriptionReplacementModeAndroid.KeepExisting; + } + throw ArgumentError('Unknown SubscriptionReplacementModeAndroid value: $value'); + } + + String toJson() => value; +} + // MARK: - Interfaces abstract class ProductCommon { @@ -865,6 +1014,71 @@ class AppTransaction { } } +/// Result of checking billing program availability (Android) +/// Available in Google Play Billing Library 8.2.0+ +class BillingProgramAvailabilityResultAndroid { + const BillingProgramAvailabilityResultAndroid({ + /// The billing program that was checked + required this.billingProgram, + /// Whether the billing program is available for the user + required this.isAvailable, + }); + + /// The billing program that was checked + final BillingProgramAndroid billingProgram; + /// Whether the billing program is available for the user + final bool isAvailable; + + factory BillingProgramAvailabilityResultAndroid.fromJson(Map json) { + return BillingProgramAvailabilityResultAndroid( + billingProgram: BillingProgramAndroid.fromJson(json['billingProgram'] as String), + isAvailable: json['isAvailable'] as bool, + ); + } + + Map toJson() { + return { + '__typename': 'BillingProgramAvailabilityResultAndroid', + 'billingProgram': billingProgram.toJson(), + 'isAvailable': isAvailable, + }; + } +} + +/// Reporting details for transactions made outside of Google Play Billing (Android) +/// Contains the external transaction token needed for reporting +/// Available in Google Play Billing Library 8.2.0+ +class BillingProgramReportingDetailsAndroid { + const BillingProgramReportingDetailsAndroid({ + /// The billing program that the reporting details are associated with + required this.billingProgram, + /// External transaction token used to report transactions made outside of Google Play Billing. + /// This token must be used when reporting the external transaction to Google. + required this.externalTransactionToken, + }); + + /// The billing program that the reporting details are associated with + final BillingProgramAndroid billingProgram; + /// External transaction token used to report transactions made outside of Google Play Billing. + /// This token must be used when reporting the external transaction to Google. + final String externalTransactionToken; + + factory BillingProgramReportingDetailsAndroid.fromJson(Map json) { + return BillingProgramReportingDetailsAndroid( + billingProgram: BillingProgramAndroid.fromJson(json['billingProgram'] as String), + externalTransactionToken: json['externalTransactionToken'] as String, + ); + } + + Map toJson() { + return { + '__typename': 'BillingProgramReportingDetailsAndroid', + 'billingProgram': billingProgram.toJson(), + 'externalTransactionToken': externalTransactionToken, + }; + } +} + /// Discount amount details for one-time purchase offers (Android) /// Available in Google Play Billing Library 7.0+ class DiscountAmountAndroid { @@ -1056,6 +1270,58 @@ class EntitlementIOS { } } +/// External offer availability result (Android) +/// @deprecated Use BillingProgramAvailabilityResultAndroid with isBillingProgramAvailableAsync instead +/// Available in Google Play Billing Library 6.2.0+, deprecated in 8.2.0 +class ExternalOfferAvailabilityResultAndroid { + const ExternalOfferAvailabilityResultAndroid({ + /// Whether external offers are available for the user + required this.isAvailable, + }); + + /// Whether external offers are available for the user + final bool isAvailable; + + factory ExternalOfferAvailabilityResultAndroid.fromJson(Map json) { + return ExternalOfferAvailabilityResultAndroid( + isAvailable: json['isAvailable'] as bool, + ); + } + + Map toJson() { + return { + '__typename': 'ExternalOfferAvailabilityResultAndroid', + 'isAvailable': isAvailable, + }; + } +} + +/// External offer reporting details (Android) +/// @deprecated Use BillingProgramReportingDetailsAndroid with createBillingProgramReportingDetailsAsync instead +/// Available in Google Play Billing Library 6.2.0+, deprecated in 8.2.0 +class ExternalOfferReportingDetailsAndroid { + const ExternalOfferReportingDetailsAndroid({ + /// External transaction token for reporting external offer transactions + required this.externalTransactionToken, + }); + + /// External transaction token for reporting external offer transactions + final String externalTransactionToken; + + factory ExternalOfferReportingDetailsAndroid.fromJson(Map json) { + return ExternalOfferReportingDetailsAndroid( + externalTransactionToken: json['externalTransactionToken'] as String, + ); + } + + Map toJson() { + return { + '__typename': 'ExternalOfferReportingDetailsAndroid', + 'externalTransactionToken': externalTransactionToken, + }; + } +} + /// Result of presenting an external purchase link (iOS 18.2+) class ExternalPurchaseLinkResultIOS { const ExternalPurchaseLinkResultIOS({ @@ -2772,6 +3038,49 @@ class InitConnectionConfig { } } +/// Parameters for launching an external link (Android) +/// Used with launchExternalLink to initiate external offer or app install flows +/// Available in Google Play Billing Library 8.2.0+ +class LaunchExternalLinkParamsAndroid { + const LaunchExternalLinkParamsAndroid({ + /// The billing program (EXTERNAL_CONTENT_LINK or EXTERNAL_OFFER) + required this.billingProgram, + /// The external link launch mode + required this.launchMode, + /// The type of the external link + required this.linkType, + /// The URI where the content will be accessed from + required this.linkUri, + }); + + /// The billing program (EXTERNAL_CONTENT_LINK or EXTERNAL_OFFER) + final BillingProgramAndroid billingProgram; + /// The external link launch mode + final ExternalLinkLaunchModeAndroid launchMode; + /// The type of the external link + final ExternalLinkTypeAndroid linkType; + /// The URI where the content will be accessed from + final String linkUri; + + factory LaunchExternalLinkParamsAndroid.fromJson(Map json) { + return LaunchExternalLinkParamsAndroid( + billingProgram: BillingProgramAndroid.fromJson(json['billingProgram'] as String), + launchMode: ExternalLinkLaunchModeAndroid.fromJson(json['launchMode'] as String), + linkType: ExternalLinkTypeAndroid.fromJson(json['linkType'] as String), + linkUri: json['linkUri'] as String, + ); + } + + Map toJson() { + return { + 'billingProgram': billingProgram.toJson(), + 'launchMode': launchMode.toJson(), + 'linkType': linkType.toJson(), + 'linkUri': linkUri, + }; + } +} + class ProductRequest { const ProductRequest({ required this.skus, @@ -3023,11 +3332,15 @@ class RequestSubscriptionAndroidProps { /// Purchase token for upgrades/downgrades this.purchaseTokenAndroid, /// Replacement mode for subscription changes + /// @deprecated Use subscriptionProductReplacementParams instead for item-level replacement (8.1.0+) this.replacementModeAndroid, /// List of subscription SKUs required this.skus, /// Subscription offers this.subscriptionOffers, + /// Product-level replacement parameters (8.1.0+) + /// Use this instead of replacementModeAndroid for item-level replacement + this.subscriptionProductReplacementParams, }); /// Personalized offer flag @@ -3039,11 +3352,15 @@ class RequestSubscriptionAndroidProps { /// Purchase token for upgrades/downgrades final String? purchaseTokenAndroid; /// Replacement mode for subscription changes + /// @deprecated Use subscriptionProductReplacementParams instead for item-level replacement (8.1.0+) final int? replacementModeAndroid; /// List of subscription SKUs final List skus; /// Subscription offers final List? subscriptionOffers; + /// Product-level replacement parameters (8.1.0+) + /// Use this instead of replacementModeAndroid for item-level replacement + final SubscriptionProductReplacementParamsAndroid? subscriptionProductReplacementParams; factory RequestSubscriptionAndroidProps.fromJson(Map json) { return RequestSubscriptionAndroidProps( @@ -3054,6 +3371,7 @@ class RequestSubscriptionAndroidProps { replacementModeAndroid: json['replacementModeAndroid'] as int?, skus: (json['skus'] as List).map((e) => e as String).toList(), subscriptionOffers: (json['subscriptionOffers'] as List?) == null ? null : (json['subscriptionOffers'] as List?)!.map((e) => AndroidSubscriptionOfferInput.fromJson(e as Map)).toList(), + subscriptionProductReplacementParams: json['subscriptionProductReplacementParams'] != null ? SubscriptionProductReplacementParamsAndroid.fromJson(json['subscriptionProductReplacementParams'] as Map) : null, ); } @@ -3066,6 +3384,7 @@ class RequestSubscriptionAndroidProps { 'replacementModeAndroid': replacementModeAndroid, 'skus': skus.map((e) => e).toList(), 'subscriptionOffers': subscriptionOffers == null ? null : subscriptionOffers!.map((e) => e.toJson()).toList(), + 'subscriptionProductReplacementParams': subscriptionProductReplacementParams?.toJson(), }; } } @@ -3224,6 +3543,37 @@ class RequestVerifyPurchaseWithIapkitProps { } } +/// Product-level subscription replacement parameters (Android) +/// Used with setSubscriptionProductReplacementParams in BillingFlowParams.ProductDetailsParams +/// Available in Google Play Billing Library 8.1.0+ +class SubscriptionProductReplacementParamsAndroid { + const SubscriptionProductReplacementParamsAndroid({ + /// The old product ID that needs to be replaced + required this.oldProductId, + /// The replacement mode for this product change + required this.replacementMode, + }); + + /// The old product ID that needs to be replaced + final String oldProductId; + /// The replacement mode for this product change + final SubscriptionReplacementModeAndroid replacementMode; + + factory SubscriptionProductReplacementParamsAndroid.fromJson(Map json) { + return SubscriptionProductReplacementParamsAndroid( + oldProductId: json['oldProductId'] as String, + replacementMode: SubscriptionReplacementModeAndroid.fromJson(json['replacementMode'] as String), + ); + } + + Map toJson() { + return { + 'oldProductId': oldProductId, + 'replacementMode': replacementMode.toJson(), + }; + } +} + class VerifyPurchaseAndroidOptions { const VerifyPurchaseAndroidOptions({ required this.accessToken, diff --git a/packages/gql/src/generated/types.ts b/packages/gql/src/generated/types.ts index ab5709ef..bdd4ac65 100644 --- a/packages/gql/src/generated/types.ts +++ b/packages/gql/src/generated/types.ts @@ -65,6 +65,38 @@ export interface AppTransaction { signedDate: number; } +/** + * Billing program types for external content links and external offers (Android) + * Available in Google Play Billing Library 8.2.0+ + */ +export type BillingProgramAndroid = 'unspecified' | 'external-content-link' | 'external-offer'; + +/** + * Result of checking billing program availability (Android) + * Available in Google Play Billing Library 8.2.0+ + */ +export interface BillingProgramAvailabilityResultAndroid { + /** The billing program that was checked */ + billingProgram: BillingProgramAndroid; + /** Whether the billing program is available for the user */ + isAvailable: boolean; +} + +/** + * Reporting details for transactions made outside of Google Play Billing (Android) + * Contains the external transaction token needed for reporting + * Available in Google Play Billing Library 8.2.0+ + */ +export interface BillingProgramReportingDetailsAndroid { + /** The billing program that the reporting details are associated with */ + billingProgram: BillingProgramAndroid; + /** + * External transaction token used to report transactions made outside of Google Play Billing. + * This token must be used when reporting the external transaction to Google. + */ + externalTransactionToken: string; +} + export interface DeepLinkOptions { /** Android package name to target (required on Android) */ packageNameAndroid?: (string | null); @@ -183,6 +215,40 @@ export enum ErrorCode { UserError = 'user-error' } +/** + * Launch mode for external link flow (Android) + * Determines how the external URL is launched + * Available in Google Play Billing Library 8.2.0+ + */ +export type ExternalLinkLaunchModeAndroid = 'unspecified' | 'launch-in-external-browser-or-app' | 'caller-will-launch-link'; + +/** + * Link type for external link flow (Android) + * Specifies the type of external link destination + * Available in Google Play Billing Library 8.2.0+ + */ +export type ExternalLinkTypeAndroid = 'unspecified' | 'link-to-digital-content-offer' | 'link-to-app-download'; + +/** + * External offer availability result (Android) + * @deprecated Use BillingProgramAvailabilityResultAndroid with isBillingProgramAvailableAsync instead + * Available in Google Play Billing Library 6.2.0+, deprecated in 8.2.0 + */ +export interface ExternalOfferAvailabilityResultAndroid { + /** Whether external offers are available for the user */ + isAvailable: boolean; +} + +/** + * External offer reporting details (Android) + * @deprecated Use BillingProgramReportingDetailsAndroid with createBillingProgramReportingDetailsAsync instead + * Available in Google Play Billing Library 6.2.0+, deprecated in 8.2.0 + */ +export interface ExternalOfferReportingDetailsAndroid { + /** External transaction token for reporting external offer transactions */ + externalTransactionToken: string; +} + /** Result of presenting an external purchase link (iOS 18.2+) */ export interface ExternalPurchaseLinkResultIOS { /** Optional error message if the presentation failed */ @@ -222,6 +288,22 @@ export interface InitConnectionConfig { alternativeBillingModeAndroid?: (AlternativeBillingModeAndroid | null); } +/** + * Parameters for launching an external link (Android) + * Used with launchExternalLink to initiate external offer or app install flows + * Available in Google Play Billing Library 8.2.0+ + */ +export interface LaunchExternalLinkParamsAndroid { + /** The billing program (EXTERNAL_CONTENT_LINK or EXTERNAL_OFFER) */ + billingProgram: BillingProgramAndroid; + /** The external link launch mode */ + launchMode: ExternalLinkLaunchModeAndroid; + /** The type of the external link */ + linkType: ExternalLinkTypeAndroid; + /** The URI where the content will be accessed from */ + linkUri: string; +} + /** * Limited quantity information for one-time purchase offers (Android) * Available in Google Play Billing Library 7.0+ @@ -857,12 +939,20 @@ export interface RequestSubscriptionAndroidProps { obfuscatedProfileIdAndroid?: (string | null); /** Purchase token for upgrades/downgrades */ purchaseTokenAndroid?: (string | null); - /** Replacement mode for subscription changes */ + /** + * Replacement mode for subscription changes + * @deprecated Use subscriptionProductReplacementParams instead for item-level replacement (8.1.0+) + */ replacementModeAndroid?: (number | null); /** List of subscription SKUs */ skus: string[]; /** Subscription offers */ subscriptionOffers?: (AndroidSubscriptionOfferInput[] | null); + /** + * Product-level replacement parameters (8.1.0+) + * Use this instead of replacementModeAndroid for item-level replacement + */ + subscriptionProductReplacementParams?: (SubscriptionProductReplacementParamsAndroid | null); } export interface RequestSubscriptionIosProps { @@ -952,6 +1042,25 @@ export interface SubscriptionPeriodValueIOS { value: number; } +/** + * Product-level subscription replacement parameters (Android) + * Used with setSubscriptionProductReplacementParams in BillingFlowParams.ProductDetailsParams + * Available in Google Play Billing Library 8.1.0+ + */ +export interface SubscriptionProductReplacementParamsAndroid { + /** The old product ID that needs to be replaced */ + oldProductId: string; + /** The replacement mode for this product change */ + replacementMode: SubscriptionReplacementModeAndroid; +} + +/** + * Replacement mode for subscription changes (Android) + * These modes determine how the subscription replacement affects billing. + * Available in Google Play Billing Library 8.1.0+ + */ +export type SubscriptionReplacementModeAndroid = 'unknown-replacement-mode' | 'with-time-proration' | 'charge-prorated-price' | 'charge-full-price' | 'without-proration' | 'deferred' | 'keep-existing'; + export interface SubscriptionStatusIOS { renewalInfo?: (RenewalInfoIOS | null); state: string; diff --git a/packages/gql/src/type-android.graphql b/packages/gql/src/type-android.graphql index 83bd1330..7503c0a3 100644 --- a/packages/gql/src/type-android.graphql +++ b/packages/gql/src/type-android.graphql @@ -292,12 +292,71 @@ input RequestSubscriptionAndroidProps { purchaseTokenAndroid: String """ Replacement mode for subscription changes + @deprecated Use subscriptionProductReplacementParams instead for item-level replacement (8.1.0+) """ replacementModeAndroid: Int """ Subscription offers """ subscriptionOffers: [AndroidSubscriptionOfferInput!] + """ + Product-level replacement parameters (8.1.0+) + Use this instead of replacementModeAndroid for item-level replacement + """ + subscriptionProductReplacementParams: SubscriptionProductReplacementParamsAndroid +} + +# Subscription Replacement (Google Play Billing Library 8.1.0+) +""" +Replacement mode for subscription changes (Android) +These modes determine how the subscription replacement affects billing. +Available in Google Play Billing Library 8.1.0+ +""" +enum SubscriptionReplacementModeAndroid { + """ + Unknown replacement mode. Do not use. + """ + UNKNOWN_REPLACEMENT_MODE + """ + Replacement takes effect immediately, and the new expiration time will be prorated. + """ + WITH_TIME_PRORATION + """ + Replacement takes effect immediately, and the billing cycle remains the same. + """ + CHARGE_PRORATED_PRICE + """ + Replacement takes effect immediately, and the user is charged full price immediately. + """ + CHARGE_FULL_PRICE + """ + Replacement takes effect when the old plan expires. + """ + WITHOUT_PRORATION + """ + Replacement takes effect when the old plan expires, and the user is not charged. + """ + DEFERRED + """ + Keep the existing payment schedule unchanged for the item (8.1.0+) + """ + KEEP_EXISTING +} + +""" +Product-level subscription replacement parameters (Android) +Used with setSubscriptionProductReplacementParams in BillingFlowParams.ProductDetailsParams +Available in Google Play Billing Library 8.1.0+ +""" +input SubscriptionProductReplacementParamsAndroid { + """ + The old product ID that needs to be replaced + """ + oldProductId: String! + """ + The replacement mode for this product change + """ + replacementMode: SubscriptionReplacementModeAndroid! } input AndroidSubscriptionOfferInput { @@ -378,3 +437,154 @@ type UserChoiceBillingDetails { """ products: [String!]! } + +# External Billing Programs (Google Play Billing Library 8.2.0+) +""" +Billing program types for external content links and external offers (Android) +Available in Google Play Billing Library 8.2.0+ +""" +enum BillingProgramAndroid { + """ + Unspecified billing program. Do not use. + """ + UNSPECIFIED + """ + External Content Links program. + Allows linking to external content outside the app. + """ + EXTERNAL_CONTENT_LINK + """ + External Offers program. + Allows offering digital content purchases outside the app. + """ + EXTERNAL_OFFER +} + +""" +Launch mode for external link flow (Android) +Determines how the external URL is launched +Available in Google Play Billing Library 8.2.0+ +""" +enum ExternalLinkLaunchModeAndroid { + """ + Unspecified launch mode. Do not use. + """ + UNSPECIFIED + """ + Play will launch the URL in an external browser or eligible app + """ + LAUNCH_IN_EXTERNAL_BROWSER_OR_APP + """ + Play will not launch the URL. The app handles launching the URL after Play returns control. + """ + CALLER_WILL_LAUNCH_LINK +} + +""" +Link type for external link flow (Android) +Specifies the type of external link destination +Available in Google Play Billing Library 8.2.0+ +""" +enum ExternalLinkTypeAndroid { + """ + Unspecified link type. Do not use. + """ + UNSPECIFIED + """ + The link will direct users to a digital content offer + """ + LINK_TO_DIGITAL_CONTENT_OFFER + """ + The link will direct users to download an app + """ + LINK_TO_APP_DOWNLOAD +} + +""" +Parameters for launching an external link (Android) +Used with launchExternalLink to initiate external offer or app install flows +Available in Google Play Billing Library 8.2.0+ +""" +input LaunchExternalLinkParamsAndroid { + """ + The billing program (EXTERNAL_CONTENT_LINK or EXTERNAL_OFFER) + """ + billingProgram: BillingProgramAndroid! + """ + The external link launch mode + """ + launchMode: ExternalLinkLaunchModeAndroid! + """ + The type of the external link + """ + linkType: ExternalLinkTypeAndroid! + """ + The URI where the content will be accessed from + """ + linkUri: String! +} + +""" +Reporting details for transactions made outside of Google Play Billing (Android) +Contains the external transaction token needed for reporting +Available in Google Play Billing Library 8.2.0+ +""" +type BillingProgramReportingDetailsAndroid { + """ + The billing program that the reporting details are associated with + """ + billingProgram: BillingProgramAndroid! + """ + External transaction token used to report transactions made outside of Google Play Billing. + This token must be used when reporting the external transaction to Google. + """ + externalTransactionToken: String! +} + +""" +Result of checking billing program availability (Android) +Available in Google Play Billing Library 8.2.0+ +""" +type BillingProgramAvailabilityResultAndroid { + """ + Whether the billing program is available for the user + """ + isAvailable: Boolean! + """ + The billing program that was checked + """ + billingProgram: BillingProgramAndroid! +} + +# Deprecated External Offers APIs (Google Play Billing Library 8.2.0) +# These APIs are deprecated in favor of the new BillingProgram APIs above + +""" +External offer reporting details (Android) +@deprecated Use BillingProgramReportingDetailsAndroid with createBillingProgramReportingDetailsAsync instead +Available in Google Play Billing Library 6.2.0+, deprecated in 8.2.0 +""" +type ExternalOfferReportingDetailsAndroid + @deprecated( + reason: "Use BillingProgramReportingDetailsAndroid with createBillingProgramReportingDetailsAsync instead" + ) { + """ + External transaction token for reporting external offer transactions + """ + externalTransactionToken: String! +} + +""" +External offer availability result (Android) +@deprecated Use BillingProgramAvailabilityResultAndroid with isBillingProgramAvailableAsync instead +Available in Google Play Billing Library 6.2.0+, deprecated in 8.2.0 +""" +type ExternalOfferAvailabilityResultAndroid + @deprecated( + reason: "Use BillingProgramAvailabilityResultAndroid with isBillingProgramAvailableAsync instead" + ) { + """ + Whether external offers are available for the user + """ + isAvailable: Boolean! +} From ca306c7efe2e1b964c350d280ba8256bcf7ae611 Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 11 Dec 2025 12:02:45 +0900 Subject: [PATCH 2/8] docs: add Billing Programs API (8.2.0+) to external-purchase docs and Example - Add Billing Programs API section to external-purchase.tsx docs - Add deprecated warnings for legacy alternative billing APIs - Add API migration guide table - Update Example AlternativeBillingScreen with new 8.2.0+ Billing Programs mode - Add enableBillingProgram() method to OpenIapStore - Support ExternalOffer and ExternalContentLink program types --- .../pages/docs/features/external-purchase.tsx | 353 +++++++++++++- .../screens/AlternativeBillingScreen.kt | 447 +++++++++++++----- .../dev/hyo/openiap/store/OpenIapStore.kt | 20 + 3 files changed, 686 insertions(+), 134 deletions(-) diff --git a/packages/docs/src/pages/docs/features/external-purchase.tsx b/packages/docs/src/pages/docs/features/external-purchase.tsx index 2ea8b28f..343906dd 100644 --- a/packages/docs/src/pages/docs/features/external-purchase.tsx +++ b/packages/docs/src/pages/docs/features/external-purchase.tsx @@ -57,9 +57,9 @@ function ExternalPurchase() { Android - Alternative Billing + Alternative Billing / Billing Programs Android 6.0+ (API 23) - Google Play Billing 6.2+ + Google Play Billing 6.2+ (legacy), 8.2.0+ (recommended) @@ -985,16 +985,292 @@ Future handleUserChoicePurchase(String productId) async { }} +
+

+ āš ļø Deprecated APIs: The above APIs ( + checkAlternativeBillingAvailability,{' '} + showAlternativeBillingInformationDialog,{' '} + createAlternativeBillingReportingToken) are + deprecated in Google Play Billing Library 8.2.0+. For new + implementations, use the Billing Programs API{' '} + described below. +

+
+ +

Billing Programs API (8.2.0+)

+

+ Google Play Billing Library 8.2.0 introduces the new{' '} + Billing Programs API which replaces the legacy + alternative billing APIs. This provides better support for + External Content Links and External Offers. +

+ +
Program Types
+
    +
  • + ExternalContentLink - For apps that link to + external content (e.g., reader apps, music streaming) +
  • +
  • + ExternalOffer - For apps offering alternative + payment options +
  • +
+ + + {{ + typescript: ( + {`import { + initConnection, + enableBillingProgramAndroid, + isBillingProgramAvailableAndroid, + createBillingProgramReportingDetailsAndroid, + launchExternalLinkAndroid, +} from 'expo-iap'; + +// Step 0: Enable billing program BEFORE initConnection +enableBillingProgramAndroid('EXTERNAL_OFFER'); +// or enableBillingProgramAndroid('EXTERNAL_CONTENT_LINK'); + +await initConnection(); + +// Purchase flow with Billing Programs API (8.2.0+) +async function handleExternalPurchaseWithBillingPrograms(productId: string) { + try { + // Step 1: Check if billing program is available + const result = await isBillingProgramAvailableAndroid('EXTERNAL_OFFER'); + if (!result.isAvailable) { + console.log('External offer program not available'); + return; + } + + // Step 2: Launch external link (replaces showAlternativeBillingDialog) + const launched = await launchExternalLinkAndroid({ + billingProgram: 'EXTERNAL_OFFER', + launchMode: 'LAUNCH_IN_EXTERNAL_BROWSER_OR_APP', + linkType: 'LINK_TO_DIGITAL_CONTENT_OFFER', + linkUri: 'https://your-payment-site.com/checkout', + }); + + if (!launched) { + console.log('Failed to launch external link'); + return; + } + + // Step 3: Process payment with your backend API + const paymentResult = await yourBackend.createPayment({ + productId, + userId, + amount: productPrice, + }); + + if (!paymentResult.success) { + console.log(\`Payment failed: \${paymentResult.error}\`); + return; + } + + // Step 4: Create reporting details (replaces createAlternativeBillingToken) + const reportingDetails = await createBillingProgramReportingDetailsAndroid('EXTERNAL_OFFER'); + console.log(\`Token created: \${reportingDetails.externalTransactionToken.slice(0, 20)}...\`); + + // Step 5: Send token to your backend server + await yourBackend.reportToken({ + token: reportingDetails.externalTransactionToken, + orderId: paymentResult.orderId, + productId, + }); + + console.log('Purchase completed!'); + } catch (error) { + console.error('Purchase error:', error); + } +}`} + ), + kotlin: ( + {`import dev.hyo.openiap.store.OpenIapStore +import dev.hyo.openiap.BillingProgramAndroid +import dev.hyo.openiap.LaunchExternalLinkParamsAndroid +import dev.hyo.openiap.ExternalLinkLaunchModeAndroid +import dev.hyo.openiap.ExternalLinkTypeAndroid + +// Initialize store +val iapStore = OpenIapStore(context = applicationContext) + +// Step 0: Enable billing program BEFORE initConnection +iapStore.enableBillingProgram(BillingProgramAndroid.ExternalOffer) +// or BillingProgramAndroid.ExternalContentLink + +iapStore.initConnection(null) + +// Purchase flow with Billing Programs API (8.2.0+) +suspend fun handleExternalPurchaseWithBillingPrograms(productId: String) { + try { + // Step 1: Check if billing program is available + val result = iapStore.isBillingProgramAvailable(BillingProgramAndroid.ExternalOffer) + if (!result.isAvailable) { + Log.e("IAP", "External offer program not available") + return + } + + // Step 2: Launch external link (replaces showAlternativeBillingDialog) + val launched = iapStore.launchExternalLink( + activity, + LaunchExternalLinkParamsAndroid( + billingProgram = BillingProgramAndroid.ExternalOffer, + launchMode = ExternalLinkLaunchModeAndroid.LaunchInExternalBrowserOrApp, + linkType = ExternalLinkTypeAndroid.LinkToDigitalContentOffer, + linkUri = "https://your-payment-site.com/checkout" + ) + ) + + if (!launched) { + Log.e("IAP", "Failed to launch external link") + return + } + + // Step 3: Process payment with your backend API + val paymentResult = yourBackend.createPayment( + productId = productId, + userId = userId, + amount = productPrice + ) + + if (!paymentResult.success) { + Log.e("IAP", "Payment failed: \${paymentResult.error}") + return + } + + // Step 4: Create reporting details (replaces createAlternativeBillingToken) + val reportingDetails = iapStore.createBillingProgramReportingDetails( + BillingProgramAndroid.ExternalOffer + ) + Log.d("IAP", "Token created: \${reportingDetails.externalTransactionToken.take(20)}...") + + // Step 5: Send token to your backend server + yourBackend.reportToken( + token = reportingDetails.externalTransactionToken, + orderId = paymentResult.orderId, + productId = productId + ) + + Log.d("IAP", "Purchase completed!") + } catch (e: Exception) { + Log.e("IAP", "Purchase error: \${e.message}") + } +}`} + ), + dart: ( + {`import 'package:flutter_inapp_purchase/flutter_inapp_purchase.dart'; + +// Step 0: Enable billing program BEFORE initConnection +FlutterInappPurchase.instance.enableBillingProgramAndroid( + BillingProgramAndroid.externalOffer, +); +// or BillingProgramAndroid.externalContentLink + +await FlutterInappPurchase.instance.initConnection(); + +// Purchase flow with Billing Programs API (8.2.0+) +Future handleExternalPurchaseWithBillingPrograms(String productId) async { + try { + // Step 1: Check if billing program is available + final result = await FlutterInappPurchase.instance + .isBillingProgramAvailableAndroid(BillingProgramAndroid.externalOffer); + if (!result.isAvailable) { + print('External offer program not available'); + return; + } + + // Step 2: Launch external link (replaces showAlternativeBillingDialog) + final launched = await FlutterInappPurchase.instance.launchExternalLinkAndroid( + LaunchExternalLinkParamsAndroid( + billingProgram: BillingProgramAndroid.externalOffer, + launchMode: ExternalLinkLaunchModeAndroid.launchInExternalBrowserOrApp, + linkType: ExternalLinkTypeAndroid.linkToDigitalContentOffer, + linkUri: 'https://your-payment-site.com/checkout', + ), + ); + + if (!launched) { + print('Failed to launch external link'); + return; + } + + // Step 3: Process payment with your backend API + final paymentResult = await yourBackend.createPayment( + productId: productId, + userId: userId, + amount: productPrice, + ); + + if (!paymentResult.success) { + print('Payment failed: \${paymentResult.error}'); + return; + } + + // Step 4: Create reporting details (replaces createAlternativeBillingToken) + final reportingDetails = await FlutterInappPurchase.instance + .createBillingProgramReportingDetailsAndroid(BillingProgramAndroid.externalOffer); + print('Token created: \${reportingDetails.externalTransactionToken.substring(0, 20)}...'); + + // Step 5: Send token to your backend server + await yourBackend.reportToken( + token: reportingDetails.externalTransactionToken, + orderId: paymentResult.orderId, + productId: productId, + ); + + print('Purchase completed!'); + } catch (e) { + print('Purchase error: $e'); + } +}`} + ), + }} + + +
API Migration Guide
+ + + + + + + + + + + + + + + + + + + + + + + + + +
Legacy API (6.2+)New API (8.2.0+)
checkAlternativeBillingAvailability()isBillingProgramAvailable(program)
showAlternativeBillingInformationDialog()launchExternalLink(activity, params)
createAlternativeBillingReportingToken()createBillingProgramReportingDetails(program)
enableAlternativeBillingOnly()enableBillingProgram(program)
+

Requirements

  • Google Play Billing 6.2+ - For alternative - billing only mode + billing only mode (legacy)
  • Google Play Billing 7.0+ - For user choice mode
  • +
  • + Google Play Billing 8.2.0+ - For Billing + Programs API (recommended) +
  • Play Console Setup - Configure alternative billing in console @@ -1183,7 +1459,76 @@ await FlutterInappPurchase.instance.initConnection( ), android: ( <> -

    Alternative Billing Only

    +

    Billing Programs API (8.2.0+ Recommended)

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    StepAPI / ActionDescription
    0 + enableBillingProgram(program) + + Enable billing program BEFORE initConnection +
    1 + isBillingProgramAvailable(program) + + Check if billing program is available for this user +
    2 + launchExternalLink(activity, params) + + Launch external link (browser or app) with configured params +
    3Backend API Call + Call backend API to process payment with payment gateway + (Stripe, PayPal, etc.) +
    4 + createBillingProgramReportingDetails(program) + After successful payment, create reporting details with token
    5Token Reporting + Send externalTransactionToken to Google Play backend within 24 hours +
    6Unlock Content + Backend grants entitlements and app unlocks content +
    + +

    Alternative Billing Only (Legacy 6.2+)

    diff --git a/packages/google/Example/src/main/java/dev/hyo/martie/screens/AlternativeBillingScreen.kt b/packages/google/Example/src/main/java/dev/hyo/martie/screens/AlternativeBillingScreen.kt index bd1f175d..3decbc22 100644 --- a/packages/google/Example/src/main/java/dev/hyo/martie/screens/AlternativeBillingScreen.kt +++ b/packages/google/Example/src/main/java/dev/hyo/martie/screens/AlternativeBillingScreen.kt @@ -35,9 +35,20 @@ import dev.hyo.openiap.PurchaseInput import dev.hyo.openiap.AlternativeBillingMode import dev.hyo.openiap.AlternativeBillingModeAndroid import dev.hyo.openiap.InitConnectionConfig +import dev.hyo.openiap.BillingProgramAndroid +import dev.hyo.openiap.LaunchExternalLinkParamsAndroid +import dev.hyo.openiap.ExternalLinkLaunchModeAndroid +import dev.hyo.openiap.ExternalLinkTypeAndroid import dev.hyo.martie.util.findActivity import kotlinx.coroutines.delay +// Billing mode options including new 8.2.0+ Billing Programs +private enum class BillingModeOption { + ALTERNATIVE_ONLY, // Legacy 6.2+ API + USER_CHOICE, // Legacy 7.0+ API + BILLING_PROGRAMS // New 8.2.0+ API (recommended) +} + @OptIn(ExperimentalMaterial3Api::class) @Composable fun AlternativeBillingScreen(navController: NavController) { @@ -48,8 +59,9 @@ fun AlternativeBillingScreen(navController: NavController) { // Platform detection (runtime detection) val isHorizon = remember { dev.hyo.martie.IapConstants.isHorizonOS() } - var selectedMode by remember { mutableStateOf(AlternativeBillingMode.ALTERNATIVE_ONLY) } + var selectedMode by remember { mutableStateOf(BillingModeOption.BILLING_PROGRAMS) } var isModeDropdownExpanded by remember { mutableStateOf(false) } + var selectedBillingProgram by remember { mutableStateOf(BillingProgramAndroid.ExternalOffer) } // Initialize store - use default constructor for auto-detection (compatible with both Play and Horizon) val iapStore = remember { @@ -63,7 +75,7 @@ fun AlternativeBillingScreen(navController: NavController) { // Set up User Choice Billing listener when mode changes LaunchedEffect(selectedMode) { - if (selectedMode == AlternativeBillingMode.USER_CHOICE) { + if (selectedMode == BillingModeOption.USER_CHOICE) { iapStore.addUserChoiceBillingListener { details -> android.util.Log.d("UserChoiceEvent", "=== User Choice Billing Event ===") android.util.Log.d("UserChoiceEvent", "External Token: ${details.externalTransactionToken}") @@ -112,7 +124,7 @@ fun AlternativeBillingScreen(navController: NavController) { } // Initialize connection when mode changes - LaunchedEffect(selectedMode) { + LaunchedEffect(selectedMode, selectedBillingProgram) { try { android.util.Log.d("AlternativeBillingScreen", "Initializing with mode: $selectedMode") @@ -126,13 +138,18 @@ fun AlternativeBillingScreen(navController: NavController) { // Create config based on selected mode val config = when (selectedMode) { - AlternativeBillingMode.USER_CHOICE -> InitConnectionConfig( + BillingModeOption.USER_CHOICE -> InitConnectionConfig( alternativeBillingModeAndroid = AlternativeBillingModeAndroid.UserChoice ) - AlternativeBillingMode.ALTERNATIVE_ONLY -> InitConnectionConfig( + BillingModeOption.ALTERNATIVE_ONLY -> InitConnectionConfig( alternativeBillingModeAndroid = AlternativeBillingModeAndroid.AlternativeOnly ) - else -> null + BillingModeOption.BILLING_PROGRAMS -> { + // For 8.2.0+ Billing Programs, enable the program before connection + android.util.Log.d("AlternativeBillingScreen", "Enabling billing program: $selectedBillingProgram") + iapStore.enableBillingProgram(selectedBillingProgram) + null // No special config needed, program is enabled separately + } } android.util.Log.d("AlternativeBillingScreen", "Reconnecting with config: $config") @@ -255,9 +272,9 @@ fun AlternativeBillingScreen(navController: NavController) { ) { OutlinedTextField( value = when (selectedMode) { - AlternativeBillingMode.ALTERNATIVE_ONLY -> "Alternative Billing Only" - AlternativeBillingMode.USER_CHOICE -> "User Choice Billing" - else -> "None" + BillingModeOption.ALTERNATIVE_ONLY -> "Alternative Billing Only (Legacy)" + BillingModeOption.USER_CHOICE -> "User Choice Billing (Legacy)" + BillingModeOption.BILLING_PROGRAMS -> "Billing Programs (8.2.0+)" }, onValueChange = {}, readOnly = true, @@ -275,10 +292,39 @@ fun AlternativeBillingScreen(navController: NavController) { onDismissRequest = { isModeDropdownExpanded = false } ) { DropdownMenuItem( - text = { Text("Alternative Billing Only") }, + text = { + Column { + Text("Billing Programs (8.2.0+)") + Text( + "Recommended - New API", + style = MaterialTheme.typography.bodySmall, + color = AppColors.success + ) + } + }, + onClick = { + selectedProduct = null + selectedMode = BillingModeOption.BILLING_PROGRAMS + isModeDropdownExpanded = false + }, + leadingIcon = { + Icon(Icons.Default.Star, contentDescription = null, tint = AppColors.success) + } + ) + DropdownMenuItem( + text = { + Column { + Text("Alternative Billing Only") + Text( + "Legacy 6.2+ API", + style = MaterialTheme.typography.bodySmall, + color = AppColors.textSecondary + ) + } + }, onClick = { selectedProduct = null - selectedMode = AlternativeBillingMode.ALTERNATIVE_ONLY + selectedMode = BillingModeOption.ALTERNATIVE_ONLY isModeDropdownExpanded = false }, leadingIcon = { @@ -286,10 +332,19 @@ fun AlternativeBillingScreen(navController: NavController) { } ) DropdownMenuItem( - text = { Text("User Choice Billing") }, + text = { + Column { + Text("User Choice Billing") + Text( + "Legacy 7.0+ API", + style = MaterialTheme.typography.bodySmall, + color = AppColors.textSecondary + ) + } + }, onClick = { selectedProduct = null - selectedMode = AlternativeBillingMode.USER_CHOICE + selectedMode = BillingModeOption.USER_CHOICE isModeDropdownExpanded = false }, leadingIcon = { @@ -298,6 +353,31 @@ fun AlternativeBillingScreen(navController: NavController) { ) } } + + // Billing Program Type selector (only for BILLING_PROGRAMS mode) + if (selectedMode == BillingModeOption.BILLING_PROGRAMS) { + Spacer(modifier = Modifier.height(12.dp)) + Text( + "Program Type", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Medium + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(top = 8.dp) + ) { + FilterChip( + selected = selectedBillingProgram == BillingProgramAndroid.ExternalOffer, + onClick = { selectedBillingProgram = BillingProgramAndroid.ExternalOffer }, + label = { Text("External Offer") } + ) + FilterChip( + selected = selectedBillingProgram == BillingProgramAndroid.ExternalContentLink, + onClick = { selectedBillingProgram = BillingProgramAndroid.ExternalContentLink }, + label = { Text("External Content Link") } + ) + } + } } } } @@ -320,42 +400,61 @@ fun AlternativeBillingScreen(navController: NavController) { horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Icon( - Icons.Default.Info, + if (selectedMode == BillingModeOption.BILLING_PROGRAMS) Icons.Default.Star else Icons.Default.Info, contentDescription = null, - tint = AppColors.warning + tint = if (selectedMode == BillingModeOption.BILLING_PROGRAMS) AppColors.success else AppColors.warning ) Text( - if (selectedMode == AlternativeBillingMode.ALTERNATIVE_ONLY) - "Alternative Billing Only" - else - "User Choice Billing", + when (selectedMode) { + BillingModeOption.BILLING_PROGRAMS -> "Billing Programs (8.2.0+)" + BillingModeOption.ALTERNATIVE_ONLY -> "Alternative Billing Only (Legacy)" + BillingModeOption.USER_CHOICE -> "User Choice Billing (Legacy)" + }, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold ) } Text( - if (selectedMode == AlternativeBillingMode.ALTERNATIVE_ONLY) { - "Alternative Billing Only Mode:\n\n" + - "• Users CANNOT use Google Play billing\n" + - "• Only your payment system is available\n" + - "• Requires manual 3-step flow:\n" + - " 1. Check availability\n" + - " 2. Show info dialog\n" + - " 3. Process payment → Create token\n\n" + - "• No onPurchaseUpdated callback\n" + - "• Must report to Google within 24h" - } else { - "User Choice Billing Mode:\n\n" + - "• Users CAN choose between:\n" + - " - Google Play (30% fee)\n" + - " - Your payment system (lower fee)\n" + - "• Google shows selection dialog automatically\n" + - "• If user selects Google Play:\n" + - " → onPurchaseUpdated callback\n" + - "• If user selects alternative:\n" + - " → UserChoiceBillingListener callback\n" + - " → Process payment → Report to Google" + when (selectedMode) { + BillingModeOption.BILLING_PROGRAMS -> { + "Billing Programs API (8.2.0+):\n\n" + + "✨ NEW: Recommended approach\n\n" + + "• Program Types:\n" + + " - ExternalOffer: Alternative payment\n" + + " - ExternalContentLink: Reader/music apps\n\n" + + "• Flow:\n" + + " 1. enableBillingProgram() before init\n" + + " 2. isBillingProgramAvailable() check\n" + + " 3. launchExternalLink() to browser\n" + + " 4. Process payment in your system\n" + + " 5. createBillingProgramReportingDetails()\n\n" + + "• Must report token to Google within 24h" + } + BillingModeOption.ALTERNATIVE_ONLY -> { + "Alternative Billing Only Mode (Legacy):\n\n" + + "āš ļø Deprecated in 8.2.0+\n\n" + + "• Users CANNOT use Google Play billing\n" + + "• Only your payment system is available\n" + + "• Requires manual 3-step flow:\n" + + " 1. Check availability\n" + + " 2. Show info dialog\n" + + " 3. Process payment → Create token\n\n" + + "• No onPurchaseUpdated callback\n" + + "• Must report to Google within 24h" + } + BillingModeOption.USER_CHOICE -> { + "User Choice Billing Mode (Legacy):\n\n" + + "• Users CAN choose between:\n" + + " - Google Play (30% fee)\n" + + " - Your payment system (lower fee)\n" + + "• Google shows selection dialog automatically\n" + + "• If user selects Google Play:\n" + + " → onPurchaseUpdated callback\n" + + "• If user selects alternative:\n" + + " → UserChoiceBillingListener callback\n" + + " → Process payment → Report to Google" + } }, style = MaterialTheme.typography.bodySmall, color = AppColors.textSecondary @@ -394,7 +493,11 @@ fun AlternativeBillingScreen(navController: NavController) { ) Text( if (connectionStatus) { - "Connected (${if (selectedMode == AlternativeBillingMode.ALTERNATIVE_ONLY) "Alternative Only" else "User Choice"})" + "Connected (${when (selectedMode) { + BillingModeOption.BILLING_PROGRAMS -> "Billing Programs" + BillingModeOption.ALTERNATIVE_ONLY -> "Alternative Only" + BillingModeOption.USER_CHOICE -> "User Choice" + }})" } else "Disconnected", style = MaterialTheme.typography.bodySmall, color = AppColors.textSecondary @@ -515,113 +618,197 @@ fun AlternativeBillingScreen(navController: NavController) { } // Show button based on selected mode - if (selectedMode == AlternativeBillingMode.ALTERNATIVE_ONLY) { - // Alternative Billing Only Button - Button( - onClick = { - scope.launch { - try { - iapStore.setActivity(activity) - - // Step 1: Check availability - val isAvailable = iapStore.checkAlternativeBillingAvailability() - if (!isAvailable) { - iapStore.postStatusMessage( - "Alternative billing not available", - PurchaseResultStatus.Error + when (selectedMode) { + BillingModeOption.BILLING_PROGRAMS -> { + // Billing Programs (8.2.0+) Button + Button( + onClick = { + scope.launch { + try { + iapStore.setActivity(activity) + + // Step 1: Check availability + val availabilityResult = iapStore.isBillingProgramAvailable(selectedBillingProgram) + if (!availabilityResult.isAvailable) { + iapStore.postStatusMessage( + "Billing program not available: $selectedBillingProgram\n\nPossible causes:\n• Requires Billing Library 8.2.0+\n• Not configured in Play Console\n• Region restrictions", + PurchaseResultStatus.Error + ) + return@launch + } + + // Step 2: Launch external link + val launched = iapStore.launchExternalLink( + activity!!, + LaunchExternalLinkParamsAndroid( + billingProgram = selectedBillingProgram, + launchMode = ExternalLinkLaunchModeAndroid.LaunchInExternalBrowserOrApp, + linkType = ExternalLinkTypeAndroid.LinkToDigitalContentOffer, + linkUri = "https://example.com/checkout?product=${selectedProduct!!.id}" + ) ) - return@launch - } - // Step 2: Show information dialog - val dialogAccepted = iapStore.showAlternativeBillingInformationDialog(activity!!) - if (!dialogAccepted) { - iapStore.postStatusMessage( - "User canceled", - PurchaseResultStatus.Info - ) - return@launch - } + if (!launched) { + iapStore.postStatusMessage( + "Failed to launch external link", + PurchaseResultStatus.Error + ) + return@launch + } - // Step 2.5: Process payment (DEMO - not implemented) - android.util.Log.d("AlternativeBilling", "āš ļø Payment processing not implemented") + // Step 3: Process payment (DEMO - not implemented) + android.util.Log.d("BillingPrograms", "āš ļø Payment processing not implemented - this is a demo") - // Step 3: Create token - val token = iapStore.createAlternativeBillingReportingToken() - if (token != null) { + // Step 4: Create reporting details + val reportingDetails = iapStore.createBillingProgramReportingDetails(selectedBillingProgram) iapStore.postStatusMessage( - "Alternative billing completed (DEMO)\nToken: ${token.take(20)}...\nāš ļø Backend reporting required", + "āœ… Billing Programs flow completed (DEMO)\n\n" + + "Program: ${reportingDetails.billingProgram}\n" + + "Token: ${reportingDetails.externalTransactionToken.take(20)}...\n\n" + + "āš ļø Next steps:\n" + + "1. Process payment in your system\n" + + "2. Report token to Google within 24h", PurchaseResultStatus.Info, selectedProduct!!.id ) - } else { + } catch (e: Exception) { + android.util.Log.e("BillingPrograms", "Error: ${e.message}", e) iapStore.postStatusMessage( - "Failed to create reporting token", + "Error: ${e.message}", PurchaseResultStatus.Error ) } - } catch (e: Exception) { - // Error handled by store } - } - }, - modifier = Modifier.fillMaxWidth(), - enabled = !status.isLoading && connectionStatus, - colors = ButtonDefaults.buttonColors( - containerColor = AppColors.primary - ) - ) { - Icon( - Icons.Default.ShoppingCart, - contentDescription = null, - modifier = Modifier.size(20.dp) - ) - Spacer(Modifier.width(8.dp)) - Text("Buy (Alternative Billing Only)") + }, + modifier = Modifier.fillMaxWidth(), + enabled = !status.isLoading && connectionStatus, + colors = ButtonDefaults.buttonColors( + containerColor = AppColors.success + ) + ) { + Icon( + Icons.Default.Star, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(Modifier.width(8.dp)) + Text("Buy (Billing Programs 8.2.0+)") + } } - } else { - // User Choice Button - Button( - onClick = { - scope.launch { - try { - iapStore.setActivity(activity) - - // User Choice: Just call requestPurchase - // Google will show selection dialog automatically - val props = RequestPurchaseProps( - request = RequestPurchaseProps.Request.Purchase( - RequestPurchasePropsByPlatforms( - android = RequestPurchaseAndroidProps( - skus = listOf(selectedProduct!!.id) - ) + + BillingModeOption.ALTERNATIVE_ONLY -> { + // Alternative Billing Only Button (Legacy) + Button( + onClick = { + scope.launch { + try { + iapStore.setActivity(activity) + + // Step 1: Check availability + @Suppress("DEPRECATION") + val isAvailable = iapStore.checkAlternativeBillingAvailability() + if (!isAvailable) { + iapStore.postStatusMessage( + "Alternative billing not available", + PurchaseResultStatus.Error ) - ), - type = ProductQueryType.InApp - ) + return@launch + } + + // Step 2: Show information dialog + @Suppress("DEPRECATION") + val dialogAccepted = iapStore.showAlternativeBillingInformationDialog(activity!!) + if (!dialogAccepted) { + iapStore.postStatusMessage( + "User canceled", + PurchaseResultStatus.Info + ) + return@launch + } + + // Step 2.5: Process payment (DEMO - not implemented) + android.util.Log.d("AlternativeBilling", "āš ļø Payment processing not implemented") + + // Step 3: Create token + @Suppress("DEPRECATION") + val token = iapStore.createAlternativeBillingReportingToken() + if (token != null) { + iapStore.postStatusMessage( + "Alternative billing completed (DEMO)\nToken: ${token.take(20)}...\nāš ļø Backend reporting required", + PurchaseResultStatus.Info, + selectedProduct!!.id + ) + } else { + iapStore.postStatusMessage( + "Failed to create reporting token", + PurchaseResultStatus.Error + ) + } + } catch (e: Exception) { + // Error handled by store + } + } + }, + modifier = Modifier.fillMaxWidth(), + enabled = !status.isLoading && connectionStatus, + colors = ButtonDefaults.buttonColors( + containerColor = AppColors.primary + ) + ) { + Icon( + Icons.Default.ShoppingCart, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(Modifier.width(8.dp)) + Text("Buy (Legacy Alternative Billing)") + } + } - iapStore.requestPurchase(props) + BillingModeOption.USER_CHOICE -> { + // User Choice Button (Legacy) + Button( + onClick = { + scope.launch { + try { + iapStore.setActivity(activity) + + // User Choice: Just call requestPurchase + // Google will show selection dialog automatically + val props = RequestPurchaseProps( + request = RequestPurchaseProps.Request.Purchase( + RequestPurchasePropsByPlatforms( + android = RequestPurchaseAndroidProps( + skus = listOf(selectedProduct!!.id) + ) + ) + ), + type = ProductQueryType.InApp + ) - // If user selects Google Play → onPurchaseUpdated callback - // If user selects alternative → UserChoiceBillingListener callback - } catch (e: Exception) { - // Error handled by store + iapStore.requestPurchase(props) + + // If user selects Google Play → onPurchaseUpdated callback + // If user selects alternative → UserChoiceBillingListener callback + } catch (e: Exception) { + // Error handled by store + } } - } - }, - modifier = Modifier.fillMaxWidth(), - enabled = !status.isLoading && connectionStatus, - colors = ButtonDefaults.buttonColors( - containerColor = AppColors.secondary - ) - ) { - Icon( - Icons.Default.Person, - contentDescription = null, - modifier = Modifier.size(20.dp) - ) - Spacer(Modifier.width(8.dp)) - Text("Buy (User Choice)") + }, + modifier = Modifier.fillMaxWidth(), + enabled = !status.isLoading && connectionStatus, + colors = ButtonDefaults.buttonColors( + containerColor = AppColors.secondary + ) + ) { + Icon( + Icons.Default.Person, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(Modifier.width(8.dp)) + Text("Buy (Legacy User Choice)") + } } } } 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 dba32c51..67f52341 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 @@ -463,6 +463,26 @@ class OpenIapStore(private val module: OpenIapProtocol) { suspend fun launchExternalLink(activity: Activity, params: LaunchExternalLinkParamsAndroid): Boolean = module.launchExternalLink(activity, params) + /** + * Enable a billing program for external content links or external offers (8.2.0+). + * This should be called BEFORE initConnection to configure the BillingClient. + * + * @param program The billing program to enable (ExternalOffer or ExternalContentLink) + */ + fun enableBillingProgram(program: BillingProgramAndroid) { + // Use reflection to call enableBillingProgram on the module + // This is needed because the method is only available in the Play flavor + try { + val method = module.javaClass.getMethod("enableBillingProgram", BillingProgramAndroid::class.java) + method.invoke(module, program) + OpenIapLog.d("Billing program enabled via store: $program", "OpenIapStore") + } catch (e: NoSuchMethodException) { + OpenIapLog.w("enableBillingProgram not available (Horizon flavor or older library)", "OpenIapStore") + } catch (e: Exception) { + OpenIapLog.e("Failed to enable billing program: ${e.message}", e, "OpenIapStore") + } + } + // ------------------------------------------------------------------------- // Event listeners passthrough // ------------------------------------------------------------------------- From 51e420f27d9f7b002dcd56f2a57c6ff4255f164f Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 11 Dec 2025 13:02:48 +0900 Subject: [PATCH 3/8] fix: address CodeRabbit review issues for Billing Programs API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add error logging and status messages to empty catch blocks in AlternativeBillingScreen.kt - Fix exception propagation in OpenIapModule.kt Proxy handler using resumeWithException() - Add null-safe activity handling to prevent potential NPE šŸ¤– Generated with [Claude Code](https://claude.com/claude-code) --- .../screens/AlternativeBillingScreen.kt | 23 ++++++++++++++++--- .../java/dev/hyo/openiap/OpenIapModule.kt | 7 +++--- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/packages/google/Example/src/main/java/dev/hyo/martie/screens/AlternativeBillingScreen.kt b/packages/google/Example/src/main/java/dev/hyo/martie/screens/AlternativeBillingScreen.kt index 3decbc22..4594ee8d 100644 --- a/packages/google/Example/src/main/java/dev/hyo/martie/screens/AlternativeBillingScreen.kt +++ b/packages/google/Example/src/main/java/dev/hyo/martie/screens/AlternativeBillingScreen.kt @@ -638,8 +638,17 @@ fun AlternativeBillingScreen(navController: NavController) { } // Step 2: Launch external link + val currentActivity = activity + if (currentActivity == null) { + iapStore.postStatusMessage( + "Activity not available", + PurchaseResultStatus.Error + ) + return@launch + } + val launched = iapStore.launchExternalLink( - activity!!, + currentActivity, LaunchExternalLinkParamsAndroid( billingProgram = selectedBillingProgram, launchMode = ExternalLinkLaunchModeAndroid.LaunchInExternalBrowserOrApp, @@ -745,7 +754,11 @@ fun AlternativeBillingScreen(navController: NavController) { ) } } catch (e: Exception) { - // Error handled by store + android.util.Log.e("AlternativeBilling", "Legacy alternative billing error: ${e.message}", e) + iapStore.postStatusMessage( + "Alternative billing failed: ${e.message}", + PurchaseResultStatus.Error + ) } } }, @@ -791,7 +804,11 @@ fun AlternativeBillingScreen(navController: NavController) { // If user selects Google Play → onPurchaseUpdated callback // If user selects alternative → UserChoiceBillingListener callback } catch (e: Exception) { - // Error handled by store + android.util.Log.e("AlternativeBilling", "User choice billing error: ${e.message}", e) + iapStore.postStatusMessage( + "User choice billing failed: ${e.message}", + PurchaseResultStatus.Error + ) } } }, 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 3a3a54f9..dbabdebb 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 @@ -68,6 +68,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException import java.lang.ref.WeakReference // AlternativeBillingMode moved to main source set (shared between Play and Horizon) @@ -515,15 +516,15 @@ class OpenIapModule( externalTransactionToken = token )) } else if (continuation.isActive) { - throw OpenIapError.PurchaseFailed + continuation.resumeWithException(OpenIapError.PurchaseFailed) } } catch (e: Exception) { OpenIapLog.e("Failed to extract token: ${e.message}", e, TAG) - if (continuation.isActive) throw OpenIapError.PurchaseFailed + if (continuation.isActive) continuation.resumeWithException(OpenIapError.PurchaseFailed) } } else { OpenIapLog.e("Reporting details creation failed: ${result?.debugMessage}", tag = TAG) - if (continuation.isActive) throw OpenIapError.PurchaseFailed + if (continuation.isActive) continuation.resumeWithException(OpenIapError.PurchaseFailed) } } null From cbc4eaaebcc4f9527d9980acdda9ed97c55c6893 Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 11 Dec 2025 13:08:12 +0900 Subject: [PATCH 4/8] docs: add release notes for openiap-gql v1.3.2 and openiap-google v1.3.12 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit šŸ¤– Generated with [Claude Code](https://claude.com/claude-code) --- CHANGELOG.md | 99 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45286e42..e48d76c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,105 @@ All notable changes to the OpenIAP monorepo will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [openiap-gql 1.3.2] - 2025-12-11 + +### Added + +#### Google Play Billing Library 8.1.0 Support + +- **`SubscriptionProductReplacementParamsAndroid`**: New type for per-product subscription replacement configuration + - `oldProductId`: The product ID being replaced + - `replacementMode`: The replacement mode enum value +- **`SubscriptionReplacementModeAndroid.KeepExisting`**: New replacement mode (8.1.0+) to keep the existing payment schedule unchanged + +#### Google Play Billing Library 8.2.0 Support (Billing Programs API) + +- **`BillingProgramAndroid`**: Enum for billing program types + - `ExternalContentLink`: For apps linking to external content (reader apps, music streaming) + - `ExternalOffer`: For apps offering alternative payment options + - `Unspecified`: Default/unspecified value +- **`BillingProgramAvailabilityResultAndroid`**: Result type for billing program availability checks + - `billingProgram`: The program that was checked + - `isAvailable`: Whether the program is available +- **`BillingProgramReportingDetailsAndroid`**: Reporting details for external transactions + - `billingProgram`: The billing program used + - `externalTransactionToken`: Token for reporting to Google Play +- **`LaunchExternalLinkParamsAndroid`**: Parameters for launching external links + - `billingProgram`: Which billing program to use + - `launchMode`: How to launch the link + - `linkType`: Type of external link + - `linkUri`: The URI to launch +- **`ExternalLinkLaunchModeAndroid`**: Enum for external link launch modes + - `LaunchInExternalBrowserOrApp`: Launch in external browser or app + - `CallerWillLaunchLink`: Caller handles the link launch + - `Unspecified`: Default value +- **`ExternalLinkTypeAndroid`**: Enum for external link types + - `LinkToDigitalContentOffer`: Link to digital content offer + - `LinkToAppDownload`: Link to app download + - `Unspecified`: Default value + +### Changed + +- Updated `RequestSubscriptionAndroidProps` to include optional `subscriptionProductReplacementParams` field + +--- + +## [openiap-google 1.3.12] - 2025-12-11 + +### Added + +#### Google Play Billing Library 8.1.0 APIs + +- **`applySubscriptionProductReplacementParams()`**: Apply per-product replacement params to subscription upgrades/downgrades + - Enables `KeepExisting` replacement mode (only available via this API) + - More granular control over subscription replacements at the product level + +#### Google Play Billing Library 8.2.0 APIs (Billing Programs) + +- **`enableBillingProgram(program: BillingProgramAndroid)`**: Enable a billing program before `initConnection()` + - Must be called before connecting to configure the BillingClient + - Available via both `OpenIapModule` and `OpenIapStore` +- **`isBillingProgramAvailable(program: BillingProgramAndroid)`**: Check if a billing program is available + - Replaces deprecated `checkAlternativeBillingAvailability()` for external offers + - Returns `BillingProgramAvailabilityResultAndroid` with availability status +- **`createBillingProgramReportingDetails(program: BillingProgramAndroid)`**: Create reporting details for external transactions + - Replaces deprecated `createAlternativeBillingReportingToken()` + - Returns `BillingProgramReportingDetailsAndroid` with `externalTransactionToken` +- **`launchExternalLink(activity: Activity, params: LaunchExternalLinkParamsAndroid)`**: Launch external link for external offers + - Replaces deprecated `showAlternativeBillingInformationDialog()` + - Supports configurable launch modes and link types + +### Deprecated + +The following APIs are deprecated in favor of the new Billing Programs API (8.2.0+): + +- `checkAlternativeBillingAvailability()` → Use `isBillingProgramAvailable(BillingProgramAndroid.ExternalOffer)` +- `showAlternativeBillingInformationDialog()` → Use `launchExternalLink(activity, params)` +- `createAlternativeBillingReportingToken()` → Use `createBillingProgramReportingDetails(BillingProgramAndroid.ExternalOffer)` + +### Changed + +- **Example App (`AlternativeBillingScreen`)**: Updated to demonstrate all three billing modes: + - Billing Programs (8.2.0+) - Recommended + - Alternative Billing Only (Legacy 6.2+) + - User Choice Billing (Legacy 7.0+) +- **Error Handling**: Improved exception propagation in Proxy handlers using `resumeWithException()` +- **Null Safety**: Added null-safe activity handling to prevent potential NPE + +### Fixed + +- Empty catch blocks now properly log errors and display status messages to users +- Exception handling in coroutine Proxy handlers now correctly propagates exceptions + +### Documentation + +- Updated `external-purchase.tsx` with Billing Programs API (8.2.0+) documentation +- Added API Migration Guide table for legacy to new API mapping +- Updated Implementation Flow section with new step-by-step guide +- Added code examples for TypeScript, Kotlin, and Dart + +--- + ## [1.2.2] - 2025-10-16 ### Added From df633597897f0941683988c16741b579b0175b8f Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 11 Dec 2025 13:14:01 +0900 Subject: [PATCH 5/8] docs: add release notes for openiap-gql v1.3.2 and openiap-google v1.3.12 - Document Billing Programs API (8.2.0+) with new methods - Add new types (BillingProgramAndroid, LaunchExternalLinkParamsAndroid, etc.) - Document Google Play Billing 8.1.0 support - Include code example and deprecated APIs section --- .../docs/src/pages/docs/updates/notes.tsx | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) diff --git a/packages/docs/src/pages/docs/updates/notes.tsx b/packages/docs/src/pages/docs/updates/notes.tsx index 7f72f298..146d81f5 100644 --- a/packages/docs/src/pages/docs/updates/notes.tsx +++ b/packages/docs/src/pages/docs/updates/notes.tsx @@ -19,6 +19,162 @@ function Notes() {

    šŸ“ API & Terminology Changes

    +
    +

    + šŸ“… openiap-google v1.3.12 / openiap-gql v1.3.2 -{' '} + + Google Play Billing 8.2.0 + {' '} + Billing Programs API +

    +

    + New Billing Programs API (8.2.0+): +

    +
      +
    • + + enableBillingProgram() + {' '} + - Enable a billing program before initConnection() +
    • +
    • + + isBillingProgramAvailable() + {' '} + - Check if a billing program is available (replaces{' '} + checkAlternativeBillingAvailability()) +
    • +
    • + + createBillingProgramReportingDetails() + {' '} + - Create reporting details with token (replaces{' '} + createAlternativeBillingReportingToken()) +
    • +
    • + + launchExternalLink() + {' '} + - Launch external link for external offers (replaces{' '} + showAlternativeBillingInformationDialog()) +
    • +
    +

    + New Types: +

    +
      +
    • + + BillingProgramAndroid + {' '} + - Enum: ExternalContentLink, ExternalOffer +
    • +
    • + + LaunchExternalLinkParamsAndroid + {' '} + - Parameters for launching external links +
    • +
    • + + ExternalLinkLaunchModeAndroid + {' '} + - Launch mode options +
    • +
    • + + ExternalLinkTypeAndroid + {' '} + - Link type options +
    • +
    +

    + Google Play Billing 8.1.0 Support: +

    +
      +
    • + + SubscriptionProductReplacementParamsAndroid + {' '} + - Per-product subscription replacement configuration +
    • +
    • + + SubscriptionReplacementModeAndroid.KeepExisting + {' '} + - New replacement mode to keep existing payment schedule +
    • +
    + + {`// Billing Programs API (8.2.0+) - Recommended approach +val iapStore = OpenIapStore(context) + +// Step 0: Enable billing program BEFORE initConnection +iapStore.enableBillingProgram(BillingProgramAndroid.ExternalOffer) +iapStore.initConnection(null) + +// Step 1: Check availability +val result = iapStore.isBillingProgramAvailable(BillingProgramAndroid.ExternalOffer) +if (!result.isAvailable) return + +// Step 2: Launch external link +val launched = iapStore.launchExternalLink( + activity, + LaunchExternalLinkParamsAndroid( + billingProgram = BillingProgramAndroid.ExternalOffer, + launchMode = ExternalLinkLaunchModeAndroid.LaunchInExternalBrowserOrApp, + linkType = ExternalLinkTypeAndroid.LinkToDigitalContentOffer, + linkUri = "https://your-payment-site.com/checkout" + ) +) + +// Step 3: Process payment with your backend... + +// Step 4: Create reporting details +val reportingDetails = iapStore.createBillingProgramReportingDetails( + BillingProgramAndroid.ExternalOffer +) +// Send reportingDetails.externalTransactionToken to Google within 24h`} + +

    + Deprecated APIs: +

    +
      +
    • + checkAlternativeBillingAvailability() → Use{' '} + isBillingProgramAvailable() +
    • +
    • + showAlternativeBillingInformationDialog() → Use{' '} + launchExternalLink() +
    • +
    • + createAlternativeBillingReportingToken() → Use{' '} + createBillingProgramReportingDetails() +
    • +
    +

    + See:{' '} + External Purchase Guide + ,{' '} + + Subscription Upgrade/Downgrade + +

    +
    +
    Date: Thu, 11 Dec 2025 13:15:54 +0900 Subject: [PATCH 6/8] docs: add documentation links to release notes types and features --- .../docs/src/pages/docs/updates/notes.tsx | 32 ++++++++++++++----- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/packages/docs/src/pages/docs/updates/notes.tsx b/packages/docs/src/pages/docs/updates/notes.tsx index 146d81f5..810c8ae5 100644 --- a/packages/docs/src/pages/docs/updates/notes.tsx +++ b/packages/docs/src/pages/docs/updates/notes.tsx @@ -72,47 +72,63 @@ function Notes() {

    - New Types: + New Types: (See{' '} + Types Reference)

    - Google Play Billing 8.1.0 Support: + Google Play Billing 8.1.0 Support: (See{' '} + + Subscription Upgrade/Downgrade + )

    • - SubscriptionProductReplacementParamsAndroid + + SubscriptionProductReplacementParamsAndroid + {' '} - Per-product subscription replacement configuration
    • - SubscriptionReplacementModeAndroid.KeepExisting + + SubscriptionReplacementModeAndroid.KeepExisting + {' '} - New replacement mode to keep existing payment schedule
    • From 9de006ec4916c8b72b196d66ca014ab0ff143bb8 Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 11 Dec 2025 13:22:29 +0900 Subject: [PATCH 7/8] fix: address CodeRabbit review issues - Add null check for activity in ALTERNATIVE_ONLY mode to prevent NPE - Remove init block to defer BillingClient construction for enableBillingProgram --- .../hyo/martie/screens/AlternativeBillingScreen.kt | 11 ++++++++++- .../src/play/java/dev/hyo/openiap/OpenIapModule.kt | 6 +++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/google/Example/src/main/java/dev/hyo/martie/screens/AlternativeBillingScreen.kt b/packages/google/Example/src/main/java/dev/hyo/martie/screens/AlternativeBillingScreen.kt index 4594ee8d..e50e62f4 100644 --- a/packages/google/Example/src/main/java/dev/hyo/martie/screens/AlternativeBillingScreen.kt +++ b/packages/google/Example/src/main/java/dev/hyo/martie/screens/AlternativeBillingScreen.kt @@ -725,8 +725,17 @@ fun AlternativeBillingScreen(navController: NavController) { } // Step 2: Show information dialog + val currentActivity = activity + if (currentActivity == null) { + iapStore.postStatusMessage( + "Activity not available", + PurchaseResultStatus.Error + ) + return@launch + } + @Suppress("DEPRECATION") - val dialogAccepted = iapStore.showAlternativeBillingInformationDialog(activity!!) + val dialogAccepted = iapStore.showAlternativeBillingInformationDialog(currentActivity) if (!dialogAccepted) { iapStore.postStatusMessage( "User canceled", 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 dbabdebb..3546f875 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 @@ -1134,9 +1134,9 @@ class OpenIapModule( purchaseUpdated = purchaseUpdated ) - init { - buildBillingClient() - } + // BillingClient is built lazily in initBillingClient() so that + // alternativeBillingMode and enabledBillingPrograms can be configured + // before the first client instance is created. suspend fun getStorefront() = withContext(Dispatchers.IO) { val client = billingClient ?: return@withContext "" From 1a774d4ea80388f68f0e2523b66a8cfa1bdf0e5e Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 11 Dec 2025 13:35:52 +0900 Subject: [PATCH 8/8] docs: add CHANGELOG entries for v1.3.0 and v1.3.1 - v1.3.0: ios/android to apple/google rename, verifyPurchaseWithProvider, IapStore enum - v1.3.1: One-time purchase discount offers, Billing 8.1.0 (PreorderDetails, isSuspended) --- CHANGELOG.md | 96 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e48d76c2..53b7bb2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -104,6 +104,102 @@ The following APIs are deprecated in favor of the new Billing Programs API (8.2. --- +## [openiap-gql 1.3.1 / openiap-google 1.3.11] - 2025-12-10 + +### Added + +#### One-Time Purchase Discount Offers (Google Play Billing 7.0+) + +- **`oneTimePurchaseOfferDetailsAndroid`**: Changed from single object to array to support multiple discount offers +- **`DiscountDisplayInfoAndroid`**: Discount display information + - `discountPercent`: Percentage off + - `discountAmount`: Discount amount details +- **`DiscountAmountAndroid`**: Discount amount with currency +- **`ValidTimeWindowAndroid`**: Start and end time for limited-time offers +- **`LimitedQuantityInfoAndroid`**: Limited quantity information +- **`RentalDetailsAndroid`**: Rental product metadata + +#### Google Play Billing 8.1.0 Support + +- **`PreorderDetailsAndroid`**: Pre-order product details + - `preorderPresaleEndTimeMillis`: Presale end time + - `preorderReleaseTimeMillis`: Release time +- **`isSuspendedAndroid`**: Detect suspended subscriptions due to payment failures + +### Changed + +- **Upgraded Google Play Billing Library**: 8.0.0 → 8.1.0 +- **Increased minSdk**: 21 → 23 (Android 6.0) +- **Upgraded Kotlin**: 2.0.21 → 2.2.0 + +### Breaking Changes + +- **`oneTimePurchaseOfferDetailsAndroid`** type changed from single object to array + - Before: `product.oneTimePurchaseOfferDetailsAndroid?.formattedPrice` + - After: `product.oneTimePurchaseOfferDetailsAndroid?.firstOrNull()?.formattedPrice` + +### Documentation + +- Added comprehensive "Discounts (Android)" documentation +- Updated types page with new Android fields + +--- + +## [openiap-gql 1.3.0 / openiap-google 1.3.0 / openiap-apple 1.3.0] - 2025-12-08 + +### Added + +- **`IapStore` enum**: Unified store identification + - `Unknown`: Unknown store + - `Apple`: Apple App Store + - `Google`: Google Play Store + - `Horizon`: Meta Horizon Store +- **`store` field**: Added to `PurchaseCommon` interface for consistent store identification +- **`verifyPurchaseWithProvider()`**: Server-side purchase verification API + +### Changed + +- **Renamed request props**: `ios`/`android` → `apple`/`google` in request payloads + - `RequestPurchasePropsByPlatforms`: `ios` → `apple`, `android` → `google` + - `RequestSubscriptionPropsByPlatforms`: `ios` → `apple`, `android` → `google` + +### Deprecated + +- **`platform` field**: Use `store` field instead +- **`ios`/`android` props**: Use `apple`/`google` props in request payloads + +### Migration Guide + +**Request props migration:** +```typescript +// Before (deprecated) +requestPurchase({ + request: { + ios: { sku: 'product_id' }, + android: { skus: ['product_id'] } + } +}); + +// After (recommended) +requestPurchase({ + request: { + apple: { sku: 'product_id' }, + google: { skus: ['product_id'] } + } +}); +``` + +**Platform field migration:** +```typescript +// Before (deprecated) +if (purchase.platform === 'ios') { ... } + +// After (recommended) +if (purchase.store === 'Apple') { ... } +``` + +--- + ## [1.2.2] - 2025-10-16 ### Added