diff --git a/knowledge/_claude-context/context.md b/knowledge/_claude-context/context.md index bef00f74..2ec75d8c 100644 --- a/knowledge/_claude-context/context.md +++ b/knowledge/_claude-context/context.md @@ -1,7 +1,7 @@ # OpenIAP Project Context > **Auto-generated for Claude Code** -> Last updated: 2026-01-18T13:00:35.017Z +> Last updated: 2026-01-20T01:17:35.950Z > > Usage: `claude --context knowledge/_claude-context/context.md` @@ -72,6 +72,64 @@ fun buildModuleAndroid() **Exception**: Only use `Android` suffix for types that are part of a cross-platform API (e.g., `ProductAndroid`, `PurchaseAndroid` that contrast with iOS types). +## Platform-Specific Field Naming (CRITICAL) + +> **This is the most commonly violated rule. Pay extra attention.** + +### GraphQL Input Types (API Fields) + +Fields inside platform-specific input types do NOT need platform suffix (the type name already indicates the platform): + +```graphql +# CORRECT - Fields inside AndroidProps don't need Android suffix +input RequestPurchaseAndroidProps { + skus: [String!]! # Cross-platform, no suffix + offerToken: String # No suffix - already in Android type + isOfferPersonalized: Boolean # No suffix - already in Android type + obfuscatedAccountId: String # No suffix - already in Android type + obfuscatedProfileId: String # No suffix - already in Android type + developerBillingOption: DeveloperBillingOptionParamsAndroid # Type has suffix (cross-platform type) +} + +# INCORRECT - Redundant Android suffix inside Android-specific type +input RequestPurchaseAndroidProps { + offerTokenAndroid: String # ❌ Redundant - type already indicates Android + isOfferPersonalizedAndroid: Boolean # ❌ Redundant - type already indicates Android +} +``` + +### Why This Matters + +1. **Parent type context**: `RequestPurchaseAndroidProps` already indicates Android +2. **Cleaner API**: `google: { offerToken: "..." }` is cleaner than `google: { offerTokenAndroid: "..." }` +3. **Type names still use suffix**: Cross-platform types like `DeveloperBillingOptionParamsAndroid` keep the suffix + +### Field Suffix Rules + +| Field Location | Suffix Required? | Example | +|----------------|------------------|---------| +| Inside Android-only input type | NO | `offerToken` in `RequestPurchaseAndroidProps` | +| Inside iOS-only input type | NO | `appAccountToken` in `RequestPurchaseIOSProps` | +| Cross-platform type | YES for platform-specific | `nameAndroid` in `ProductAndroid` | +| Cross-platform type reference | YES | `developerBillingOption: DeveloperBillingOptionParamsAndroid` | +| Internal implementation | NO (not API) | `val offerToken` in Kotlin data class | + +### Internal vs API Fields + +- **API fields** (GraphQL schema): ALWAYS use platform suffix +- **Internal fields** (Kotlin/Swift data classes not exposed): No suffix needed + +```kotlin +// Internal helper data class - no suffix needed +internal data class AndroidPurchaseArgs( + val offerToken: String?, // Internal, no suffix OK + val isOfferPersonalized: Boolean? // Internal, no suffix OK +) + +// But when reading from API props, use the suffixed names: +val offerToken = params.offerTokenAndroid // ✓ API uses suffix +``` + ### Cross-Platform Functions Functions available on BOTH platforms have **NO** platform suffix: @@ -705,6 +763,60 @@ swift test # Run tests swift build # Build package ``` +### Objective-C Bridge (CRITICAL for kmp-iap) + +**IMPORTANT**: When updating iOS functions in `OpenIapModule.swift`, you **MUST** also update `OpenIapModule+ObjC.swift`. + +The Objective-C bridge (`OpenIapModule+ObjC.swift`) exposes Swift async functions to Objective-C/Kotlin for: +- **kmp-iap** (Kotlin Multiplatform via cinterop) +- Any other platform that requires Objective-C interoperability + +#### When to Update ObjC Bridge + +Update `OpenIapModule+ObjC.swift` when: +- [ ] Adding new public functions to `OpenIapModule.swift` +- [ ] Changing function signatures (parameters, return types) +- [ ] Adding new input options or parameters +- [ ] Changing existing function behavior + +#### Bridge Pattern + +Every Swift async function needs an Objective-C completion handler wrapper: + +```swift +// In OpenIapModule.swift (Swift async) +public func newFeatureIOS(param: String) async throws -> ResultType { + // implementation +} + +// In OpenIapModule+ObjC.swift (ObjC bridge - MUST ADD) +@objc func newFeatureIOSWithParam( + _ param: String, + completion: @escaping (Any?, Error?) -> Void +) { + Task { + do { + let result = try await newFeatureIOS(param: param) + let dictionary = OpenIapSerialization.encode(result) + completion(dictionary, nil) + } catch { + completion(nil, error) + } + } +} +``` + +#### Files to Update Together + +| Swift Function Changed | ObjC Bridge Required | +|------------------------|----------------------| +| `OpenIapModule.swift` | `OpenIapModule+ObjC.swift` | + +**Verification**: After updating, run: +```bash +swift build # Verifies ObjC bridge compiles +``` + --- ## Google Package (packages/google) @@ -1094,6 +1206,21 @@ This will: - **Separate versioning**: Apple and Google packages have independent versions - **Swift Package Manager**: Automatically works via Git tags, no separate deployment step +--- + +## Version File Management + +### openiap-versions.json + +**CRITICAL: NEVER manually edit `openiap-versions.json`** + +This file is automatically managed by CI/CD workflows during releases: +- Apple releases update `apple` version +- Google releases update `google` version +- GQL releases update `gql` and `docs` versions + +Manual edits will cause version conflicts and deployment issues. Always use the GitHub Actions workflows to update versions. + --- @@ -1490,15 +1617,15 @@ await endConnection(); Google Play Billing Library enables in-app purchases and subscriptions on Android devices. -## Google Play Billing Version History +## Version History | Version | Release Date | Key Features | |---------|--------------|--------------| -| 8.0 | 2025-06-30 | One-time product improvements, multiple purchase options/offers for one-time products, product-level status for unfetched products | -| 8.1 | 2025-11-06 | Minor release with bug fixes and improvements | +| 8.0 | 2025-06-30 | Auto-reconnect, product-level status codes, one-time products with multiple offers, sub-response codes | +| 8.1 | 2025-11-06 | Suspended subscriptions (`isSuspended`), `includeSuspended` parameter, pre-order details, product-level subscription replacement, `KEEP_EXISTING` mode | | 8.2 | 2025-12-09 | Billing Programs API (external content links, external offers), deprecates old External Offers API | | 8.2.1 | 2025-12-15 | Bug fix for `isBillingProgramAvailableAsync()` and `createBillingProgramReportingDetailsAsync()` | -| 8.3 | 2025-12-23 | External Payments program, developer billing options | +| 8.3 | 2025-12-23 | External Payments program (Japan only), developer billing options | **Current Version**: 8.3.0 (as of January 2026) @@ -2571,7 +2698,7 @@ This document provides external API reference for Apple's StoreKit 2 framework. | UI context for purchases | iOS 18.2 | Required for proper payment sheet display | | External purchase notice | iOS 18.2 | `presentExternalPurchaseNoticeSheetIOS` | | `appTransactionID` | iOS 18.4 | Globally unique app transaction identifier (back-deployed to iOS 15) | -| `originalPlatform` | iOS 18.4 | Original purchase platform | +| `originalPlatform` | iOS 18.4 | Original purchase platform (back-deployed to iOS 15) | | `Offer.Period` | iOS 18.4 | Offer period information | | `advancedCommerceInfo` | iOS 18.4 | Advanced Commerce API data | | Expanded offer codes | iOS 18.4 | For consumables/non-consumables | @@ -2762,10 +2889,8 @@ let result = try await product.purchase(confirmIn: window) ```swift let appTransaction = try await AppTransaction.shared -// appTransactionID: New in iOS 18.4 (back-deployed to iOS 15) +// New in iOS 18.4 (back-deployed to iOS 15) let appTransactionID = appTransaction.appTransactionID // Globally unique per Apple Account - -// originalPlatform: New in iOS 18.4 (iOS 18.4+ only, NOT back-deployed) let originalPlatform = appTransaction.originalPlatform // Original purchase platform ``` diff --git a/knowledge/internal/01-naming-conventions.md b/knowledge/internal/01-naming-conventions.md index 9fb3f863..a0c2516d 100644 --- a/knowledge/internal/01-naming-conventions.md +++ b/knowledge/internal/01-naming-conventions.md @@ -54,6 +54,65 @@ fun buildModuleAndroid() **Exception**: Only use `Android` suffix for types that are part of a cross-platform API (e.g., `ProductAndroid`, `PurchaseAndroid` that contrast with iOS types). +## Platform-Specific Field Naming (CRITICAL) + +> **This is the most commonly violated rule. Pay extra attention.** + +### GraphQL Input Types (API Fields) + +Fields inside platform-specific input types do NOT need platform suffix (the type name already indicates the platform): + +```graphql +# CORRECT - Fields inside AndroidProps don't need Android suffix +input RequestPurchaseAndroidProps { + skus: [String!]! # Cross-platform, no suffix + offerToken: String # No suffix - already in Android type + isOfferPersonalized: Boolean # No suffix - already in Android type + obfuscatedAccountId: String # No suffix - already in Android type + obfuscatedProfileId: String # No suffix - already in Android type + developerBillingOption: DeveloperBillingOptionParamsAndroid # Type has suffix (cross-platform type) +} + +# INCORRECT - Redundant Android suffix inside Android-specific type +input RequestPurchaseAndroidProps { + offerTokenAndroid: String # ❌ Redundant - type already indicates Android + isOfferPersonalizedAndroid: Boolean # ❌ Redundant - type already indicates Android +} +``` + +### Why This Matters + +1. **Parent type context**: `RequestPurchaseAndroidProps` already indicates Android +2. **Cleaner API**: `google: { offerToken: "..." }` is cleaner than `google: { offerTokenAndroid: "..." }` +3. **Type names still use suffix**: Cross-platform types like `DeveloperBillingOptionParamsAndroid` keep the suffix + +### Field Suffix Rules + +| Field Location | Suffix Required? | Example | +|----------------|------------------|---------| +| Inside Android-only input type | NO | `offerToken` in `RequestPurchaseAndroidProps` | +| Inside iOS-only input type | NO | `appAccountToken` in `RequestPurchaseIOSProps` | +| Cross-platform type | YES for platform-specific | `nameAndroid` in `ProductAndroid` | +| Cross-platform type reference | YES | `developerBillingOption: DeveloperBillingOptionParamsAndroid` | +| Internal implementation | NO (not API) | `val offerToken` in Kotlin data class | + +### Type vs Field Suffix + +- **Type names**: Cross-platform types ALWAYS use platform suffix (`DeveloperBillingOptionParamsAndroid`) +- **Fields in platform-specific inputs**: NO suffix needed (parent type indicates platform) +- **Fields in cross-platform types**: Use suffix for platform-specific fields + +```kotlin +// Cross-platform SDK usage +requestPurchase { + google { + skus = listOf("product_id") + offerToken = "discount_offer_token" // ✓ Clean - no redundant suffix + isOfferPersonalized = false + } +} +``` + ### Cross-Platform Functions Functions available on BOTH platforms have **NO** platform suffix: diff --git a/packages/apple/README.md b/packages/apple/README.md index b2518bb9..e24a3e45 100644 --- a/packages/apple/README.md +++ b/packages/apple/README.md @@ -2,12 +2,12 @@
OpenIAP Apple Logo -
-
- A comprehensive Swift implementation of the OpenIAP specification for iOS, macOS, tvOS, and watchOS applications. +

