Skip to content

Commit ea2cb78

Browse files
hyochanclaude
andauthored
feat: sync with openiap v1.3.15 (#610)
## Summary - Sync with OpenIAP v1.3.15 (gql: 1.3.15, google: 1.3.26, apple: 1.3.13) - Simplified input field names for Android request props (per OpenIAP naming convention) - Added `offerToken` support for one-time purchase discounts (Android 7.0+) ## Changes ### Field Name Simplification Input fields in Android-specific request props now use simplified names (without redundant `Android` suffix): | Old Name | New Name | |----------|----------| | `obfuscatedAccountIdAndroid` | `obfuscatedAccountId` | | `obfuscatedProfileIdAndroid` | `obfuscatedProfileId` | | `purchaseTokenAndroid` | `purchaseToken` | | `replacementModeAndroid` | `replacementMode` | ### New Feature: One-Time Purchase Discounts Added `offerToken` field to `RequestPurchaseAndroidProps` for applying discounts to one-time (non-subscription) purchases (Android 7.0+). ### Files Changed - `openiap-versions.json` - Updated version tracking - `lib/types.dart` - Regenerated with new field names - `lib/flutter_inapp_purchase.dart` - Updated to pass new field names to native - `lib/builders.dart` - Updated builder classes with new field names - `android/.../AndroidInappPurchasePlugin.kt` - Updated native code to handle new fields - `test/builders_unit_test.dart` - Updated tests - `test/flutter_inapp_purchase_channel_test.dart` - Updated tests - `docs/blog/2026-01-20-8.2.3.release.md` - Added release blog post ## Test Plan - [x] `flutter analyze` passes - [x] `flutter test` passes (198 tests) - [x] Blog post created ## OpenIAP Versions | Package | Previous | Current | |---------|----------|---------| | openiap-gql | 1.3.14 | 1.3.15 | | openiap-google | 1.3.25 | 1.3.26 | | openiap-apple | 1.3.12 | 1.3.13 | 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added support for one-time purchase discounts on Android 7.0+ using offer tokens * **Breaking Changes** * Simplified Android field names by removing "Android" suffixes across purchase and subscription configurations; migration guide included in documentation * **Dependencies** * Updated underlying billing library versions for improved Google Play integration * **Documentation** * Added comprehensive release notes with field migration examples and builder usage updates <sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub> <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent a17c28e commit ea2cb78

File tree

10 files changed

+265
-104
lines changed

10 files changed

+265
-104
lines changed

android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -135,8 +135,9 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act
135135
obfuscatedProfileId: String?,
136136
isOfferPersonalized: Boolean,
137137
subscriptionOffers: List<AndroidSubscriptionOfferInput>,
138-
purchaseTokenAndroid: String?,
139-
replacementModeAndroid: Int?,
138+
purchaseToken: String?,
139+
replacementMode: Int?,
140+
offerToken: String? = null,
140141
developerBillingOption: DeveloperBillingOptionParamsAndroid? = null,
141142
subscriptionProductReplacementParams: SubscriptionProductReplacementParamsAndroid? = null
142143
): RequestPurchaseProps {
@@ -154,8 +155,8 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act
154155

155156
return when (type) {
156157
ProductQueryType.Subs -> {
157-
purchaseTokenAndroid?.let { androidPayload[KEY_PURCHASE_TOKEN] = it }
158-
replacementModeAndroid?.let { androidPayload[KEY_REPLACEMENT_MODE] = it }
158+
purchaseToken?.let { androidPayload[KEY_PURCHASE_TOKEN] = it }
159+
replacementMode?.let { androidPayload[KEY_REPLACEMENT_MODE] = it }
159160
subscriptionProductReplacementParams?.let {
160161
androidPayload[KEY_SUBSCRIPTION_PRODUCT_REPLACEMENT_PARAMS] = it.toJson()
161162
}
@@ -166,6 +167,8 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act
166167
RequestPurchaseProps.fromJson(root)
167168
}
168169
ProductQueryType.InApp -> {
170+
// offerToken for one-time purchase discounts (Android 7.0+)
171+
offerToken?.let { androidPayload[KEY_OFFER_TOKEN] = it }
169172
root[KEY_REQUEST_PURCHASE] = mapOf(KEY_ANDROID to androidPayload)
170173
RequestPurchaseProps.fromJson(root)
171174
}
@@ -439,12 +442,16 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act
439442
?: emptyList()
440443
val skusNormalized = skus.filter { it.isNotBlank() }
441444
val obfuscatedAccountId =
442-
(params["obfuscatedAccountIdAndroid"] ?: params["obfuscatedAccountId"]) as? String
445+
(params["obfuscatedAccountId"] ?: params["obfuscatedAccountIdAndroid"]) as? String
443446
val obfuscatedProfileId =
444-
(params["obfuscatedProfileIdAndroid"] ?: params["obfuscatedProfileId"]) as? String
447+
(params["obfuscatedProfileId"] ?: params["obfuscatedProfileIdAndroid"]) as? String
445448
val isOfferPersonalized = params["isOfferPersonalized"] as? Boolean ?: false
446-
val purchaseTokenAndroid = params["purchaseTokenAndroid"] as? String
447-
val replacementModeAndroid = (params["replacementModeAndroid"] as? Number)?.toInt()
449+
val purchaseTokenAndroid =
450+
(params["purchaseToken"] ?: params["purchaseTokenAndroid"]) as? String
451+
val replacementModeAndroid =
452+
((params["replacementMode"] ?: params["replacementModeAndroid"]) as? Number)?.toInt()
453+
// offerToken for one-time purchase discounts (Android 7.0+)
454+
val offerToken = params["offerToken"] as? String
448455
val useAlternativeBilling = params["useAlternativeBilling"] as? Boolean
449456

450457
// Parse developerBillingOption for External Payments (8.3.0+)
@@ -548,8 +555,9 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act
548555
obfuscatedProfileId = obfuscatedProfileId,
549556
isOfferPersonalized = isOfferPersonalized,
550557
subscriptionOffers = offers,
551-
purchaseTokenAndroid = purchaseTokenAndroid,
552-
replacementModeAndroid = replacementModeAndroid,
558+
purchaseToken = purchaseTokenAndroid,
559+
replacementMode = replacementModeAndroid,
560+
offerToken = offerToken,
553561
developerBillingOption = developerBillingOption,
554562
subscriptionProductReplacementParams = subscriptionProductReplacementParams
555563
)
@@ -928,8 +936,8 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act
928936
obfuscatedProfileId = obfuscatedProfileId,
929937
isOfferPersonalized = isOfferPersonalized,
930938
subscriptionOffers = emptyList(),
931-
purchaseTokenAndroid = null,
932-
replacementModeAndroid = null
939+
purchaseToken = null,
940+
replacementMode = null
933941
)
934942

