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 {
-
-
1 (WITH_TIME_PRORATION) - Immediate change
+ WITH_TIME_PRORATION - Immediate change
with prorated credit
-
-
2 (CHARGE_PRORATED_PRICE) - Immediate change,
+ CHARGE_PRORATED_PRICE - Immediate change,
charge difference (upgrade only)
-
-
3 (WITHOUT_PRORATION) - Immediate change, no
+ WITHOUT_PRORATION - Immediate change, no
proration
-
-
5 (CHARGE_FULL_PRICE) - Immediate change,
+ CHARGE_FULL_PRICE - Immediate change,
charge full price
-
-
6 (DEFERRED) - Change at next billing cycle
+ DEFERRED - Change at next billing cycle
+
+ -
+
KEEP_EXISTING - Keep the existing payment schedule unchanged (8.1.0+)
@@ -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)
+
+
+
+ | Step |
+ API / Action |
+ Description |
+
+
+
+
+ | 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
+ |
+
+
+ | 3 |
+ Backend 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 |
+
+
+ | 5 |
+ Token Reporting |
+
+ Send externalTransactionToken to Google Play backend within 24 hours
+ |
+
+
+ | 6 |
+ Unlock 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