Swift implementation of the OpenIAP specification for iOS, macOS, tvOS, and watchOS.

+
+
Swift Package @@ -20,34 +20,19 @@
-
- -**OpenIAP** is a unified specification for in-app purchases across platforms, frameworks, and emerging technologies. This Apple ecosystem implementation standardizes IAP implementations to reduce fragmentation and enable consistent behavioral across all Apple platforms. - -In the AI coding era, having a unified IAP specification becomes increasingly important as developers build applications across multiple platforms and frameworks with automated tools. +## Documentation -## 🌐 Learn More +Visit [**openiap.dev**](https://openiap.dev) for complete documentation, API reference, guides, and examples. -Visit [**openiap.dev**](https://openiap.dev) for complete documentation, guides, and the full OpenIAP specification. +## Features -## ✨ Features +- StoreKit 2 support (iOS 15+) +- Cross-platform (iOS, macOS, tvOS, watchOS) +- Thread-safe with MainActor isolation +- Automatic transaction verification +- Event-driven purchase observation -- ✅ **StoreKit 2** support with full iOS 15+ compatibility -- ✅ **Cross-platform** support (iOS, macOS, tvOS, watchOS) -- ✅ **Thread-safe** operations with MainActor isolation -- ✅ **Explicit connection management** with automatic listener cleanup -- ✅ **Multiple API levels** - Use `OpenIapModule.shared` or `OpenIapStore` -- ✅ **Product management** with intelligent caching -- ✅ **Purchase handling** with automatic transaction verification - - Processes only StoreKit 2 verified transactions and emits updates. -- ✅ **Subscription management** with cancel/reactivate support - - Opens App Store manage subscriptions UI for user cancel/reactivate and detects state changes. -- ✅ **Receipt validation** and transaction security - - Provides Base64 receipt and JWS; verifies latest transaction via StoreKit and supports server-side validation. -- ✅ **Event-driven** purchase observation -- ✅ **Swift Package Manager** and **CocoaPods** support - -## 📋 Requirements +## Requirements | Platform | Minimum Version | | -------- | --------------- | @@ -57,11 +42,11 @@ Visit [**openiap.dev**](https://openiap.dev) for complete documentation, guides, | watchOS | 8.0+ | | Swift | 5.9+ | -## 📦 Installation +## Installation ### Swift Package Manager -Add OpenIAP to your `Package.swift`: +Add to your `Package.swift`: ```swift dependencies: [ @@ -69,12 +54,6 @@ dependencies: [ ] ``` -Or through Xcode: - -1. **File** → **Add Package Dependencies** -2. Enter: `https://github.com/hyodotdev/openiap.git` -3. Select version and add to your target - ### CocoaPods Add to your `Podfile`: @@ -83,21 +62,9 @@ Add to your `Podfile`: pod 'openiap', '~> $version' ``` -Then run: - -```bash -pod install -``` - -> 📌 **Latest Version**: Check [`openiap-versions.json`](../../openiap-versions.json) for the current version, or see the badges above. - -## 🚀 Quick Start +> Check [`openiap-versions.json`](../../openiap-versions.json) for the current version. -OpenIAP provides multiple ways to integrate in-app purchases, from super simple one-liners to advanced control. Choose the approach that fits your needs! - -### Option 1: Shared Instance (Simplest) - -Use `OpenIapModule.shared` for quick integration: +## Quick Start ```swift import OpenIAP @@ -112,301 +79,18 @@ let products = try await module.fetchProducts( ProductRequest(skus: ["premium", "coins"], type: .all) ) -// Make a purchase -let purchase = try await module.requestPurchase( - let purchase = try await module.requestPurchase(RequestPurchaseProps(request: .purchase(RequestPurchasePropsByPlatforms(android: nil, ios: RequestPurchaseIosProps(andDangerouslyFinishTransactionAutomatically: nil, appAccountToken: nil, quantity: 1, sku: "premium", withOffer: nil))), type: .inApp)) -) - -// Get available/restored purchases -let restored = try await module.getAvailablePurchases(nil) - // End connection when done _ = try await module.endConnection() ``` -### Option 2: OpenIapStore (SwiftUI Ready) - -For more control while keeping it simple: - -```swift -import OpenIAP - -@MainActor -class StoreViewModel: ObservableObject { - private let iapStore: OpenIapStore - - init() { - // Setup store with event handlers - self.iapStore = OpenIapStore( - onPurchaseSuccess: { purchase in - print("Purchase successful: \(purchase.productId)") - }, - onPurchaseError: { error in - print("Purchase failed: \(error.message)") - } - ) - - Task { - // Initialize connection - try await iapStore.initConnection() - - // Fetch products - try await iapStore.fetchProducts( - skus: ["product1", "product2"], - type: .inApp - ) - } - } - - deinit { - Task { - // End connection when done - try await iapStore.endConnection() - } - } -} -``` - -### Option 3: OpenIapModule Direct (Low-level) - -For complete control over the purchase flow: - -```swift -import OpenIAP - -@MainActor -func setupStore() async throws { - let module = OpenIapModule.shared - - // Initialize connection first - _ = try await module.initConnection() - - // Setup listeners - let subscription = module.purchaseUpdatedListener { purchase in - print("Purchase updated: \(purchase.productId)") - } - - // Fetch and purchase - let request = ProductRequest(skus: ["premium"], type: .all) - let products = try await module.fetchProducts(request) - - let purchase = try await store.requestPurchase(sku: "premium") - let purchase = try await module.requestPurchase(props) - - // When done, clean up - module.removeListener(subscription) - _ = try await module.endConnection() -} -``` - -## 🎯 API Architecture - -OpenIAP now has a **simplified, minimal API** with just 2 main components: - -### Core Components - -1. **OpenIapModule** (`OpenIapModule.swift`) - - - Core StoreKit 2 implementation - - Shared instance for simple usage - - Low-level instance methods for advanced control - -2. **OpenIapStore** (`OpenIapStore.swift`) - - SwiftUI-ready with `@Published` properties - - Explicit connection management (initConnection/endConnection) - - Event callbacks for purchase success/error - - Perfect for MVVM architecture - -### Why This Design? - -- **No Duplication**: Each component has a distinct purpose -- **Flexibility**: Use the shared module or the SwiftUI store -- **Simplicity**: Only 2 files to understand instead of 4+ -- **Compatibility**: Maintains openiap.dev spec compliance - -## 🧪 Testing - -### Run Tests - -```bash -# Via Swift Package Manager -swift test - -# Via Xcode -⌘U (Product → Test) -``` - -### Test with Sandbox - -1. Configure your products in **App Store Connect** -2. Create a **Sandbox Apple ID** -3. Use test card: `4242 4242 4242 4242` - -### Server-Side Validation - -OpenIAP provides comprehensive transaction verification with server-side receipt validation: - -```swift -let store = OpenIapStore() -try await store.initConnection() - -// Request purchase (validate server-side first) -let purchase = try await store.requestPurchase( - try await store.requestPurchase(sku: "dev.hyo.premium") -) - -// Validate on your server using purchase.purchaseToken -// Then finish the transaction manually -_ = try await store.finishTransaction(purchase: purchase, isConsumable: false) -``` - -## 🔄 Connection Management - -The library provides explicit connection management with automatic listener cleanup. - -### Key Benefits - -1. **Explicit Connection Control**: You decide when to connect and disconnect -2. **Automatic Listener Cleanup**: Listeners are cleaned up on endConnection() -3. **Built-in Event Handling**: Purchase success/error callbacks are managed for you -4. **SwiftUI Ready**: Published properties for reactive UI updates -5. **Simplified API**: All common operations with sensible defaults - -### Usage Pattern - -```swift -class StoreViewModel: ObservableObject { - private let iapStore = OpenIapStore() - - init() { - Task { - // Initialize connection - try await iapStore.initConnection() - - // Fetch products - try await iapStore.fetchProducts(skus: productIds) - } - } - - deinit { - Task { - // End connection (listeners cleaned up automatically) - try await iapStore.endConnection() - } - } -} -``` - -## 📚 Data Models - -Our Swift data models are generated from the shared GraphQL schema in the [`openiap` monorepo](https://github.com/hyodotdev/openiap/tree/main/packages/gql). Run `./scripts/generate-types.sh` to update `Sources/Models/Types.swift`, and every consumer—including the example app—should rely on those generated definitions instead of hand-written structs. - -
-ProductIOS snapshot - -```swift -struct ProductIOS { - let id: String - let title: String - let description: String - let type: ProductType - let displayPrice: String - let currency: String - let price: Double? - let platform: IapPlatform - - // iOS-specific properties - let displayNameIOS: String - let typeIOS: ProductTypeIOS - let subscriptionInfoIOS: SubscriptionInfoIOS? - let discountsIOS: [DiscountIOS]? - let isFamilyShareableIOS: Bool -} -``` - -
- -
-PurchaseIOS snapshot - -```swift -struct PurchaseIOS { - let id: String - let productId: String - let transactionDate: Double - let purchaseToken: String? - let purchaseState: PurchaseState - let isAutoRenewing: Bool - let quantity: Int - let platform: IapPlatform - - // iOS-specific properties - let appAccountToken: String? - let environmentIOS: String? - let storefrontCountryCodeIOS: String? - let subscriptionGroupIdIOS: String? - let transactionReasonIOS: String? - let offerIOS: PurchaseOfferIOS? -} -``` - -
- -### DiscountOffer +For detailed usage, see the [documentation](https://openiap.dev). -```swift -struct DiscountOffer { - let identifier: String - let keyIdentifier: String - let nonce: String - let signature: String - let timestamp: String -} -``` +## License -## ⚡ Error Handling - -OpenIAP provides comprehensive error handling: - -```swift -// Unified error model -struct PurchaseError: LocalizedError { - let code: String - let message: String - let productId: String? +MIT License - see [LICENSE](../../LICENSE) for details. - var errorDescription: String? { message } -} +## Support -// Create errors with predefined codes -let error = PurchaseError(code: "E_USER_CANCELLED", message: "User cancelled the purchase") -``` - -## 🤝 Contributing - -We welcome contributions! Please see our [Contributing Guidelines](CONTRIBUTING.md) for details. - -## 📄 License - -This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) file for details. - -## 🔄 Best Practices - -1. **Choose the right API level**: Use `OpenIapModule.shared` for simple flows, or `OpenIapStore` for SwiftUI apps -2. **Handle errors appropriately**: Always check for user cancellations vs actual errors -3. **Validate receipts server-side**: Use `andDangerouslyFinishTransactionAutomatically: false` for server validation -4. **Test with Sandbox**: Always test purchases in App Store Connect Sandbox environment -5. **Monitor events**: Set up purchase listeners before making purchases - -## 💬 Support - -- 📖 **Documentation**: [openiap.dev](https://openiap.dev) -- 🐛 **Bug Reports**: [GitHub Issues](https://github.com/hyodotdev/openiap/issues) -- 💡 **Feature Requests**: [GitHub Discussions](https://github.com/hyodotdev/openiap/discussions) -- 💬 **Community**: [Discord](https://discord.gg/openiap) (Coming Soon) - ---- - -
- 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/apple/Sources/Models/Types.swift b/packages/apple/Sources/Models/Types.swift index 19407cb1..0ce716da 100644 --- a/packages/apple/Sources/Models/Types.swift +++ b/packages/apple/Sources/Models/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/docs/public/llms-full.txt b/packages/docs/public/llms-full.txt index 4b8d40cf..b01960bc 100644 --- a/packages/docs/public/llms-full.txt +++ b/packages/docs/public/llms-full.txt @@ -3,7 +3,7 @@ > OpenIAP: Unified in-app purchase specification for iOS & Android > Documentation: https://openiap.dev > Quick Reference: https://openiap.dev/llms.txt -> Generated: 2026-01-18T13:00:35.102Z +> Generated: 2026-01-20T01:17:35.964Z ## Table of Contents 1. Installation diff --git a/packages/docs/public/llms.txt b/packages/docs/public/llms.txt index 3e545b9a..f74823ba 100644 --- a/packages/docs/public/llms.txt +++ b/packages/docs/public/llms.txt @@ -3,7 +3,7 @@ > OpenIAP: Unified in-app purchase specification for iOS & Android > Documentation: https://openiap.dev > Full Reference: https://openiap.dev/llms-full.txt -> Generated: 2026-01-18T13:00:35.102Z +> Generated: 2026-01-20T01:17:35.964Z ## Installation diff --git a/packages/docs/src/pages/docs/apis/android.tsx b/packages/docs/src/pages/docs/apis/android.tsx index 694438ed..42122044 100644 --- a/packages/docs/src/pages/docs/apis/android.tsx +++ b/packages/docs/src/pages/docs/apis/android.tsx @@ -353,6 +353,13 @@ suspend fun launchExternalLink( Step 3: Create reporting details after successful payment. Returns external transaction token for reporting.