935943
iap.requestPurchase(requestProps)
@@ -1148,6 +1156,10 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act
11481156
}
11491157

11501158
val props = dev.hyo.openiap.VerifyPurchaseWithProviderProps.fromJson(propsMap)
1159+
if (props == null) {
1160+
safe.error(OpenIapError.DeveloperError.CODE, "Invalid props for verifyPurchaseWithProvider", null)
1161+
return@withBillingReady
1162+
}
11511163
val result = iap.verifyPurchaseWithProvider(props)
11521164

11531165
// Convert result to JSON
@@ -1284,10 +1296,12 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act
12841296
private const val KEY_TYPE = "type"
12851297
private const val KEY_SKUS = "skus"
12861298
private const val KEY_IS_OFFER_PERSONALIZED = "isOfferPersonalized"
1287-
private const val KEY_OBFUSCATED_ACCOUNT = "obfuscatedAccountIdAndroid"
1288-
private const val KEY_OBFUSCATED_PROFILE = "obfuscatedProfileIdAndroid"
1289-
private const val KEY_PURCHASE_TOKEN = "purchaseTokenAndroid"
1290-
private const val KEY_REPLACEMENT_MODE = "replacementModeAndroid"
1299+
// Input field names use simplified naming (without Android suffix) per OpenIAP 1.3.15+
1300+
private const val KEY_OBFUSCATED_ACCOUNT = "obfuscatedAccountId"
1301+
private const val KEY_OBFUSCATED_PROFILE = "obfuscatedProfileId"
1302+
private const val KEY_PURCHASE_TOKEN = "purchaseToken"
1303+
private const val KEY_REPLACEMENT_MODE = "replacementMode"
1304+
private const val KEY_OFFER_TOKEN = "offerToken"
12911305
private const val KEY_SUBSCRIPTION_OFFERS = "subscriptionOffers"
12921306
private const val KEY_DEVELOPER_BILLING_OPTION = "developerBillingOption"
12931307
private const val KEY_SUBSCRIPTION_PRODUCT_REPLACEMENT_PARAMS = "subscriptionProductReplacementParams"
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
---
2+
slug: 8.2.3-release
3+
title: "8.2.3 - One-Time Purchase Discounts and Simplified Field Names"
4+
authors: [hyochan]
5+
tags: [release, android, billing]
6+
---
7+
8+
v8.2.3 syncs with [OpenIAP v1.3.15](https://www.openiap.dev/docs/updates/notes#gql-1-3-15-google-1-3-26-apple-1-3-13) introducing one-time purchase discount support for Android 7.0+ and simplified input field naming conventions.
9+
10+
<!-- truncate -->
11+
12+
## New Features
13+
14+
### One-Time Purchase Discounts (Android 7.0+)
15+
16+
Apply discount offers to one-time (non-subscription) purchases using the new `offerToken` field:
17+
18+
```dart
19+
// Fetch products and get available discount offers
20+
final products = await iap.fetchProducts(
21+
skus: ['premium_unlock'],
22+
type: ProductQueryType.InApp,
23+
);
24+
25+
final product = products.firstWhere((p) => p.id == 'premium_unlock');
26+
27+
// Check for available discount offers
28+
if (product is ProductAndroid) {
29+
final discountOffer = product.oneTimePurchaseOfferDetailsAndroid
30+
?.discountOffers
31+
?.firstOrNull;
32+
33+
if (discountOffer != null) {
34+
// Purchase with discount applied
35+
await iap.requestPurchase(
36+
RequestPurchaseProps.inApp((
37+
apple: RequestPurchaseIosProps(sku: 'premium_unlock'),
38+
google: RequestPurchaseAndroidProps(
39+
skus: ['premium_unlock'],
40+
offerToken: discountOffer.offerToken, // Apply discount
41+
),
42+
useAlternativeBilling: null,
43+
)),
44+
);
45+
}
46+
}
47+
```
48+
49+
### Simplified Input Field Names
50+
51+
Input field names for Android-specific request props have been simplified by removing redundant `Android` suffixes. This follows the OpenIAP naming convention where input types inside platform-specific props don't need platform suffixes (they're already scoped to Android).
52+
53+
**Migration Guide:**
54+
55+
```dart
56+
// Before (8.2.2 and earlier)
57+
RequestPurchaseAndroidProps(
58+
skus: ['sku'],
59+
obfuscatedAccountIdAndroid: 'account-123',
60+
obfuscatedProfileIdAndroid: 'profile-456',
61+
);
62+
63+
RequestSubscriptionAndroidProps(
64+
skus: ['sub'],
65+
purchaseTokenAndroid: 'token',
66+
replacementModeAndroid: 1,
67+
obfuscatedAccountIdAndroid: 'account-123',
68+
obfuscatedProfileIdAndroid: 'profile-456',
69+
);
70+
71+
// After (8.2.3+)
72+
RequestPurchaseAndroidProps(
73+
skus: ['sku'],
74+
obfuscatedAccountId: 'account-123',
75+
obfuscatedProfileId: 'profile-456',
76+
offerToken: 'discount-token', // NEW: for one-time purchase discounts
77+
);
78+
79+
RequestSubscriptionAndroidProps(
80+
skus: ['sub'],
81+
purchaseToken: 'token',
82+
replacementMode: 1,
83+
obfuscatedAccountId: 'account-123',
84+
obfuscatedProfileId: 'profile-456',
85+
);
86+
```
87+
88+
**Builder Updates:**
89+
90+
```dart
91+
// Before
92+
final builder = RequestPurchaseAndroidBuilder()
93+
..skus = ['sku']
94+
..obfuscatedAccountIdAndroid = 'account-123'
95+
..obfuscatedProfileIdAndroid = 'profile-456';
96+
97+
// After
98+
final builder = RequestPurchaseAndroidBuilder()
99+
..skus = ['sku']
100+
..obfuscatedAccountId = 'account-123'
101+
..obfuscatedProfileId = 'profile-456'
102+
..offerToken = 'discount-token';
103+
```
104+
105+
## Field Changes Summary
106+
107+
### RequestPurchaseAndroidProps
108+
109+
| Old Name | New Name |
110+
|----------|----------|
111+
| `obfuscatedAccountIdAndroid` | `obfuscatedAccountId` |
112+
| `obfuscatedProfileIdAndroid` | `obfuscatedProfileId` |
113+
| - | `offerToken` (NEW) |
114+
115+
### RequestSubscriptionAndroidProps
116+
117+
| Old Name | New Name |
118+
|----------|----------|
119+
| `obfuscatedAccountIdAndroid` | `obfuscatedAccountId` |
120+
| `obfuscatedProfileIdAndroid` | `obfuscatedProfileId` |
121+
| `purchaseTokenAndroid` | `purchaseToken` |
122+
| `replacementModeAndroid` | `replacementMode` |
123+
124+
## Dependencies
125+
126+
| Package | Previous | Current |
127+
|---------|----------|---------|
128+
| openiap-gql | 1.3.14 | 1.3.15 |
129+
| openiap-google | 1.3.25 | 1.3.26 |
130+
| openiap-apple | 1.3.12 | 1.3.13 |
131+
132+
## References
133+
134+
- [OpenIAP Release Notes](https://www.openiap.dev/docs/updates/notes#gql-1-3-15-google-1-3-26-apple-1-3-13)
135+
- [Google Play Billing 7.0 One-Time Purchase Discounts](https://developer.android.com/google/play/billing/migrate-billing-7)

docs/static/llms-full.txt

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1429,16 +1429,14 @@ class SubscriptionService {
14291429
}
14301430

14311431
Future<void> upgradeSubscription(String newProductId, String oldPurchaseToken) async {
1432-
await iap.requestPurchaseWithBuilder(
1433-
build: (builder) {
1434-
builder
1435-
..type = ProductQueryType.Subs
1436-
..ios.sku = newProductId
1437-
..android.skus = [newProductId]
1438-
..android.purchaseTokenAndroid = oldPurchaseToken
1439-
..android.replacementModeAndroid = 2; // CHARGE_PRORATED_PRICE
1440-
},
1441-
);
1432+
final subBuilder = RequestSubscriptionBuilder()
1433+
..withIOS((ios) => ios..sku = newProductId)
1434+
..withAndroid((android) => android
1435+
..skus = [newProductId]
1436+
..purchaseToken = oldPurchaseToken
1437+
..replacementMode = 2); // CHARGE_PRORATED_PRICE
1438+
1439+
await iap.requestPurchase(subBuilder.build());
14421440
}
14431441
}
14441442
```

example/lib/src/screens/builder_demo_screen.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,8 +179,8 @@ class _BuilderDemoScreenState extends State<BuilderDemoScreen> {
179179
final subBuilder = RequestSubscriptionBuilder()
180180
..withAndroid((RequestSubscriptionAndroidBuilder a) => a
181181
..skus = [IapConstants.subscriptionProductIds[0]]
182-
..replacementModeAndroid = prorationMode
183-
..purchaseTokenAndroid = token);
182+
..replacementMode = prorationMode
183+
..purchaseToken = token);
184184

185185
await _iap.requestPurchase(subBuilder.build());
186186
setState(() => _status = 'Subscription upgrade initiated');

lib/builders.dart

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -63,19 +63,21 @@ class RequestSubscriptionIosBuilder {
6363
/// Builder for Android purchase props
6464
class RequestPurchaseAndroidBuilder {
6565
List<String> skus = const [];
66-
String? obfuscatedAccountIdAndroid;
67-
String? obfuscatedProfileIdAndroid;
66+
String? obfuscatedAccountId;
67+
String? obfuscatedProfileId;
6868
bool? isOfferPersonalized;
69+
String? offerToken;
6970
DeveloperBillingOptionParamsAndroid? developerBillingOption;
7071

7172
RequestPurchaseAndroidBuilder();
7273

7374
RequestPurchaseAndroidProps build() {
7475
return RequestPurchaseAndroidProps(
7576
skus: skus,
76-
obfuscatedAccountIdAndroid: obfuscatedAccountIdAndroid,
77-
obfuscatedProfileIdAndroid: obfuscatedProfileIdAndroid,
77+
obfuscatedAccountId: obfuscatedAccountId,
78+
obfuscatedProfileId: obfuscatedProfileId,
7879
isOfferPersonalized: isOfferPersonalized,
80+
offerToken: offerToken,
7981
developerBillingOption: developerBillingOption,
8082
);
8183
}
@@ -85,10 +87,10 @@ class RequestPurchaseAndroidBuilder {
8587
class RequestSubscriptionAndroidBuilder {
8688
List<String> skus = const [];
8789
List<AndroidSubscriptionOfferInput> subscriptionOffers = const [];
88-
String? obfuscatedAccountIdAndroid;
89-
String? obfuscatedProfileIdAndroid;
90-
String? purchaseTokenAndroid;
91-
int? replacementModeAndroid;
90+
String? obfuscatedAccountId;
91+
String? obfuscatedProfileId;
92+
String? purchaseToken;
93+
int? replacementMode;
9294
bool? isOfferPersonalized;
9395
DeveloperBillingOptionParamsAndroid? developerBillingOption;
9496

@@ -99,10 +101,10 @@ class RequestSubscriptionAndroidBuilder {
99101
skus: skus,
100102
subscriptionOffers:
101103
subscriptionOffers.isEmpty ? null : subscriptionOffers,
102-
obfuscatedAccountIdAndroid: obfuscatedAccountIdAndroid,
103-
obfuscatedProfileIdAndroid: obfuscatedProfileIdAndroid,
104-
purchaseTokenAndroid: purchaseTokenAndroid,
105-
replacementModeAndroid: replacementModeAndroid,
104+
obfuscatedAccountId: obfuscatedAccountId,
105+
obfuscatedProfileId: obfuscatedProfileId,
106+
purchaseToken: purchaseToken,
107+
replacementMode: replacementMode,
106108
isOfferPersonalized: isOfferPersonalized,
107109
developerBillingOption: developerBillingOption,
108110
);
@@ -179,12 +181,10 @@ class RequestPurchaseBuilder {
179181
: RequestSubscriptionAndroidProps(
180182
skus: androidProps.skus,
181183
isOfferPersonalized: androidProps.isOfferPersonalized,
182-
obfuscatedAccountIdAndroid:
183-
androidProps.obfuscatedAccountIdAndroid,
184-
obfuscatedProfileIdAndroid:
185-
androidProps.obfuscatedProfileIdAndroid,
186-
purchaseTokenAndroid: null,
187-
replacementModeAndroid: null,
184+
obfuscatedAccountId: androidProps.obfuscatedAccountId,
185+
obfuscatedProfileId: androidProps.obfuscatedProfileId,
186+
purchaseToken: null,
187+
replacementMode: null,
188188
subscriptionOffers: null,
189189
developerBillingOption: androidProps.developerBillingOption,
190190
);

0 commit comments

Comments
 (0)