Skip to content

Commit 7ec0d86

Browse files
authored
Fix Android purchase state mapping and improvements (#525)
## Summary - Fix Android purchase state mapping (#524) - Add mounted checks to prevent setState errors - Deprecate old methods in favor of unified API - Update documentation <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Unified product/request API and richer iOS/Android product & purchase metadata (StoreKit2 support, subscription details). - **Bug Fixes** - Corrected Android purchase-state mapping; fixed iOS subscription loading and active-subscription detection; various null-reference fixes. - **Improvements** - Safer async UI handling (mounted guards), stronger type-safety, clearer error messages, improved cross-platform subscription detection. - **Documentation** - README and release notes updated with 6.3.0 migration guidance. - **Tests** - Test suite reorganized by business flows; coverage increased (~26% → 28.2%). - **Chores** - Version bumped to 6.3.0.
1 parent 57315cf commit 7ec0d86

File tree

12 files changed

+1091
-258
lines changed

12 files changed

+1091
-258
lines changed

CHANGELOG.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,67 @@
11
# CHANGELOG
22

3+
## 6.3.0
4+
5+
### Bug Fixes
6+
7+
- **CRITICAL FIX: Android Purchase State Mapping**: Fixed incorrect mapping of Android purchase states (#524)
8+
- Previously mapped: 0=PURCHASED, 1=PENDING (incorrect)
9+
- Now correctly maps: 0=UNSPECIFIED_STATE, 1=PURCHASED, 2=PENDING
10+
- This fix aligns with official Google Play Billing documentation
11+
- Prevents misinterpreting UNSPECIFIED_STATE as a completed purchase
12+
- UNSPECIFIED_STATE (0) and unknown states now properly map to `PurchaseState.unspecified`
13+
14+
### Features
15+
16+
- **Enhanced OpenIAP Compliance**: Extended OpenIAP specification support with comprehensive field mapping
17+
- Added full iOS-specific field support: `displayName`, `displayPrice`, `isFamilyShareable`, `jsonRepresentation`, `discountsIOS`, `subscription` info, and promotional offer fields
18+
- Added comprehensive Android-specific field support: `originalPrice`, `originalPriceAmount`, `freeTrialPeriod`, `subscriptionOffersAndroid`, and billing cycle information
19+
- Enhanced Purchase object with StoreKit 2 fields: `verificationResultIOS`, `environmentIOS`, `expirationDateIOS`, `revocationDateIOS`, and transaction metadata
20+
21+
- **Improved Test Organization**: Restructured test suite by business flows
22+
- **Purchase Flow Tests**: General purchase operations and error handling
23+
- **Subscription Flow Tests**: Subscription-specific operations and lifecycle management
24+
- **Available Purchases Tests**: Purchase history, restoration, and transaction management
25+
- Enhanced test coverage from 26% to 28.2%
26+
27+
### Improvements
28+
29+
- **Type Safety**: Enhanced type casting and JSON parsing reliability
30+
- Fixed `Map<Object?, Object?>` to `Map<String, dynamic>` conversion issues
31+
- Improved null safety handling for platform-specific fields
32+
- Better error handling for malformed data
33+
34+
- **Subscription Management**: Enhanced active subscription detection
35+
- Improved iOS subscription detection logic for better reliability
36+
- Added fallback logic for subscription identification across platforms
37+
38+
- **Code Quality**: Comprehensive test suite improvements
39+
- All 95 tests now pass consistently
40+
- Flexible test assertions that adapt to mock data variations
41+
- Better separation of platform-specific test scenarios
42+
43+
### Bug Fixes
44+
45+
- **Critical Fix**: Fixed iOS subscription loading issue where `requestProducts` with `PurchaseType.subs` returned empty arrays
46+
- iOS now correctly uses `getItems` method instead of unsupported `getSubscriptions`
47+
- Resolves GitHub issues where users couldn't load subscription products on iOS
48+
- Fixed type casting errors in purchase data conversion
49+
- Fixed subscription detection on iOS platform
50+
- Fixed Android purchase state mapping in active subscription queries
51+
- Resolved null reference exceptions for platform-specific fields
52+
- Fixed test expectations to match actual implementation behavior
53+
54+
### Technical Improvements
55+
56+
- Enhanced mock data consistency across test files
57+
- Improved JSON serialization/deserialization robustness
58+
- Better error messages and debugging information
59+
- Standardized field naming conventions following OpenIAP specification
60+
61+
### Breaking Changes
62+
63+
None - This version maintains full backward compatibility while extending functionality.
64+
365
## 6.2.0
466

567
### Features

CLAUDE.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,13 @@ Before committing any changes, run these commands in order and ensure ALL pass:
2020

2121
- **iOS-related code**: Use `IOS` suffix (e.g., `PurchaseIOS`, `SubscriptionOfferIOS`)
2222
- When iOS is not the final suffix, use `Ios` (e.g., `IosManager`, `IosHelper`)
23+
- For field names with iOS in the middle: use `Id` before `IOS` (e.g., `subscriptionGroupIdIOS`, `webOrderLineItemIdIOS`)
2324
- **Android-related code**: Use `Android` suffix (e.g., `PurchaseAndroid`, `SubscriptionOfferAndroid`)
2425
- **IAP-related code**: When IAP is not the final suffix, use `Iap` (e.g., `IapPurchase`, not `IAPPurchase`)
26+
- **ID vs Id convention**:
27+
- Use `Id` consistently across all platforms (e.g., `productId`, `transactionId`, `offerId`)
28+
- When combined with platform suffixes: use `Id` before the suffix (e.g., `subscriptionGroupIdIOS`, `webOrderLineItemIdIOS`, `obfuscatedAccountIdAndroid`)
29+
- Exception: Standalone iOS fields that end with ID use `ID` (e.g., `transactionID`, `webOrderLineItemID` in iOS-only contexts)
2530
- This applies to both functions and types
2631

2732
### API Method Naming

README.md

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,15 @@
1919

2020
```yaml
2121
dependencies:
22-
flutter_inapp_purchase: ^6.2.0
22+
flutter_inapp_purchase: ^6.3.0
2323
```
2424
25+
## 🔄 What's New in 6.3.0
26+
27+
**📝 [Read the full release blog post](docs/blog/2025-08-19-6.3.0.release.md)**
28+
29+
Version 6.3.0 brings critical bug fixes, enhanced OpenIAP compliance, and improved test coverage while maintaining full backward compatibility.
30+
2531
## 🔧 Quick Start
2632
2733
### Basic Usage
@@ -35,16 +41,21 @@ final iap = FlutterInappPurchase();
3541
// Initialize connection
3642
await iap.initConnection();
3743

38-
// Get products
39-
final products = await iap.getProducts(['product_id']);
44+
// Get products (using the new unified API)
45+
final products = await iap.requestProducts(
46+
RequestProductsParams(
47+
productIds: ['product_id'],
48+
type: PurchaseType.inapp,
49+
),
50+
);
4051

4152
// Request purchase
4253
await iap.requestPurchase(
43-
RequestPurchase(
44-
ios: RequestPurchaseIosProps(sku: 'product_id'),
45-
android: RequestPurchaseAndroidProps(skus: ['product_id']),
54+
request: RequestPurchase(
55+
ios: RequestPurchaseIOS(sku: 'product_id'),
56+
android: RequestPurchaseAndroid(skus: ['product_id']),
4657
),
47-
PurchaseType.inapp,
58+
type: PurchaseType.inapp,
4859
);
4960
```
5061

android/src/main/kotlin/dev/hyo/flutterinapppurchase/AndroidInappPurchasePlugin.kt

Lines changed: 81 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -227,19 +227,30 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler,
227227
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
228228
for (purchase in productDetailList) {
229229
val item = JSONObject()
230+
item.put("id", purchase.orderId)
230231
item.put("productId", purchase.products[0])
231-
item.put("transactionId", purchase.orderId)
232+
item.put("ids", JSONArray(purchase.products))
233+
item.put("transactionId", purchase.orderId) // @deprecated - use id instead
232234
item.put("transactionDate", purchase.purchaseTime)
233235
item.put("transactionReceipt", purchase.originalJson)
234236
item.put("purchaseToken", purchase.purchaseToken) // Unified field
235237
item.put("purchaseTokenAndroid", purchase.purchaseToken) // Deprecated - use purchaseToken
238+
item.put("dataAndroid", purchase.originalJson)
236239
item.put("signatureAndroid", purchase.signature)
237240
item.put("purchaseStateAndroid", purchase.purchaseState)
241+
item.put("packageNameAndroid", purchase.packageName)
242+
item.put("developerPayloadAndroid", purchase.developerPayload)
243+
item.put("platform", "android")
238244
if (type == BillingClient.ProductType.INAPP) {
239245
item.put("isAcknowledgedAndroid", purchase.isAcknowledged)
240246
} else if (type == BillingClient.ProductType.SUBS) {
241247
item.put("autoRenewingAndroid", purchase.isAutoRenewing)
242248
}
249+
val accountIdentifiers = purchase.accountIdentifiers
250+
if (accountIdentifiers != null) {
251+
item.put("obfuscatedAccountIdAndroid", accountIdentifiers.obfuscatedAccountId)
252+
item.put("obfuscatedProfileIdAndroid", accountIdentifiers.obfuscatedProfileId)
253+
}
243254
items.put(item)
244255
}
245256
safeChannel.success(items.toString())
@@ -347,13 +358,25 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler,
347358
val item = JSONObject()
348359
item.put("id", purchase.orderId)
349360
item.put("productId", purchase.products[0])
350-
item.put("transactionId", purchase.orderId)
361+
item.put("ids", JSONArray(purchase.products))
362+
item.put("transactionId", purchase.orderId) // @deprecated - use id instead
351363
item.put("transactionDate", purchase.purchaseTime)
352364
item.put("transactionReceipt", purchase.originalJson)
353365
item.put("purchaseToken", purchase.purchaseToken) // Unified field
354366
item.put("purchaseTokenAndroid", purchase.purchaseToken) // Deprecated - use purchaseToken
355367
item.put("dataAndroid", purchase.originalJson)
356368
item.put("signatureAndroid", purchase.signature)
369+
item.put("purchaseStateAndroid", purchase.purchaseState)
370+
item.put("autoRenewingAndroid", purchase.isAutoRenewing)
371+
item.put("isAcknowledgedAndroid", purchase.isAcknowledged)
372+
item.put("packageNameAndroid", purchase.packageName)
373+
item.put("developerPayloadAndroid", purchase.developerPayload)
374+
item.put("platform", "android")
375+
val accountIdentifiers = purchase.accountIdentifiers
376+
if (accountIdentifiers != null) {
377+
item.put("obfuscatedAccountIdAndroid", accountIdentifiers.obfuscatedAccountId)
378+
item.put("obfuscatedProfileIdAndroid", accountIdentifiers.obfuscatedProfileId)
379+
}
357380
items.put(item)
358381
}
359382
safeChannel.success(items.toString())
@@ -397,10 +420,24 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler,
397420

398421
// Create flutter objects
399422
val item = JSONObject()
423+
item.put("id", productDetails.productId)
400424
item.put("productId", productDetails.productId)
401425
item.put("type", productDetails.productType)
402426
item.put("title", productDetails.title)
427+
item.put("displayName", productDetails.name)
403428
item.put("description", productDetails.description)
429+
item.put("platform", "android")
430+
431+
// Set currency and displayPrice based on product type
432+
val currency = productDetails.oneTimePurchaseOfferDetails?.priceCurrencyCode
433+
?: productDetails.subscriptionOfferDetails?.firstOrNull()?.pricingPhases?.pricingPhaseList?.firstOrNull()?.priceCurrencyCode
434+
?: "Unknown"
435+
val displayPrice = productDetails.oneTimePurchaseOfferDetails?.formattedPrice
436+
?: productDetails.subscriptionOfferDetails?.firstOrNull()?.pricingPhases?.pricingPhaseList?.firstOrNull()?.formattedPrice
437+
?: "N/A"
438+
439+
item.put("currency", currency)
440+
item.put("displayPrice", displayPrice)
404441

405442
// One-time offer details have changed in 5.0
406443
if (productDetails.oneTimePurchaseOfferDetails != null) {
@@ -412,8 +449,14 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler,
412449

413450
if (oneTimePurchaseOfferDetails != null) {
414451
item.put("price", (oneTimePurchaseOfferDetails.priceAmountMicros / 1000000f).toString())
415-
item.put("currency", oneTimePurchaseOfferDetails.priceCurrencyCode)
416452
item.put("localizedPrice", oneTimePurchaseOfferDetails.formattedPrice)
453+
454+
// Add structured oneTimePurchaseOfferDetails
455+
val offerDetails = JSONObject()
456+
offerDetails.put("priceCurrencyCode", oneTimePurchaseOfferDetails.priceCurrencyCode)
457+
offerDetails.put("formattedPrice", oneTimePurchaseOfferDetails.formattedPrice)
458+
offerDetails.put("priceAmountMicros", oneTimePurchaseOfferDetails.priceAmountMicros.toString())
459+
item.put("oneTimePurchaseOfferDetails", offerDetails)
417460
}
418461
} else if (productDetails.productType == BillingClient.ProductType.SUBS) {
419462
// These generalized values are derived from the first pricing object, mainly for backwards compatibility
@@ -424,11 +467,41 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler,
424467
if (firstOffer != null && firstOffer.pricingPhases.pricingPhaseList.isNotEmpty()) {
425468
val defaultPricingPhase = firstOffer.pricingPhases.pricingPhaseList[0]
426469
item.put("price", (defaultPricingPhase.priceAmountMicros / 1000000f).toString())
427-
item.put("currency", defaultPricingPhase.priceCurrencyCode)
428470
item.put("localizedPrice", defaultPricingPhase.formattedPrice)
429471
item.put("subscriptionPeriodAndroid", defaultPricingPhase.billingPeriod)
430472
}
431473

474+
// Add structured subscriptionOfferDetails
475+
val subsOffers = JSONArray()
476+
if (productDetails.subscriptionOfferDetails != null) {
477+
for (offer in productDetails.subscriptionOfferDetails!!) {
478+
val offerItem = JSONObject()
479+
offerItem.put("basePlanId", offer.basePlanId)
480+
offerItem.put("offerId", offer.offerId)
481+
offerItem.put("offerToken", offer.offerToken)
482+
offerItem.put("offerTags", JSONArray(offer.offerTags))
483+
484+
// Add pricingPhases
485+
val pricingPhasesObj = JSONObject()
486+
val pricingPhasesList = JSONArray()
487+
for (pricing in offer.pricingPhases.pricingPhaseList) {
488+
val pricingPhase = JSONObject()
489+
pricingPhase.put("formattedPrice", pricing.formattedPrice)
490+
pricingPhase.put("priceCurrencyCode", pricing.priceCurrencyCode)
491+
pricingPhase.put("billingPeriod", pricing.billingPeriod)
492+
pricingPhase.put("billingCycleCount", pricing.billingCycleCount)
493+
pricingPhase.put("priceAmountMicros", pricing.priceAmountMicros.toString())
494+
pricingPhase.put("recurrenceMode", pricing.recurrenceMode)
495+
pricingPhasesList.put(pricingPhase)
496+
}
497+
pricingPhasesObj.put("pricingPhaseList", pricingPhasesList)
498+
offerItem.put("pricingPhases", pricingPhasesObj)
499+
subsOffers.put(offerItem)
500+
}
501+
}
502+
item.put("subscriptionOfferDetails", subsOffers)
503+
504+
// Keep backward compatibility with subscriptionOffers
432505
val subs = JSONArray()
433506
if (productDetails.subscriptionOfferDetails != null ) {
434507
for (offer in productDetails.subscriptionOfferDetails!!) {
@@ -651,8 +724,10 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler,
651724
for (purchase in purchases) {
652725
Log.d(TAG, "Processing purchase: productId=${purchase.products[0]}, orderId=${purchase.orderId}")
653726
val item = JSONObject()
727+
item.put("id", purchase.orderId)
654728
item.put("productId", purchase.products[0])
655-
item.put("transactionId", purchase.orderId)
729+
item.put("ids", JSONArray(purchase.products))
730+
item.put("transactionId", purchase.orderId) // @deprecated - use id instead
656731
item.put("transactionDate", purchase.purchaseTime)
657732
item.put("transactionReceipt", purchase.originalJson)
658733
item.put("purchaseToken", purchase.purchaseToken) // Unified field for iOS JWS and Android purchaseToken
@@ -664,6 +739,7 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler,
664739
item.put("isAcknowledgedAndroid", purchase.isAcknowledged)
665740
item.put("packageNameAndroid", purchase.packageName)
666741
item.put("developerPayloadAndroid", purchase.developerPayload)
742+
item.put("platform", "android")
667743
val accountIdentifiers = purchase.accountIdentifiers
668744
if (accountIdentifiers != null) {
669745
item.put("obfuscatedAccountIdAndroid", accountIdentifiers.obfuscatedAccountId)

0 commit comments

Comments
 (0)