+
+

+ Note: This API uses{' '} + BillingProgramReportingDetailsParams internally, which + requires Billing Library 8.3.0+. OpenIAP handles this automatically. +

+
{`// Returns BillingProgramReportingDetailsAndroid with externalTransactionToken // Token must be reported to Google Play backend within 24 hours // Throws OpenIapError.NotPrepared if billing client not ready diff --git a/packages/docs/src/pages/docs/features/discount.tsx b/packages/docs/src/pages/docs/features/discount.tsx index a428fc9a..bf78b34c 100644 --- a/packages/docs/src/pages/docs/features/discount.tsx +++ b/packages/docs/src/pages/docs/features/discount.tsx @@ -853,11 +853,13 @@ async function purchaseWithOffer( const selectedOffer = offers[offerIndex]; await requestPurchase({ - type: 'inapp', + type: 'in-app', request: { - skus: [product.id], - // Include offerToken for discounted purchases - offerToken: selectedOffer.offerToken, + google: { + skus: [product.id], + // Include offerTokenAndroid for discounted purchases (Android 7.0+) + offerToken: selectedOffer.offerToken, + }, }, }); }`} @@ -881,11 +883,14 @@ async function purchaseWithOffer( iapStore.requestPurchase( activity = activity, props = RequestPurchaseProps( - type = "inapp", - request = RequestPurchasePropsByPlatforms( - android = RequestPurchaseAndroidProps( - skus = listOf(product.id), - offerToken = selectedOffer.offerToken + type = ProductQueryType.InApp, + request = RequestPurchaseProps.Request.Purchase( + RequestPurchasePropsByPlatforms( + google = RequestPurchaseAndroidProps( + skus = listOf(product.id), + // Include offerTokenAndroid for discounted purchases (Android 7.0+) + offerToken = selectedOffer.offerToken + ) ) ) ) diff --git a/packages/docs/src/pages/docs/features/subscription-upgrade-downgrade.tsx b/packages/docs/src/pages/docs/features/subscription-upgrade-downgrade.tsx index a290fedc..613cec2f 100644 --- a/packages/docs/src/pages/docs/features/subscription-upgrade-downgrade.tsx +++ b/packages/docs/src/pages/docs/features/subscription-upgrade-downgrade.tsx @@ -1212,8 +1212,8 @@ if (currentSub) { // Upgrade to premium with time proration await requestPurchase({ sku: 'premium_monthly', - purchaseTokenAndroid: currentSub.purchaseToken, - replacementModeAndroid: 1, // WITH_TIME_PRORATION + purchaseToken: currentSub.purchaseToken, + replacementMode: 1, // WITH_TIME_PRORATION }); console.log('✅ Upgrade initiated'); @@ -1376,8 +1376,8 @@ if (premiumPurchase) { // Downgrade - takes effect at next billing cycle await requestPurchase({ sku: 'basic_monthly', - purchaseTokenAndroid: premiumPurchase.purchaseToken, - replacementModeAndroid: 6, // DEFERRED - Change at renewal + purchaseToken: premiumPurchase.purchaseToken, + replacementMode: 6, // DEFERRED - Change at renewal }); console.log('✅ Downgrade scheduled for next billing cycle'); @@ -1703,7 +1703,7 @@ for purchase in purchases:
  1. Specify replacement mode when needed: Pass{' '} - replacementModeAndroid when you want to + replacementMode when you want to override the default configured in Google Play Console
  2. @@ -1764,8 +1764,8 @@ async function changeSubscription( try { await requestPurchase({ sku: newSku, - purchaseTokenAndroid: currentSub.purchaseToken, - replacementModeAndroid: replacementMode, + purchaseToken: currentSub.purchaseToken, + replacementMode: replacementMode, }); // If DEFERRED, store pending change in your backend diff --git a/packages/docs/src/pages/docs/types/request.tsx b/packages/docs/src/pages/docs/types/request.tsx index 8514ceef..90f1659d 100644 --- a/packages/docs/src/pages/docs/types/request.tsx +++ b/packages/docs/src/pages/docs/types/request.tsx @@ -525,13 +525,24 @@ await iap.request_purchase(subs_props)`} - obfuscatedAccountIdAndroid + offerToken + + + Offer token for one-time purchase discounts (7.0+). + Pass the offerToken from{' '} + oneTimePurchaseOfferDetailsAndroid or{' '} + discountOffers to apply a discount. + + + + + obfuscatedAccountId Obfuscated user account ID - obfuscatedProfileIdAndroid + obfuscatedProfileId Obfuscated user profile ID @@ -583,13 +594,13 @@ await iap.request_purchase(subs_props)`} - purchaseTokenAndroid + purchaseToken Existing subscription token for upgrade/downgrade - 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
  3. - developerBillingOption{' '} + developerBillingOptionAndroid{' '} - New field in RequestPurchaseAndroidProps and RequestSubscriptionAndroidProps
  4. 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 @@
    OpenIAP Google Logo - +

    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 """