- replacementModeAndroid
+ replacementMode
|
How to handle subscription change (proration mode)
diff --git a/packages/docs/src/pages/docs/updates/notes.tsx b/packages/docs/src/pages/docs/updates/notes.tsx
index b110d962..5c7dd0df 100644
--- a/packages/docs/src/pages/docs/updates/notes.tsx
+++ b/packages/docs/src/pages/docs/updates/notes.tsx
@@ -573,7 +573,7 @@ let products = try await OpenIapModule.shared.fetchProducts(request)`}
- New listener for when user selects developer billing
- developerBillingOption{' '}
+ developerBillingOptionAndroid{' '}
- New field in RequestPurchaseAndroidProps and RequestSubscriptionAndroidProps
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 011bcb34..d5dfe780 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
@@ -786,9 +786,9 @@ fun SubscriptionFlowScreen(
RequestSubscriptionPropsByPlatforms(
android = RequestSubscriptionAndroidProps(
isOfferPersonalized = null,
- obfuscatedAccountIdAndroid = null,
- obfuscatedProfileIdAndroid = null,
- purchaseTokenAndroid = purchaseToken,
+ obfuscatedAccountId = null,
+ obfuscatedProfileId = null,
+ purchaseToken = purchaseToken,
// New 8.1.0+ API: per-product replacement params
subscriptionProductReplacementParams = SubscriptionProductReplacementParamsAndroid(
oldProductId = IapConstants.PREMIUM_PRODUCT_ID,
@@ -1019,10 +1019,10 @@ fun SubscriptionFlowScreen(
RequestSubscriptionPropsByPlatforms(
android = RequestSubscriptionAndroidProps(
isOfferPersonalized = null,
- obfuscatedAccountIdAndroid = null,
- obfuscatedProfileIdAndroid = null,
- purchaseTokenAndroid = purchaseToken,
- replacementModeAndroid = replacementMode,
+ obfuscatedAccountId = null,
+ obfuscatedProfileId = null,
+ purchaseToken = purchaseToken,
+ replacementMode = replacementMode,
skus = listOf(PREMIUM_SUBSCRIPTION_PRODUCT_ID),
subscriptionOffers = offerInputs
)
@@ -1168,10 +1168,10 @@ fun SubscriptionFlowScreen(
RequestSubscriptionPropsByPlatforms(
android = RequestSubscriptionAndroidProps(
isOfferPersonalized = null,
- obfuscatedAccountIdAndroid = null,
- obfuscatedProfileIdAndroid = null,
- purchaseTokenAndroid = purchaseToken,
- replacementModeAndroid = replacementMode,
+ obfuscatedAccountId = null,
+ obfuscatedProfileId = null,
+ purchaseToken = purchaseToken,
+ replacementMode = replacementMode,
skus = listOf(product.id),
subscriptionOffers = subscriptionOffers
)
@@ -1185,8 +1185,8 @@ fun SubscriptionFlowScreen(
RequestPurchasePropsByPlatforms(
android = RequestPurchaseAndroidProps(
isOfferPersonalized = null,
- obfuscatedAccountIdAndroid = null,
- obfuscatedProfileIdAndroid = null,
+ obfuscatedAccountId = null,
+ obfuscatedProfileId = null,
skus = listOf(product.id)
)
)
@@ -1447,10 +1447,10 @@ fun SubscriptionFlowScreen(
RequestSubscriptionPropsByPlatforms(
android = RequestSubscriptionAndroidProps(
isOfferPersonalized = null,
- obfuscatedAccountIdAndroid = null,
- obfuscatedProfileIdAndroid = null,
- purchaseTokenAndroid = null,
- replacementModeAndroid = null,
+ obfuscatedAccountId = null,
+ obfuscatedProfileId = null,
+ purchaseToken = null,
+ replacementMode = null,
skus = listOf(product.id),
subscriptionOffers = null
)
@@ -1464,8 +1464,8 @@ fun SubscriptionFlowScreen(
RequestPurchasePropsByPlatforms(
android = RequestPurchaseAndroidProps(
isOfferPersonalized = null,
- obfuscatedAccountIdAndroid = null,
- obfuscatedProfileIdAndroid = null,
+ obfuscatedAccountId = null,
+ obfuscatedProfileId = null,
skus = listOf(product.id)
)
)
diff --git a/packages/google/README.md b/packages/google/README.md
index f3846edb..07870bd9 100644
--- a/packages/google/README.md
+++ b/packages/google/README.md
@@ -2,7 +2,7 @@

-
+
Android implementation of the OpenIAP specification using Google Play Billing.
@@ -16,33 +16,27 @@
Modern Android Kotlin library for in-app purchases using Google Play Billing Library v8.
-## 🌐 Learn More
-
-Visit [**openiap.dev**](https://openiap.dev) for complete documentation, guides, and the full OpenIAP specification.
+## Documentation
-## 🎯 Overview
+Visit [**openiap.dev**](https://openiap.dev) for complete documentation, API reference, guides, and examples.
-OpenIAP GMS is a modern, type-safe Kotlin library that simplifies Google Play in-app billing integration. It provides a clean, coroutine-based API that handles all the complexity of Google Play Billing while offering robust error handling and real-time purchase tracking.
+## Features
-## ✨ Features
+- Google Play Billing v8
+- Kotlin Coroutines
+- Type-safe API with sealed classes
+- Real-time purchase events
+- Thread-safe operations
+- Comprehensive error handling
-- 🔐 **Google Play Billing v8** - Latest billing library with enhanced security
-- ⚡ **Kotlin Coroutines** - Modern async/await API
-- 🎯 **Type Safe** - Full Kotlin type safety with sealed classes
-- 🔄 **Real-time Events** - Purchase update and error listeners
-- 🧵 **Thread Safe** - Concurrent operations with proper synchronization
-- 📱 **Easy Integration** - Simple singleton pattern with context management
-- 🛡️ **Robust Error Handling** - Comprehensive error types with detailed messages
-- 🚀 **Production Ready** - Used in production apps
-
-## 📋 Requirements
+## Requirements
- **Minimum SDK**: 21 (Android 5.0)
- **Compile SDK**: 34+
- **Google Play Billing**: v8.0.0
- **Kotlin**: 1.9.20+
-## 📦 Installation
+## Installation
Add to your module's `build.gradle.kts`:
@@ -52,411 +46,51 @@ dependencies {
}
```
-Or `build.gradle`:
-
-```groovy
-dependencies {
- implementation 'io.github.hyochan.openiap:openiap-google:$version'
-}
-```
-
-> 📌 **Latest Version**: Check [`openiap-versions.json`](../../openiap-versions.json) for the current version, or see the [Maven Central badge](https://central.sonatype.com/artifact/io.github.hyochan.openiap/openiap-google) above.
+> Check [`openiap-versions.json`](../../openiap-versions.json) for the current version.
-## 🚀 Quick Start
-
-### 1. Initialize in Application
+## Quick Start
```kotlin
-class MyApplication : Application() {
- override fun onCreate() {
- super.onCreate()
- OpenIAP.initialize(this)
- }
-}
-```
+import dev.hyo.openiap.store.OpenIapStore
-### 2. Basic Usage
-
-```kotlin
class MainActivity : AppCompatActivity() {
- private lateinit var openIAP: OpenIAP
+ private lateinit var iapStore: OpenIapStore
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- openIAP = OpenIAP.getInstance()
-
- // Set up listeners
- openIAP.addPurchaseUpdateListener { purchase ->
- handlePurchaseUpdate(purchase)
- }
+ iapStore = OpenIapStore(this)
- openIAP.addPurchaseErrorListener { error ->
- handlePurchaseError(error)
- }
-
- // Initialize connection
lifecycleScope.launch {
- try {
- val connected = openIAP.initConnection()
- if (connected) {
- loadProducts()
- }
- } catch (e: OpenIapError) {
- // Handle connection error
- }
- }
- }
+ // Initialize connection
+ iapStore.initConnection()
- private suspend fun loadProducts() {
- try {
- val products = openIAP.fetchProducts(listOf("premium_upgrade", "remove_ads"))
- // Display products in UI
- } catch (e: OpenIapError) {
- // Handle error
- }
- }
-
- private suspend fun purchaseProduct(productId: String) {
- try {
- openIAP.requestPurchase(
- activity = this,
- sku = productId
+ // Fetch products
+ val products = iapStore.fetchProducts(
+ ProductRequest(skus = listOf("premium_upgrade"))
)
- } catch (e: OpenIapError) {
- // Handle purchase error
}
}
-
- private fun handlePurchaseUpdate(purchase: OpenIapPurchase) {
- when (purchase.purchaseState) {
- PurchaseState.Purchased -> {
- // Acknowledge or consume the purchase
- lifecycleScope.launch {
- try {
- purchase.purchaseToken?.let { token ->
- openIAP.acknowledgePurchase(token)
- // Or for consumables: openIAP.consumePurchase(token)
- }
- } catch (e: OpenIapError) {
- // Handle error
- }
- }
- }
- PurchaseState.Pending -> {
- // Purchase is pending (e.g., awaiting payment)
- }
- // Handle other states...
- }
- }
-
- override fun onDestroy() {
- super.onDestroy()
- openIAP.clearListeners()
- openIAP.endConnection()
- }
}
```
-## 📚 API Reference
-
-### Core Methods
-
-#### Connection Management
-
-```kotlin
-suspend fun initConnection(): Boolean
-fun endConnection()
-fun isReady(): Boolean
-```
-
-#### Product Management
-
-```kotlin
-suspend fun fetchProducts(skus: List): List
-suspend fun fetchProducts(type: String, skus: List): List
-fun getCachedProduct(sku: String): ProductDetails?
-fun getAllCachedProducts(): Map
-```
-
-#### Purchase Operations
-
-```kotlin
-suspend fun requestPurchase(
- activity: Activity,
- sku: String,
- offerToken: String? = null,
- obfuscatedAccountId: String? = null,
- obfuscatedProfileId: String? = null
-)
-
-suspend fun requestPurchase(params: Map, activity: Activity)
-suspend fun finishTransaction(purchase: OpenIapPurchase, isConsumable: Boolean? = null)
-suspend fun getAvailablePurchases(): List
-suspend fun getAvailablePurchases(options: Map?): List // options ignored on Android
-suspend fun getAvailableItemsByType(type: String): List
-suspend fun acknowledgePurchase(purchaseToken: String): Boolean
-suspend fun consumePurchase(purchaseToken: String): Boolean
-```
-
-> Note: Use `"in-app"` for in-app product types. The legacy alias `"inapp"` remains available for compatibility but will be removed in version 1.2.0.
+For detailed usage, see the [documentation](https://openiap.dev).
-#### Store Information
+## Sample App
-```kotlin
-suspend fun getStorefront(): String
-```
-
-### Subscription Management
-
-```kotlin
-suspend fun getActiveSubscriptions(subscriptionIds: List? = null): List
-suspend fun hasActiveSubscriptions(subscriptionIds: List? = null): Boolean
-fun deepLinkToSubscriptions(): Boolean
-```
-
-### Event Listeners
-
-```kotlin
-fun addPurchaseUpdateListener(listener: OpenIapPurchaseUpdateListener)
-fun removePurchaseUpdateListener(listener: OpenIapPurchaseUpdateListener)
-fun addPurchaseErrorListener(listener: OpenIapPurchaseErrorListener)
-fun removePurchaseErrorListener(listener: OpenIapPurchaseErrorListener)
-
-// Convenience methods
-fun addListener(listener: OpenIapListener)
-fun removeListener(listener: OpenIapListener)
-fun clearListeners()
-```
-
-### Data Models
-
-#### OpenIapProduct
-
-```kotlin
-data class OpenIapProduct(
- val id: String,
- val title: String,
- val description: String,
- val price: Double?,
- val displayPrice: String,
- val currency: String,
- val type: ProductType,
- val platform: String = "android",
- val displayName: String?,
- val debugDescription: String?,
- val nameAndroid: String?,
- val oneTimePurchaseOfferDetails: OneTimePurchaseOfferDetails?,
- val subscriptionOfferDetails: List?
-)
-```
-
-#### OpenIapPurchase
-
-```kotlin
-data class OpenIapPurchase(
- val id: String, // transactionId
- val productId: String,
- val ids: List?, // alias of productIds
- val transactionDate: Double,
- val transactionReceipt: String,
- val purchaseToken: String?,
- val platform: String = "android",
- val quantity: Int = 1,
- val transactionId: String?,
- val purchaseTime: Long,
- val purchaseState: PurchaseState,
- val isAutoRenewing: Boolean,
- // ... Android-specific fields
- val isAcknowledgedAndroid: Boolean?,
- val autoRenewingAndroid: Boolean?,
- // ... many more fields
-)
-```
-
-#### Error Handling
-
-```kotlin
-sealed class OpenIapError : Exception {
- object UserCancelled : OpenIapError()
- object ItemAlreadyOwned : OpenIapError()
- object ItemNotOwned : OpenIapError()
- data class ProductNotFound(val productId: String) : OpenIapError()
- data class PurchaseFailed(override val message: String) : OpenIapError()
- // ... many more error types
-}
-```
-
-## 🔄 Purchase Flow
-
-1. **Initialize**: Call `initConnection()`
-2. **Fetch Products**: Use `fetchProducts()` to load available items
-3. **Request Purchase**: Call `requestPurchase()` with the product SKU
-4. **Handle Events**: Listen for purchase updates via listeners
-5. **Process Purchase**: Acknowledge non-consumables or consume consumables
-6. **Server Verification**: Always verify purchases on your backend
-
-## 🛡️ Security Best Practices
-
-- **Server-Side Verification**: Always verify purchases on your backend server
-- **Acknowledge Promptly**: Acknowledge non-consumable purchases within 3 days
-- **Consume Consumables**: Consume consumable purchases after granting content
-- **Handle All States**: Implement proper handling for all purchase states
-- **Error Handling**: Implement comprehensive error handling
-
-## 🧪 Testing
-
-The library includes a comprehensive sample app demonstrating all features:
+Run the included sample app:
```bash
-git clone https://github.com/hyodotdev/openiap.git
-cd openiap/packages/google
+cd packages/google
./gradlew :Example:installDebug
```
-### Test Products
-
-For development, use Google Play's test SKUs:
-
-- `android.test.purchased` - Always succeeds
-- `android.test.canceled` - Always cancels
-- `android.test.item_unavailable` - Always fails
-
-For production testing, configure products in Google Play Console and use internal testing.
-
-## 📱 Sample App
+## License
-The included sample app (`Example/` directory) demonstrates:
+MIT License - see [LICENSE](../../LICENSE) for details.
-- ✅ Connection management with retry logic
-- ✅ Product listing and purchase flow
-- ✅ Real-time purchase event handling
-- ✅ Purchase history and management
-- ✅ Error handling and user feedback
-- ✅ Android-specific billing features
+## Support
-## 🔧 Advanced Usage
-
-### Custom Error Handling
-
-```kotlin
-try {
- openIAP.requestPurchase(this, "premium_upgrade")
-} catch (e: OpenIapError) {
- when (e) {
- OpenIapError.UserCancelled -> {
- // User cancelled, no action needed
- }
- OpenIapError.ItemAlreadyOwned -> {
- // Item already purchased
- showMessage("You already own this item!")
- }
- is OpenIapError.ProductNotFound -> {
- // Product not available
- showError("Product ${e.productId} not found")
- }
- // Handle other error types...
- else -> {
- showError("Purchase failed: ${e.message}")
- }
- }
-}
-```
-
-### Subscription Offers
-
-```kotlin
-// Get subscription offers
-val product = openIAP.getCachedProduct("monthly_subscription")
-val offers = product?.subscriptionOfferDetails
-
-// Purchase with specific offer
-val offerToken = offers?.firstOrNull()?.offerToken
-openIAP.requestPurchase(
- activity = this,
- sku = "monthly_subscription",
- offerToken = offerToken
-)
-```
-
-## ⚠️ Important Notes
-
-- This library requires Google Play Billing Library v8
-- Test with real Google Play Console products for production
-- Always verify purchases server-side for security
-- Handle all purchase states properly
-- Clean up listeners and connections in `onDestroy()`
-
-## 🔧 Troubleshooting
-
-### Common Issues
-
-1. **Product not found**
-
- - Ensure products are configured in Google Play Console
- - App must be uploaded to Google Play Console (even as draft)
- - Wait up to 24 hours for products to become available
-
-2. **Billing unavailable**
-
- - Verify Google Play Services are installed and updated
- - Check that app is signed with release key for testing
- - Ensure billing permissions are in AndroidManifest.xml
-
-3. **Purchase not triggering**
- - Use real device with Google Play Store
- - Avoid emulators without Google Play Services
- - Check that test account has payment method
-
-### Debug Mode
-
-Enable verbose logging to see detailed billing operations:
-
-```kotlin
-// In development builds
-if (BuildConfig.DEBUG) {
- Log.d("OpenIAP", "Debug mode enabled")
-}
-```
-
-## 📄 License
-
-```txt
-MIT License
-
-Copyright (c) 2025 hyo.dev
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
-```
-
-## 🤝 Contributing
-
-Contributions are welcome! Please read our contributing guidelines and submit pull requests.
-
-## 📞 Support
-
-- **Issues**: [GitHub Issues](https://github.com/hyodotdev/openiap/issues)
-- **Discussions**: [OpenIAP Discussions](https://github.com/hyodotdev/openiap/discussions)
-
----
-
-
- Built with ❤️ for the OpenIAP community
-
-
+- [Documentation](https://openiap.dev)
+- [GitHub Issues](https://github.com/hyodotdev/openiap/issues)
+- [Discussions](https://github.com/hyodotdev/openiap/discussions)
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 4aaf4857..dc6f8da0 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
@@ -426,6 +426,23 @@ class OpenIapModule(
}
builder.setOfferToken(resolved)
+ } else if (androidArgs.type == ProductQueryType.InApp && !androidArgs.offerToken.isNullOrEmpty()) {
+ // Handle one-time purchase discount offers
+ // Note: Horizon SDK doesn't currently support one-time purchase discount offers,
+ // but we pass the offer token through in case future SDK versions add support.
+ OpenIapLog.d("Setting offer token for one-time product ${productDetails.productId}: ${androidArgs.offerToken}", TAG)
+
+ // Validate offerToken format (basic sanity check)
+ if (androidArgs.offerToken.isBlank()) {
+ OpenIapLog.w("Invalid empty offerToken provided for ${productDetails.productId}", TAG)
+ val err = OpenIapError.SkuOfferMismatch
+ for (listener in purchaseErrorListeners) { runCatching { listener.onPurchaseError(err) } }
+ currentPurchaseCallback?.invoke(Result.success(emptyList()))
+ return
+ }
+
+ OpenIapLog.w("Note: Horizon SDK may not support one-time purchase discount offers", TAG)
+ builder.setOfferToken(androidArgs.offerToken)
}
paramsList += builder.build()
@@ -438,22 +455,22 @@ class OpenIapModule(
androidArgs.obfuscatedAccountId?.let { flowBuilder.setObfuscatedAccountId(it) }
// For subscription upgrades/downgrades, purchaseToken and obfuscatedProfileId are mutually exclusive
- if (androidArgs.type == ProductQueryType.Subs && !androidArgs.purchaseTokenAndroid.isNullOrBlank()) {
+ if (androidArgs.type == ProductQueryType.Subs && !androidArgs.purchaseToken.isNullOrBlank()) {
// This is a subscription upgrade/downgrade - do not set obfuscatedProfileId
OpenIapLog.d("=== Subscription Upgrade Flow ===", TAG)
- OpenIapLog.d(" - Old Token: ${androidArgs.purchaseTokenAndroid.take(10)}...", TAG)
+ OpenIapLog.d(" - Old Token: ${androidArgs.purchaseToken.take(10)}...", TAG)
OpenIapLog.d(" - Target SKUs: ${androidArgs.skus}", TAG)
- OpenIapLog.d(" - Replacement mode: ${androidArgs.replacementModeAndroid}", TAG)
+ OpenIapLog.d(" - Replacement mode: ${androidArgs.replacementMode}", TAG)
OpenIapLog.d(" - Product Details Count: ${paramsList.size}", TAG)
paramsList.forEachIndexed { idx, params ->
OpenIapLog.d(" - Product[$idx]: SKU=${details[idx].productId}, offerToken=...", TAG)
}
val updateParamsBuilder = BillingFlowParams.SubscriptionUpdateParams.newBuilder()
- .setOldPurchaseToken(androidArgs.purchaseTokenAndroid)
+ .setOldPurchaseToken(androidArgs.purchaseToken)
// Set replacement mode - this is critical for upgrades
- val replacementMode = androidArgs.replacementModeAndroid ?: 5 // Default to CHARGE_FULL_PRICE
+ val replacementMode = androidArgs.replacementMode ?: 5 // Default to CHARGE_FULL_PRICE
updateParamsBuilder.setSubscriptionReplacementMode(replacementMode)
OpenIapLog.d(" - Final replacement mode: $replacementMode", TAG)
diff --git a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapViewModel.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapViewModel.kt
index aa9e9f7a..a96c1e18 100644
--- a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapViewModel.kt
+++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapViewModel.kt
@@ -47,8 +47,8 @@ class OpenIapViewModel(app: Application) : AndroidViewModel(app) {
ProductQueryType.InApp -> {
val android = RequestPurchaseAndroidProps(
isOfferPersonalized = null,
- obfuscatedAccountIdAndroid = null,
- obfuscatedProfileIdAndroid = null,
+ obfuscatedAccountId = null,
+ obfuscatedProfileId = null,
skus = skus
)
RequestPurchaseProps(
@@ -61,10 +61,10 @@ class OpenIapViewModel(app: Application) : AndroidViewModel(app) {
ProductQueryType.Subs -> {
val android = RequestSubscriptionAndroidProps(
isOfferPersonalized = null,
- obfuscatedAccountIdAndroid = null,
- obfuscatedProfileIdAndroid = null,
- purchaseTokenAndroid = null,
- replacementModeAndroid = null,
+ obfuscatedAccountId = null,
+ obfuscatedProfileId = null,
+ purchaseToken = null,
+ replacementMode = null,
skus = skus,
subscriptionOffers = null
)
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 faf6a2f5..004d8343 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
@@ -55,8 +55,9 @@ internal data class AndroidPurchaseArgs(
val isOfferPersonalized: Boolean?,
val obfuscatedAccountId: String?,
val obfuscatedProfileId: String?,
- val purchaseTokenAndroid: String?,
- val replacementModeAndroid: Int?,
+ val offerToken: String?,
+ val purchaseToken: String?,
+ val replacementMode: Int?,
val subscriptionOffers: List?,
val subscriptionProductReplacementParams: SubscriptionProductReplacementParamsAndroid?,
val type: ProductQueryType,
@@ -75,10 +76,11 @@ internal fun RequestPurchaseProps.toAndroidPurchaseArgs(): AndroidPurchaseArgs {
AndroidPurchaseArgs(
skus = params.skus,
isOfferPersonalized = params.isOfferPersonalized,
- obfuscatedAccountId = params.obfuscatedAccountIdAndroid,
- obfuscatedProfileId = params.obfuscatedProfileIdAndroid,
- purchaseTokenAndroid = null,
- replacementModeAndroid = null,
+ obfuscatedAccountId = params.obfuscatedAccountId,
+ obfuscatedProfileId = params.obfuscatedProfileId,
+ offerToken = params.offerToken,
+ purchaseToken = null,
+ replacementMode = null,
subscriptionOffers = null,
subscriptionProductReplacementParams = null,
type = type,
@@ -91,16 +93,17 @@ internal fun RequestPurchaseProps.toAndroidPurchaseArgs(): AndroidPurchaseArgs {
?: throw IllegalArgumentException("Google subscription parameters are required (use 'google' field)")
// For subscription upgrades/downgrades:
- // - purchaseTokenAndroid: Identifies which existing subscription to upgrade/downgrade
+ // - purchaseToken: Identifies which existing subscription to upgrade/downgrade
// - obfuscatedProfileId: Optional user identifier for fraud prevention and attribution
// Both can be provided together - they serve different purposes and are not mutually exclusive
AndroidPurchaseArgs(
skus = params.skus,
isOfferPersonalized = params.isOfferPersonalized,
- obfuscatedAccountId = params.obfuscatedAccountIdAndroid,
- obfuscatedProfileId = params.obfuscatedProfileIdAndroid,
- purchaseTokenAndroid = params.purchaseTokenAndroid,
- replacementModeAndroid = params.replacementModeAndroid,
+ obfuscatedAccountId = params.obfuscatedAccountId,
+ obfuscatedProfileId = params.obfuscatedProfileId,
+ offerToken = null,
+ purchaseToken = params.purchaseToken,
+ replacementMode = params.replacementMode,
subscriptionOffers = params.subscriptionOffers,
subscriptionProductReplacementParams = params.subscriptionProductReplacementParams,
type = type,
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 3224cdec..fd99e8ce 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
@@ -3187,8 +3187,8 @@ public data class AndroidSubscriptionOfferInput(
val sku = json["sku"] as? String
if (offerToken == null || sku == null) return null
return AndroidSubscriptionOfferInput(
- offerToken = offerToken!!,
- sku = sku!!,
+ offerToken = offerToken,
+ sku = sku,
)
}
}
@@ -3250,9 +3250,9 @@ public data class DeveloperBillingOptionParamsAndroid(
val linkUri = json["linkUri"] as? String
if (billingProgram == null || launchMode == null || linkUri == null) return null
return DeveloperBillingOptionParamsAndroid(
- billingProgram = billingProgram!!,
- launchMode = launchMode!!,
- linkUri = linkUri!!,
+ billingProgram = billingProgram,
+ launchMode = launchMode,
+ linkUri = linkUri,
)
}
}
@@ -3295,11 +3295,11 @@ public data class DiscountOfferInputIOS(
val timestamp = (json["timestamp"] as? Number)?.toDouble()
if (identifier == null || keyIdentifier == null || nonce == null || signature == null || timestamp == null) return null
return DiscountOfferInputIOS(
- identifier = identifier!!,
- keyIdentifier = keyIdentifier!!,
- nonce = nonce!!,
- signature = signature!!,
- timestamp = timestamp!!,
+ identifier = identifier,
+ keyIdentifier = keyIdentifier,
+ nonce = nonce,
+ signature = signature,
+ timestamp = timestamp,
)
}
}
@@ -3380,10 +3380,10 @@ public data class LaunchExternalLinkParamsAndroid(
val linkUri = json["linkUri"] as? String
if (billingProgram == null || launchMode == null || linkType == null || linkUri == null) return null
return LaunchExternalLinkParamsAndroid(
- billingProgram = billingProgram!!,
- launchMode = launchMode!!,
- linkType = linkType!!,
- linkUri = linkUri!!,
+ billingProgram = billingProgram,
+ launchMode = launchMode,
+ linkType = linkType,
+ linkUri = linkUri,
)
}
}
@@ -3406,7 +3406,7 @@ public data class ProductRequest(
val type = (json["type"] as? String)?.let { ProductQueryType.fromJson(it) }
if (skus == null) return null
return ProductRequest(
- skus = skus!!,
+ skus = skus,
type = type,
)
}
@@ -3442,8 +3442,8 @@ public data class PromotionalOfferJWSInputIOS(
val offerId = json["offerId"] as? String
if (jws == null || offerId == null) return null
return PromotionalOfferJWSInputIOS(
- jws = jws!!,
- offerId = offerId!!,
+ jws = jws,
+ offerId = offerId,
)
}
}
@@ -3498,17 +3498,24 @@ public data class RequestPurchaseAndroidProps(
*/
val developerBillingOption: DeveloperBillingOptionParamsAndroid? = null,
/**
- * Personalized offer flag
+ * Personalized offer flag.
+ * When true, indicates the price was customized for this user.
*/
val isOfferPersonalized: Boolean? = null,
/**
* Obfuscated account ID
*/
- val obfuscatedAccountIdAndroid: String? = null,
+ val obfuscatedAccountId: String? = null,
/**
* Obfuscated profile ID
*/
- val obfuscatedProfileIdAndroid: String? = null,
+ val obfuscatedProfileId: String? = null,
+ /**
+ * Offer token for one-time purchase discounts (7.0+).
+ * Pass the offerToken from oneTimePurchaseOfferDetailsAndroid or discountOffers
+ * to apply a discount offer to the purchase.
+ */
+ val offerToken: String? = null,
/**
* List of product SKUs
*/
@@ -3518,16 +3525,18 @@ public data class RequestPurchaseAndroidProps(
fun fromJson(json: Map): RequestPurchaseAndroidProps? {
val developerBillingOption = (json["developerBillingOption"] as? Map)?.let { DeveloperBillingOptionParamsAndroid.fromJson(it) }
val isOfferPersonalized = json["isOfferPersonalized"] as? Boolean
- val obfuscatedAccountIdAndroid = json["obfuscatedAccountIdAndroid"] as? String
- val obfuscatedProfileIdAndroid = json["obfuscatedProfileIdAndroid"] as? String
+ val obfuscatedAccountId = json["obfuscatedAccountId"] as? String
+ val obfuscatedProfileId = json["obfuscatedProfileId"] as? String
+ val offerToken = json["offerToken"] as? String
val skus = (json["skus"] as? List<*>)?.mapNotNull { it as? String }
if (skus == null) return null
return RequestPurchaseAndroidProps(
developerBillingOption = developerBillingOption,
isOfferPersonalized = isOfferPersonalized,
- obfuscatedAccountIdAndroid = obfuscatedAccountIdAndroid,
- obfuscatedProfileIdAndroid = obfuscatedProfileIdAndroid,
- skus = skus!!,
+ obfuscatedAccountId = obfuscatedAccountId,
+ obfuscatedProfileId = obfuscatedProfileId,
+ offerToken = offerToken,
+ skus = skus,
)
}
}
@@ -3535,8 +3544,9 @@ public data class RequestPurchaseAndroidProps(
fun toJson(): Map = mapOf(
"developerBillingOption" to developerBillingOption?.toJson(),
"isOfferPersonalized" to isOfferPersonalized,
- "obfuscatedAccountIdAndroid" to obfuscatedAccountIdAndroid,
- "obfuscatedProfileIdAndroid" to obfuscatedProfileIdAndroid,
+ "obfuscatedAccountId" to obfuscatedAccountId,
+ "obfuscatedProfileId" to obfuscatedProfileId,
+ "offerToken" to offerToken,
"skus" to skus,
)
}
@@ -3585,7 +3595,7 @@ public data class RequestPurchaseIosProps(
andDangerouslyFinishTransactionAutomatically = andDangerouslyFinishTransactionAutomatically,
appAccountToken = appAccountToken,
quantity = quantity,
- sku = sku!!,
+ sku = sku,
withOffer = withOffer,
)
}
@@ -3707,26 +3717,27 @@ public data class RequestSubscriptionAndroidProps(
*/
val developerBillingOption: DeveloperBillingOptionParamsAndroid? = null,
/**
- * Personalized offer flag
+ * Personalized offer flag.
+ * When true, indicates the price was customized for this user.
*/
val isOfferPersonalized: Boolean? = null,
/**
* Obfuscated account ID
*/
- val obfuscatedAccountIdAndroid: String? = null,
+ val obfuscatedAccountId: String? = null,
/**
* Obfuscated profile ID
*/
- val obfuscatedProfileIdAndroid: String? = null,
+ val obfuscatedProfileId: String? = null,
/**
* Purchase token for upgrades/downgrades
*/
- val purchaseTokenAndroid: String? = null,
+ val purchaseToken: String? = null,
/**
* Replacement mode for subscription changes
* @deprecated Use subscriptionProductReplacementParams instead for item-level replacement (8.1.0+)
*/
- val replacementModeAndroid: Int? = null,
+ val replacementMode: Int? = null,
/**
* List of subscription SKUs
*/
@@ -3737,7 +3748,7 @@ public data class RequestSubscriptionAndroidProps(
val subscriptionOffers: List? = null,
/**
* Product-level replacement parameters (8.1.0+)
- * Use this instead of replacementModeAndroid for item-level replacement
+ * Use this instead of replacementMode for item-level replacement
*/
val subscriptionProductReplacementParams: SubscriptionProductReplacementParamsAndroid? = null
) {
@@ -3745,22 +3756,22 @@ public data class RequestSubscriptionAndroidProps(
fun fromJson(json: Map): RequestSubscriptionAndroidProps? {
val developerBillingOption = (json["developerBillingOption"] as? Map)?.let { DeveloperBillingOptionParamsAndroid.fromJson(it) }
val isOfferPersonalized = json["isOfferPersonalized"] as? Boolean
- val obfuscatedAccountIdAndroid = json["obfuscatedAccountIdAndroid"] as? String
- val obfuscatedProfileIdAndroid = json["obfuscatedProfileIdAndroid"] as? String
- val purchaseTokenAndroid = json["purchaseTokenAndroid"] as? String
- val replacementModeAndroid = (json["replacementModeAndroid"] as? Number)?.toInt()
+ val obfuscatedAccountId = json["obfuscatedAccountId"] as? String
+ val obfuscatedProfileId = json["obfuscatedProfileId"] as? String
+ val purchaseToken = json["purchaseToken"] as? String
+ val replacementMode = (json["replacementMode"] as? Number)?.toInt()
val skus = (json["skus"] as? List<*>)?.mapNotNull { it as? String }
- val subscriptionOffers = (json["subscriptionOffers"] as? List<*>)?.mapNotNull { (it as? Map)?.let { AndroidSubscriptionOfferInput.fromJson(it) } ?: throw IllegalArgumentException("Missing required object for AndroidSubscriptionOfferInput") }
+ val subscriptionOffers = (json["subscriptionOffers"] as? List<*>)?.mapNotNull { (it as? Map)?.let { AndroidSubscriptionOfferInput.fromJson(it) } }
val subscriptionProductReplacementParams = (json["subscriptionProductReplacementParams"] as? Map)?.let { SubscriptionProductReplacementParamsAndroid.fromJson(it) }
if (skus == null) return null
return RequestSubscriptionAndroidProps(
developerBillingOption = developerBillingOption,
isOfferPersonalized = isOfferPersonalized,
- obfuscatedAccountIdAndroid = obfuscatedAccountIdAndroid,
- obfuscatedProfileIdAndroid = obfuscatedProfileIdAndroid,
- purchaseTokenAndroid = purchaseTokenAndroid,
- replacementModeAndroid = replacementModeAndroid,
- skus = skus!!,
+ obfuscatedAccountId = obfuscatedAccountId,
+ obfuscatedProfileId = obfuscatedProfileId,
+ purchaseToken = purchaseToken,
+ replacementMode = replacementMode,
+ skus = skus,
subscriptionOffers = subscriptionOffers,
subscriptionProductReplacementParams = subscriptionProductReplacementParams,
)
@@ -3770,10 +3781,10 @@ public data class RequestSubscriptionAndroidProps(
fun toJson(): Map = mapOf(
"developerBillingOption" to developerBillingOption?.toJson(),
"isOfferPersonalized" to isOfferPersonalized,
- "obfuscatedAccountIdAndroid" to obfuscatedAccountIdAndroid,
- "obfuscatedProfileIdAndroid" to obfuscatedProfileIdAndroid,
- "purchaseTokenAndroid" to purchaseTokenAndroid,
- "replacementModeAndroid" to replacementModeAndroid,
+ "obfuscatedAccountId" to obfuscatedAccountId,
+ "obfuscatedProfileId" to obfuscatedProfileId,
+ "purchaseToken" to purchaseToken,
+ "replacementMode" to replacementMode,
"skus" to skus,
"subscriptionOffers" to subscriptionOffers?.map { it.toJson() },
"subscriptionProductReplacementParams" to subscriptionProductReplacementParams?.toJson(),
@@ -3837,7 +3848,7 @@ public data class RequestSubscriptionIosProps(
introductoryOfferEligibility = introductoryOfferEligibility,
promotionalOfferJWS = promotionalOfferJWS,
quantity = quantity,
- sku = sku!!,
+ sku = sku,
winBackOffer = winBackOffer,
withOffer = withOffer,
)
@@ -3913,7 +3924,7 @@ public data class RequestVerifyPurchaseWithIapkitAppleProps(
val jws = json["jws"] as? String
if (jws == null) return null
return RequestVerifyPurchaseWithIapkitAppleProps(
- jws = jws!!,
+ jws = jws,
)
}
}
@@ -3934,7 +3945,7 @@ public data class RequestVerifyPurchaseWithIapkitGoogleProps(
val purchaseToken = json["purchaseToken"] as? String
if (purchaseToken == null) return null
return RequestVerifyPurchaseWithIapkitGoogleProps(
- purchaseToken = purchaseToken!!,
+ purchaseToken = purchaseToken,
)
}
}
@@ -4002,8 +4013,8 @@ public data class SubscriptionProductReplacementParamsAndroid(
val replacementMode = (json["replacementMode"] as? String)?.let { SubscriptionReplacementModeAndroid.fromJson(it) } ?: SubscriptionReplacementModeAndroid.UnknownReplacementMode
if (oldProductId == null || replacementMode == null) return null
return SubscriptionProductReplacementParamsAndroid(
- oldProductId = oldProductId!!,
- replacementMode = replacementMode!!,
+ oldProductId = oldProductId,
+ replacementMode = replacementMode,
)
}
}
@@ -4029,7 +4040,7 @@ public data class VerifyPurchaseAppleOptions(
val sku = json["sku"] as? String
if (sku == null) return null
return VerifyPurchaseAppleOptions(
- sku = sku!!,
+ sku = sku,
)
}
}
@@ -4078,11 +4089,11 @@ public data class VerifyPurchaseGoogleOptions(
val sku = json["sku"] as? String
if (accessToken == null || packageName == null || purchaseToken == null || sku == null) return null
return VerifyPurchaseGoogleOptions(
- accessToken = accessToken!!,
+ accessToken = accessToken,
isSub = isSub,
- packageName = packageName!!,
- purchaseToken = purchaseToken!!,
- sku = sku!!,
+ packageName = packageName,
+ purchaseToken = purchaseToken,
+ sku = sku,
)
}
}
@@ -4125,9 +4136,9 @@ public data class VerifyPurchaseHorizonOptions(
val userId = json["userId"] as? String
if (accessToken == null || sku == null || userId == null) return null
return VerifyPurchaseHorizonOptions(
- accessToken = accessToken!!,
- sku = sku!!,
- userId = userId!!,
+ accessToken = accessToken,
+ sku = sku,
+ userId = userId,
)
}
}
@@ -4188,7 +4199,7 @@ public data class VerifyPurchaseWithProviderProps(
if (provider == null) return null
return VerifyPurchaseWithProviderProps(
iapkit = iapkit,
- provider = provider!!,
+ provider = provider,
)
}
}
@@ -4216,7 +4227,7 @@ public data class WinBackOfferInputIOS(
val offerId = json["offerId"] as? String
if (offerId == null) return null
return WinBackOfferInputIOS(
- offerId = offerId!!,
+ offerId = offerId,
)
}
}
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 bb33a16c..c0a6eb65 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
@@ -499,9 +499,11 @@ class OpenIapModule(
}
/**
- * Create reporting details for transactions made outside of Google Play Billing (8.2.0+)
+ * Create reporting details for transactions made outside of Google Play Billing (8.3.0+)
* This is the new API that replaces createAlternativeBillingReportingToken for external offers.
*
+ * Note: This method uses BillingProgramReportingDetailsParams which was introduced in 8.3.0.
+ *
* @param program The billing program (EXTERNAL_CONTENT_LINK or EXTERNAL_OFFER)
* @return Reporting details containing the external transaction token
*/
@@ -526,7 +528,8 @@ class OpenIapModule(
listenerClass.classLoader,
arrayOf(listenerClass)
) { _, method, args ->
- if (method.name == "onBillingProgramReportingDetailsResponse") {
+ // Note: Callback method name is onCreateBillingProgramReportingDetailsResponse (not onBillingProgramReportingDetailsResponse)
+ if (method.name == "onCreateBillingProgramReportingDetailsResponse") {
val result = args?.get(0) as? BillingResult
val details = args?.getOrNull(1)
@@ -556,14 +559,33 @@ class OpenIapModule(
null
}
+ // Build BillingProgramReportingDetailsParams using reflection (Billing Library 8.3.0+)
+ val paramsClass = Class.forName("com.android.billingclient.api.BillingProgramReportingDetailsParams")
+ val paramsBuilderClass = Class.forName("com.android.billingclient.api.BillingProgramReportingDetailsParams\$Builder")
+
+ val newBuilderMethod = paramsClass.getMethod("newBuilder")
+ val paramsBuilder = newBuilderMethod.invoke(null)
+
+ // Set billing program
+ val setBillingProgramMethod = paramsBuilderClass.getMethod("setBillingProgram", Int::class.javaPrimitiveType)
+ setBillingProgramMethod.invoke(paramsBuilder, billingProgramConstant)
+
+ // Build the params
+ val buildMethod = paramsBuilderClass.getMethod("build")
+ val reportingParams = buildMethod.invoke(paramsBuilder)
+
+ // Call createBillingProgramReportingDetailsAsync with (BillingProgramReportingDetailsParams, Listener)
val method = client.javaClass.getMethod(
"createBillingProgramReportingDetailsAsync",
- Int::class.javaPrimitiveType,
+ paramsClass,
listenerClass
)
- method.invoke(client, billingProgramConstant, listener)
+ method.invoke(client, reportingParams, listener)
} catch (e: NoSuchMethodException) {
- OpenIapLog.e("createBillingProgramReportingDetailsAsync not found. Requires Billing Library 8.2.0+", e, TAG)
+ OpenIapLog.e("createBillingProgramReportingDetailsAsync not found. Requires Billing Library 8.3.0+", e, TAG)
+ throw OpenIapError.FeatureNotSupported
+ } catch (e: ClassNotFoundException) {
+ OpenIapLog.e("BillingProgramReportingDetailsParams not found. Requires Billing Library 8.3.0+", e, TAG)
throw OpenIapError.FeatureNotSupported
} catch (e: Exception) {
OpenIapLog.e("Failed to create billing program reporting details: ${e.message}", e, TAG)
@@ -845,6 +867,21 @@ class OpenIapModule(
val paramsList = mutableListOf()
val requestedOffersBySku = mutableMapOf>()
+ // Reject multi-SKU one-time purchase requests when offerToken is provided
+ // A single offerToken cannot be applied to multiple SKUs
+ if (androidArgs.type == ProductQueryType.InApp &&
+ !androidArgs.offerToken.isNullOrEmpty() &&
+ androidArgs.skus.size > 1) {
+ OpenIapLog.w(
+ "offerToken requires a single SKU. Provided SKUs: ${androidArgs.skus}",
+ TAG
+ )
+ val err = OpenIapError.SkuOfferMismatch
+ for (listener in purchaseErrorListeners) { runCatching { listener.onPurchaseError(err) } }
+ currentPurchaseCallback?.invoke(Result.success(emptyList()))
+ return
+ }
+
if (androidArgs.type == ProductQueryType.Subs) {
for (offer in androidArgs.subscriptionOffers.orEmpty()) {
if (offer.offerToken.isNotEmpty()) {
@@ -892,6 +929,32 @@ class OpenIapModule(
applySubscriptionProductReplacementParams(builder, replacementParams)
}
}
+ } else if (androidArgs.type == ProductQueryType.InApp && !androidArgs.offerToken.isNullOrEmpty()) {
+ // Handle one-time purchase discount offers (Android 7.0+)
+ OpenIapLog.d("Setting offer token for one-time product ${productDetails.productId}: ${androidArgs.offerToken}", TAG)
+
+ // Validate offer token exists in available one-time purchase offers
+ // Use oneTimePurchaseOfferDetailsList (Billing Library 7.0+) for discount offers
+ val oneTimePurchaseOffers = productDetails.oneTimePurchaseOfferDetailsList
+ val availableTokens = oneTimePurchaseOffers?.map { it.offerToken } ?: emptyList()
+
+ if (availableTokens.isEmpty()) {
+ OpenIapLog.w("No one-time purchase offers available for ${productDetails.productId}, but offerToken was provided: ${androidArgs.offerToken}", TAG)
+ val err = OpenIapError.SkuOfferMismatch
+ for (listener in purchaseErrorListeners) { runCatching { listener.onPurchaseError(err) } }
+ currentPurchaseCallback?.invoke(Result.success(emptyList()))
+ return
+ }
+
+ if (!availableTokens.contains(androidArgs.offerToken)) {
+ OpenIapLog.w("Invalid one-time offer token: ${androidArgs.offerToken} not in $availableTokens", TAG)
+ val err = OpenIapError.SkuOfferMismatch
+ for (listener in purchaseErrorListeners) { runCatching { listener.onPurchaseError(err) } }
+ currentPurchaseCallback?.invoke(Result.success(emptyList()))
+ return
+ }
+
+ builder.setOfferToken(androidArgs.offerToken)
}
paramsList += builder.build()
@@ -920,25 +983,25 @@ class OpenIapModule(
}
// For subscription upgrades/downgrades, purchaseToken and obfuscatedProfileId are mutually exclusive
- if (androidArgs.type == ProductQueryType.Subs && !androidArgs.purchaseTokenAndroid.isNullOrBlank()) {
+ if (androidArgs.type == ProductQueryType.Subs && !androidArgs.purchaseToken.isNullOrBlank()) {
// This is a subscription upgrade/downgrade - do not set obfuscatedProfileId
OpenIapLog.d("=== Subscription Upgrade Flow ===", TAG)
- OpenIapLog.d(" - Old Token: ${androidArgs.purchaseTokenAndroid.take(10)}...", TAG)
+ OpenIapLog.d(" - Old Token: ${androidArgs.purchaseToken.take(10)}...", TAG)
OpenIapLog.d(" - Target SKUs: ${androidArgs.skus}", TAG)
- OpenIapLog.d(" - Replacement mode: ${androidArgs.replacementModeAndroid}", TAG)
+ OpenIapLog.d(" - Replacement mode: ${androidArgs.replacementMode}", TAG)
OpenIapLog.d(" - Product Details Count: ${paramsList.size}", TAG)
for ((index, params) in paramsList.withIndex()) {
OpenIapLog.d(" - Product[$index]: SKU=${details[index].productId}, offerToken=...", TAG)
}
val updateParamsBuilder = BillingFlowParams.SubscriptionUpdateParams.newBuilder()
- .setOldPurchaseToken(androidArgs.purchaseTokenAndroid)
+ .setOldPurchaseToken(androidArgs.purchaseToken)
// Set replacement mode - this is critical for upgrades
// Note: setSubscriptionReplacementMode() is deprecated in Billing 8.1.0
// in favor of SubscriptionProductReplacementParams for per-product control.
// However, for single-product upgrades, the legacy API still works.
- val replacementMode = androidArgs.replacementModeAndroid ?: 5 // Default to CHARGE_FULL_PRICE
+ val replacementMode = androidArgs.replacementMode ?: 5 // Default to CHARGE_FULL_PRICE
@Suppress("DEPRECATION")
updateParamsBuilder.setSubscriptionReplacementMode(replacementMode)
OpenIapLog.d(" - Final replacement mode: $replacementMode", TAG)
@@ -1554,11 +1617,12 @@ class OpenIapModule(
}
// Build SubscriptionProductReplacementParams using reflection
+ // Note: SubscriptionProductReplacementParams is nested under ProductDetailsParams (Billing Library 8.1.0+)
val replacementParamsClass = Class.forName(
- "com.android.billingclient.api.BillingFlowParams\$SubscriptionProductReplacementParams"
+ "com.android.billingclient.api.BillingFlowParams\$ProductDetailsParams\$SubscriptionProductReplacementParams"
)
val replacementBuilderClass = Class.forName(
- "com.android.billingclient.api.BillingFlowParams\$SubscriptionProductReplacementParams\$Builder"
+ "com.android.billingclient.api.BillingFlowParams\$ProductDetailsParams\$SubscriptionProductReplacementParams\$Builder"
)
// Create new builder
diff --git a/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapViewModel.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapViewModel.kt
index aa9e9f7a..a96c1e18 100644
--- a/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapViewModel.kt
+++ b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapViewModel.kt
@@ -47,8 +47,8 @@ class OpenIapViewModel(app: Application) : AndroidViewModel(app) {
ProductQueryType.InApp -> {
val android = RequestPurchaseAndroidProps(
isOfferPersonalized = null,
- obfuscatedAccountIdAndroid = null,
- obfuscatedProfileIdAndroid = null,
+ obfuscatedAccountId = null,
+ obfuscatedProfileId = null,
skus = skus
)
RequestPurchaseProps(
@@ -61,10 +61,10 @@ class OpenIapViewModel(app: Application) : AndroidViewModel(app) {
ProductQueryType.Subs -> {
val android = RequestSubscriptionAndroidProps(
isOfferPersonalized = null,
- obfuscatedAccountIdAndroid = null,
- obfuscatedProfileIdAndroid = null,
- purchaseTokenAndroid = null,
- replacementModeAndroid = null,
+ obfuscatedAccountId = null,
+ obfuscatedProfileId = null,
+ purchaseToken = null,
+ replacementMode = null,
skus = skus,
subscriptionOffers = null
)
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 87f4e406..fa83ce23 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
@@ -118,8 +118,9 @@ internal data class AndroidPurchaseArgs(
val isOfferPersonalized: Boolean?,
val obfuscatedAccountId: String?,
val obfuscatedProfileId: String?,
- val purchaseTokenAndroid: String?,
- val replacementModeAndroid: Int?,
+ val offerToken: String?,
+ val purchaseToken: String?,
+ val replacementMode: Int?,
val subscriptionOffers: List?,
val subscriptionProductReplacementParams: SubscriptionProductReplacementParamsAndroid?,
val developerBillingOption: DeveloperBillingOptionParamsAndroid?,
@@ -136,10 +137,11 @@ internal fun RequestPurchaseProps.toAndroidPurchaseArgs(): AndroidPurchaseArgs {
AndroidPurchaseArgs(
skus = params.skus,
isOfferPersonalized = params.isOfferPersonalized,
- obfuscatedAccountId = params.obfuscatedAccountIdAndroid,
- obfuscatedProfileId = params.obfuscatedProfileIdAndroid,
- purchaseTokenAndroid = null,
- replacementModeAndroid = null,
+ obfuscatedAccountId = params.obfuscatedAccountId,
+ obfuscatedProfileId = params.obfuscatedProfileId,
+ offerToken = params.offerToken,
+ purchaseToken = null,
+ replacementMode = null,
subscriptionOffers = null,
subscriptionProductReplacementParams = null,
developerBillingOption = params.developerBillingOption,
@@ -153,16 +155,17 @@ internal fun RequestPurchaseProps.toAndroidPurchaseArgs(): AndroidPurchaseArgs {
?: throw IllegalArgumentException("Google subscription parameters are required (use 'google' field)")
// For subscription upgrades/downgrades:
- // - purchaseTokenAndroid: Identifies which existing subscription to upgrade/downgrade
+ // - purchaseToken: Identifies which existing subscription to upgrade/downgrade
// - obfuscatedProfileId: Optional user identifier for fraud prevention and attribution
// Both can be provided together - they serve different purposes and are not mutually exclusive
AndroidPurchaseArgs(
skus = params.skus,
isOfferPersonalized = params.isOfferPersonalized,
- obfuscatedAccountId = params.obfuscatedAccountIdAndroid,
- obfuscatedProfileId = params.obfuscatedProfileIdAndroid,
- purchaseTokenAndroid = params.purchaseTokenAndroid,
- replacementModeAndroid = params.replacementModeAndroid,
+ obfuscatedAccountId = params.obfuscatedAccountId,
+ obfuscatedProfileId = params.obfuscatedProfileId,
+ offerToken = null,
+ purchaseToken = params.purchaseToken,
+ replacementMode = params.replacementMode,
subscriptionOffers = params.subscriptionOffers,
subscriptionProductReplacementParams = params.subscriptionProductReplacementParams,
developerBillingOption = params.developerBillingOption,
diff --git a/packages/google/openiap/src/test/java/dev/hyo/openiap/BillingLibraryClassPathTest.kt b/packages/google/openiap/src/test/java/dev/hyo/openiap/BillingLibraryClassPathTest.kt
new file mode 100644
index 00000000..7059bda1
--- /dev/null
+++ b/packages/google/openiap/src/test/java/dev/hyo/openiap/BillingLibraryClassPathTest.kt
@@ -0,0 +1,781 @@
+package dev.hyo.openiap
+
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
+import org.junit.Assert.fail
+import org.junit.Test
+
+/**
+ * Tests to verify that reflection-based class paths used in OpenIapModule
+ * match the actual Google Play Billing Library class structure.
+ *
+ * These tests prevent issues like #70 where SubscriptionProductReplacementParams
+ * was referenced at the wrong path (missing ProductDetailsParams in the hierarchy).
+ *
+ * IMPORTANT: Every Class.forName() and getMethod() call in OpenIapModule.kt
+ * should have a corresponding test here to catch API changes early.
+ *
+ * @see Issue #70
+ */
+class BillingLibraryClassPathTest {
+
+ // ============================================================================
+ // MARK: - SubscriptionProductReplacementParams (Billing Library 8.1.0+)
+ // Used in: OpenIapModule.applySubscriptionProductReplacementParams()
+ // ============================================================================
+
+ @Test
+ fun `SubscriptionProductReplacementParams class exists at correct path`() {
+ // Issue #70: Was incorrectly using BillingFlowParams$SubscriptionProductReplacementParams
+ // Correct path: BillingFlowParams$ProductDetailsParams$SubscriptionProductReplacementParams
+ val className = "com.android.billingclient.api.BillingFlowParams\$ProductDetailsParams\$SubscriptionProductReplacementParams"
+ assertClassExists(className, "8.1.0+")
+ }
+
+ @Test
+ fun `SubscriptionProductReplacementParams Builder class exists at correct path`() {
+ val className = "com.android.billingclient.api.BillingFlowParams\$ProductDetailsParams\$SubscriptionProductReplacementParams\$Builder"
+ assertClassExists(className, "8.1.0+")
+ }
+
+ @Test
+ fun `SubscriptionProductReplacementParams has newBuilder method`() {
+ assertClassHasMethod(
+ "com.android.billingclient.api.BillingFlowParams\$ProductDetailsParams\$SubscriptionProductReplacementParams",
+ "newBuilder"
+ )
+ }
+
+ @Test
+ fun `SubscriptionProductReplacementParams Builder has setOldProductId method`() {
+ assertClassHasMethod(
+ "com.android.billingclient.api.BillingFlowParams\$ProductDetailsParams\$SubscriptionProductReplacementParams\$Builder",
+ "setOldProductId",
+ String::class.java
+ )
+ }
+
+ @Test
+ fun `SubscriptionProductReplacementParams Builder has setReplacementMode method`() {
+ assertClassHasMethod(
+ "com.android.billingclient.api.BillingFlowParams\$ProductDetailsParams\$SubscriptionProductReplacementParams\$Builder",
+ "setReplacementMode",
+ Int::class.javaPrimitiveType!!
+ )
+ }
+
+ @Test
+ fun `SubscriptionProductReplacementParams Builder has build method`() {
+ assertClassHasMethod(
+ "com.android.billingclient.api.BillingFlowParams\$ProductDetailsParams\$SubscriptionProductReplacementParams\$Builder",
+ "build"
+ )
+ }
+
+ @Test
+ fun `WRONG path for SubscriptionProductReplacementParams should NOT exist`() {
+ // This is the WRONG path that was causing Issue #70
+ val wrongClassName = "com.android.billingclient.api.BillingFlowParams\$SubscriptionProductReplacementParams"
+ assertClassDoesNotExist(wrongClassName)
+ }
+
+ @Test
+ fun `SubscriptionProductReplacementParams ReplacementMode annotation exists`() {
+ val className = "com.android.billingclient.api.BillingFlowParams\$ProductDetailsParams\$SubscriptionProductReplacementParams\$ReplacementMode"
+ try {
+ val clazz = Class.forName(className)
+ assertNotNull("ReplacementMode annotation should exist", clazz)
+ assertTrue("ReplacementMode should be an annotation", clazz.isAnnotation)
+ } catch (e: ClassNotFoundException) {
+ fail("ReplacementMode annotation not found: $className")
+ }
+ }
+
+ // ============================================================================
+ // MARK: - ProductDetailsParams (base class)
+ // Used in: OpenIapModule for subscription replacement params
+ // ============================================================================
+
+ @Test
+ fun `ProductDetailsParams class exists`() {
+ assertClassExists(
+ "com.android.billingclient.api.BillingFlowParams\$ProductDetailsParams",
+ "5.0+"
+ )
+ }
+
+ @Test
+ fun `ProductDetailsParams Builder class exists`() {
+ assertClassExists(
+ "com.android.billingclient.api.BillingFlowParams\$ProductDetailsParams\$Builder",
+ "5.0+"
+ )
+ }
+
+ @Test
+ fun `ProductDetailsParams Builder has setSubscriptionProductReplacementParams method`() {
+ val builderClassName = "com.android.billingclient.api.BillingFlowParams\$ProductDetailsParams\$Builder"
+ val replacementParamsClassName = "com.android.billingclient.api.BillingFlowParams\$ProductDetailsParams\$SubscriptionProductReplacementParams"
+
+ try {
+ val builderClass = Class.forName(builderClassName)
+ val replacementParamsClass = Class.forName(replacementParamsClassName)
+ val setMethod = builderClass.getMethod("setSubscriptionProductReplacementParams", replacementParamsClass)
+ assertNotNull("setSubscriptionProductReplacementParams method should exist", setMethod)
+ } catch (e: ClassNotFoundException) {
+ fail("Class not found: ${e.message}")
+ } catch (e: NoSuchMethodException) {
+ fail("setSubscriptionProductReplacementParams method not found. Requires Billing Library 8.1.0+")
+ }
+ }
+
+ // ============================================================================
+ // MARK: - SubscriptionUpdateParams (legacy)
+ // Used for backwards compatibility
+ // ============================================================================
+
+ @Test
+ fun `SubscriptionUpdateParams class exists for legacy support`() {
+ assertClassExists(
+ "com.android.billingclient.api.BillingFlowParams\$SubscriptionUpdateParams",
+ "any version"
+ )
+ }
+
+ // ============================================================================
+ // MARK: - AlternativeBillingOnlyAvailabilityListener (Billing Library 6.0+)
+ // Used in: OpenIapModule.checkAlternativeBillingAvailability()
+ // ============================================================================
+
+ @Test
+ fun `AlternativeBillingOnlyAvailabilityListener class exists`() {
+ assertClassExists(
+ "com.android.billingclient.api.AlternativeBillingOnlyAvailabilityListener",
+ "6.0+"
+ )
+ }
+
+ @Test
+ fun `AlternativeBillingOnlyAvailabilityListener has callback method`() {
+ assertClassHasMethod(
+ "com.android.billingclient.api.AlternativeBillingOnlyAvailabilityListener",
+ "onAlternativeBillingOnlyAvailabilityResponse",
+ com.android.billingclient.api.BillingResult::class.java
+ )
+ }
+
+ // ============================================================================
+ // MARK: - AlternativeBillingOnlyInformationDialogListener (Billing Library 6.0+)
+ // Used in: OpenIapModule.showAlternativeBillingInformationDialog()
+ // ============================================================================
+
+ @Test
+ fun `AlternativeBillingOnlyInformationDialogListener class exists`() {
+ assertClassExists(
+ "com.android.billingclient.api.AlternativeBillingOnlyInformationDialogListener",
+ "6.0+"
+ )
+ }
+
+ @Test
+ fun `AlternativeBillingOnlyInformationDialogListener has callback method`() {
+ assertClassHasMethod(
+ "com.android.billingclient.api.AlternativeBillingOnlyInformationDialogListener",
+ "onAlternativeBillingOnlyInformationDialogResponse",
+ com.android.billingclient.api.BillingResult::class.java
+ )
+ }
+
+ // ============================================================================
+ // MARK: - AlternativeBillingOnlyReportingDetailsListener (Billing Library 6.0+)
+ // Used in: OpenIapModule.createAlternativeBillingReportingToken()
+ // ============================================================================
+
+ @Test
+ fun `AlternativeBillingOnlyReportingDetailsListener class exists`() {
+ assertClassExists(
+ "com.android.billingclient.api.AlternativeBillingOnlyReportingDetailsListener",
+ "6.0+"
+ )
+ }
+
+ @Test
+ fun `AlternativeBillingOnlyReportingDetailsListener has callback method`() {
+ // The callback receives BillingResult and AlternativeBillingOnlyReportingDetails
+ val listenerClass = Class.forName("com.android.billingclient.api.AlternativeBillingOnlyReportingDetailsListener")
+ val methods = listenerClass.methods.filter { it.name == "onAlternativeBillingOnlyTokenResponse" }
+ assertTrue(
+ "onAlternativeBillingOnlyTokenResponse method should exist",
+ methods.isNotEmpty()
+ )
+ }
+
+ // ============================================================================
+ // MARK: - BillingProgramAvailabilityListener (Billing Library 7.0+/8.2.0+)
+ // Used in: OpenIapModule.isBillingProgramAvailable()
+ // ============================================================================
+
+ @Test
+ fun `BillingProgramAvailabilityListener class exists`() {
+ assertClassExists(
+ "com.android.billingclient.api.BillingProgramAvailabilityListener",
+ "7.0+"
+ )
+ }
+
+ @Test
+ fun `BillingProgramAvailabilityListener has callback method`() {
+ // Callback receives (BillingResult, BillingProgramAvailabilityDetails)
+ val listenerClass = Class.forName("com.android.billingclient.api.BillingProgramAvailabilityListener")
+ val methods = listenerClass.declaredMethods.filter { it.name == "onBillingProgramAvailabilityResponse" }
+ assertTrue(
+ "onBillingProgramAvailabilityResponse method should exist",
+ methods.isNotEmpty()
+ )
+ // Verify it has 2 parameters
+ val method = methods.first()
+ assertTrue(
+ "onBillingProgramAvailabilityResponse should have 2 parameters (BillingResult, BillingProgramAvailabilityDetails)",
+ method.parameterTypes.size == 2
+ )
+ }
+
+ // ============================================================================
+ // MARK: - BillingProgramReportingDetailsListener (Billing Library 8.3.0+)
+ // Used in: OpenIapModule.createBillingProgramReportingDetails()
+ // Note: Requires BillingProgramReportingDetailsParams in 8.3.0+
+ // ============================================================================
+
+ @Test
+ fun `BillingProgramReportingDetailsListener class exists`() {
+ assertClassExists(
+ "com.android.billingclient.api.BillingProgramReportingDetailsListener",
+ "8.3.0+"
+ )
+ }
+
+ @Test
+ fun `BillingProgramReportingDetailsListener has callback method`() {
+ // The callback receives (BillingResult, BillingProgramReportingDetails)
+ // Note: Actual method name is onCreateBillingProgramReportingDetailsResponse
+ val listenerClass = Class.forName("com.android.billingclient.api.BillingProgramReportingDetailsListener")
+ val methods = listenerClass.declaredMethods.filter { it.name == "onCreateBillingProgramReportingDetailsResponse" }
+ assertTrue(
+ "onCreateBillingProgramReportingDetailsResponse method should exist",
+ methods.isNotEmpty()
+ )
+ }
+
+ @Test
+ fun `BillingProgramReportingDetailsParams class exists`() {
+ // Required parameter for createBillingProgramReportingDetailsAsync in 8.3.0+
+ assertClassExists(
+ "com.android.billingclient.api.BillingProgramReportingDetailsParams",
+ "8.3.0+"
+ )
+ }
+
+ @Test
+ fun `BillingProgramReportingDetailsParams Builder class exists`() {
+ assertClassExists(
+ "com.android.billingclient.api.BillingProgramReportingDetailsParams\$Builder",
+ "8.3.0+"
+ )
+ }
+
+ @Test
+ fun `BillingProgramReportingDetailsParams has newBuilder method`() {
+ assertClassHasMethod(
+ "com.android.billingclient.api.BillingProgramReportingDetailsParams",
+ "newBuilder"
+ )
+ }
+
+ @Test
+ fun `BillingProgramReportingDetailsParams Builder has setBillingProgram method`() {
+ assertClassHasMethod(
+ "com.android.billingclient.api.BillingProgramReportingDetailsParams\$Builder",
+ "setBillingProgram",
+ Int::class.javaPrimitiveType!!
+ )
+ }
+
+ @Test
+ fun `BillingProgramReportingDetailsParams Builder has build method`() {
+ assertClassHasMethod(
+ "com.android.billingclient.api.BillingProgramReportingDetailsParams\$Builder",
+ "build"
+ )
+ }
+
+ // ============================================================================
+ // MARK: - LaunchExternalLinkParams (Billing Library 6.0+/8.2.0+)
+ // Used in: OpenIapModule.launchExternalLink()
+ // ============================================================================
+
+ @Test
+ fun `LaunchExternalLinkParams class exists`() {
+ assertClassExists(
+ "com.android.billingclient.api.LaunchExternalLinkParams",
+ "6.0+"
+ )
+ }
+
+ @Test
+ fun `LaunchExternalLinkParams Builder class exists`() {
+ assertClassExists(
+ "com.android.billingclient.api.LaunchExternalLinkParams\$Builder",
+ "6.0+"
+ )
+ }
+
+ @Test
+ fun `LaunchExternalLinkParams has newBuilder method`() {
+ assertClassHasMethod(
+ "com.android.billingclient.api.LaunchExternalLinkParams",
+ "newBuilder"
+ )
+ }
+
+ @Test
+ fun `LaunchExternalLinkParams Builder has setBillingProgram method`() {
+ assertClassHasMethod(
+ "com.android.billingclient.api.LaunchExternalLinkParams\$Builder",
+ "setBillingProgram",
+ Int::class.javaPrimitiveType!!
+ )
+ }
+
+ @Test
+ fun `LaunchExternalLinkParams Builder has setLaunchMode method`() {
+ assertClassHasMethod(
+ "com.android.billingclient.api.LaunchExternalLinkParams\$Builder",
+ "setLaunchMode",
+ Int::class.javaPrimitiveType!!
+ )
+ }
+
+ @Test
+ fun `LaunchExternalLinkParams Builder has setLinkType method`() {
+ assertClassHasMethod(
+ "com.android.billingclient.api.LaunchExternalLinkParams\$Builder",
+ "setLinkType",
+ Int::class.javaPrimitiveType!!
+ )
+ }
+
+ @Test
+ fun `LaunchExternalLinkParams Builder has setLinkUri method`() {
+ assertClassHasMethod(
+ "com.android.billingclient.api.LaunchExternalLinkParams\$Builder",
+ "setLinkUri",
+ android.net.Uri::class.java
+ )
+ }
+
+ @Test
+ fun `LaunchExternalLinkParams Builder has build method`() {
+ assertClassHasMethod(
+ "com.android.billingclient.api.LaunchExternalLinkParams\$Builder",
+ "build"
+ )
+ }
+
+ // ============================================================================
+ // MARK: - LaunchExternalLinkResponseListener (Billing Library 6.0+)
+ // Used in: OpenIapModule.launchExternalLink()
+ // ============================================================================
+
+ @Test
+ fun `LaunchExternalLinkResponseListener class exists`() {
+ assertClassExists(
+ "com.android.billingclient.api.LaunchExternalLinkResponseListener",
+ "6.0+"
+ )
+ }
+
+ @Test
+ fun `LaunchExternalLinkResponseListener has callback method`() {
+ assertClassHasMethod(
+ "com.android.billingclient.api.LaunchExternalLinkResponseListener",
+ "onLaunchExternalLinkResponse",
+ com.android.billingclient.api.BillingResult::class.java
+ )
+ }
+
+ // ============================================================================
+ // MARK: - UserChoiceBillingListener (Billing Library 5.0+)
+ // Used in: OpenIapModule alternative billing mode USER_CHOICE
+ // ============================================================================
+
+ @Test
+ fun `UserChoiceBillingListener class exists`() {
+ assertClassExists(
+ "com.android.billingclient.api.UserChoiceBillingListener",
+ "5.0+"
+ )
+ }
+
+ @Test
+ fun `UserChoiceBillingListener has callback method`() {
+ // The callback receives UserChoiceDetails
+ val listenerClass = Class.forName("com.android.billingclient.api.UserChoiceBillingListener")
+ val methods = listenerClass.methods.filter { it.name == "userSelectedAlternativeBilling" }
+ assertTrue(
+ "userSelectedAlternativeBilling method should exist",
+ methods.isNotEmpty()
+ )
+ }
+
+ // ============================================================================
+ // MARK: - DeveloperProvidedBillingListener (Billing Library 8.3.0+)
+ // Used in: OpenIapModule.enableExternalPaymentsProgram()
+ // ============================================================================
+
+ @Test
+ fun `DeveloperProvidedBillingListener class exists`() {
+ assertClassExists(
+ "com.android.billingclient.api.DeveloperProvidedBillingListener",
+ "8.3.0+"
+ )
+ }
+
+ @Test
+ fun `DeveloperProvidedBillingListener has callback method`() {
+ // The callback receives DeveloperProvidedBillingDetails
+ val listenerClass = Class.forName("com.android.billingclient.api.DeveloperProvidedBillingListener")
+ val methods = listenerClass.methods.filter { it.name == "onUserSelectedDeveloperBilling" }
+ assertTrue(
+ "onUserSelectedDeveloperBilling method should exist",
+ methods.isNotEmpty()
+ )
+ }
+
+ // ============================================================================
+ // MARK: - EnableBillingProgramParams (Billing Library 8.3.0+)
+ // Used in: OpenIapModule.enableExternalPaymentsProgram()
+ // ============================================================================
+
+ @Test
+ fun `EnableBillingProgramParams class exists`() {
+ assertClassExists(
+ "com.android.billingclient.api.EnableBillingProgramParams",
+ "8.3.0+"
+ )
+ }
+
+ @Test
+ fun `EnableBillingProgramParams Builder class exists`() {
+ assertClassExists(
+ "com.android.billingclient.api.EnableBillingProgramParams\$Builder",
+ "8.3.0+"
+ )
+ }
+
+ @Test
+ fun `EnableBillingProgramParams has newBuilder method`() {
+ assertClassHasMethod(
+ "com.android.billingclient.api.EnableBillingProgramParams",
+ "newBuilder"
+ )
+ }
+
+ @Test
+ fun `EnableBillingProgramParams Builder has setBillingProgram method`() {
+ assertClassHasMethod(
+ "com.android.billingclient.api.EnableBillingProgramParams\$Builder",
+ "setBillingProgram",
+ Int::class.javaPrimitiveType!!
+ )
+ }
+
+ @Test
+ fun `EnableBillingProgramParams Builder has setDeveloperProvidedBillingListener method`() {
+ val builderClassName = "com.android.billingclient.api.EnableBillingProgramParams\$Builder"
+ val listenerClassName = "com.android.billingclient.api.DeveloperProvidedBillingListener"
+
+ try {
+ val builderClass = Class.forName(builderClassName)
+ val listenerClass = Class.forName(listenerClassName)
+ val method = builderClass.getMethod("setDeveloperProvidedBillingListener", listenerClass)
+ assertNotNull("setDeveloperProvidedBillingListener method should exist", method)
+ } catch (e: ClassNotFoundException) {
+ fail("Class not found: ${e.message}")
+ } catch (e: NoSuchMethodException) {
+ fail("setDeveloperProvidedBillingListener method not found. Requires Billing Library 8.3.0+")
+ }
+ }
+
+ @Test
+ fun `EnableBillingProgramParams Builder has build method`() {
+ assertClassHasMethod(
+ "com.android.billingclient.api.EnableBillingProgramParams\$Builder",
+ "build"
+ )
+ }
+
+ // ============================================================================
+ // MARK: - DeveloperBillingOptionParams (Billing Library 8.3.0+)
+ // Used in: OpenIapModule.applyDeveloperBillingOption()
+ // ============================================================================
+
+ @Test
+ fun `DeveloperBillingOptionParams class exists`() {
+ assertClassExists(
+ "com.android.billingclient.api.DeveloperBillingOptionParams",
+ "8.3.0+"
+ )
+ }
+
+ @Test
+ fun `DeveloperBillingOptionParams Builder class exists`() {
+ assertClassExists(
+ "com.android.billingclient.api.DeveloperBillingOptionParams\$Builder",
+ "8.3.0+"
+ )
+ }
+
+ @Test
+ fun `DeveloperBillingOptionParams has newBuilder method`() {
+ assertClassHasMethod(
+ "com.android.billingclient.api.DeveloperBillingOptionParams",
+ "newBuilder"
+ )
+ }
+
+ @Test
+ fun `DeveloperBillingOptionParams Builder has setBillingProgram method`() {
+ assertClassHasMethod(
+ "com.android.billingclient.api.DeveloperBillingOptionParams\$Builder",
+ "setBillingProgram",
+ Int::class.javaPrimitiveType!!
+ )
+ }
+
+ @Test
+ fun `DeveloperBillingOptionParams Builder has setLinkUri method`() {
+ assertClassHasMethod(
+ "com.android.billingclient.api.DeveloperBillingOptionParams\$Builder",
+ "setLinkUri",
+ android.net.Uri::class.java
+ )
+ }
+
+ @Test
+ fun `DeveloperBillingOptionParams Builder has setLaunchMode method`() {
+ assertClassHasMethod(
+ "com.android.billingclient.api.DeveloperBillingOptionParams\$Builder",
+ "setLaunchMode",
+ Int::class.javaPrimitiveType!!
+ )
+ }
+
+ @Test
+ fun `DeveloperBillingOptionParams Builder has build method`() {
+ assertClassHasMethod(
+ "com.android.billingclient.api.DeveloperBillingOptionParams\$Builder",
+ "build"
+ )
+ }
+
+ // ============================================================================
+ // MARK: - BillingFlowParams.Builder (for enableDeveloperBillingOption)
+ // Used in: OpenIapModule.applyDeveloperBillingOption()
+ // ============================================================================
+
+ @Test
+ fun `BillingFlowParams Builder has enableDeveloperBillingOption method`() {
+ val builderClassName = "com.android.billingclient.api.BillingFlowParams\$Builder"
+ val paramsClassName = "com.android.billingclient.api.DeveloperBillingOptionParams"
+
+ try {
+ val builderClass = Class.forName(builderClassName)
+ val paramsClass = Class.forName(paramsClassName)
+ val method = builderClass.getMethod("enableDeveloperBillingOption", paramsClass)
+ assertNotNull("enableDeveloperBillingOption method should exist", method)
+ } catch (e: ClassNotFoundException) {
+ fail("Class not found: ${e.message}")
+ } catch (e: NoSuchMethodException) {
+ fail("enableDeveloperBillingOption method not found. Requires Billing Library 8.3.0+")
+ }
+ }
+
+ // ============================================================================
+ // MARK: - BillingClient.Builder (for enableUserChoiceBilling and enableBillingProgram)
+ // Used in: OpenIapModule connection setup
+ // ============================================================================
+
+ @Test
+ fun `BillingClient Builder class exists`() {
+ assertClassExists(
+ "com.android.billingclient.api.BillingClient\$Builder",
+ "any version"
+ )
+ }
+
+ @Test
+ fun `BillingClient Builder has enableUserChoiceBilling method`() {
+ val builderClassName = "com.android.billingclient.api.BillingClient\$Builder"
+ val listenerClassName = "com.android.billingclient.api.UserChoiceBillingListener"
+
+ try {
+ val builderClass = Class.forName(builderClassName)
+ val listenerClass = Class.forName(listenerClassName)
+ val method = builderClass.getMethod("enableUserChoiceBilling", listenerClass)
+ assertNotNull("enableUserChoiceBilling method should exist", method)
+ } catch (e: ClassNotFoundException) {
+ fail("Class not found: ${e.message}")
+ } catch (e: NoSuchMethodException) {
+ fail("enableUserChoiceBilling method not found. Requires Billing Library 5.0+")
+ }
+ }
+
+ @Test
+ fun `BillingClient Builder has enableBillingProgram method`() {
+ val builderClassName = "com.android.billingclient.api.BillingClient\$Builder"
+ val paramsClassName = "com.android.billingclient.api.EnableBillingProgramParams"
+
+ try {
+ val builderClass = Class.forName(builderClassName)
+ val paramsClass = Class.forName(paramsClassName)
+ val method = builderClass.getMethod("enableBillingProgram", paramsClass)
+ assertNotNull("enableBillingProgram method should exist", method)
+ } catch (e: ClassNotFoundException) {
+ fail("Class not found: ${e.message}")
+ } catch (e: NoSuchMethodException) {
+ fail("enableBillingProgram method not found. Requires Billing Library 8.3.0+")
+ }
+ }
+
+ // ============================================================================
+ // MARK: - BillingClient methods (called via reflection)
+ // ============================================================================
+
+ @Test
+ fun `BillingClient has isBillingProgramAvailableAsync method`() {
+ val clientClassName = "com.android.billingclient.api.BillingClient"
+ val listenerClassName = "com.android.billingclient.api.BillingProgramAvailabilityListener"
+
+ try {
+ val clientClass = Class.forName(clientClassName)
+ val listenerClass = Class.forName(listenerClassName)
+ val method = clientClass.getMethod(
+ "isBillingProgramAvailableAsync",
+ Int::class.javaPrimitiveType,
+ listenerClass
+ )
+ assertNotNull("isBillingProgramAvailableAsync method should exist", method)
+ } catch (e: ClassNotFoundException) {
+ fail("Class not found: ${e.message}")
+ } catch (e: NoSuchMethodException) {
+ fail("isBillingProgramAvailableAsync method not found. Requires Billing Library 8.2.0+")
+ }
+ }
+
+ @Test
+ fun `BillingClient has createBillingProgramReportingDetailsAsync method`() {
+ // Billing Library 8.3.0+: Takes (BillingProgramReportingDetailsParams, Listener)
+ val clientClassName = "com.android.billingclient.api.BillingClient"
+ val paramsClassName = "com.android.billingclient.api.BillingProgramReportingDetailsParams"
+ val listenerClassName = "com.android.billingclient.api.BillingProgramReportingDetailsListener"
+
+ try {
+ val clientClass = Class.forName(clientClassName)
+ val paramsClass = Class.forName(paramsClassName)
+ val listenerClass = Class.forName(listenerClassName)
+ val method = clientClass.getMethod(
+ "createBillingProgramReportingDetailsAsync",
+ paramsClass,
+ listenerClass
+ )
+ assertNotNull("createBillingProgramReportingDetailsAsync method should exist", method)
+ } catch (e: ClassNotFoundException) {
+ fail("Class not found: ${e.message}")
+ } catch (e: NoSuchMethodException) {
+ fail("createBillingProgramReportingDetailsAsync(BillingProgramReportingDetailsParams, Listener) not found. Requires Billing Library 8.3.0+")
+ }
+ }
+
+ @Test
+ fun `BillingClient has launchExternalLink method`() {
+ val clientClassName = "com.android.billingclient.api.BillingClient"
+ val paramsClassName = "com.android.billingclient.api.LaunchExternalLinkParams"
+ val listenerClassName = "com.android.billingclient.api.LaunchExternalLinkResponseListener"
+
+ try {
+ val clientClass = Class.forName(clientClassName)
+ val paramsClass = Class.forName(paramsClassName)
+ val listenerClass = Class.forName(listenerClassName)
+ val method = clientClass.getMethod(
+ "launchExternalLink",
+ android.app.Activity::class.java,
+ paramsClass,
+ listenerClass
+ )
+ assertNotNull("launchExternalLink method should exist", method)
+ } catch (e: ClassNotFoundException) {
+ fail("Class not found: ${e.message}")
+ } catch (e: NoSuchMethodException) {
+ fail("launchExternalLink method not found. Requires Billing Library 8.2.0+")
+ }
+ }
+
+ // ============================================================================
+ // MARK: - Core Billing Classes
+ // ============================================================================
+
+ @Test
+ fun `BillingClient class exists`() {
+ assertClassExists("com.android.billingclient.api.BillingClient", "any version")
+ }
+
+ @Test
+ fun `BillingFlowParams class exists`() {
+ assertClassExists("com.android.billingclient.api.BillingFlowParams", "any version")
+ }
+
+ @Test
+ fun `BillingResult class exists`() {
+ assertClassExists("com.android.billingclient.api.BillingResult", "any version")
+ }
+
+ // ============================================================================
+ // MARK: - Helper Methods
+ // ============================================================================
+
+ private fun assertClassExists(className: String, minVersion: String) {
+ try {
+ val clazz = Class.forName(className)
+ assertNotNull("$className should exist", clazz)
+ } catch (e: ClassNotFoundException) {
+ fail("$className not found. Requires Billing Library $minVersion")
+ }
+ }
+
+ private fun assertClassDoesNotExist(className: String) {
+ try {
+ Class.forName(className)
+ fail("Class should NOT exist at: $className")
+ } catch (e: ClassNotFoundException) {
+ // Expected - the class should not exist
+ assertTrue("Class correctly does not exist at $className", true)
+ }
+ }
+
+ private fun assertClassHasMethod(
+ className: String,
+ methodName: String,
+ vararg paramTypes: Class<*>
+ ) {
+ try {
+ val clazz = Class.forName(className)
+ val method = clazz.getMethod(methodName, *paramTypes)
+ assertNotNull("$className.$methodName should exist", method)
+ } catch (e: ClassNotFoundException) {
+ fail("Class not found: $className")
+ } catch (e: NoSuchMethodException) {
+ val params = paramTypes.joinToString(", ") { it.simpleName }
+ fail("Method not found: $className.$methodName($params)")
+ }
+ }
+}
diff --git a/packages/google/openiap/src/test/java/dev/hyo/openiap/StandardizedOfferTypesTest.kt b/packages/google/openiap/src/test/java/dev/hyo/openiap/StandardizedOfferTypesTest.kt
index 90233ba3..c77a5787 100644
--- a/packages/google/openiap/src/test/java/dev/hyo/openiap/StandardizedOfferTypesTest.kt
+++ b/packages/google/openiap/src/test/java/dev/hyo/openiap/StandardizedOfferTypesTest.kt
@@ -404,4 +404,123 @@ class StandardizedOfferTypesTest {
assertEquals("sub_intro", product.subscriptionOffers.first().id)
assertEquals(PaymentMode.FreeTrial, product.subscriptionOffers.first().paymentMode)
}
+
+ // MARK: - RequestPurchaseAndroidProps offerToken Tests
+
+ @Test
+ fun `RequestPurchaseAndroidProps supports offerToken for one-time purchases`() {
+ val props = RequestPurchaseAndroidProps(
+ skus = listOf("premium_upgrade"),
+ offerToken = "discount_offer_token_abc123"
+ )
+
+ assertEquals(listOf("premium_upgrade"), props.skus)
+ assertEquals("discount_offer_token_abc123", props.offerToken)
+ assertNull(props.isOfferPersonalized)
+ assertNull(props.obfuscatedAccountId)
+ }
+
+ @Test
+ fun `RequestPurchaseAndroidProps toJson includes offerToken`() {
+ val props = RequestPurchaseAndroidProps(
+ skus = listOf("product_id"),
+ offerToken = "test_offer_token",
+ isOfferPersonalized = true
+ )
+
+ val json = props.toJson()
+ assertEquals(listOf("product_id"), json["skus"])
+ assertEquals("test_offer_token", json["offerToken"])
+ assertEquals(true, json["isOfferPersonalized"])
+ }
+
+ @Test
+ fun `RequestPurchaseAndroidProps fromJson parses offerToken`() {
+ val json = mapOf(
+ "skus" to listOf("sku_001"),
+ "offerToken" to "parsed_offer_token",
+ "obfuscatedAccountId" to "account_123"
+ )
+
+ val props = RequestPurchaseAndroidProps.fromJson(json)
+ assertEquals(listOf("sku_001"), props?.skus)
+ assertEquals("parsed_offer_token", props?.offerToken)
+ assertEquals("account_123", props?.obfuscatedAccountId)
+ }
+
+ @Test
+ fun `RequestPurchaseAndroidProps allows null offerToken`() {
+ val props = RequestPurchaseAndroidProps(
+ skus = listOf("regular_product")
+ )
+
+ assertNull(props.offerToken)
+
+ val json = props.toJson()
+ assertNull(json["offerToken"])
+ }
+
+ @Test
+ fun `DiscountOffer offerTokenAndroid can be used for purchase`() {
+ // Simulate fetching a product with discount offer
+ val discountOffer = DiscountOffer(
+ id = "summer_sale",
+ displayPrice = "$4.99",
+ price = 4.99,
+ currency = "USD",
+ type = DiscountOfferType.OneTime,
+ offerTokenAndroid = "summer_sale_offer_token_xyz",
+ percentageDiscountAndroid = 50
+ )
+
+ // Create purchase props using the offer token from the discount offer
+ val purchaseProps = RequestPurchaseAndroidProps(
+ skus = listOf("premium_upgrade"),
+ offerToken = discountOffer.offerTokenAndroid
+ )
+
+ assertEquals("summer_sale_offer_token_xyz", purchaseProps.offerToken)
+ assertEquals(discountOffer.offerTokenAndroid, purchaseProps.offerToken)
+ }
+
+ @Test
+ fun `ProductAndroid discountOffers can provide offerTokenAndroid for purchase`() {
+ val discountOffer = DiscountOffer(
+ id = "flash_sale",
+ displayPrice = "$2.99",
+ price = 2.99,
+ currency = "USD",
+ type = DiscountOfferType.OneTime,
+ offerTokenAndroid = "flash_sale_token"
+ )
+
+ val product = ProductAndroid(
+ id = "consumable_gems",
+ title = "100 Gems",
+ description = "A pack of 100 gems",
+ displayName = "Gems Pack",
+ displayPrice = "$4.99",
+ price = 4.99,
+ currency = "USD",
+ platform = IapPlatform.Android,
+ type = ProductType.InApp,
+ nameAndroid = "100 Gems",
+ discountOffers = listOf(discountOffer),
+ subscriptionOffers = null,
+ oneTimePurchaseOfferDetailsAndroid = null,
+ subscriptionOfferDetailsAndroid = null
+ )
+
+ // Get the offer token from product's discount offers
+ val offerToken = product.discountOffers?.firstOrNull()?.offerTokenAndroid
+
+ // Create purchase request with the offer token
+ val purchaseProps = RequestPurchaseAndroidProps(
+ skus = listOf(product.id),
+ offerToken = offerToken
+ )
+
+ assertEquals("consumable_gems", purchaseProps.skus.first())
+ assertEquals("flash_sale_token", purchaseProps.offerToken)
+ }
}
diff --git a/packages/gql/src/generated/Types.kt b/packages/gql/src/generated/Types.kt
index b16f4bda..cb285f31 100644
--- a/packages/gql/src/generated/Types.kt
+++ b/packages/gql/src/generated/Types.kt
@@ -3581,17 +3581,24 @@ public data class RequestPurchaseAndroidProps(
*/
val developerBillingOption: DeveloperBillingOptionParamsAndroid? = null,
/**
- * Personalized offer flag
+ * Personalized offer flag.
+ * When true, indicates the price was customized for this user.
*/
val isOfferPersonalized: Boolean? = null,
/**
* Obfuscated account ID
*/
- val obfuscatedAccountIdAndroid: String? = null,
+ val obfuscatedAccountId: String? = null,
/**
* Obfuscated profile ID
*/
- val obfuscatedProfileIdAndroid: String? = null,
+ val obfuscatedProfileId: String? = null,
+ /**
+ * Offer token for one-time purchase discounts (7.0+).
+ * Pass the offerToken from oneTimePurchaseOfferDetailsAndroid or discountOffers
+ * to apply a discount offer to the purchase.
+ */
+ val offerToken: String? = null,
/**
* List of product SKUs
*/
@@ -3601,15 +3608,17 @@ public data class RequestPurchaseAndroidProps(
fun fromJson(json: Map): RequestPurchaseAndroidProps? {
val developerBillingOption = (json["developerBillingOption"] as? Map)?.let { DeveloperBillingOptionParamsAndroid.fromJson(it) }
val isOfferPersonalized = json["isOfferPersonalized"] as? Boolean
- val obfuscatedAccountIdAndroid = json["obfuscatedAccountIdAndroid"] as? String
- val obfuscatedProfileIdAndroid = json["obfuscatedProfileIdAndroid"] as? String
+ val obfuscatedAccountId = json["obfuscatedAccountId"] as? String
+ val obfuscatedProfileId = json["obfuscatedProfileId"] as? String
+ val offerToken = json["offerToken"] as? String
val skus = (json["skus"] as? List<*>)?.mapNotNull { it as? String }
if (skus == null) return null
return RequestPurchaseAndroidProps(
developerBillingOption = developerBillingOption,
isOfferPersonalized = isOfferPersonalized,
- obfuscatedAccountIdAndroid = obfuscatedAccountIdAndroid,
- obfuscatedProfileIdAndroid = obfuscatedProfileIdAndroid,
+ obfuscatedAccountId = obfuscatedAccountId,
+ obfuscatedProfileId = obfuscatedProfileId,
+ offerToken = offerToken,
skus = skus,
)
}
@@ -3618,8 +3627,9 @@ public data class RequestPurchaseAndroidProps(
fun toJson(): Map = mapOf(
"developerBillingOption" to developerBillingOption?.toJson(),
"isOfferPersonalized" to isOfferPersonalized,
- "obfuscatedAccountIdAndroid" to obfuscatedAccountIdAndroid,
- "obfuscatedProfileIdAndroid" to obfuscatedProfileIdAndroid,
+ "obfuscatedAccountId" to obfuscatedAccountId,
+ "obfuscatedProfileId" to obfuscatedProfileId,
+ "offerToken" to offerToken,
"skus" to skus,
)
}
@@ -3790,26 +3800,27 @@ public data class RequestSubscriptionAndroidProps(
*/
val developerBillingOption: DeveloperBillingOptionParamsAndroid? = null,
/**
- * Personalized offer flag
+ * Personalized offer flag.
+ * When true, indicates the price was customized for this user.
*/
val isOfferPersonalized: Boolean? = null,
/**
* Obfuscated account ID
*/
- val obfuscatedAccountIdAndroid: String? = null,
+ val obfuscatedAccountId: String? = null,
/**
* Obfuscated profile ID
*/
- val obfuscatedProfileIdAndroid: String? = null,
+ val obfuscatedProfileId: String? = null,
/**
* Purchase token for upgrades/downgrades
*/
- val purchaseTokenAndroid: String? = null,
+ val purchaseToken: String? = null,
/**
* Replacement mode for subscription changes
* @deprecated Use subscriptionProductReplacementParams instead for item-level replacement (8.1.0+)
*/
- val replacementModeAndroid: Int? = null,
+ val replacementMode: Int? = null,
/**
* List of subscription SKUs
*/
@@ -3820,7 +3831,7 @@ public data class RequestSubscriptionAndroidProps(
val subscriptionOffers: List? = null,
/**
* Product-level replacement parameters (8.1.0+)
- * Use this instead of replacementModeAndroid for item-level replacement
+ * Use this instead of replacementMode for item-level replacement
*/
val subscriptionProductReplacementParams: SubscriptionProductReplacementParamsAndroid? = null
) {
@@ -3828,10 +3839,10 @@ public data class RequestSubscriptionAndroidProps(
fun fromJson(json: Map): RequestSubscriptionAndroidProps? {
val developerBillingOption = (json["developerBillingOption"] as? Map)?.let { DeveloperBillingOptionParamsAndroid.fromJson(it) }
val isOfferPersonalized = json["isOfferPersonalized"] as? Boolean
- val obfuscatedAccountIdAndroid = json["obfuscatedAccountIdAndroid"] as? String
- val obfuscatedProfileIdAndroid = json["obfuscatedProfileIdAndroid"] as? String
- val purchaseTokenAndroid = json["purchaseTokenAndroid"] as? String
- val replacementModeAndroid = (json["replacementModeAndroid"] as? Number)?.toInt()
+ val obfuscatedAccountId = json["obfuscatedAccountId"] as? String
+ val obfuscatedProfileId = json["obfuscatedProfileId"] as? String
+ val purchaseToken = json["purchaseToken"] as? String
+ val replacementMode = (json["replacementMode"] as? Number)?.toInt()
val skus = (json["skus"] as? List<*>)?.mapNotNull { it as? String }
val subscriptionOffers = (json["subscriptionOffers"] as? List<*>)?.mapNotNull { (it as? Map)?.let { AndroidSubscriptionOfferInput.fromJson(it) } }
val subscriptionProductReplacementParams = (json["subscriptionProductReplacementParams"] as? Map)?.let { SubscriptionProductReplacementParamsAndroid.fromJson(it) }
@@ -3839,10 +3850,10 @@ public data class RequestSubscriptionAndroidProps(
return RequestSubscriptionAndroidProps(
developerBillingOption = developerBillingOption,
isOfferPersonalized = isOfferPersonalized,
- obfuscatedAccountIdAndroid = obfuscatedAccountIdAndroid,
- obfuscatedProfileIdAndroid = obfuscatedProfileIdAndroid,
- purchaseTokenAndroid = purchaseTokenAndroid,
- replacementModeAndroid = replacementModeAndroid,
+ obfuscatedAccountId = obfuscatedAccountId,
+ obfuscatedProfileId = obfuscatedProfileId,
+ purchaseToken = purchaseToken,
+ replacementMode = replacementMode,
skus = skus,
subscriptionOffers = subscriptionOffers,
subscriptionProductReplacementParams = subscriptionProductReplacementParams,
@@ -3853,10 +3864,10 @@ public data class RequestSubscriptionAndroidProps(
fun toJson(): Map = mapOf(
"developerBillingOption" to developerBillingOption?.toJson(),
"isOfferPersonalized" to isOfferPersonalized,
- "obfuscatedAccountIdAndroid" to obfuscatedAccountIdAndroid,
- "obfuscatedProfileIdAndroid" to obfuscatedProfileIdAndroid,
- "purchaseTokenAndroid" to purchaseTokenAndroid,
- "replacementModeAndroid" to replacementModeAndroid,
+ "obfuscatedAccountId" to obfuscatedAccountId,
+ "obfuscatedProfileId" to obfuscatedProfileId,
+ "purchaseToken" to purchaseToken,
+ "replacementMode" to replacementMode,
"skus" to skus,
"subscriptionOffers" to subscriptionOffers?.map { it.toJson() },
"subscriptionProductReplacementParams" to subscriptionProductReplacementParams?.toJson(),
diff --git a/packages/gql/src/generated/Types.swift b/packages/gql/src/generated/Types.swift
index 19407cb1..0ce716da 100644
--- a/packages/gql/src/generated/Types.swift
+++ b/packages/gql/src/generated/Types.swift
@@ -1383,26 +1383,33 @@ public struct RequestPurchaseAndroidProps: Codable {
/// When provided, the purchase flow will show a side-by-side choice between
/// Google Play Billing and the developer's external payment option.
public var developerBillingOption: DeveloperBillingOptionParamsAndroid?
- /// Personalized offer flag
+ /// Personalized offer flag.
+ /// When true, indicates the price was customized for this user.
public var isOfferPersonalized: Bool?
/// Obfuscated account ID
- public var obfuscatedAccountIdAndroid: String?
+ public var obfuscatedAccountId: String?
/// Obfuscated profile ID
- public var obfuscatedProfileIdAndroid: String?
+ public var obfuscatedProfileId: String?
+ /// Offer token for one-time purchase discounts (7.0+).
+ /// Pass the offerToken from oneTimePurchaseOfferDetailsAndroid or discountOffers
+ /// to apply a discount offer to the purchase.
+ public var offerToken: String?
/// List of product SKUs
public var skus: [String]
public init(
developerBillingOption: DeveloperBillingOptionParamsAndroid? = nil,
isOfferPersonalized: Bool? = nil,
- obfuscatedAccountIdAndroid: String? = nil,
- obfuscatedProfileIdAndroid: String? = nil,
+ obfuscatedAccountId: String? = nil,
+ obfuscatedProfileId: String? = nil,
+ offerToken: String? = nil,
skus: [String]
) {
self.developerBillingOption = developerBillingOption
self.isOfferPersonalized = isOfferPersonalized
- self.obfuscatedAccountIdAndroid = obfuscatedAccountIdAndroid
- self.obfuscatedProfileIdAndroid = obfuscatedProfileIdAndroid
+ self.obfuscatedAccountId = obfuscatedAccountId
+ self.obfuscatedProfileId = obfuscatedProfileId
+ self.offerToken = offerToken
self.skus = skus
}
}
@@ -1546,42 +1553,43 @@ public struct RequestSubscriptionAndroidProps: Codable {
/// When provided, the purchase flow will show a side-by-side choice between
/// Google Play Billing and the developer's external payment option.
public var developerBillingOption: DeveloperBillingOptionParamsAndroid?
- /// Personalized offer flag
+ /// Personalized offer flag.
+ /// When true, indicates the price was customized for this user.
public var isOfferPersonalized: Bool?
/// Obfuscated account ID
- public var obfuscatedAccountIdAndroid: String?
+ public var obfuscatedAccountId: String?
/// Obfuscated profile ID
- public var obfuscatedProfileIdAndroid: String?
+ public var obfuscatedProfileId: String?
/// Purchase token for upgrades/downgrades
- public var purchaseTokenAndroid: String?
+ public var purchaseToken: String?
/// Replacement mode for subscription changes
/// @deprecated Use subscriptionProductReplacementParams instead for item-level replacement (8.1.0+)
- public var replacementModeAndroid: Int?
+ public var replacementMode: 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
+ /// Use this instead of replacementMode for item-level replacement
public var subscriptionProductReplacementParams: SubscriptionProductReplacementParamsAndroid?
public init(
developerBillingOption: DeveloperBillingOptionParamsAndroid? = nil,
isOfferPersonalized: Bool? = nil,
- obfuscatedAccountIdAndroid: String? = nil,
- obfuscatedProfileIdAndroid: String? = nil,
- purchaseTokenAndroid: String? = nil,
- replacementModeAndroid: Int? = nil,
+ obfuscatedAccountId: String? = nil,
+ obfuscatedProfileId: String? = nil,
+ purchaseToken: String? = nil,
+ replacementMode: Int? = nil,
skus: [String],
subscriptionOffers: [AndroidSubscriptionOfferInput]? = nil,
subscriptionProductReplacementParams: SubscriptionProductReplacementParamsAndroid? = nil
) {
self.developerBillingOption = developerBillingOption
self.isOfferPersonalized = isOfferPersonalized
- self.obfuscatedAccountIdAndroid = obfuscatedAccountIdAndroid
- self.obfuscatedProfileIdAndroid = obfuscatedProfileIdAndroid
- self.purchaseTokenAndroid = purchaseTokenAndroid
- self.replacementModeAndroid = replacementModeAndroid
+ self.obfuscatedAccountId = obfuscatedAccountId
+ self.obfuscatedProfileId = obfuscatedProfileId
+ self.purchaseToken = purchaseToken
+ self.replacementMode = replacementMode
self.skus = skus
self.subscriptionOffers = subscriptionOffers
self.subscriptionProductReplacementParams = subscriptionProductReplacementParams
diff --git a/packages/gql/src/generated/types.dart b/packages/gql/src/generated/types.dart
index b873b3ef..373737f3 100644
--- a/packages/gql/src/generated/types.dart
+++ b/packages/gql/src/generated/types.dart
@@ -3602,8 +3602,9 @@ class RequestPurchaseAndroidProps {
const RequestPurchaseAndroidProps({
this.developerBillingOption,
this.isOfferPersonalized,
- this.obfuscatedAccountIdAndroid,
- this.obfuscatedProfileIdAndroid,
+ this.obfuscatedAccountId,
+ this.obfuscatedProfileId,
+ this.offerToken,
required this.skus,
});
@@ -3611,12 +3612,17 @@ class RequestPurchaseAndroidProps {
/// When provided, the purchase flow will show a side-by-side choice between
/// Google Play Billing and the developer's external payment option.
final DeveloperBillingOptionParamsAndroid? developerBillingOption;
- /// Personalized offer flag
+ /// Personalized offer flag.
+ /// When true, indicates the price was customized for this user.
final bool? isOfferPersonalized;
/// Obfuscated account ID
- final String? obfuscatedAccountIdAndroid;
+ final String? obfuscatedAccountId;
/// Obfuscated profile ID
- final String? obfuscatedProfileIdAndroid;
+ final String? obfuscatedProfileId;
+ /// Offer token for one-time purchase discounts (7.0+).
+ /// Pass the offerToken from oneTimePurchaseOfferDetailsAndroid or discountOffers
+ /// to apply a discount offer to the purchase.
+ final String? offerToken;
/// List of product SKUs
final List skus;
@@ -3624,8 +3630,9 @@ class RequestPurchaseAndroidProps {
return RequestPurchaseAndroidProps(
developerBillingOption: json['developerBillingOption'] != null ? DeveloperBillingOptionParamsAndroid.fromJson(json['developerBillingOption'] as Map) : null,
isOfferPersonalized: json['isOfferPersonalized'] as bool?,
- obfuscatedAccountIdAndroid: json['obfuscatedAccountIdAndroid'] as String?,
- obfuscatedProfileIdAndroid: json['obfuscatedProfileIdAndroid'] as String?,
+ obfuscatedAccountId: json['obfuscatedAccountId'] as String?,
+ obfuscatedProfileId: json['obfuscatedProfileId'] as String?,
+ offerToken: json['offerToken'] as String?,
skus: (json['skus'] as List).map((e) => e as String).toList(),
);
}
@@ -3634,8 +3641,9 @@ class RequestPurchaseAndroidProps {
return {
'developerBillingOption': developerBillingOption?.toJson(),
'isOfferPersonalized': isOfferPersonalized,
- 'obfuscatedAccountIdAndroid': obfuscatedAccountIdAndroid,
- 'obfuscatedProfileIdAndroid': obfuscatedProfileIdAndroid,
+ 'obfuscatedAccountId': obfuscatedAccountId,
+ 'obfuscatedProfileId': obfuscatedProfileId,
+ 'offerToken': offerToken,
'skus': skus,
};
}
@@ -3797,10 +3805,10 @@ class RequestSubscriptionAndroidProps {
const RequestSubscriptionAndroidProps({
this.developerBillingOption,
this.isOfferPersonalized,
- this.obfuscatedAccountIdAndroid,
- this.obfuscatedProfileIdAndroid,
- this.purchaseTokenAndroid,
- this.replacementModeAndroid,
+ this.obfuscatedAccountId,
+ this.obfuscatedProfileId,
+ this.purchaseToken,
+ this.replacementMode,
required this.skus,
this.subscriptionOffers,
this.subscriptionProductReplacementParams,
@@ -3810,33 +3818,34 @@ class RequestSubscriptionAndroidProps {
/// When provided, the purchase flow will show a side-by-side choice between
/// Google Play Billing and the developer's external payment option.
final DeveloperBillingOptionParamsAndroid? developerBillingOption;
- /// Personalized offer flag
+ /// Personalized offer flag.
+ /// When true, indicates the price was customized for this user.
final bool? isOfferPersonalized;
/// Obfuscated account ID
- final String? obfuscatedAccountIdAndroid;
+ final String? obfuscatedAccountId;
/// Obfuscated profile ID
- final String? obfuscatedProfileIdAndroid;
+ final String? obfuscatedProfileId;
/// Purchase token for upgrades/downgrades
- final String? purchaseTokenAndroid;
+ final String? purchaseToken;
/// Replacement mode for subscription changes
/// @deprecated Use subscriptionProductReplacementParams instead for item-level replacement (8.1.0+)
- final int? replacementModeAndroid;
+ final int? replacementMode;
/// 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
+ /// Use this instead of replacementMode for item-level replacement
final SubscriptionProductReplacementParamsAndroid? subscriptionProductReplacementParams;
factory RequestSubscriptionAndroidProps.fromJson(Map json) {
return RequestSubscriptionAndroidProps(
developerBillingOption: json['developerBillingOption'] != null ? DeveloperBillingOptionParamsAndroid.fromJson(json['developerBillingOption'] as Map) : null,
isOfferPersonalized: json['isOfferPersonalized'] as bool?,
- obfuscatedAccountIdAndroid: json['obfuscatedAccountIdAndroid'] as String?,
- obfuscatedProfileIdAndroid: json['obfuscatedProfileIdAndroid'] as String?,
- purchaseTokenAndroid: json['purchaseTokenAndroid'] as String?,
- replacementModeAndroid: json['replacementModeAndroid'] as int?,
+ obfuscatedAccountId: json['obfuscatedAccountId'] as String?,
+ obfuscatedProfileId: json['obfuscatedProfileId'] as String?,
+ purchaseToken: json['purchaseToken'] as String?,
+ replacementMode: json['replacementMode'] 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,
@@ -3847,10 +3856,10 @@ class RequestSubscriptionAndroidProps {
return {
'developerBillingOption': developerBillingOption?.toJson(),
'isOfferPersonalized': isOfferPersonalized,
- 'obfuscatedAccountIdAndroid': obfuscatedAccountIdAndroid,
- 'obfuscatedProfileIdAndroid': obfuscatedProfileIdAndroid,
- 'purchaseTokenAndroid': purchaseTokenAndroid,
- 'replacementModeAndroid': replacementModeAndroid,
+ 'obfuscatedAccountId': obfuscatedAccountId,
+ 'obfuscatedProfileId': obfuscatedProfileId,
+ 'purchaseToken': purchaseToken,
+ 'replacementMode': replacementMode,
'skus': skus,
'subscriptionOffers': subscriptionOffers == null ? null : subscriptionOffers!.map((e) => e.toJson()).toList(),
'subscriptionProductReplacementParams': subscriptionProductReplacementParams?.toJson(),
diff --git a/packages/gql/src/generated/types.gd b/packages/gql/src/generated/types.gd
index 3642e4f7..46e41e1e 100644
--- a/packages/gql/src/generated/types.gd
+++ b/packages/gql/src/generated/types.gd
@@ -3195,11 +3195,13 @@ class RequestPurchaseAndroidProps:
## List of product SKUs
var skus: Array[String]
## Obfuscated account ID
- var obfuscated_account_id_android: String
+ var obfuscated_account_id: String
## Obfuscated profile ID
- var obfuscated_profile_id_android: String
- ## Personalized offer flag
+ var obfuscated_profile_id: String
+ ## Personalized offer flag.
var is_offer_personalized: bool
+ ## Offer token for one-time purchase discounts (7.0+).
+ var offer_token: String
## Developer billing option parameters for external payments flow (8.3.0+).
var developer_billing_option: DeveloperBillingOptionParamsAndroid
@@ -3207,12 +3209,14 @@ class RequestPurchaseAndroidProps:
var obj = RequestPurchaseAndroidProps.new()
if data.has("skus") and data["skus"] != null:
obj.skus = data["skus"]
- if data.has("obfuscatedAccountIdAndroid") and data["obfuscatedAccountIdAndroid"] != null:
- obj.obfuscated_account_id_android = data["obfuscatedAccountIdAndroid"]
- if data.has("obfuscatedProfileIdAndroid") and data["obfuscatedProfileIdAndroid"] != null:
- obj.obfuscated_profile_id_android = data["obfuscatedProfileIdAndroid"]
+ if data.has("obfuscatedAccountId") and data["obfuscatedAccountId"] != null:
+ obj.obfuscated_account_id = data["obfuscatedAccountId"]
+ if data.has("obfuscatedProfileId") and data["obfuscatedProfileId"] != null:
+ obj.obfuscated_profile_id = data["obfuscatedProfileId"]
if data.has("isOfferPersonalized") and data["isOfferPersonalized"] != null:
obj.is_offer_personalized = data["isOfferPersonalized"]
+ if data.has("offerToken") and data["offerToken"] != null:
+ obj.offer_token = data["offerToken"]
if data.has("developerBillingOption") and data["developerBillingOption"] != null:
if data["developerBillingOption"] is Dictionary:
obj.developer_billing_option = DeveloperBillingOptionParamsAndroid.from_dict(data["developerBillingOption"])
@@ -3224,12 +3228,14 @@ class RequestPurchaseAndroidProps:
var dict = {}
if skus != null:
dict["skus"] = skus
- if obfuscated_account_id_android != null:
- dict["obfuscatedAccountIdAndroid"] = obfuscated_account_id_android
- if obfuscated_profile_id_android != null:
- dict["obfuscatedProfileIdAndroid"] = obfuscated_profile_id_android
+ if obfuscated_account_id != null:
+ dict["obfuscatedAccountId"] = obfuscated_account_id
+ if obfuscated_profile_id != null:
+ dict["obfuscatedProfileId"] = obfuscated_profile_id
if is_offer_personalized != null:
dict["isOfferPersonalized"] = is_offer_personalized
+ if offer_token != null:
+ dict["offerToken"] = offer_token
if developer_billing_option != null:
if developer_billing_option.has_method("to_dict"):
dict["developerBillingOption"] = developer_billing_option.to_dict()
@@ -3405,15 +3411,15 @@ class RequestSubscriptionAndroidProps:
## List of subscription SKUs
var skus: Array[String]
## Obfuscated account ID
- var obfuscated_account_id_android: String
+ var obfuscated_account_id: String
## Obfuscated profile ID
- var obfuscated_profile_id_android: String
- ## Personalized offer flag
+ var obfuscated_profile_id: String
+ ## Personalized offer flag.
var is_offer_personalized: bool
## Purchase token for upgrades/downgrades
- var purchase_token_android: String
+ var purchase_token: String
## Replacement mode for subscription changes
- var replacement_mode_android: int
+ var replacement_mode: int
## Subscription offers
var subscription_offers: Array[AndroidSubscriptionOfferInput]
## Product-level replacement parameters (8.1.0+)
@@ -3425,16 +3431,16 @@ class RequestSubscriptionAndroidProps:
var obj = RequestSubscriptionAndroidProps.new()
if data.has("skus") and data["skus"] != null:
obj.skus = data["skus"]
- if data.has("obfuscatedAccountIdAndroid") and data["obfuscatedAccountIdAndroid"] != null:
- obj.obfuscated_account_id_android = data["obfuscatedAccountIdAndroid"]
- if data.has("obfuscatedProfileIdAndroid") and data["obfuscatedProfileIdAndroid"] != null:
- obj.obfuscated_profile_id_android = data["obfuscatedProfileIdAndroid"]
+ if data.has("obfuscatedAccountId") and data["obfuscatedAccountId"] != null:
+ obj.obfuscated_account_id = data["obfuscatedAccountId"]
+ if data.has("obfuscatedProfileId") and data["obfuscatedProfileId"] != null:
+ obj.obfuscated_profile_id = data["obfuscatedProfileId"]
if data.has("isOfferPersonalized") and data["isOfferPersonalized"] != null:
obj.is_offer_personalized = data["isOfferPersonalized"]
- if data.has("purchaseTokenAndroid") and data["purchaseTokenAndroid"] != null:
- obj.purchase_token_android = data["purchaseTokenAndroid"]
- if data.has("replacementModeAndroid") and data["replacementModeAndroid"] != null:
- obj.replacement_mode_android = data["replacementModeAndroid"]
+ if data.has("purchaseToken") and data["purchaseToken"] != null:
+ obj.purchase_token = data["purchaseToken"]
+ if data.has("replacementMode") and data["replacementMode"] != null:
+ obj.replacement_mode = data["replacementMode"]
if data.has("subscriptionOffers") and data["subscriptionOffers"] != null:
var arr = []
for item in data["subscriptionOffers"]:
@@ -3459,16 +3465,16 @@ class RequestSubscriptionAndroidProps:
var dict = {}
if skus != null:
dict["skus"] = skus
- if obfuscated_account_id_android != null:
- dict["obfuscatedAccountIdAndroid"] = obfuscated_account_id_android
- if obfuscated_profile_id_android != null:
- dict["obfuscatedProfileIdAndroid"] = obfuscated_profile_id_android
+ if obfuscated_account_id != null:
+ dict["obfuscatedAccountId"] = obfuscated_account_id
+ if obfuscated_profile_id != null:
+ dict["obfuscatedProfileId"] = obfuscated_profile_id
if is_offer_personalized != null:
dict["isOfferPersonalized"] = is_offer_personalized
- if purchase_token_android != null:
- dict["purchaseTokenAndroid"] = purchase_token_android
- if replacement_mode_android != null:
- dict["replacementModeAndroid"] = replacement_mode_android
+ if purchase_token != null:
+ dict["purchaseToken"] = purchase_token
+ if replacement_mode != null:
+ dict["replacementMode"] = replacement_mode
if subscription_offers != null:
var arr = []
for item in subscription_offers:
diff --git a/packages/gql/src/generated/types.ts b/packages/gql/src/generated/types.ts
index c646d5bc..3478d6e1 100644
--- a/packages/gql/src/generated/types.ts
+++ b/packages/gql/src/generated/types.ts
@@ -1194,12 +1194,21 @@ export interface RequestPurchaseAndroidProps {
* Google Play Billing and the developer's external payment option.
*/
developerBillingOption?: (DeveloperBillingOptionParamsAndroid | null);
- /** Personalized offer flag */
+ /**
+ * Personalized offer flag.
+ * When true, indicates the price was customized for this user.
+ */
isOfferPersonalized?: (boolean | null);
/** Obfuscated account ID */
- obfuscatedAccountIdAndroid?: (string | null);
+ obfuscatedAccountId?: (string | null);
/** Obfuscated profile ID */
- obfuscatedProfileIdAndroid?: (string | null);
+ obfuscatedProfileId?: (string | null);
+ /**
+ * Offer token for one-time purchase discounts (7.0+).
+ * Pass the offerToken from oneTimePurchaseOfferDetailsAndroid or discountOffers
+ * to apply a discount offer to the purchase.
+ */
+ offerToken?: (string | null);
/** List of product SKUs */
skus: string[];
}
@@ -1271,26 +1280,29 @@ export interface RequestSubscriptionAndroidProps {
* Google Play Billing and the developer's external payment option.
*/
developerBillingOption?: (DeveloperBillingOptionParamsAndroid | null);
- /** Personalized offer flag */
+ /**
+ * Personalized offer flag.
+ * When true, indicates the price was customized for this user.
+ */
isOfferPersonalized?: (boolean | null);
/** Obfuscated account ID */
- obfuscatedAccountIdAndroid?: (string | null);
+ obfuscatedAccountId?: (string | null);
/** Obfuscated profile ID */
- obfuscatedProfileIdAndroid?: (string | null);
+ obfuscatedProfileId?: (string | null);
/** Purchase token for upgrades/downgrades */
- purchaseTokenAndroid?: (string | null);
+ purchaseToken?: (string | null);
/**
* Replacement mode for subscription changes
* @deprecated Use subscriptionProductReplacementParams instead for item-level replacement (8.1.0+)
*/
- replacementModeAndroid?: (number | null);
+ replacementMode?: (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
+ * Use this instead of replacementMode for item-level replacement
*/
subscriptionProductReplacementParams?: (SubscriptionProductReplacementParamsAndroid | null);
}
diff --git a/packages/gql/src/type-android.graphql b/packages/gql/src/type-android.graphql
index 128c6875..01ce9611 100644
--- a/packages/gql/src/type-android.graphql
+++ b/packages/gql/src/type-android.graphql
@@ -355,16 +355,23 @@ input RequestPurchaseAndroidProps {
"""
Obfuscated account ID
"""
- obfuscatedAccountIdAndroid: String
+ obfuscatedAccountId: String
"""
Obfuscated profile ID
"""
- obfuscatedProfileIdAndroid: String
+ obfuscatedProfileId: String
"""
- Personalized offer flag
+ Personalized offer flag.
+ When true, indicates the price was customized for this user.
"""
isOfferPersonalized: Boolean
"""
+ Offer token for one-time purchase discounts (7.0+).
+ Pass the offerToken from oneTimePurchaseOfferDetailsAndroid or discountOffers
+ to apply a discount offer to the purchase.
+ """
+ offerToken: String
+ """
Developer billing option parameters for external payments flow (8.3.0+).
When provided, the purchase flow will show a side-by-side choice between
Google Play Billing and the developer's external payment option.
@@ -380,31 +387,32 @@ input RequestSubscriptionAndroidProps {
"""
Obfuscated account ID
"""
- obfuscatedAccountIdAndroid: String
+ obfuscatedAccountId: String
"""
Obfuscated profile ID
"""
- obfuscatedProfileIdAndroid: String
+ obfuscatedProfileId: String
"""
- Personalized offer flag
+ Personalized offer flag.
+ When true, indicates the price was customized for this user.
"""
isOfferPersonalized: Boolean
"""
Purchase token for upgrades/downgrades
"""
- purchaseTokenAndroid: String
+ purchaseToken: String
"""
Replacement mode for subscription changes
@deprecated Use subscriptionProductReplacementParams instead for item-level replacement (8.1.0+)
"""
- replacementModeAndroid: Int
+ replacementMode: Int
"""
Subscription offers
"""
subscriptionOffers: [AndroidSubscriptionOfferInput!]
"""
Product-level replacement parameters (8.1.0+)
- Use this instead of replacementModeAndroid for item-level replacement
+ Use this instead of replacementMode for item-level replacement
"""
subscriptionProductReplacementParams: SubscriptionProductReplacementParamsAndroid
"""
|