From e120dcd26542004b851965be98ac2bacf03475ac Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 9 Jul 2026 23:46:13 +0900 Subject: [PATCH 01/15] fix(libraries): preserve product metadata bridges Propagate iOS pricing terms and subscription metadata through the React Native Nitro bridge, preserve generated product payloads in Godot and KMP iOS paths, and parse the corresponding Flutter product fields with focused coverage. --- .../flutter_inapp_purchase/lib/helpers.dart | 205 +++++++++++++----- .../test/helpers_unit_test.dart | 127 ++++++++--- .../Sources/GodotIap/GodotIap.swift | 46 +--- .../github/hyochan/kmpiap/InAppPurchaseIOS.kt | 9 + .../java/com/margelo/nitro/iap/HybridRnIap.kt | 3 + .../react-native-iap/ios/RnIapHelper.swift | 24 ++ .../src/__tests__/utils/type-bridge.test.ts | 22 ++ .../react-native-iap/src/specs/RnIap.nitro.ts | 3 + .../react-native-iap/src/utils/type-bridge.ts | 25 ++- 9 files changed, 345 insertions(+), 119 deletions(-) diff --git a/libraries/flutter_inapp_purchase/lib/helpers.dart b/libraries/flutter_inapp_purchase/lib/helpers.dart index d9e8a2c9f..29c3d5191 100644 --- a/libraries/flutter_inapp_purchase/lib/helpers.dart +++ b/libraries/flutter_inapp_purchase/lib/helpers.dart @@ -50,9 +50,10 @@ gentype.ProductCommon parseProductFromNative( // Heuristics based on well-known platform-specific fields final looksAndroid = json.containsKey('oneTimePurchaseOfferDetailsAndroid') || - json.containsKey('subscriptionOfferDetailsAndroid') || - json.containsKey('nameAndroid'); - final looksIOS = json.containsKey('subscriptionGroupIdIOS') || + json.containsKey('subscriptionOfferDetailsAndroid') || + json.containsKey('nameAndroid'); + final looksIOS = + json.containsKey('subscriptionGroupIdIOS') || json.containsKey('jsonRepresentationIOS') || json.containsKey('environmentIOS'); if (looksAndroid && !looksIOS) { @@ -60,8 +61,9 @@ gentype.ProductCommon parseProductFromNative( } else if (looksIOS && !looksAndroid) { platform = gentype.IapPlatform.IOS; } else { - platform = - fallbackIsIOS ? gentype.IapPlatform.IOS : gentype.IapPlatform.Android; + platform = fallbackIsIOS + ? gentype.IapPlatform.IOS + : gentype.IapPlatform.Android; } } @@ -71,16 +73,18 @@ gentype.ProductCommon parseProductFromNative( return null; } - final productId = (json['id']?.toString() ?? - json['productId']?.toString() ?? - json['sku']?.toString() ?? - json['productIdentifier']?.toString() ?? - '') - .trim(); + final productId = + (json['id']?.toString() ?? + json['productId']?.toString() ?? + json['sku']?.toString() ?? + json['productIdentifier']?.toString() ?? + '') + .trim(); final title = json['title']?.toString() ?? productId; final description = json['description']?.toString() ?? ''; final currency = json['currency']?.toString() ?? ''; - final displayPrice = json['displayPrice']?.toString() ?? + final displayPrice = + json['displayPrice']?.toString() ?? json['localizedPrice']?.toString() ?? '0'; final priceValue = parsePrice(json['price']); @@ -106,8 +110,8 @@ gentype.ProductCommon parseProductFromNative( json['discountsIOS'] ?? json['discounts'], ), displayName: json['displayName']?.toString(), - introductoryPriceAsAmountIOS: - json['introductoryPriceAsAmountIOS']?.toString(), + introductoryPriceAsAmountIOS: json['introductoryPriceAsAmountIOS'] + ?.toString(), introductoryPriceIOS: json['introductoryPriceIOS']?.toString(), introductoryPriceNumberOfPeriodsIOS: json['introductoryPriceNumberOfPeriodsIOS']?.toString(), @@ -118,15 +122,18 @@ gentype.ProductCommon parseProductFromNative( json['introductoryPriceSubscriptionPeriodIOS'], ), price: priceValue, + pricingTermsIOS: _parseSubscriptionPricingTermsIOS( + json['pricingTermsIOS'], + ), subscriptionInfoIOS: _parseSubscriptionInfoIOS( json['subscriptionInfoIOS'] ?? json['subscription'], ), - subscriptionOffers: _parseSubscriptionOffersIOS( + subscriptionOffers: _parseStandardizedSubscriptionOffers( json['subscriptionOffers'], ), subscriptionGroupIdIOS: json['subscriptionGroupIdIOS']?.toString(), - subscriptionPeriodNumberIOS: - json['subscriptionPeriodNumberIOS']?.toString(), + subscriptionPeriodNumberIOS: json['subscriptionPeriodNumberIOS'] + ?.toString(), subscriptionPeriodUnitIOS: _parseSubscriptionPeriod( json['subscriptionPeriodUnitIOS'], ), @@ -136,16 +143,20 @@ gentype.ProductCommon parseProductFromNative( final subscriptionOfferDetails = _parseOfferDetails( json['subscriptionOfferDetailsAndroid'], ); + final subscriptionOffers = + _parseStandardizedSubscriptionOffers(json['subscriptionOffers']) ?? + _parseSubscriptionOffers(subscriptionOfferDetails); return gentype.ProductSubscriptionAndroid( currency: currency, description: description, + discountOffers: _parseDiscountOffers(json['discountOffers']), displayPrice: displayPrice, id: productId, nameAndroid: json['nameAndroid']?.toString() ?? productId, platform: platform, subscriptionOfferDetailsAndroid: subscriptionOfferDetails, - subscriptionOffers: _parseSubscriptionOffers(subscriptionOfferDetails), + subscriptionOffers: subscriptionOffers, title: title, type: productType, debugDescription: json['debugDescription']?.toString(), @@ -154,6 +165,9 @@ gentype.ProductCommon parseProductFromNative( json['oneTimePurchaseOfferDetailsAndroid'], ), price: priceValue, + productStatusAndroid: _parseProductStatusAndroid( + json['productStatusAndroid'], + ), ); } @@ -173,9 +187,15 @@ gentype.ProductCommon parseProductFromNative( debugDescription: json['debugDescription']?.toString(), displayName: json['displayName']?.toString(), price: priceValue, + pricingTermsIOS: _parseSubscriptionPricingTermsIOS( + json['pricingTermsIOS'], + ), subscriptionInfoIOS: _parseSubscriptionInfoIOS( json['subscriptionInfoIOS'] ?? json['subscription'], ), + subscriptionOffers: _parseStandardizedSubscriptionOffers( + json['subscriptionOffers'], + ), ); } @@ -193,13 +213,23 @@ gentype.ProductCommon parseProductFromNative( title: title, type: productType, debugDescription: json['debugDescription']?.toString(), + discountOffers: _parseDiscountOffers(json['discountOffers']), displayName: json['displayName']?.toString(), oneTimePurchaseOfferDetailsAndroid: _parseOneTimePurchaseOfferDetails( json['oneTimePurchaseOfferDetailsAndroid'], ), price: priceValue, - subscriptionOfferDetailsAndroid: - androidOffers.isEmpty ? null : androidOffers, + productStatusAndroid: _parseProductStatusAndroid( + json['productStatusAndroid'], + ), + subscriptionOfferDetailsAndroid: androidOffers.isEmpty + ? null + : androidOffers, + subscriptionOffers: + _parseStandardizedSubscriptionOffers(json['subscriptionOffers']) ?? + (androidOffers.isEmpty + ? null + : _parseSubscriptionOffers(androidOffers)), ); } @@ -260,7 +290,8 @@ gentype.Purchase convertToPurchase( 'productId': productId, 'platform': gentype.IapPlatform.Android.toJson(), 'store': storeValue, - 'isAutoRenewing': itemJson['isAutoRenewing'] as bool? ?? + 'isAutoRenewing': + itemJson['isAutoRenewing'] as bool? ?? itemJson['autoRenewingAndroid'] as bool? ?? false, 'purchaseState': purchaseState, @@ -269,18 +300,18 @@ gentype.Purchase convertToPurchase( 'purchaseToken': itemJson['purchaseToken']?.toString(), 'autoRenewingAndroid': itemJson['autoRenewingAndroid'] as bool?, 'dataAndroid': itemJson['originalJsonAndroid']?.toString(), - 'developerPayloadAndroid': - itemJson['developerPayloadAndroid']?.toString(), + 'developerPayloadAndroid': itemJson['developerPayloadAndroid'] + ?.toString(), 'ids': (itemJson['ids'] as List?) ?.map((e) => e.toString()) .toList(), 'isAcknowledgedAndroid': itemJson['isAcknowledgedAndroid'] as bool?, 'obfuscatedAccountIdAndroid': itemJson['obfuscatedAccountIdAndroid']?.toString() ?? - originalJson?['obfuscatedAccountIdAndroid']?.toString(), + originalJson?['obfuscatedAccountIdAndroid']?.toString(), 'obfuscatedProfileIdAndroid': itemJson['obfuscatedProfileIdAndroid']?.toString() ?? - originalJson?['obfuscatedProfileIdAndroid']?.toString(), + originalJson?['obfuscatedProfileIdAndroid']?.toString(), 'packageNameAndroid': itemJson['packageNameAndroid']?.toString(), 'signatureAndroid': itemJson['signatureAndroid']?.toString(), 'transactionId': transactionId, @@ -303,7 +334,7 @@ gentype.Purchase convertToPurchase( double? originalTransactionDateIOS; final originalTransactionDateValue = itemJson['originalTransactionDateIOS'] ?? - originalJson?['originalTransactionDateIOS']; + originalJson?['originalTransactionDateIOS']; if (originalTransactionDateValue is num) { originalTransactionDateIOS = originalTransactionDateValue.toDouble(); } else if (originalTransactionDateValue is String) { @@ -325,7 +356,8 @@ gentype.Purchase convertToPurchase( 'purchaseState': stateIOS, 'quantity': quantity, 'transactionDate': transactionDate, - 'purchaseToken': itemJson['transactionReceipt']?.toString() ?? + 'purchaseToken': + itemJson['transactionReceipt']?.toString() ?? itemJson['purchaseToken']?.toString(), 'ids': (itemJson['ids'] as List?) ?.map((e) => e.toString()) @@ -336,8 +368,8 @@ gentype.Purchase convertToPurchase( 'currencyCodeIOS': itemJson['currencyCodeIOS']?.toString(), 'currencySymbolIOS': itemJson['currencySymbolIOS']?.toString(), 'environmentIOS': itemJson['environmentIOS']?.toString(), - 'expirationDateIOS': - (originalJson?['expirationDateIOS'] as num?)?.toDouble(), + 'expirationDateIOS': (originalJson?['expirationDateIOS'] as num?) + ?.toDouble(), 'originalTransactionIdentifierIOS': itemJson['originalTransactionIdentifierIOS']?.toString(), 'originalTransactionDateIOS': originalTransactionDateIOS, @@ -346,8 +378,8 @@ gentype.Purchase convertToPurchase( 'webOrderLineItemIdIOS': itemJson['webOrderLineItemIdIOS']?.toString(), 'offerIOS': originalJson?['offerIOS'], 'priceIOS': (originalJson?['priceIOS'] as num?)?.toDouble(), - 'revocationDateIOS': - (originalJson?['revocationDateIOS'] as num?)?.toDouble(), + 'revocationDateIOS': (originalJson?['revocationDateIOS'] as num?) + ?.toDouble(), 'revocationReasonIOS': originalJson?['revocationReasonIOS']?.toString(), 'transactionId': transactionId ?? purchaseId, }; @@ -534,6 +566,62 @@ List? _parseDiscountsIOS(dynamic json) { .toList(); } +List? _parseNativeList(dynamic value) { + if (value == null) return null; + if (value is List) return value; + if (value is String) { + try { + final decoded = jsonDecode(value); + return decoded is List ? decoded : null; + } catch (_) { + return null; + } + } + return null; +} + +List? _parseGeneratedList( + dynamic value, + T Function(Map) fromJson, +) { + final list = _parseNativeList(value); + if (list == null) return null; + + final parsed = []; + for (final item in list) { + final map = normalizeDynamicMap(item); + if (map == null) continue; + try { + parsed.add(fromJson(map)); + } catch (_) { + return null; + } + } + return parsed.isEmpty ? null : parsed; +} + +List? _parseSubscriptionPricingTermsIOS( + dynamic value, +) => _parseGeneratedList(value, gentype.SubscriptionPricingTermsIOS.fromJson); + +List? _parseDiscountOffers(dynamic value) => + _parseGeneratedList(value, gentype.DiscountOffer.fromJson); + +List? _parseStandardizedSubscriptionOffers( + dynamic value, +) => + _parseGeneratedList(value, gentype.SubscriptionOffer.fromJson) ?? + _parseSubscriptionOffersIOS(value); + +gentype.ProductStatusAndroid? _parseProductStatusAndroid(dynamic value) { + if (value == null) return null; + try { + return gentype.ProductStatusAndroid.fromJson(value.toString()); + } catch (_) { + return null; + } +} + List _parseOfferDetails( dynamic json, ) { @@ -574,12 +662,21 @@ List _parseOfferDetails( // Skip invalid items return null; } + final installmentPlanDetailsMap = normalizeDynamicMap( + e['installmentPlanDetails'], + ); return gentype.ProductSubscriptionAndroidOfferDetails( basePlanId: e['basePlanId'] as String? ?? '', + installmentPlanDetails: installmentPlanDetailsMap != null + ? gentype.InstallmentPlanDetailsAndroid.fromJson( + installmentPlanDetailsMap, + ) + : null, offerId: e['offerId'] as String?, offerToken: e['offerToken'] as String? ?? '', - offerTags: (e['offerTags'] as List?) + offerTags: + (e['offerTags'] as List?) ?.map((tag) => tag.toString()) .toList() ?? const [], @@ -728,22 +825,24 @@ List? _parseSubscriptionOffersIOS(dynamic json) { } } - offers.add(gentype.SubscriptionOffer( - id: map['id']?.toString() ?? '', - displayPrice: map['displayPrice']?.toString() ?? '', - price: _toDouble(map['price']) ?? 0, - currency: map['currency']?.toString(), - type: type, - paymentMode: paymentMode, - period: period, - periodCount: _toInt(map['periodCount']), - keyIdentifierIOS: map['keyIdentifierIOS']?.toString(), - nonceIOS: map['nonceIOS']?.toString(), - signatureIOS: map['signatureIOS']?.toString(), - timestampIOS: _toDouble(map['timestampIOS']), - numberOfPeriodsIOS: _toInt(map['numberOfPeriodsIOS']), - localizedPriceIOS: map['localizedPriceIOS']?.toString(), - )); + offers.add( + gentype.SubscriptionOffer( + id: map['id']?.toString() ?? '', + displayPrice: map['displayPrice']?.toString() ?? '', + price: _toDouble(map['price']) ?? 0, + currency: map['currency']?.toString(), + type: type, + paymentMode: paymentMode, + period: period, + periodCount: _toInt(map['periodCount']), + keyIdentifierIOS: map['keyIdentifierIOS']?.toString(), + nonceIOS: map['nonceIOS']?.toString(), + signatureIOS: map['signatureIOS']?.toString(), + timestampIOS: _toDouble(map['timestampIOS']), + numberOfPeriodsIOS: _toInt(map['numberOfPeriodsIOS']), + localizedPriceIOS: map['localizedPriceIOS']?.toString(), + ), + ); } return offers.isEmpty ? null : offers; @@ -859,7 +958,7 @@ gentype.PaymentModeIOS _parsePaymentMode(dynamic value) { /// Parses oneTimePurchaseOfferDetailsAndroid - handles both array (new 7.0+) /// and single object (legacy) for backwards compatibility List? - _parseOneTimePurchaseOfferDetails(dynamic value) { +_parseOneTimePurchaseOfferDetails(dynamic value) { if (value == null) return null; // New format: array of offers (Android 7.0+) @@ -880,13 +979,14 @@ List? } gentype.ProductAndroidOneTimePurchaseOfferDetail? - _parseSingleOneTimePurchaseOfferDetail(dynamic value) { +_parseSingleOneTimePurchaseOfferDetail(dynamic value) { if (value is Map) { return gentype.ProductAndroidOneTimePurchaseOfferDetail( formattedPrice: value['formattedPrice']?.toString() ?? '0', priceAmountMicros: value['priceAmountMicros']?.toString() ?? '0', priceCurrencyCode: value['priceCurrencyCode']?.toString() ?? 'USD', - offerTags: (value['offerTags'] as List?) + offerTags: + (value['offerTags'] as List?) ?.map((e) => e.toString()) .toList() ?? [], @@ -924,7 +1024,8 @@ gentype.ProductAndroidOneTimePurchaseOfferDetail? formattedPrice: map['formattedPrice']?.toString() ?? '0', priceAmountMicros: map['priceAmountMicros']?.toString() ?? '0', priceCurrencyCode: map['priceCurrencyCode']?.toString() ?? 'USD', - offerTags: (map['offerTags'] as List?) + offerTags: + (map['offerTags'] as List?) ?.map((e) => e.toString()) .toList() ?? [], diff --git a/libraries/flutter_inapp_purchase/test/helpers_unit_test.dart b/libraries/flutter_inapp_purchase/test/helpers_unit_test.dart index a4d304347..2e663a0c0 100644 --- a/libraries/flutter_inapp_purchase/test/helpers_unit_test.dart +++ b/libraries/flutter_inapp_purchase/test/helpers_unit_test.dart @@ -32,6 +32,19 @@ void main() { 'jsonRepresentationIOS': '{}', 'subscriptionGroupIdIOS': '21686373', 'typeIOS': 'AUTO_RENEWABLE_SUBSCRIPTION', + 'pricingTermsIOS': >[ + { + 'billingDisplayPrice': '\$9.99', + 'billingPeriod': {'unit': 'month', 'value': 1}, + 'billingPlanType': 'monthly', + 'billingPrice': 9.99, + 'commitmentInfo': { + 'displayPrice': '\$119.88', + 'period': {'unit': 'year', 'value': 1}, + 'price': 119.88, + }, + }, + ], }, 'subs', fallbackIsIOS: true, @@ -43,6 +56,12 @@ void main() { expect(subscription.platform, types.IapPlatform.IOS); expect(subscription.isFamilyShareableIOS, isTrue); expect(subscription.subscriptionGroupIdIOS, '21686373'); + expect(subscription.pricingTermsIOS, isNotNull); + expect(subscription.pricingTermsIOS, hasLength(1)); + expect( + subscription.pricingTermsIOS!.first.billingPlanType, + types.SubscriptionBillingPlanTypeIOS.Monthly, + ); expect(subscription.type, types.ProductType.Subs); }); @@ -69,10 +88,7 @@ void main() { 'type': 'INTRODUCTORY', 'paymentMode': 'FREE_TRIAL', 'periodCount': 1, - 'period': { - 'unit': 'WEEK', - 'value': 1, - }, + 'period': {'unit': 'WEEK', 'value': 1}, }, { 'id': 'promo_offer', @@ -134,6 +150,17 @@ void main() { 'priceAmountMicros': '2990000', 'priceCurrencyCode': 'USD', }, + 'discountOffers': >[ + { + 'currency': 'USD', + 'displayPrice': '\$1.99', + 'id': 'discount_001', + 'offerTokenAndroid': 'discount-token', + 'price': 1.99, + 'type': 'one-time', + }, + ], + 'productStatusAndroid': 'ok', }, 'inapp', fallbackIsIOS: false, @@ -145,6 +172,15 @@ void main() { expect(androidProduct.platform, types.IapPlatform.Android); expect(androidProduct.price, closeTo(2.99, 0.0001)); expect(androidProduct.oneTimePurchaseOfferDetailsAndroid, isNotNull); + expect(androidProduct.discountOffers, hasLength(1)); + expect( + androidProduct.discountOffers!.first.offerTokenAndroid, + 'discount-token', + ); + expect( + androidProduct.productStatusAndroid, + types.ProductStatusAndroid.Ok, + ); }, ); @@ -309,6 +345,10 @@ void main() { 'subscriptionOfferDetailsAndroid': >[ { 'basePlanId': 'base', + 'installmentPlanDetails': { + 'commitmentPaymentsCount': 12, + 'subsequentCommitmentPaymentsCount': 0, + }, 'offerToken': 'token', 'offerTags': ['tag'], 'pricingPhases': { @@ -325,6 +365,18 @@ void main() { }, }, ], + 'subscriptionOffers': >[ + { + 'id': 'base', + 'displayPrice': '\$49.99', + 'price': 49.99, + 'type': 'introductory', + 'basePlanIdAndroid': 'base', + 'offerTokenAndroid': 'token', + 'offerTagsAndroid': ['tag'], + }, + ], + 'productStatusAndroid': 'ok', }, 'subs', fallbackIsIOS: false, @@ -337,6 +389,22 @@ void main() { subscription.subscriptionOfferDetailsAndroid.single.offerToken, 'token', ); + expect( + subscription + .subscriptionOfferDetailsAndroid + .single + .installmentPlanDetails + ?.commitmentPaymentsCount, + 12, + ); + expect( + subscription.subscriptionOffers.single.offerTokenAndroid, + 'token', + ); + expect( + subscription.productStatusAndroid, + types.ProductStatusAndroid.Ok, + ); }, ); @@ -586,33 +654,30 @@ void main() { }, ); - test( - 'convertToPurchaseError forwards debugMessage and responseCode ' - 'from PurchaseResult', - () { - final result = PurchaseResult.fromJSON({ - 'responseCode': 5, - 'debugMessage': - 'Deferred replacement requires the base offer, got a promo offer', - 'code': 'developer-error', - 'message': 'Invalid arguments provided to the API', - }); - - final error = convertToPurchaseError( - result, - platform: types.IapPlatform.Android, - ); + test('convertToPurchaseError forwards debugMessage and responseCode ' + 'from PurchaseResult', () { + final result = PurchaseResult.fromJSON({ + 'responseCode': 5, + 'debugMessage': + 'Deferred replacement requires the base offer, got a promo offer', + 'code': 'developer-error', + 'message': 'Invalid arguments provided to the API', + }); - expect(error, isA()); - expect(error.code, types.ErrorCode.DeveloperError); - expect(error.message, 'Invalid arguments provided to the API'); - expect( - error.debugMessage, - 'Deferred replacement requires the base offer, got a promo offer', - ); - expect(error.responseCode, 5); - expect(error.platform, types.IapPlatform.Android); - }, - ); + final error = convertToPurchaseError( + result, + platform: types.IapPlatform.Android, + ); + + expect(error, isA()); + expect(error.code, types.ErrorCode.DeveloperError); + expect(error.message, 'Invalid arguments provided to the API'); + expect( + error.debugMessage, + 'Deferred replacement requires the base offer, got a promo offer', + ); + expect(error.responseCode, 5); + expect(error.platform, types.IapPlatform.Android); + }); }); } diff --git a/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift b/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift index a6dc3ebd2..e8c62e44e 100644 --- a/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift +++ b/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift @@ -1542,47 +1542,25 @@ public class GodotIap: RefCounted, @unchecked Sendable { } private func productToDictionary(_ product: OpenIAP.Product) -> [String: Any] { - return [ - "id": product.id, - "title": product.title, - "description": product.description, - "displayPrice": product.displayPrice, - "price": product.price ?? 0, - "currency": product.currency, - "type": product.type.rawValue, - "platform": "ios" - ] + switch product { + case .productIos(let ios): + return OpenIapSerialization.encode(ios) + case .productAndroid(let android): + return OpenIapSerialization.encode(android) + } } private func productIOSToDictionary(_ product: ProductIOS) -> [String: Any] { - return [ - "id": product.id, - "title": product.title, - "description": product.description, - "displayPrice": product.displayPrice, - "price": product.price ?? 0, - "currency": product.currency, - "type": product.type.rawValue, - "platform": "ios" - ] + return OpenIapSerialization.encode(product) } private func subscriptionToDictionary(_ subscription: ProductSubscription) -> [String: Any] { - var dictionary: [String: Any] = [ - "id": subscription.id, - "title": subscription.title, - "description": subscription.description, - "displayPrice": subscription.displayPrice, - "price": subscription.price ?? 0, - "currency": subscription.currency, - "type": "subs", - "platform": "ios" - ] - if case let .productSubscriptionIos(ios) = subscription, - let groupId = ios.subscriptionGroupIdIOS { - dictionary["subscriptionGroupIdIOS"] = groupId + switch subscription { + case .productSubscriptionIos(let ios): + return OpenIapSerialization.encode(ios) + case .productSubscriptionAndroid(let android): + return OpenIapSerialization.encode(android) } - return dictionary } private func jsonString(_ object: [String: Any]) -> String { diff --git a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt index bdb256ec5..80287f489 100644 --- a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt +++ b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt @@ -1273,6 +1273,9 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { } val map = dict.mapKeys { it.key.toString() } + runCatching { Product.fromJson(map) }.getOrNull()?.let { + return@mapNotNull it + } // Parse subscription offers from the data (if product has subscription info) val subscriptionOffers = convertAnyListToSubscriptionOffers( @@ -1318,6 +1321,9 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { list.mapNotNull { item -> val dict = (item as? Map<*, *>) ?: return@mapNotNull null val map = dict.mapKeys { it.key.toString() } + runCatching { ProductSubscription.fromJson(map) }.getOrNull()?.let { + return@mapNotNull it + } // Parse subscription offers from the data val subscriptionOffers = convertAnyListToSubscriptionOffers( @@ -1394,6 +1400,9 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { return try { val dict = (data as? Map<*, *>) ?: return null val map = dict.mapKeys { it.key.toString() } + runCatching { ProductIOS.fromJson(map) }.getOrNull()?.let { + return it + } ProductIOS( currency = map["currency"] as? String ?: "", diff --git a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt index 867f9d6c9..cfaaec648 100644 --- a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt +++ b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt @@ -1177,6 +1177,7 @@ class HybridRnIap : HybridRnIapSpec() { id = product.id, title = product.title, description = product.description, + debugDescription = product.debugDescription.wrapVariant(), type = product.type.rawValue, displayName = product.displayName.wrapVariant(), displayPrice = product.displayPrice, @@ -1186,6 +1187,8 @@ class HybridRnIap : HybridRnIapSpec() { typeIOS = null, isFamilyShareableIOS = null, jsonRepresentationIOS = null, + pricingTermsIOS = null, + subscriptionInfoIOS = null, discountsIOS = null, subscriptionPeriodUnitIOS = null, subscriptionPeriodNumberIOS = null, diff --git a/libraries/react-native-iap/ios/RnIapHelper.swift b/libraries/react-native-iap/ios/RnIapHelper.swift index a635f65af..c3590a67e 100644 --- a/libraries/react-native-iap/ios/RnIapHelper.swift +++ b/libraries/react-native-iap/ios/RnIapHelper.swift @@ -145,6 +145,24 @@ enum RnIapHelper { } } + var pricingTermsIOS: Variant_NullType_String? = nil + if let pricingTermsArray = dictionary["pricingTermsIOS"] as? [[String: Any]] { + if let json = serializeToJSON(pricingTermsArray) { + pricingTermsIOS = .second(json) + } else { + NSLog("⚠️ [RnIapHelper] Failed to serialize pricingTermsIOS") + } + } + + var subscriptionInfoIOS: Variant_NullType_String? = nil + if let subscriptionInfo = dictionary["subscriptionInfoIOS"] as? [String: Any] { + if let json = serializeToJSON(subscriptionInfo) { + subscriptionInfoIOS = .second(json) + } else { + NSLog("⚠️ [RnIapHelper] Failed to serialize subscriptionInfoIOS") + } + } + // Handle subscriptionOffers - standardized cross-platform offers (OpenIAP 1.3.10+) var subscriptionOffers: Variant_NullType_String? = nil if let offersArray = dictionary["subscriptionOffers"] as? [[String: Any]], !offersArray.isEmpty { @@ -169,6 +187,7 @@ enum RnIapHelper { id: dictionary["id"] as? String ?? "", title: dictionary["title"] as? String ?? "", description: dictionary["description"] as? String ?? "", + debugDescription: wrapString(dictionary["debugDescription"] as? String), type: dictionary["type"] as? String ?? "", displayName: displayName, displayPrice: dictionary["displayPrice"] as? String, @@ -178,6 +197,8 @@ enum RnIapHelper { typeIOS: wrapString(dictionary["typeIOS"] as? String), isFamilyShareableIOS: wrapBool(boolValue(dictionary["isFamilyShareableIOS"])), jsonRepresentationIOS: wrapString(dictionary["jsonRepresentationIOS"] as? String), + pricingTermsIOS: pricingTermsIOS, + subscriptionInfoIOS: subscriptionInfoIOS, discountsIOS: discountsIOS, introductoryPriceIOS: wrapString(dictionary["introductoryPriceIOS"] as? String), introductoryPriceAsAmountIOS: wrapDouble(doubleValue(dictionary["introductoryPriceAsAmountIOS"])), @@ -417,6 +438,7 @@ enum RnIapHelper { id: id, title: id, description: "", + debugDescription: nil, type: "inapp", displayName: nil, displayPrice: nil, @@ -426,6 +448,8 @@ enum RnIapHelper { typeIOS: nil, isFamilyShareableIOS: nil, jsonRepresentationIOS: nil, + pricingTermsIOS: nil, + subscriptionInfoIOS: nil, discountsIOS: nil, introductoryPriceIOS: nil, introductoryPriceAsAmountIOS: nil, diff --git a/libraries/react-native-iap/src/__tests__/utils/type-bridge.test.ts b/libraries/react-native-iap/src/__tests__/utils/type-bridge.test.ts index b569acfb9..1b66487a5 100644 --- a/libraries/react-native-iap/src/__tests__/utils/type-bridge.test.ts +++ b/libraries/react-native-iap/src/__tests__/utils/type-bridge.test.ts @@ -113,8 +113,26 @@ describe('type-bridge utilities', () => { currency: 'USD', price: 4.99, platform: 'ios', + debugDescription: 'StoreKit subscription debug', typeIOS: 'autoRenewableSubscription', subscriptionGroupIdIOS: '21686373', + pricingTermsIOS: JSON.stringify([ + { + billingDisplayPrice: '$4.99', + billingPeriod: {unit: 'month', value: 1}, + billingPlanType: 'monthly', + billingPrice: 4.99, + commitmentInfo: { + displayPrice: '$59.88', + period: {unit: 'year', value: 1}, + price: 59.88, + }, + }, + ]), + subscriptionInfoIOS: JSON.stringify({ + subscriptionGroupId: '21686373', + subscriptionPeriod: {unit: 'month', value: 1}, + }), subscriptionOffers: JSON.stringify([ { id: 'intro_weekly', @@ -147,7 +165,11 @@ describe('type-bridge utilities', () => { expect(result.type).toBe('subs'); expect(result.platform).toBe('ios'); + expect(result.debugDescription).toBe('StoreKit subscription debug'); expect(result.subscriptionGroupIdIOS).toBe('21686373'); + expect(result.pricingTermsIOS).toHaveLength(1); + expect(result.pricingTermsIOS[0].billingPlanType).toBe('monthly'); + expect(result.subscriptionInfoIOS.subscriptionGroupId).toBe('21686373'); expect(Array.isArray(result.subscriptionOffers)).toBe(true); expect(result.subscriptionOffers.length).toBe(2); expect(result.subscriptionOffers[0].id).toBe('intro_weekly'); diff --git a/libraries/react-native-iap/src/specs/RnIap.nitro.ts b/libraries/react-native-iap/src/specs/RnIap.nitro.ts index 668afe716..c7a90df94 100644 --- a/libraries/react-native-iap/src/specs/RnIap.nitro.ts +++ b/libraries/react-native-iap/src/specs/RnIap.nitro.ts @@ -602,6 +602,7 @@ export interface NitroProduct { id: ProductCommon['id']; title: ProductCommon['title']; description: ProductCommon['description']; + debugDescription?: ProductCommon['debugDescription']; type: string; displayName?: ProductCommon['displayName']; displayPrice?: ProductCommon['displayPrice']; @@ -612,6 +613,8 @@ export interface NitroProduct { typeIOS?: string | null; isFamilyShareableIOS?: boolean | null; jsonRepresentationIOS?: string | null; + pricingTermsIOS?: string | null; + subscriptionInfoIOS?: string | null; discountsIOS?: string | null; introductoryPriceIOS?: string | null; introductoryPriceAsAmountIOS?: number | null; diff --git a/libraries/react-native-iap/src/utils/type-bridge.ts b/libraries/react-native-iap/src/utils/type-bridge.ts index e1fe30193..ea2e741cc 100644 --- a/libraries/react-native-iap/src/utils/type-bridge.ts +++ b/libraries/react-native-iap/src/utils/type-bridge.ts @@ -232,7 +232,7 @@ export function convertNitroProductToProduct( displayPrice: nitroProduct.displayPrice ?? '', currency: nitroProduct.currency ?? '', price: toNullableNumber(nitroProduct.price), - debugDescription: null, + debugDescription: nitroProduct.debugDescription ?? null, platform, }; @@ -244,7 +244,6 @@ export function convertNitroProductToProduct( jsonRepresentationIOS: nitroProduct.jsonRepresentationIOS ?? DEFAULT_JSON_REPR, typeIOS: normalizeProductTypeIOS(nitroProduct.typeIOS), - subscriptionInfoIOS: undefined, }; iosProduct.introductoryPriceAsAmountIOS = toNullableString( @@ -284,6 +283,28 @@ export function convertNitroProductToProduct( iosProduct.discountsIOS = null; } + if (nitroProduct.pricingTermsIOS) { + try { + iosProduct.pricingTermsIOS = JSON.parse(nitroProduct.pricingTermsIOS); + } catch { + iosProduct.pricingTermsIOS = null; + } + } else { + iosProduct.pricingTermsIOS = null; + } + + if (nitroProduct.subscriptionInfoIOS) { + try { + iosProduct.subscriptionInfoIOS = JSON.parse( + nitroProduct.subscriptionInfoIOS, + ); + } catch { + iosProduct.subscriptionInfoIOS = null; + } + } else { + iosProduct.subscriptionInfoIOS = null; + } + // Parse standardized subscriptionOffers (cross-platform, OpenIAP 1.3.10+) if (nitroProduct.subscriptionOffers) { try { From 1561c22ef485fc8fd0be13b3e0d5191aafc5efb1 Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 9 Jul 2026 23:50:01 +0900 Subject: [PATCH 02/15] fix(flutter): tolerate malformed product metadata entries Keep parsing valid generated product metadata list entries when one malformed entry is present, and cover the mixed-list case in helper tests. --- .../flutter_inapp_purchase/lib/helpers.dart | 91 +++++++++---------- .../test/helpers_unit_test.dart | 14 +-- 2 files changed, 49 insertions(+), 56 deletions(-) diff --git a/libraries/flutter_inapp_purchase/lib/helpers.dart b/libraries/flutter_inapp_purchase/lib/helpers.dart index 29c3d5191..a016900e9 100644 --- a/libraries/flutter_inapp_purchase/lib/helpers.dart +++ b/libraries/flutter_inapp_purchase/lib/helpers.dart @@ -50,10 +50,9 @@ gentype.ProductCommon parseProductFromNative( // Heuristics based on well-known platform-specific fields final looksAndroid = json.containsKey('oneTimePurchaseOfferDetailsAndroid') || - json.containsKey('subscriptionOfferDetailsAndroid') || - json.containsKey('nameAndroid'); - final looksIOS = - json.containsKey('subscriptionGroupIdIOS') || + json.containsKey('subscriptionOfferDetailsAndroid') || + json.containsKey('nameAndroid'); + final looksIOS = json.containsKey('subscriptionGroupIdIOS') || json.containsKey('jsonRepresentationIOS') || json.containsKey('environmentIOS'); if (looksAndroid && !looksIOS) { @@ -61,9 +60,8 @@ gentype.ProductCommon parseProductFromNative( } else if (looksIOS && !looksAndroid) { platform = gentype.IapPlatform.IOS; } else { - platform = fallbackIsIOS - ? gentype.IapPlatform.IOS - : gentype.IapPlatform.Android; + platform = + fallbackIsIOS ? gentype.IapPlatform.IOS : gentype.IapPlatform.Android; } } @@ -73,18 +71,16 @@ gentype.ProductCommon parseProductFromNative( return null; } - final productId = - (json['id']?.toString() ?? - json['productId']?.toString() ?? - json['sku']?.toString() ?? - json['productIdentifier']?.toString() ?? - '') - .trim(); + final productId = (json['id']?.toString() ?? + json['productId']?.toString() ?? + json['sku']?.toString() ?? + json['productIdentifier']?.toString() ?? + '') + .trim(); final title = json['title']?.toString() ?? productId; final description = json['description']?.toString() ?? ''; final currency = json['currency']?.toString() ?? ''; - final displayPrice = - json['displayPrice']?.toString() ?? + final displayPrice = json['displayPrice']?.toString() ?? json['localizedPrice']?.toString() ?? '0'; final priceValue = parsePrice(json['price']); @@ -110,8 +106,8 @@ gentype.ProductCommon parseProductFromNative( json['discountsIOS'] ?? json['discounts'], ), displayName: json['displayName']?.toString(), - introductoryPriceAsAmountIOS: json['introductoryPriceAsAmountIOS'] - ?.toString(), + introductoryPriceAsAmountIOS: + json['introductoryPriceAsAmountIOS']?.toString(), introductoryPriceIOS: json['introductoryPriceIOS']?.toString(), introductoryPriceNumberOfPeriodsIOS: json['introductoryPriceNumberOfPeriodsIOS']?.toString(), @@ -132,8 +128,8 @@ gentype.ProductCommon parseProductFromNative( json['subscriptionOffers'], ), subscriptionGroupIdIOS: json['subscriptionGroupIdIOS']?.toString(), - subscriptionPeriodNumberIOS: json['subscriptionPeriodNumberIOS'] - ?.toString(), + subscriptionPeriodNumberIOS: + json['subscriptionPeriodNumberIOS']?.toString(), subscriptionPeriodUnitIOS: _parseSubscriptionPeriod( json['subscriptionPeriodUnitIOS'], ), @@ -145,7 +141,7 @@ gentype.ProductCommon parseProductFromNative( ); final subscriptionOffers = _parseStandardizedSubscriptionOffers(json['subscriptionOffers']) ?? - _parseSubscriptionOffers(subscriptionOfferDetails); + _parseSubscriptionOffers(subscriptionOfferDetails); return gentype.ProductSubscriptionAndroid( currency: currency, @@ -222,14 +218,13 @@ gentype.ProductCommon parseProductFromNative( productStatusAndroid: _parseProductStatusAndroid( json['productStatusAndroid'], ), - subscriptionOfferDetailsAndroid: androidOffers.isEmpty - ? null - : androidOffers, + subscriptionOfferDetailsAndroid: + androidOffers.isEmpty ? null : androidOffers, subscriptionOffers: _parseStandardizedSubscriptionOffers(json['subscriptionOffers']) ?? - (androidOffers.isEmpty - ? null - : _parseSubscriptionOffers(androidOffers)), + (androidOffers.isEmpty + ? null + : _parseSubscriptionOffers(androidOffers)), ); } @@ -290,8 +285,7 @@ gentype.Purchase convertToPurchase( 'productId': productId, 'platform': gentype.IapPlatform.Android.toJson(), 'store': storeValue, - 'isAutoRenewing': - itemJson['isAutoRenewing'] as bool? ?? + 'isAutoRenewing': itemJson['isAutoRenewing'] as bool? ?? itemJson['autoRenewingAndroid'] as bool? ?? false, 'purchaseState': purchaseState, @@ -300,18 +294,18 @@ gentype.Purchase convertToPurchase( 'purchaseToken': itemJson['purchaseToken']?.toString(), 'autoRenewingAndroid': itemJson['autoRenewingAndroid'] as bool?, 'dataAndroid': itemJson['originalJsonAndroid']?.toString(), - 'developerPayloadAndroid': itemJson['developerPayloadAndroid'] - ?.toString(), + 'developerPayloadAndroid': + itemJson['developerPayloadAndroid']?.toString(), 'ids': (itemJson['ids'] as List?) ?.map((e) => e.toString()) .toList(), 'isAcknowledgedAndroid': itemJson['isAcknowledgedAndroid'] as bool?, 'obfuscatedAccountIdAndroid': itemJson['obfuscatedAccountIdAndroid']?.toString() ?? - originalJson?['obfuscatedAccountIdAndroid']?.toString(), + originalJson?['obfuscatedAccountIdAndroid']?.toString(), 'obfuscatedProfileIdAndroid': itemJson['obfuscatedProfileIdAndroid']?.toString() ?? - originalJson?['obfuscatedProfileIdAndroid']?.toString(), + originalJson?['obfuscatedProfileIdAndroid']?.toString(), 'packageNameAndroid': itemJson['packageNameAndroid']?.toString(), 'signatureAndroid': itemJson['signatureAndroid']?.toString(), 'transactionId': transactionId, @@ -334,7 +328,7 @@ gentype.Purchase convertToPurchase( double? originalTransactionDateIOS; final originalTransactionDateValue = itemJson['originalTransactionDateIOS'] ?? - originalJson?['originalTransactionDateIOS']; + originalJson?['originalTransactionDateIOS']; if (originalTransactionDateValue is num) { originalTransactionDateIOS = originalTransactionDateValue.toDouble(); } else if (originalTransactionDateValue is String) { @@ -356,8 +350,7 @@ gentype.Purchase convertToPurchase( 'purchaseState': stateIOS, 'quantity': quantity, 'transactionDate': transactionDate, - 'purchaseToken': - itemJson['transactionReceipt']?.toString() ?? + 'purchaseToken': itemJson['transactionReceipt']?.toString() ?? itemJson['purchaseToken']?.toString(), 'ids': (itemJson['ids'] as List?) ?.map((e) => e.toString()) @@ -368,8 +361,8 @@ gentype.Purchase convertToPurchase( 'currencyCodeIOS': itemJson['currencyCodeIOS']?.toString(), 'currencySymbolIOS': itemJson['currencySymbolIOS']?.toString(), 'environmentIOS': itemJson['environmentIOS']?.toString(), - 'expirationDateIOS': (originalJson?['expirationDateIOS'] as num?) - ?.toDouble(), + 'expirationDateIOS': + (originalJson?['expirationDateIOS'] as num?)?.toDouble(), 'originalTransactionIdentifierIOS': itemJson['originalTransactionIdentifierIOS']?.toString(), 'originalTransactionDateIOS': originalTransactionDateIOS, @@ -378,8 +371,8 @@ gentype.Purchase convertToPurchase( 'webOrderLineItemIdIOS': itemJson['webOrderLineItemIdIOS']?.toString(), 'offerIOS': originalJson?['offerIOS'], 'priceIOS': (originalJson?['priceIOS'] as num?)?.toDouble(), - 'revocationDateIOS': (originalJson?['revocationDateIOS'] as num?) - ?.toDouble(), + 'revocationDateIOS': + (originalJson?['revocationDateIOS'] as num?)?.toDouble(), 'revocationReasonIOS': originalJson?['revocationReasonIOS']?.toString(), 'transactionId': transactionId ?? purchaseId, }; @@ -594,7 +587,7 @@ List? _parseGeneratedList( try { parsed.add(fromJson(map)); } catch (_) { - return null; + continue; } } return parsed.isEmpty ? null : parsed; @@ -602,7 +595,8 @@ List? _parseGeneratedList( List? _parseSubscriptionPricingTermsIOS( dynamic value, -) => _parseGeneratedList(value, gentype.SubscriptionPricingTermsIOS.fromJson); +) => + _parseGeneratedList(value, gentype.SubscriptionPricingTermsIOS.fromJson); List? _parseDiscountOffers(dynamic value) => _parseGeneratedList(value, gentype.DiscountOffer.fromJson); @@ -675,8 +669,7 @@ List _parseOfferDetails( : null, offerId: e['offerId'] as String?, offerToken: e['offerToken'] as String? ?? '', - offerTags: - (e['offerTags'] as List?) + offerTags: (e['offerTags'] as List?) ?.map((tag) => tag.toString()) .toList() ?? const [], @@ -958,7 +951,7 @@ gentype.PaymentModeIOS _parsePaymentMode(dynamic value) { /// Parses oneTimePurchaseOfferDetailsAndroid - handles both array (new 7.0+) /// and single object (legacy) for backwards compatibility List? -_parseOneTimePurchaseOfferDetails(dynamic value) { + _parseOneTimePurchaseOfferDetails(dynamic value) { if (value == null) return null; // New format: array of offers (Android 7.0+) @@ -979,14 +972,13 @@ _parseOneTimePurchaseOfferDetails(dynamic value) { } gentype.ProductAndroidOneTimePurchaseOfferDetail? -_parseSingleOneTimePurchaseOfferDetail(dynamic value) { + _parseSingleOneTimePurchaseOfferDetail(dynamic value) { if (value is Map) { return gentype.ProductAndroidOneTimePurchaseOfferDetail( formattedPrice: value['formattedPrice']?.toString() ?? '0', priceAmountMicros: value['priceAmountMicros']?.toString() ?? '0', priceCurrencyCode: value['priceCurrencyCode']?.toString() ?? 'USD', - offerTags: - (value['offerTags'] as List?) + offerTags: (value['offerTags'] as List?) ?.map((e) => e.toString()) .toList() ?? [], @@ -1024,8 +1016,7 @@ _parseSingleOneTimePurchaseOfferDetail(dynamic value) { formattedPrice: map['formattedPrice']?.toString() ?? '0', priceAmountMicros: map['priceAmountMicros']?.toString() ?? '0', priceCurrencyCode: map['priceCurrencyCode']?.toString() ?? 'USD', - offerTags: - (map['offerTags'] as List?) + offerTags: (map['offerTags'] as List?) ?.map((e) => e.toString()) .toList() ?? [], diff --git a/libraries/flutter_inapp_purchase/test/helpers_unit_test.dart b/libraries/flutter_inapp_purchase/test/helpers_unit_test.dart index 2e663a0c0..af41853ac 100644 --- a/libraries/flutter_inapp_purchase/test/helpers_unit_test.dart +++ b/libraries/flutter_inapp_purchase/test/helpers_unit_test.dart @@ -151,6 +151,10 @@ void main() { 'priceCurrencyCode': 'USD', }, 'discountOffers': >[ + { + 'id': 'malformed_discount', + 'offerTokenAndroid': 'bad-token', + }, { 'currency': 'USD', 'displayPrice': '\$1.99', @@ -390,11 +394,8 @@ void main() { 'token', ); expect( - subscription - .subscriptionOfferDetailsAndroid - .single - .installmentPlanDetails - ?.commitmentPaymentsCount, + subscription.subscriptionOfferDetailsAndroid.single + .installmentPlanDetails?.commitmentPaymentsCount, 12, ); expect( @@ -654,7 +655,8 @@ void main() { }, ); - test('convertToPurchaseError forwards debugMessage and responseCode ' + test( + 'convertToPurchaseError forwards debugMessage and responseCode ' 'from PurchaseResult', () { final result = PurchaseResult.fromJSON({ 'responseCode': 5, From 55c17ef3dbe550de3cbb602a8f8003be48a381c1 Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 9 Jul 2026 23:52:02 +0900 Subject: [PATCH 03/15] fix(flutter): parse installment metadata defensively Avoid generated int casts for native installment plan details and cover string/double platform-channel numeric values in helper tests. --- .../flutter_inapp_purchase/lib/helpers.dart | 24 ++++++++++++------- .../test/helpers_unit_test.dart | 4 ++-- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/libraries/flutter_inapp_purchase/lib/helpers.dart b/libraries/flutter_inapp_purchase/lib/helpers.dart index a016900e9..a57ed708a 100644 --- a/libraries/flutter_inapp_purchase/lib/helpers.dart +++ b/libraries/flutter_inapp_purchase/lib/helpers.dart @@ -616,6 +616,18 @@ gentype.ProductStatusAndroid? _parseProductStatusAndroid(dynamic value) { } } +gentype.InstallmentPlanDetailsAndroid? _parseInstallmentPlanDetailsAndroid( + dynamic value, +) { + final map = normalizeDynamicMap(value); + if (map == null) return null; + return gentype.InstallmentPlanDetailsAndroid( + commitmentPaymentsCount: _toInt(map['commitmentPaymentsCount']) ?? 0, + subsequentCommitmentPaymentsCount: + _toInt(map['subsequentCommitmentPaymentsCount']) ?? 0, + ); +} + List _parseOfferDetails( dynamic json, ) { @@ -656,17 +668,11 @@ List _parseOfferDetails( // Skip invalid items return null; } - final installmentPlanDetailsMap = normalizeDynamicMap( - e['installmentPlanDetails'], - ); - return gentype.ProductSubscriptionAndroidOfferDetails( basePlanId: e['basePlanId'] as String? ?? '', - installmentPlanDetails: installmentPlanDetailsMap != null - ? gentype.InstallmentPlanDetailsAndroid.fromJson( - installmentPlanDetailsMap, - ) - : null, + installmentPlanDetails: _parseInstallmentPlanDetailsAndroid( + e['installmentPlanDetails'], + ), offerId: e['offerId'] as String?, offerToken: e['offerToken'] as String? ?? '', offerTags: (e['offerTags'] as List?) diff --git a/libraries/flutter_inapp_purchase/test/helpers_unit_test.dart b/libraries/flutter_inapp_purchase/test/helpers_unit_test.dart index af41853ac..9cb4ee1db 100644 --- a/libraries/flutter_inapp_purchase/test/helpers_unit_test.dart +++ b/libraries/flutter_inapp_purchase/test/helpers_unit_test.dart @@ -350,8 +350,8 @@ void main() { { 'basePlanId': 'base', 'installmentPlanDetails': { - 'commitmentPaymentsCount': 12, - 'subsequentCommitmentPaymentsCount': 0, + 'commitmentPaymentsCount': '12', + 'subsequentCommitmentPaymentsCount': 0.0, }, 'offerToken': 'token', 'offerTags': ['tag'], From 48a3eca3591afbd30990107d104540c87982febb Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 9 Jul 2026 23:54:48 +0900 Subject: [PATCH 04/15] fix(libraries): harden metadata parser guards Handle already-typed Flutter metadata values, preserve defensive installment parsing, and avoid serializing empty iOS metadata collections over the React Native bridge. --- .../flutter_inapp_purchase/lib/helpers.dart | 7 +++++++ .../test/helpers_unit_test.dart | 18 +++++++++++++++--- .../react-native-iap/ios/RnIapHelper.swift | 4 ++-- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/libraries/flutter_inapp_purchase/lib/helpers.dart b/libraries/flutter_inapp_purchase/lib/helpers.dart index a57ed708a..219befd76 100644 --- a/libraries/flutter_inapp_purchase/lib/helpers.dart +++ b/libraries/flutter_inapp_purchase/lib/helpers.dart @@ -577,11 +577,16 @@ List? _parseGeneratedList( dynamic value, T Function(Map) fromJson, ) { + if (value is List) return value.isEmpty ? null : value; final list = _parseNativeList(value); if (list == null) return null; final parsed = []; for (final item in list) { + if (item is T) { + parsed.add(item); + continue; + } final map = normalizeDynamicMap(item); if (map == null) continue; try { @@ -609,6 +614,7 @@ List? _parseStandardizedSubscriptionOffers( gentype.ProductStatusAndroid? _parseProductStatusAndroid(dynamic value) { if (value == null) return null; + if (value is gentype.ProductStatusAndroid) return value; try { return gentype.ProductStatusAndroid.fromJson(value.toString()); } catch (_) { @@ -619,6 +625,7 @@ gentype.ProductStatusAndroid? _parseProductStatusAndroid(dynamic value) { gentype.InstallmentPlanDetailsAndroid? _parseInstallmentPlanDetailsAndroid( dynamic value, ) { + if (value is gentype.InstallmentPlanDetailsAndroid) return value; final map = normalizeDynamicMap(value); if (map == null) return null; return gentype.InstallmentPlanDetailsAndroid( diff --git a/libraries/flutter_inapp_purchase/test/helpers_unit_test.dart b/libraries/flutter_inapp_purchase/test/helpers_unit_test.dart index 9cb4ee1db..ace81c40e 100644 --- a/libraries/flutter_inapp_purchase/test/helpers_unit_test.dart +++ b/libraries/flutter_inapp_purchase/test/helpers_unit_test.dart @@ -150,7 +150,15 @@ void main() { 'priceAmountMicros': '2990000', 'priceCurrencyCode': 'USD', }, - 'discountOffers': >[ + 'discountOffers': [ + const types.DiscountOffer( + currency: 'USD', + displayPrice: '\$0.99', + id: 'typed_discount', + offerTokenAndroid: 'typed-token', + price: 0.99, + type: types.DiscountOfferType.OneTime, + ), { 'id': 'malformed_discount', 'offerTokenAndroid': 'bad-token', @@ -164,7 +172,7 @@ void main() { 'type': 'one-time', }, ], - 'productStatusAndroid': 'ok', + 'productStatusAndroid': types.ProductStatusAndroid.Ok, }, 'inapp', fallbackIsIOS: false, @@ -176,9 +184,13 @@ void main() { expect(androidProduct.platform, types.IapPlatform.Android); expect(androidProduct.price, closeTo(2.99, 0.0001)); expect(androidProduct.oneTimePurchaseOfferDetailsAndroid, isNotNull); - expect(androidProduct.discountOffers, hasLength(1)); + expect(androidProduct.discountOffers, hasLength(2)); expect( androidProduct.discountOffers!.first.offerTokenAndroid, + 'typed-token', + ); + expect( + androidProduct.discountOffers![1].offerTokenAndroid, 'discount-token', ); expect( diff --git a/libraries/react-native-iap/ios/RnIapHelper.swift b/libraries/react-native-iap/ios/RnIapHelper.swift index c3590a67e..74326976b 100644 --- a/libraries/react-native-iap/ios/RnIapHelper.swift +++ b/libraries/react-native-iap/ios/RnIapHelper.swift @@ -146,7 +146,7 @@ enum RnIapHelper { } var pricingTermsIOS: Variant_NullType_String? = nil - if let pricingTermsArray = dictionary["pricingTermsIOS"] as? [[String: Any]] { + if let pricingTermsArray = dictionary["pricingTermsIOS"] as? [[String: Any]], !pricingTermsArray.isEmpty { if let json = serializeToJSON(pricingTermsArray) { pricingTermsIOS = .second(json) } else { @@ -155,7 +155,7 @@ enum RnIapHelper { } var subscriptionInfoIOS: Variant_NullType_String? = nil - if let subscriptionInfo = dictionary["subscriptionInfoIOS"] as? [String: Any] { + if let subscriptionInfo = dictionary["subscriptionInfoIOS"] as? [String: Any], !subscriptionInfo.isEmpty { if let json = serializeToJSON(subscriptionInfo) { subscriptionInfoIOS = .second(json) } else { From 3faac88bdd4db63637feae2b1dc515628ccb2ee3 Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 9 Jul 2026 23:57:09 +0900 Subject: [PATCH 05/15] fix(flutter): decode legacy offer strings before fallback Pass decoded subscription offer lists to both generated and legacy iOS parsers, and cover legacy JSON string offers that require fallback parsing. --- .../flutter_inapp_purchase/lib/helpers.dart | 9 ++-- .../test/helpers_unit_test.dart | 41 +++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/libraries/flutter_inapp_purchase/lib/helpers.dart b/libraries/flutter_inapp_purchase/lib/helpers.dart index 219befd76..5bbd42733 100644 --- a/libraries/flutter_inapp_purchase/lib/helpers.dart +++ b/libraries/flutter_inapp_purchase/lib/helpers.dart @@ -608,9 +608,12 @@ List? _parseDiscountOffers(dynamic value) => List? _parseStandardizedSubscriptionOffers( dynamic value, -) => - _parseGeneratedList(value, gentype.SubscriptionOffer.fromJson) ?? - _parseSubscriptionOffersIOS(value); +) { + final list = _parseNativeList(value); + if (list == null) return null; + return _parseGeneratedList(list, gentype.SubscriptionOffer.fromJson) ?? + _parseSubscriptionOffersIOS(list); +} gentype.ProductStatusAndroid? _parseProductStatusAndroid(dynamic value) { if (value == null) return null; diff --git a/libraries/flutter_inapp_purchase/test/helpers_unit_test.dart b/libraries/flutter_inapp_purchase/test/helpers_unit_test.dart index ace81c40e..a44553f97 100644 --- a/libraries/flutter_inapp_purchase/test/helpers_unit_test.dart +++ b/libraries/flutter_inapp_purchase/test/helpers_unit_test.dart @@ -130,6 +130,47 @@ void main() { }, ); + test( + 'parseProductFromNative parses legacy iOS subscriptionOffers JSON string', + () { + final product = parseProductFromNative( + { + 'platform': 'ios', + 'id': 'premium_monthly', + 'title': 'Premium Monthly', + 'description': 'Monthly plan', + 'currency': 'USD', + 'displayPrice': '\$9.99', + 'price': 9.99, + 'isFamilyShareableIOS': false, + 'jsonRepresentationIOS': '{}', + 'typeIOS': 'AUTO_RENEWABLE_SUBSCRIPTION', + 'subscriptionOffers': jsonEncode(>[ + { + 'id': 'legacy_intro', + 'displayPrice': 'Free', + 'price': 0.0, + 'type': 'INTRODUCTORY', + 'paymentMode': 'FREETRIAL', + 'periodCount': 1, + 'period': {'unit': 'WEEK', 'value': 1}, + }, + ]), + }, + 'subs', + fallbackIsIOS: true, + ); + + expect(product, isA()); + final subscription = product as types.ProductSubscriptionIOS; + expect(subscription.subscriptionOffers, hasLength(1)); + expect( + subscription.subscriptionOffers!.single.paymentMode, + types.PaymentMode.FreeTrial, + ); + }, + ); + test( 'parseProductFromNative creates Android in-app product with string offers', () { From f5ab9810a49b02cfc27c3c151ba012c3bc94ef15 Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 9 Jul 2026 23:59:02 +0900 Subject: [PATCH 06/15] fix(react-native): validate ios metadata bridge shapes Fall back to legacy subscription metadata keys, validate parsed iOS metadata JSON shapes, and cover invalid-shape handling in type-bridge tests. --- .../react-native-iap/ios/RnIapHelper.swift | 4 +++- .../src/__tests__/utils/type-bridge.test.ts | 21 +++++++++++++++++++ .../react-native-iap/src/utils/type-bridge.ts | 11 ++++++---- 3 files changed, 31 insertions(+), 5 deletions(-) diff --git a/libraries/react-native-iap/ios/RnIapHelper.swift b/libraries/react-native-iap/ios/RnIapHelper.swift index 74326976b..64a558207 100644 --- a/libraries/react-native-iap/ios/RnIapHelper.swift +++ b/libraries/react-native-iap/ios/RnIapHelper.swift @@ -155,7 +155,9 @@ enum RnIapHelper { } var subscriptionInfoIOS: Variant_NullType_String? = nil - if let subscriptionInfo = dictionary["subscriptionInfoIOS"] as? [String: Any], !subscriptionInfo.isEmpty { + let subscriptionInfoDict = dictionary["subscriptionInfoIOS"] as? [String: Any] + ?? dictionary["subscription"] as? [String: Any] + if let subscriptionInfo = subscriptionInfoDict, !subscriptionInfo.isEmpty { if let json = serializeToJSON(subscriptionInfo) { subscriptionInfoIOS = .second(json) } else { diff --git a/libraries/react-native-iap/src/__tests__/utils/type-bridge.test.ts b/libraries/react-native-iap/src/__tests__/utils/type-bridge.test.ts index 1b66487a5..18e7228dc 100644 --- a/libraries/react-native-iap/src/__tests__/utils/type-bridge.test.ts +++ b/libraries/react-native-iap/src/__tests__/utils/type-bridge.test.ts @@ -179,6 +179,27 @@ describe('type-bridge utilities', () => { expect(result.subscriptionOffers[1].type).toBe('promotional'); }); + it('rejects invalid iOS metadata JSON shapes', () => { + const nitroProduct: NitroProduct = { + id: 'com.example.ios.invalid', + title: 'Invalid Metadata', + description: 'Invalid metadata subscription', + type: 'subs', + displayPrice: '$4.99', + currency: 'USD', + price: 4.99, + platform: 'ios', + typeIOS: 'autoRenewableSubscription', + pricingTermsIOS: JSON.stringify({billingPlanType: 'monthly'}), + subscriptionInfoIOS: JSON.stringify([{subscriptionGroupId: 'group'}]), + } as NitroProduct; + + const result = convertNitroProductToProduct(nitroProduct) as any; + + expect(result.pricingTermsIOS).toBeNull(); + expect(result.subscriptionInfoIOS).toBeNull(); + }); + it('converts Android subscription with standardized subscriptionOffers', () => { const nitroProduct: NitroProduct = { id: 'com.example.android.subs', diff --git a/libraries/react-native-iap/src/utils/type-bridge.ts b/libraries/react-native-iap/src/utils/type-bridge.ts index ea2e741cc..127e48a9f 100644 --- a/libraries/react-native-iap/src/utils/type-bridge.ts +++ b/libraries/react-native-iap/src/utils/type-bridge.ts @@ -285,7 +285,8 @@ export function convertNitroProductToProduct( if (nitroProduct.pricingTermsIOS) { try { - iosProduct.pricingTermsIOS = JSON.parse(nitroProduct.pricingTermsIOS); + const parsed = JSON.parse(nitroProduct.pricingTermsIOS); + iosProduct.pricingTermsIOS = Array.isArray(parsed) ? parsed : null; } catch { iosProduct.pricingTermsIOS = null; } @@ -295,9 +296,11 @@ export function convertNitroProductToProduct( if (nitroProduct.subscriptionInfoIOS) { try { - iosProduct.subscriptionInfoIOS = JSON.parse( - nitroProduct.subscriptionInfoIOS, - ); + const parsed = JSON.parse(nitroProduct.subscriptionInfoIOS); + iosProduct.subscriptionInfoIOS = + typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) + ? parsed + : null; } catch { iosProduct.subscriptionInfoIOS = null; } From 0bc8fc80a1666de1a506b629feb4e3666a9fe2bb Mon Sep 17 00:00:00 2001 From: Hyo Date: Fri, 10 Jul 2026 00:01:50 +0900 Subject: [PATCH 07/15] fix(kmp): preserve legacy ios offer payloads Merge legacy offers into successfully decoded iOS product payloads and extend React Native metadata error-path coverage. --- .../github/hyochan/kmpiap/InAppPurchaseIOS.kt | 58 ++++++++++++++++++- .../src/__tests__/utils/type-bridge.test.ts | 15 ++++- 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt index 80287f489..a9d077c4a 100644 --- a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt +++ b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt @@ -1274,7 +1274,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { val map = dict.mapKeys { it.key.toString() } runCatching { Product.fromJson(map) }.getOrNull()?.let { - return@mapNotNull it + return@mapNotNull mergeLegacySubscriptionOffers(it, map) } // Parse subscription offers from the data (if product has subscription info) @@ -1322,7 +1322,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { val dict = (item as? Map<*, *>) ?: return@mapNotNull null val map = dict.mapKeys { it.key.toString() } runCatching { ProductSubscription.fromJson(map) }.getOrNull()?.let { - return@mapNotNull it + return@mapNotNull mergeLegacySubscriptionOffers(it, map) } // Parse subscription offers from the data @@ -1377,7 +1377,9 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { // Native iOS may return either generated union JSON or raw StoreKit maps; // decode the union first, then recover by product type for legacy payloads. - runCatching { ProductOrSubscription.fromJson(map) }.getOrElse { + runCatching { ProductOrSubscription.fromJson(map) } + .map { mergeLegacySubscriptionOffers(it, map) } + .getOrElse { val type = map["type"] as? String if (type?.equals(ProductType.Subs.rawValue, ignoreCase = true) == true) { convertAnyListToProductSubscriptions(listOf(item)).firstOrNull()?.let { @@ -1393,6 +1395,56 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { } } + private fun mergeLegacySubscriptionOffers( + product: Product, + map: Map + ): Product { + val subscriptionOffers = convertAnyListToSubscriptionOffers( + map["subscriptionOffers"] ?: map["offers"] + ) + if (subscriptionOffers.isEmpty()) return product + + return when (product) { + is ProductIOS -> if (product.subscriptionOffers.isNullOrEmpty()) { + product.copy(subscriptionOffers = subscriptionOffers) + } else { + product + } + else -> product + } + } + + private fun mergeLegacySubscriptionOffers( + subscription: ProductSubscription, + map: Map + ): ProductSubscription { + val subscriptionOffers = convertAnyListToSubscriptionOffers( + map["subscriptionOffers"] ?: map["offers"] + ) + if (subscriptionOffers.isEmpty()) return subscription + + return when (subscription) { + is ProductSubscriptionIOS -> if (subscription.subscriptionOffers.isNullOrEmpty()) { + subscription.copy(subscriptionOffers = subscriptionOffers) + } else { + subscription + } + else -> subscription + } + } + + private fun mergeLegacySubscriptionOffers( + item: ProductOrSubscription, + map: Map + ): ProductOrSubscription = when (item) { + is ProductOrSubscription.ProductItem -> + ProductOrSubscription.ProductItem(mergeLegacySubscriptionOffers(item.value, map)) + is ProductOrSubscription.ProductSubscriptionItem -> + ProductOrSubscription.ProductSubscriptionItem( + mergeLegacySubscriptionOffers(item.value, map) + ) + } + @Suppress("UNCHECKED_CAST") private fun convertAnyToProductIOS(data: Any?): ProductIOS? { if (data == null) return null diff --git a/libraries/react-native-iap/src/__tests__/utils/type-bridge.test.ts b/libraries/react-native-iap/src/__tests__/utils/type-bridge.test.ts index 18e7228dc..65f6cfb7b 100644 --- a/libraries/react-native-iap/src/__tests__/utils/type-bridge.test.ts +++ b/libraries/react-native-iap/src/__tests__/utils/type-bridge.test.ts @@ -179,7 +179,7 @@ describe('type-bridge utilities', () => { expect(result.subscriptionOffers[1].type).toBe('promotional'); }); - it('rejects invalid iOS metadata JSON shapes', () => { + it('rejects invalid iOS metadata JSON payloads', () => { const nitroProduct: NitroProduct = { id: 'com.example.ios.invalid', title: 'Invalid Metadata', @@ -198,6 +198,19 @@ describe('type-bridge utilities', () => { expect(result.pricingTermsIOS).toBeNull(); expect(result.subscriptionInfoIOS).toBeNull(); + + const malformedJsonProduct: NitroProduct = { + ...nitroProduct, + pricingTermsIOS: '[', + subscriptionInfoIOS: '{', + }; + + const malformedResult = convertNitroProductToProduct( + malformedJsonProduct, + ) as any; + + expect(malformedResult.pricingTermsIOS).toBeNull(); + expect(malformedResult.subscriptionInfoIOS).toBeNull(); }); it('converts Android subscription with standardized subscriptionOffers', () => { From f15f2d48e01866434a0b91bbfd96240f5f736392 Mon Sep 17 00:00:00 2001 From: Hyo Date: Fri, 10 Jul 2026 00:05:18 +0900 Subject: [PATCH 08/15] fix(react-native): guard ios metadata serialization --- libraries/react-native-iap/ios/RnIapHelper.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/libraries/react-native-iap/ios/RnIapHelper.swift b/libraries/react-native-iap/ios/RnIapHelper.swift index 64a558207..5d601eda1 100644 --- a/libraries/react-native-iap/ios/RnIapHelper.swift +++ b/libraries/react-native-iap/ios/RnIapHelper.swift @@ -99,12 +99,14 @@ enum RnIapHelper { // MARK: - JSON serialization helpers static func serializeToJSON(_ array: [[String: Any]]) -> String? { - guard let jsonData = try? JSONSerialization.data(withJSONObject: array, options: []) else { return nil } + guard JSONSerialization.isValidJSONObject(array), + let jsonData = try? JSONSerialization.data(withJSONObject: array, options: []) else { return nil } return String(data: jsonData, encoding: .utf8) } static func serializeToJSON(_ dict: [String: Any]) -> String? { - guard let jsonData = try? JSONSerialization.data(withJSONObject: dict, options: []) else { return nil } + guard JSONSerialization.isValidJSONObject(dict), + let jsonData = try? JSONSerialization.data(withJSONObject: dict, options: []) else { return nil } return String(data: jsonData, encoding: .utf8) } From c6fd94125cd714fd5bb490427df0e3bc15cc4875 Mon Sep 17 00:00:00 2001 From: Hyo Date: Fri, 10 Jul 2026 00:43:05 +0900 Subject: [PATCH 09/15] fix(libraries): preserve product offer metadata --- .../flutter_inapp_purchase/lib/helpers.dart | 23 ++ .../test/helpers_unit_test.dart | 56 +++++ .../kotlin/io/github/hyochan/kmpiap/Helper.kt | 202 ++++++++++++++++-- .../io/github/hyochan/kmpiap/DslExtensions.kt | 11 +- .../hyochan/kmpiap/InAppPurchaseTest.kt | 135 ++++++++++++ 5 files changed, 407 insertions(+), 20 deletions(-) diff --git a/libraries/flutter_inapp_purchase/lib/helpers.dart b/libraries/flutter_inapp_purchase/lib/helpers.dart index 5bbd42733..1e1757638 100644 --- a/libraries/flutter_inapp_purchase/lib/helpers.dart +++ b/libraries/flutter_inapp_purchase/lib/helpers.dart @@ -865,6 +865,8 @@ List _parseSubscriptionOffers( return offerDetails.map((offer) { // Determine payment mode and price from first pricing phase gentype.PaymentMode? paymentMode; + gentype.SubscriptionPeriod? period; + int? periodCount; String displayPrice = ''; double price = 0; String? currency; @@ -873,6 +875,8 @@ List _parseSubscriptionOffers( final firstPhase = offer.pricingPhases.pricingPhaseList.first; final priceAmountMicros = int.tryParse(firstPhase.priceAmountMicros) ?? 0; final recurrenceMode = firstPhase.recurrenceMode; + period = _parseBillingPeriod(firstPhase.billingPeriod); + periodCount = firstPhase.billingCycleCount; if (priceAmountMicros == 0) { paymentMode = gentype.PaymentMode.FreeTrial; @@ -903,7 +907,10 @@ List _parseSubscriptionOffers( currency: currency, type: type, paymentMode: paymentMode, + period: period, + periodCount: periodCount, basePlanIdAndroid: offer.basePlanId, + installmentPlanDetailsAndroid: offer.installmentPlanDetails, offerTokenAndroid: offer.offerToken, offerTagsAndroid: offer.offerTags, pricingPhasesAndroid: offer.pricingPhases, @@ -911,6 +918,22 @@ List _parseSubscriptionOffers( }).toList(); } +gentype.SubscriptionPeriod? _parseBillingPeriod(String billingPeriod) { + final match = RegExp(r'^P(\d+)([DWMY])$').firstMatch(billingPeriod); + if (match == null) return null; + final value = int.tryParse(match.group(1) ?? ''); + if (value == null) return null; + + final unit = switch (match.group(2)) { + 'D' => gentype.SubscriptionPeriodUnit.Day, + 'W' => gentype.SubscriptionPeriodUnit.Week, + 'M' => gentype.SubscriptionPeriodUnit.Month, + 'Y' => gentype.SubscriptionPeriodUnit.Year, + _ => gentype.SubscriptionPeriodUnit.Unknown, + }; + return gentype.SubscriptionPeriod(unit: unit, value: value); +} + gentype.SubscriptionPeriodIOS? _parseSubscriptionPeriod(dynamic value) { if (value == null) return null; final raw = value.toString().toUpperCase(); diff --git a/libraries/flutter_inapp_purchase/test/helpers_unit_test.dart b/libraries/flutter_inapp_purchase/test/helpers_unit_test.dart index a44553f97..a49a9c3f9 100644 --- a/libraries/flutter_inapp_purchase/test/helpers_unit_test.dart +++ b/libraries/flutter_inapp_purchase/test/helpers_unit_test.dart @@ -462,6 +462,62 @@ void main() { }, ); + test( + 'parseProductFromNative preserves Android installment details in fallback subscriptionOffers', + () { + final product = parseProductFromNative( + { + 'platform': 'android', + 'id': 'premium_yearly', + 'title': 'Premium Yearly', + 'description': 'Yearly access', + 'currency': 'USD', + 'displayPrice': '\$49.99', + 'price': 49.99, + 'subscriptionOfferDetailsAndroid': >[ + { + 'basePlanId': 'base', + 'installmentPlanDetails': { + 'commitmentPaymentsCount': 12, + 'subsequentCommitmentPaymentsCount': 0, + }, + 'offerToken': 'token', + 'offerTags': ['tag'], + 'pricingPhases': { + 'pricingPhaseList': >[ + { + 'billingCycleCount': 1, + 'billingPeriod': 'P1Y', + 'formattedPrice': '\$49.99', + 'priceAmountMicros': '49990000', + 'priceCurrencyCode': 'USD', + 'recurrenceMode': 2, + }, + ], + }, + }, + ], + }, + 'subs', + fallbackIsIOS: false, + ); + + expect(product, isA()); + final subscription = product as types.ProductSubscriptionAndroid; + final offer = subscription.subscriptionOffers.single; + expect( + offer.installmentPlanDetailsAndroid?.commitmentPaymentsCount, + 12, + ); + expect(offer.period?.unit, types.SubscriptionPeriodUnit.Year); + expect(offer.period?.value, 1); + expect(offer.periodCount, 1); + expect( + offer.pricingPhasesAndroid?.pricingPhaseList.single.billingPeriod, + 'P1Y'); + }, + ); + test( 'parseProductFromNative builds Android in-app with one-time offer list', () { diff --git a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/Helper.kt b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/Helper.kt index 9a377f149..3e99630df 100644 --- a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/Helper.kt +++ b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/Helper.kt @@ -11,20 +11,27 @@ import com.android.billingclient.api.QueryProductDetailsResult import kotlin.coroutines.resume import kotlinx.coroutines.suspendCancellableCoroutine import io.github.hyochan.kmpiap.openiap.BillingProgramAndroid +import io.github.hyochan.kmpiap.openiap.DiscountAmountAndroid +import io.github.hyochan.kmpiap.openiap.DiscountDisplayInfoAndroid +import io.github.hyochan.kmpiap.openiap.DiscountOffer import io.github.hyochan.kmpiap.openiap.ErrorCode import io.github.hyochan.kmpiap.openiap.ExternalLinkLaunchModeAndroid import io.github.hyochan.kmpiap.openiap.ExternalLinkTypeAndroid import io.github.hyochan.kmpiap.openiap.FetchProductsResult import io.github.hyochan.kmpiap.openiap.IapPlatform import io.github.hyochan.kmpiap.openiap.IapStore +import io.github.hyochan.kmpiap.openiap.InstallmentPlanDetailsAndroid import io.github.hyochan.kmpiap.openiap.LaunchExternalLinkParamsAndroid +import io.github.hyochan.kmpiap.openiap.LimitedQuantityInfoAndroid import io.github.hyochan.kmpiap.openiap.Product import io.github.hyochan.kmpiap.openiap.ProductAndroid import io.github.hyochan.kmpiap.openiap.ProductAndroidOneTimePurchaseOfferDetail import io.github.hyochan.kmpiap.openiap.ProductRequest +import io.github.hyochan.kmpiap.openiap.ProductStatusAndroid import io.github.hyochan.kmpiap.openiap.ProductSubscriptionAndroid import io.github.hyochan.kmpiap.openiap.ProductSubscriptionAndroidOfferDetails import io.github.hyochan.kmpiap.openiap.ProductType +import io.github.hyochan.kmpiap.openiap.PreorderDetailsAndroid import io.github.hyochan.kmpiap.openiap.PricingPhaseAndroid import io.github.hyochan.kmpiap.openiap.PricingPhasesAndroid import io.github.hyochan.kmpiap.openiap.Purchase @@ -34,7 +41,11 @@ import io.github.hyochan.kmpiap.openiap.DiscountOfferType import io.github.hyochan.kmpiap.openiap.PurchaseAndroid import io.github.hyochan.kmpiap.openiap.PurchaseError import io.github.hyochan.kmpiap.openiap.PurchaseState +import io.github.hyochan.kmpiap.openiap.RentalDetailsAndroid +import io.github.hyochan.kmpiap.openiap.SubscriptionPeriod +import io.github.hyochan.kmpiap.openiap.SubscriptionPeriodUnit import io.github.hyochan.kmpiap.openiap.SubscriptionReplacementModeAndroid +import io.github.hyochan.kmpiap.openiap.ValidTimeWindowAndroid import kotlinx.coroutines.flow.MutableSharedFlow import com.android.billingclient.api.BillingFlowParams import dev.hyo.openiap.BillingProgramAndroid as OpenIapBillingProgram @@ -261,6 +272,7 @@ internal fun com.android.billingclient.api.Purchase.toPurchase(): Purchase { internal fun ProductDetails.toProduct(): Product { val oneTime = oneTimePurchaseOfferDetails + val allOneTimeOffers = runCatching { oneTimePurchaseOfferDetailsList }.getOrNull().orEmpty() val offers = subscriptionOfferDetails val pricingPhase = offers?.firstOrNull()?.pricingPhases?.pricingPhaseList?.firstOrNull() @@ -281,25 +293,34 @@ internal fun ProductDetails.toProduct(): Product { pricingPhase != null -> pricingPhase.priceCurrencyCode else -> "USD" } + val oneTimeOfferDetails = if (allOneTimeOffers.isNotEmpty()) { + allOneTimeOffers.map { it.toOfferDetail() } + } else { + oneTime?.let { listOf(it.toOfferDetail()) } + } + val discountOffers = if (allOneTimeOffers.isNotEmpty()) { + allOneTimeOffers.map { it.toDiscountOffer() } + } else { + oneTime?.let { listOf(it.toDiscountOffer()) } + } + val subscriptionOfferDetails = offers?.map { it.toOfferDetail() } + val subscriptionOffers = subscriptionOfferDetails?.map { it.toSubscriptionOffer() } return ProductAndroid( currency = currencyCode, + debugDescription = description, description = description, + discountOffers = discountOffers, + displayName = name, displayPrice = displayPrice, id = productId, nameAndroid = name, - oneTimePurchaseOfferDetailsAndroid = oneTime?.let { - listOf(ProductAndroidOneTimePurchaseOfferDetail( - formattedPrice = it.formattedPrice, - priceAmountMicros = it.priceAmountMicros.toString(), - priceCurrencyCode = it.priceCurrencyCode, - offerTags = emptyList(), - offerToken = "" - )) - }, + oneTimePurchaseOfferDetailsAndroid = oneTimeOfferDetails, platform = IapPlatform.Android, price = priceValue, - subscriptionOfferDetailsAndroid = offers?.map { it.toOfferDetail() }, + productStatusAndroid = getProductStatus(), + subscriptionOfferDetailsAndroid = subscriptionOfferDetails, + subscriptionOffers = subscriptionOffers, title = title, type = productType ) @@ -312,6 +333,7 @@ internal fun ProductDetails.toSubscriptionProduct(): ProductSubscriptionAndroid? currency = product.currency, debugDescription = product.debugDescription, description = product.description, + discountOffers = product.discountOffers, displayName = product.displayName, displayPrice = product.displayPrice, id = product.id, @@ -319,6 +341,7 @@ internal fun ProductDetails.toSubscriptionProduct(): ProductSubscriptionAndroid? oneTimePurchaseOfferDetailsAndroid = product.oneTimePurchaseOfferDetailsAndroid, platform = product.platform, price = product.price, + productStatusAndroid = product.productStatusAndroid, subscriptionOfferDetailsAndroid = offers, subscriptionOffers = offers.map { it.toSubscriptionOffer() }, title = product.title, @@ -326,21 +349,127 @@ internal fun ProductDetails.toSubscriptionProduct(): ProductSubscriptionAndroid? ) } +private fun ProductDetails.getProductStatus(): ProductStatusAndroid? = runCatching { + val statusMethod = this::class.java.getMethod("getProductStatus") + when (statusMethod.invoke(this) as? Int) { + 0 -> ProductStatusAndroid.Ok + 1 -> ProductStatusAndroid.NotFound + 2 -> ProductStatusAndroid.NoOffersAvailable + else -> ProductStatusAndroid.Unknown + } +}.getOrNull() + +private fun ProductDetails.OneTimePurchaseOfferDetails.toOfferDetail(): ProductAndroidOneTimePurchaseOfferDetail { + val discountInfo = runCatching { discountDisplayInfo }.getOrNull() + + return ProductAndroidOneTimePurchaseOfferDetail( + discountDisplayInfo = discountInfo?.let { info -> + DiscountDisplayInfoAndroid( + discountAmount = runCatching { info.discountAmount }.getOrNull()?.let { amount -> + DiscountAmountAndroid( + discountAmountMicros = amount.discountAmountMicros.toString(), + formattedDiscountAmount = amount.formattedDiscountAmount + ) + }, + percentageDiscount = runCatching { info.percentageDiscount }.getOrNull() + ) + }, + formattedPrice = formattedPrice, + fullPriceMicros = runCatching { fullPriceMicros?.toString() }.getOrNull(), + limitedQuantityInfo = runCatching { limitedQuantityInfo }.getOrNull()?.let { info -> + LimitedQuantityInfoAndroid( + maximumQuantity = info.maximumQuantity, + remainingQuantity = info.remainingQuantity + ) + }, + offerId = runCatching { offerId }.getOrNull(), + offerTags = runCatching { offerTags.orEmpty() }.getOrElse { emptyList() }, + offerToken = offerToken.orEmpty(), + preorderDetailsAndroid = runCatching { preorderDetails }.getOrNull()?.let { details -> + PreorderDetailsAndroid( + preorderPresaleEndTimeMillis = details.preorderPresaleEndTimeMillis.toString(), + preorderReleaseTimeMillis = details.preorderReleaseTimeMillis.toString() + ) + }, + priceAmountMicros = priceAmountMicros.toString(), + priceCurrencyCode = priceCurrencyCode, + purchaseOptionId = runCatching { purchaseOptionId }.getOrNull(), + rentalDetailsAndroid = runCatching { rentalDetails }.getOrNull()?.let { details -> + RentalDetailsAndroid( + rentalPeriod = details.rentalPeriod, + rentalExpirationPeriod = runCatching { details.rentalExpirationPeriod }.getOrNull() + ) + }, + validTimeWindow = runCatching { validTimeWindow }.getOrNull()?.let { window -> + ValidTimeWindowAndroid( + startTimeMillis = window.startTimeMillis.toString(), + endTimeMillis = window.endTimeMillis.toString() + ) + } + ) +} + +private fun ProductDetails.OneTimePurchaseOfferDetails.toDiscountOffer(): DiscountOffer { + val discountInfo = runCatching { discountDisplayInfo }.getOrNull() + + return DiscountOffer( + id = runCatching { offerId }.getOrNull(), + displayPrice = formattedPrice, + price = priceAmountMicros.toDouble() / 1_000_000.0, + currency = priceCurrencyCode, + type = DiscountOfferType.OneTime, + offerTokenAndroid = offerToken, + offerTagsAndroid = runCatching { offerTags.orEmpty() }.getOrElse { emptyList() }, + fullPriceMicrosAndroid = runCatching { fullPriceMicros?.toString() }.getOrNull(), + percentageDiscountAndroid = runCatching { discountInfo?.percentageDiscount }.getOrNull(), + discountAmountMicrosAndroid = runCatching { + discountInfo?.discountAmount?.discountAmountMicros?.toString() + }.getOrNull(), + formattedDiscountAmountAndroid = runCatching { + discountInfo?.discountAmount?.formattedDiscountAmount + }.getOrNull(), + validTimeWindowAndroid = runCatching { validTimeWindow }.getOrNull()?.let { window -> + ValidTimeWindowAndroid( + startTimeMillis = window.startTimeMillis.toString(), + endTimeMillis = window.endTimeMillis.toString() + ) + }, + limitedQuantityInfoAndroid = runCatching { limitedQuantityInfo }.getOrNull()?.let { info -> + LimitedQuantityInfoAndroid( + maximumQuantity = info.maximumQuantity, + remainingQuantity = info.remainingQuantity + ) + }, + preorderDetailsAndroid = runCatching { preorderDetails }.getOrNull()?.let { details -> + PreorderDetailsAndroid( + preorderPresaleEndTimeMillis = details.preorderPresaleEndTimeMillis.toString(), + preorderReleaseTimeMillis = details.preorderReleaseTimeMillis.toString() + ) + }, + rentalDetailsAndroid = runCatching { rentalDetails }.getOrNull()?.let { details -> + RentalDetailsAndroid( + rentalPeriod = details.rentalPeriod, + rentalExpirationPeriod = runCatching { details.rentalExpirationPeriod }.getOrNull() + ) + }, + purchaseOptionIdAndroid = runCatching { purchaseOptionId }.getOrNull() + ) +} + /** * Convert ProductSubscriptionAndroidOfferDetails to SubscriptionOffer. * Maps Android-specific offer details to cross-platform SubscriptionOffer type. */ internal fun ProductSubscriptionAndroidOfferDetails.toSubscriptionOffer(): SubscriptionOffer { val firstPhase = pricingPhases.pricingPhaseList.firstOrNull() + val period = firstPhase?.billingPeriod?.let { parseBillingPeriod(it) } // Determine payment mode from first pricing phase val paymentMode = firstPhase?.let { - val priceAmount = it.priceAmountMicros.toLongOrNull() ?: 0L - when { - priceAmount == 0L -> PaymentMode.FreeTrial - it.recurrenceMode == 3 -> PaymentMode.PayUpFront // NON_RECURRING - else -> PaymentMode.PayAsYouGo - } + determinePaymentMode( + recurrenceMode = it.recurrenceMode, + priceAmountMicros = it.priceAmountMicros.toLongOrNull() ?: 0L + ) } // Get price from first pricing phase @@ -367,16 +496,55 @@ internal fun ProductSubscriptionAndroidOfferDetails.toSubscriptionOffer(): Subsc currency = currency, type = type, paymentMode = paymentMode, + period = period, + periodCount = firstPhase?.billingCycleCount, basePlanIdAndroid = basePlanId, offerTokenAndroid = offerToken, offerTagsAndroid = offerTags, - pricingPhasesAndroid = pricingPhases + pricingPhasesAndroid = pricingPhases, + installmentPlanDetailsAndroid = installmentPlanDetails ) } +private fun parseBillingPeriod(billingPeriod: String): SubscriptionPeriod? { + if (billingPeriod.isEmpty()) return null + + val regex = Regex("""P(\d+)([DWMY])""") + val match = regex.matchEntire(billingPeriod) ?: return null + val value = match.groupValues[1].toIntOrNull() ?: return null + val unit = when (match.groupValues[2]) { + "D" -> SubscriptionPeriodUnit.Day + "W" -> SubscriptionPeriodUnit.Week + "M" -> SubscriptionPeriodUnit.Month + "Y" -> SubscriptionPeriodUnit.Year + else -> SubscriptionPeriodUnit.Unknown + } + return SubscriptionPeriod(unit = unit, value = value) +} + +private fun determinePaymentMode( + recurrenceMode: Int, + priceAmountMicros: Long +): PaymentMode = + when { + priceAmountMicros == 0L -> PaymentMode.FreeTrial + recurrenceMode == 3 -> PaymentMode.PayUpFront + recurrenceMode == 2 -> PaymentMode.PayAsYouGo + recurrenceMode == 1 -> PaymentMode.PayAsYouGo + else -> PaymentMode.Unknown + } + internal fun ProductDetails.SubscriptionOfferDetails.toOfferDetail(): ProductSubscriptionAndroidOfferDetails { + val installmentDetails = runCatching { installmentPlanDetails }.getOrNull()?.let { details -> + InstallmentPlanDetailsAndroid( + commitmentPaymentsCount = details.installmentPlanCommitmentPaymentsCount, + subsequentCommitmentPaymentsCount = details.subsequentInstallmentPlanCommitmentPaymentsCount + ) + } + return ProductSubscriptionAndroidOfferDetails( basePlanId = basePlanId, + installmentPlanDetails = installmentDetails, offerId = offerId, offerTags = offerTags, offerToken = offerToken, diff --git a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/DslExtensions.kt b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/DslExtensions.kt index d2fa95d71..65166bc2a 100644 --- a/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/DslExtensions.kt +++ b/libraries/kmp-iap/library/src/commonMain/kotlin/io/github/hyochan/kmpiap/DslExtensions.kt @@ -71,11 +71,11 @@ suspend fun KmpInAppPurchase.requestPurchase( private fun FetchProductsResult.asProductList(): List = when (this) { is FetchProductsResultProducts -> value.orEmpty() - is FetchProductsResultSubscriptions -> value.orEmpty().mapNotNull(ProductSubscription::toProduct) + is FetchProductsResultSubscriptions -> value.orEmpty().mapNotNull(ProductSubscription::toProductForDsl) is FetchProductsResultAll -> value.orEmpty().mapNotNull { productOrSubscription -> when (productOrSubscription) { is ProductOrSubscription.ProductItem -> productOrSubscription.value - is ProductOrSubscription.ProductSubscriptionItem -> productOrSubscription.value.toProduct() + is ProductOrSubscription.ProductSubscriptionItem -> productOrSubscription.value.toProductForDsl() } } } @@ -98,11 +98,12 @@ private fun RequestPurchaseResult?.failToFindPurchase(): Nothing = fun Purchase.toPurchaseInput(): PurchaseInput = this -private fun ProductSubscription.toProduct(): Product = when (this) { +internal fun ProductSubscription.toProductForDsl(): Product = when (this) { is ProductSubscriptionAndroid -> ProductAndroid( currency = currency, debugDescription = debugDescription, description = description, + discountOffers = discountOffers, displayName = displayName, displayPrice = displayPrice, id = id, @@ -110,7 +111,9 @@ private fun ProductSubscription.toProduct(): Product = when (this) { oneTimePurchaseOfferDetailsAndroid = oneTimePurchaseOfferDetailsAndroid, platform = platform, price = price, + productStatusAndroid = productStatusAndroid, subscriptionOfferDetailsAndroid = subscriptionOfferDetailsAndroid, + subscriptionOffers = subscriptionOffers, title = title, type = type ) @@ -126,7 +129,9 @@ private fun ProductSubscription.toProduct(): Product = when (this) { jsonRepresentationIOS = jsonRepresentationIOS, platform = platform, price = price, + pricingTermsIOS = pricingTermsIOS, subscriptionInfoIOS = subscriptionInfoIOS, + subscriptionOffers = subscriptionOffers, title = title, type = type, typeIOS = typeIOS diff --git a/libraries/kmp-iap/library/src/commonTest/kotlin/io/github/hyochan/kmpiap/InAppPurchaseTest.kt b/libraries/kmp-iap/library/src/commonTest/kotlin/io/github/hyochan/kmpiap/InAppPurchaseTest.kt index b85a68b62..dde4d862a 100644 --- a/libraries/kmp-iap/library/src/commonTest/kotlin/io/github/hyochan/kmpiap/InAppPurchaseTest.kt +++ b/libraries/kmp-iap/library/src/commonTest/kotlin/io/github/hyochan/kmpiap/InAppPurchaseTest.kt @@ -83,6 +83,141 @@ class InAppPurchaseTest { assertEquals("1", subscription.subscriptionPeriodNumberIOS) } + @Test + fun testDslAndroidSubscriptionConversionPreservesProductMetadata() { + val installmentPlanDetails = InstallmentPlanDetailsAndroid( + commitmentPaymentsCount = 12, + subsequentCommitmentPaymentsCount = 0 + ) + val pricingPhases = PricingPhasesAndroid( + pricingPhaseList = listOf( + PricingPhaseAndroid( + billingCycleCount = 1, + billingPeriod = "P1M", + formattedPrice = "$4.99", + priceAmountMicros = "4990000", + priceCurrencyCode = "USD", + recurrenceMode = 2 + ) + ) + ) + val offerDetails = ProductSubscriptionAndroidOfferDetails( + basePlanId = "monthly-base", + installmentPlanDetails = installmentPlanDetails, + offerId = "intro-offer", + offerTags = listOf("intro"), + offerToken = "offer-token", + pricingPhases = pricingPhases + ) + val subscriptionOffer = SubscriptionOffer( + basePlanIdAndroid = "monthly-base", + currency = "USD", + displayPrice = "$4.99", + id = "intro-offer", + installmentPlanDetailsAndroid = installmentPlanDetails, + offerTagsAndroid = listOf("intro"), + offerTokenAndroid = "offer-token", + price = 4.99, + pricingPhasesAndroid = pricingPhases, + type = DiscountOfferType.Introductory + ) + val discountOffer = DiscountOffer( + currency = "USD", + displayPrice = "$1.99", + id = "one-time-offer", + offerTokenAndroid = "one-time-token", + price = 1.99, + type = DiscountOfferType.OneTime + ) + val subscription = ProductSubscriptionAndroid( + currency = "USD", + debugDescription = "debug payload", + description = "Android subscription", + discountOffers = listOf(discountOffer), + displayName = "Premium monthly", + displayPrice = "$4.99", + id = "premium_monthly", + nameAndroid = "Premium monthly", + platform = IapPlatform.Android, + price = 4.99, + productStatusAndroid = ProductStatusAndroid.NoOffersAvailable, + subscriptionOfferDetailsAndroid = listOf(offerDetails), + subscriptionOffers = listOf(subscriptionOffer), + title = "Premium monthly", + type = ProductType.Subs + ) + + val product = subscription.toProductForDsl() as ProductAndroid + + assertEquals(listOf(discountOffer), product.discountOffers) + assertEquals(ProductStatusAndroid.NoOffersAvailable, product.productStatusAndroid) + assertEquals(listOf(offerDetails), product.subscriptionOfferDetailsAndroid) + assertEquals(listOf(subscriptionOffer), product.subscriptionOffers) + assertEquals("debug payload", product.debugDescription) + } + + @Test + fun testDslIosSubscriptionConversionPreservesProductMetadata() { + val period = SubscriptionPeriodValueIOS( + unit = SubscriptionPeriodIOS.Month, + value = 1 + ) + val subscriptionOffer = SubscriptionOffer( + currency = "USD", + displayPrice = "$0.99", + id = "promo-offer", + localizedPriceIOS = "$0.99", + numberOfPeriodsIOS = 1, + periodCount = 1, + price = 0.99, + type = DiscountOfferType.Promotional + ) + val pricingTerms = SubscriptionPricingTermsIOS( + billingDisplayPrice = "$9.99", + billingPeriod = period, + billingPlanType = SubscriptionBillingPlanTypeIOS.Monthly, + billingPrice = 9.99, + commitmentInfo = SubscriptionCommitmentInfoIOS( + displayPrice = "$9.99", + period = period, + price = 9.99 + ), + subscriptionOffers = listOf(subscriptionOffer) + ) + val subscriptionInfo = SubscriptionInfoIOS( + pricingTerms = listOf(pricingTerms), + subscriptionGroupId = "group-id", + subscriptionPeriod = period + ) + val subscription = ProductSubscriptionIOS( + currency = "USD", + debugDescription = "storekit json", + description = "iOS subscription", + displayName = "Premium monthly", + displayNameIOS = "Premium monthly", + displayPrice = "$9.99", + id = "premium_monthly", + introductoryPricePaymentModeIOS = PaymentModeIOS.Empty, + isFamilyShareableIOS = true, + jsonRepresentationIOS = "{}", + platform = IapPlatform.Ios, + price = 9.99, + pricingTermsIOS = listOf(pricingTerms), + subscriptionInfoIOS = subscriptionInfo, + subscriptionOffers = listOf(subscriptionOffer), + title = "Premium monthly", + type = ProductType.Subs, + typeIOS = ProductTypeIOS.AutoRenewableSubscription + ) + + val product = subscription.toProductForDsl() as ProductIOS + + assertEquals(listOf(pricingTerms), product.pricingTermsIOS) + assertEquals(subscriptionInfo, product.subscriptionInfoIOS) + assertEquals(listOf(subscriptionOffer), product.subscriptionOffers) + assertEquals("storekit json", product.debugDescription) + } + @Test fun testPurchaseTypes() { val purchase = PurchaseAndroid( From a3e341b24e2c015c5b24eaf9f9539b47454dddde Mon Sep 17 00:00:00 2001 From: Hyo Date: Fri, 10 Jul 2026 01:01:00 +0900 Subject: [PATCH 10/15] fix(kmp): preserve promoted product offers --- .../iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt index a9d077c4a..020d597fa 100644 --- a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt +++ b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt @@ -1453,7 +1453,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { val dict = (data as? Map<*, *>) ?: return null val map = dict.mapKeys { it.key.toString() } runCatching { ProductIOS.fromJson(map) }.getOrNull()?.let { - return it + return mergeLegacySubscriptionOffers(it, map) as? ProductIOS ?: it } ProductIOS( From ddc94eb4274f23a70f69108bec174aa18c554119 Mon Sep 17 00:00:00 2001 From: Hyo Date: Fri, 10 Jul 2026 01:15:28 +0900 Subject: [PATCH 11/15] fix(libraries): tighten product metadata parsing --- .../flutter_inapp_purchase/lib/helpers.dart | 4 ++- .../kotlin/io/github/hyochan/kmpiap/Helper.kt | 5 ++-- .../github/hyochan/kmpiap/InAppPurchaseIOS.kt | 29 ++++++++----------- 3 files changed, 18 insertions(+), 20 deletions(-) diff --git a/libraries/flutter_inapp_purchase/lib/helpers.dart b/libraries/flutter_inapp_purchase/lib/helpers.dart index 1e1757638..04cc5538e 100644 --- a/libraries/flutter_inapp_purchase/lib/helpers.dart +++ b/libraries/flutter_inapp_purchase/lib/helpers.dart @@ -6,6 +6,8 @@ import 'errors.dart' as iap_err; import 'flutter_inapp_purchase.dart'; import 'types.dart' as gentype; +final _billingPeriodRegExp = RegExp(r'^P(\d+)([DWMY])$'); + String resolveProductType(Object type) { if (type is String) { return type; @@ -919,7 +921,7 @@ List _parseSubscriptionOffers( } gentype.SubscriptionPeriod? _parseBillingPeriod(String billingPeriod) { - final match = RegExp(r'^P(\d+)([DWMY])$').firstMatch(billingPeriod); + final match = _billingPeriodRegExp.firstMatch(billingPeriod); if (match == null) return null; final value = int.tryParse(match.group(1) ?? ''); if (value == null) return null; diff --git a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/Helper.kt b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/Helper.kt index 3e99630df..55a8bad5a 100644 --- a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/Helper.kt +++ b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/Helper.kt @@ -53,6 +53,8 @@ import dev.hyo.openiap.ExternalLinkLaunchModeAndroid as OpenIapExternalLinkLaunc import dev.hyo.openiap.ExternalLinkTypeAndroid as OpenIapExternalLinkType import dev.hyo.openiap.LaunchExternalLinkParamsAndroid as OpenIapLaunchExternalLinkParams +private val billingPeriodRegex = Regex("""P(\d+)([DWMY])""") + internal fun emitFailureAndThrow( errorFlow: MutableSharedFlow, error: PurchaseError @@ -509,8 +511,7 @@ internal fun ProductSubscriptionAndroidOfferDetails.toSubscriptionOffer(): Subsc private fun parseBillingPeriod(billingPeriod: String): SubscriptionPeriod? { if (billingPeriod.isEmpty()) return null - val regex = Regex("""P(\d+)([DWMY])""") - val match = regex.matchEntire(billingPeriod) ?: return null + val match = billingPeriodRegex.matchEntire(billingPeriod) ?: return null val value = match.groupValues[1].toIntOrNull() ?: return null val unit = when (match.groupValues[2]) { "D" -> SubscriptionPeriodUnit.Day diff --git a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt index 020d597fa..7610db94e 100644 --- a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt +++ b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt @@ -1273,14 +1273,12 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { } val map = dict.mapKeys { it.key.toString() } - runCatching { Product.fromJson(map) }.getOrNull()?.let { + runCatching { ProductIOS.fromJson(map) }.getOrNull()?.let { return@mapNotNull mergeLegacySubscriptionOffers(it, map) } // Parse subscription offers from the data (if product has subscription info) - val subscriptionOffers = convertAnyListToSubscriptionOffers( - map["subscriptionOffers"] ?: map["offers"] - ) + val subscriptionOffers = subscriptionOffersFrom(map) ProductIOS( currency = map["currency"] as? String ?: "", @@ -1321,14 +1319,12 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { list.mapNotNull { item -> val dict = (item as? Map<*, *>) ?: return@mapNotNull null val map = dict.mapKeys { it.key.toString() } - runCatching { ProductSubscription.fromJson(map) }.getOrNull()?.let { + runCatching { ProductSubscriptionIOS.fromJson(map) }.getOrNull()?.let { return@mapNotNull mergeLegacySubscriptionOffers(it, map) } // Parse subscription offers from the data - val subscriptionOffers = convertAnyListToSubscriptionOffers( - map["subscriptionOffers"] ?: map["offers"] - ) + val subscriptionOffers = subscriptionOffersFrom(map) ProductSubscriptionIOS( currency = map["currency"] as? String ?: "", @@ -1395,13 +1391,16 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { } } + private fun subscriptionOffersFrom(map: Map): List { + val subscriptionOffers = convertAnyListToSubscriptionOffers(map["subscriptionOffers"]) + return subscriptionOffers.ifEmpty { convertAnyListToSubscriptionOffers(map["offers"]) } + } + private fun mergeLegacySubscriptionOffers( product: Product, map: Map ): Product { - val subscriptionOffers = convertAnyListToSubscriptionOffers( - map["subscriptionOffers"] ?: map["offers"] - ) + val subscriptionOffers = subscriptionOffersFrom(map) if (subscriptionOffers.isEmpty()) return product return when (product) { @@ -1418,9 +1417,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { subscription: ProductSubscription, map: Map ): ProductSubscription { - val subscriptionOffers = convertAnyListToSubscriptionOffers( - map["subscriptionOffers"] ?: map["offers"] - ) + val subscriptionOffers = subscriptionOffersFrom(map) if (subscriptionOffers.isEmpty()) return subscription return when (subscription) { @@ -1472,9 +1469,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { map["pricingTermsIOS"] ), subscriptionInfoIOS = null, // Complex object - subscriptionOffers = convertAnyListToSubscriptionOffers( - map["subscriptionOffers"] ?: map["offers"] - ).ifEmpty { null }, + subscriptionOffers = subscriptionOffersFrom(map).ifEmpty { null }, title = map["title"] as? String ?: "", type = (map["type"] as? String)?.let { ProductType.fromJson(it) } ?: ProductType.InApp, From 019f2f440a5de9792efbe8d913d0ea2899f152a8 Mon Sep 17 00:00:00 2001 From: Hyo Date: Fri, 10 Jul 2026 15:02:31 +0900 Subject: [PATCH 12/15] fix: preserve product metadata across SDKs --- .github/workflows/ci-godot-iap.yml | 16 +- .github/workflows/ci-kmp-iap.yml | 5 +- .github/workflows/release-kmp.yml | 6 +- .../flutter_inapp_purchase/lib/helpers.dart | 2 + .../test/helpers_unit_test.dart | 37 ++++ .../godot-iap/Example/tests/test_godot_iap.gd | 65 ++++++- .../Example/tests/test_types_only.gd | 80 ++++++++- .../godot-iap/addons/godot-iap/godot_iap.gd | 41 +++-- libraries/godot-iap/addons/godot-iap/types.gd | 117 +++++++++---- .../gradle/wrapper/gradle-wrapper.properties | 2 +- .../kotlin/io/github/hyochan/kmpiap/Helper.kt | 84 +++++++-- .../hyochan/kmpiap/InAppPurchaseAndroid.kt | 155 ++++++++++++---- .../kmpiap/ProductStatusAndroidTest.kt | 33 ++++ .../github/hyochan/kmpiap/InAppPurchaseIOS.kt | 58 ++++-- .../kmpiap/IosProductPayloadNormalizer.kt | 34 ++++ .../kmpiap/IosProductPayloadNormalizerTest.kt | 53 ++++++ .../java/com/margelo/nitro/iap/HybridRnIap.kt | 97 +--------- .../margelo/nitro/iap/ProductSerialization.kt | 30 ++++ .../nitro/iap/ProductSerializationTest.kt | 94 ++++++++++ .../src/__tests__/utils/type-bridge.test.ts | 44 +++++ .../react-native-iap/src/specs/RnIap.nitro.ts | 1 + .../Sources/Helpers/StoreKitTypesBridge.swift | 38 ++++ .../Sources/Models/OpenIapSerialization.swift | 33 ++-- .../apple/Sources/OpenIapModule+ObjC.swift | 43 +---- packages/apple/Sources/OpenIapModule.swift | 65 ++----- packages/apple/Tests/OpenIapTests.swift | 18 ++ .../java/dev/hyo/openiap/OpenIapModule.kt | 2 + .../hyo/openiap/utils/BillingConverters.kt | 56 +++++- .../java/dev/hyo/openiap/OpenIapModule.kt | 165 +++++++++++++----- .../java/dev/hyo/openiap/helpers/Helpers.kt | 11 ++ .../dev/hyo/openiap/helpers/ProductManager.kt | 55 ++++-- .../hyo/openiap/utils/BillingConverters.kt | 64 +++++-- .../openiap/HorizonStandardizedOfferTest.kt | 40 +++++ .../hyo/openiap/ProductStatusAndroidTest.kt | 49 ++++++ packages/gql/codegen/plugins/gdscript.ts | 67 +++++-- packages/gql/src/generated-gdscript.test.ts | 34 ++++ packages/gql/src/generated/types.gd | 117 +++++++++---- 37 files changed, 1431 insertions(+), 480 deletions(-) create mode 100644 libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/ProductStatusAndroidTest.kt create mode 100644 libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/IosProductPayloadNormalizer.kt create mode 100644 libraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/IosProductPayloadNormalizerTest.kt create mode 100644 libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/ProductSerialization.kt create mode 100644 libraries/react-native-iap/android/src/test/java/com/margelo/nitro/iap/ProductSerializationTest.kt create mode 100644 packages/google/openiap/src/testHorizon/java/dev/hyo/openiap/HorizonStandardizedOfferTest.kt create mode 100644 packages/google/openiap/src/testPlay/java/dev/hyo/openiap/ProductStatusAndroidTest.kt create mode 100644 packages/gql/src/generated-gdscript.test.ts diff --git a/.github/workflows/ci-godot-iap.yml b/.github/workflows/ci-godot-iap.yml index 2803dc10b..013a04781 100644 --- a/.github/workflows/ci-godot-iap.yml +++ b/.github/workflows/ci-godot-iap.yml @@ -5,11 +5,13 @@ on: branches: [main] paths: - 'libraries/godot-iap/**' + - 'packages/gql/codegen/plugins/gdscript.ts' - 'packages/gql/src/generated/types.gd' push: branches: [main] paths: - 'libraries/godot-iap/**' + - 'packages/gql/codegen/plugins/gdscript.ts' - 'packages/gql/src/generated/types.gd' concurrency: @@ -24,12 +26,18 @@ jobs: verify: name: Verify runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 10 steps: - uses: actions/checkout@v7 with: fetch-depth: 1 + - name: Setup Godot + uses: chickensoft-games/setup-godot@v2.4.1 + with: + version: 4.6.3 + use-dotnet: false + - name: Verify plugin files exist run: | test -f addons/godot-iap/plugin.cfg @@ -42,3 +50,9 @@ jobs: test -f android/build.gradle.kts test -f android/src/main/java/dev/hyo/godotiap/GodotIap.kt echo "All required Android plugin files exist" + + - name: Test generated types + run: godot --headless --path Example --script res://tests/test_types_only.gd + + - name: Test wrapper mapping + run: godot --headless --path Example --script res://tests/test_godot_iap.gd diff --git a/.github/workflows/ci-kmp-iap.yml b/.github/workflows/ci-kmp-iap.yml index f68e2eed8..3814404ee 100644 --- a/.github/workflows/ci-kmp-iap.yml +++ b/.github/workflows/ci-kmp-iap.yml @@ -39,7 +39,7 @@ jobs: distribution: "temurin" java-version: "17" - - uses: gradle/gradle-build-action@v3 + - uses: gradle/actions/setup-gradle@v6 with: cache-read-only: true @@ -47,6 +47,9 @@ jobs: run: | chmod +x gradlew ./gradlew \ + --no-parallel \ + -Dorg.gradle.jvmargs=-Xmx8192M \ + -Dkotlin.daemon.jvm.options=-Xmx4096M \ :library:compilePlayDebugKotlinAndroid \ :library:compileHorizonDebugKotlinAndroid \ :library:compileAmazonDebugKotlinAndroid \ diff --git a/.github/workflows/release-kmp.yml b/.github/workflows/release-kmp.yml index 4aa791694..40783bea4 100644 --- a/.github/workflows/release-kmp.yml +++ b/.github/workflows/release-kmp.yml @@ -47,7 +47,7 @@ jobs: distribution: "temurin" - name: Setup Gradle - uses: gradle/gradle-build-action@v3 + uses: gradle/actions/setup-gradle@v6 - name: Grant execute permission for gradlew run: chmod +x gradlew @@ -82,7 +82,7 @@ jobs: distribution: "temurin" - name: Setup Gradle - uses: gradle/gradle-build-action@v3 + uses: gradle/actions/setup-gradle@v6 - name: Grant execute permission for gradlew run: chmod +x gradlew @@ -123,7 +123,7 @@ jobs: distribution: "temurin" - name: Setup Gradle - uses: gradle/gradle-build-action@v3 + uses: gradle/actions/setup-gradle@v6 - name: Grant execute permission for gradlew run: chmod +x gradlew diff --git a/libraries/flutter_inapp_purchase/lib/helpers.dart b/libraries/flutter_inapp_purchase/lib/helpers.dart index 04cc5538e..3e52e5830 100644 --- a/libraries/flutter_inapp_purchase/lib/helpers.dart +++ b/libraries/flutter_inapp_purchase/lib/helpers.dart @@ -1026,6 +1026,7 @@ gentype.ProductAndroidOneTimePurchaseOfferDetail? offerToken: value['offerToken']?.toString() ?? '', offerId: value['offerId']?.toString(), fullPriceMicros: value['fullPriceMicros']?.toString(), + purchaseOptionId: value['purchaseOptionId']?.toString(), discountDisplayInfo: value['discountDisplayInfo'] != null ? gentype.DiscountDisplayInfoAndroid.fromJson( value['discountDisplayInfo'] as Map, @@ -1064,6 +1065,7 @@ gentype.ProductAndroidOneTimePurchaseOfferDetail? offerToken: map['offerToken']?.toString() ?? '', offerId: map['offerId']?.toString(), fullPriceMicros: map['fullPriceMicros']?.toString(), + purchaseOptionId: map['purchaseOptionId']?.toString(), discountDisplayInfo: map['discountDisplayInfo'] != null ? gentype.DiscountDisplayInfoAndroid.fromJson( Map.from( diff --git a/libraries/flutter_inapp_purchase/test/helpers_unit_test.dart b/libraries/flutter_inapp_purchase/test/helpers_unit_test.dart index a49a9c3f9..de80dd281 100644 --- a/libraries/flutter_inapp_purchase/test/helpers_unit_test.dart +++ b/libraries/flutter_inapp_purchase/test/helpers_unit_test.dart @@ -190,6 +190,7 @@ void main() { 'formattedPrice': '\$2.99', 'priceAmountMicros': '2990000', 'priceCurrencyCode': 'USD', + 'purchaseOptionId': 'single-purchase-option', }, 'discountOffers': [ const types.DiscountOffer( @@ -225,6 +226,11 @@ void main() { expect(androidProduct.platform, types.IapPlatform.Android); expect(androidProduct.price, closeTo(2.99, 0.0001)); expect(androidProduct.oneTimePurchaseOfferDetailsAndroid, isNotNull); + expect( + androidProduct + .oneTimePurchaseOfferDetailsAndroid!.single.purchaseOptionId, + 'single-purchase-option', + ); expect(androidProduct.discountOffers, hasLength(2)); expect( androidProduct.discountOffers!.first.offerTokenAndroid, @@ -539,6 +545,7 @@ void main() { 'offerToken': 'offer-token', 'offerId': 'offer-id', 'fullPriceMicros': '2990000', + 'purchaseOptionId': 'purchase-option', 'discountDisplayInfo': { 'discountAmount': { 'discountAmountMicros': '100000', @@ -579,6 +586,7 @@ void main() { expect(offer.offerTags, contains('launch')); expect(offer.discountDisplayInfo?.percentageDiscount, 20); expect(offer.fullPriceMicros, '2990000'); + expect(offer.purchaseOptionId, 'purchase-option'); expect(offer.limitedQuantityInfo?.maximumQuantity, 10); expect(offer.validTimeWindow?.endTimeMillis, '2000'); expect(offer.preorderDetailsAndroid?.preorderReleaseTimeMillis, '4000'); @@ -625,6 +633,35 @@ void main() { }, ); + test( + 'parseProductFromNative keeps purchaseOptionId from dynamic maps', + () { + final product = parseProductFromNative( + { + 'platform': 'android', + 'id': 'dynamic_offer', + 'title': 'Dynamic Offer', + 'description': 'Dynamic map payload', + 'currency': 'USD', + 'displayPrice': '\$1.99', + 'oneTimePurchaseOfferDetailsAndroid': { + 'formattedPrice': '\$1.99', + 'priceAmountMicros': 1990000, + 'priceCurrencyCode': 'USD', + 'purchaseOptionId': 42, + }, + }, + 'inapp', + fallbackIsIOS: false, + ) as types.ProductAndroid; + + expect( + product.oneTimePurchaseOfferDetailsAndroid!.single.purchaseOptionId, + '42', + ); + }, + ); + test('parseProductFromNative creates iOS in-app product', () { final product = parseProductFromNative( { diff --git a/libraries/godot-iap/Example/tests/test_godot_iap.gd b/libraries/godot-iap/Example/tests/test_godot_iap.gd index 497b93deb..106fc952d 100644 --- a/libraries/godot-iap/Example/tests/test_godot_iap.gd +++ b/libraries/godot-iap/Example/tests/test_godot_iap.gd @@ -1,25 +1,34 @@ -extends Node +extends SceneTree ## Unit tests for godot_iap.gd (mock mode) ## Run with: godot --headless --script tests/test_godot_iap.gd const Types = preload("res://addons/godot-iap/types.gd") +const GodotIapWrapper = preload("res://addons/godot-iap/godot_iap.gd") var _tests_passed := 0 var _tests_failed := 0 +var GodotIapPlugin: Node = null -func _ready() -> void: +func _init() -> void: + _run_suite.call_deferred() + + +func _run_suite() -> void: + GodotIapPlugin = GodotIapWrapper.new() + root.add_child(GodotIapPlugin) + await process_frame print("\n========================================") print("Running godot_iap.gd tests (mock mode)...") print("========================================\n") - _run_all_tests() + await _run_all_tests() print("\n========================================") print("Results: %d passed, %d failed" % [_tests_passed, _tests_failed]) print("========================================\n") - get_tree().quit(0 if _tests_failed == 0 else 1) + quit(0 if _tests_failed == 0 else 1) func _run_all_tests() -> void: @@ -34,6 +43,7 @@ func _run_all_tests() -> void: # Product tests await test_fetch_products_mock() + test_product_variant_mapping() # Purchase tests test_get_available_purchases_mock() @@ -70,12 +80,12 @@ func test_init_connection_idempotent() -> void: # Reset connection state to test fresh GodotIapPlugin._is_connected = false - # Calling init_connection multiple times should not error + # Repeated calls should consistently report that no native store is available. var result1 = GodotIapPlugin.init_connection() - _assert_true(result1, "First init_connection should return true") + _assert_false(result1, "First init_connection should report unavailable native plugin") var result2 = GodotIapPlugin.init_connection() - _assert_true(result2, "Second init_connection should return true") + _assert_false(result2, "Second init_connection should remain unavailable") func test_no_duplicate_signal_connections() -> void: @@ -94,9 +104,9 @@ func test_no_duplicate_signal_connections() -> void: # ============================================ func test_init_connection_mock() -> void: - # In mock mode (no native plugin), should return true + # A desktop test run has no native store plugin and must report that clearly. var result = GodotIapPlugin.init_connection() - _assert_true(result, "init_connection should return true in mock mode") + _assert_false(result, "init_connection should return false without a native plugin") func test_end_connection_mock() -> void: @@ -120,6 +130,43 @@ func test_fetch_products_mock() -> void: _assert_true(products.size() >= 0, "fetch_products should return an array") +func test_product_variant_mapping() -> void: + var original_platform = GodotIapPlugin._platform + var subscription_data = { + "id": "premium", + "title": "Premium", + "description": "Premium subscription", + "displayPrice": "$9.99", + "currency": "USD", + "type": "subs", + "subscriptionOffers": [{ + "id": "intro", + "displayPrice": "Free", + "price": 0.0, + "type": "introductory" + }] + } + + GodotIapPlugin._platform = "iOS" + var ios_product = GodotIapPlugin._product_from_dict(subscription_data) + _assert_true( + ios_product is Types.ProductSubscriptionIOS, + "iOS subscriptions should use ProductSubscriptionIOS" + ) + _assert_equal(ios_product.subscription_offers.size(), 1, "iOS offers should be preserved") + + GodotIapPlugin._platform = "Android" + subscription_data["nameAndroid"] = "Premium" + subscription_data["subscriptionOfferDetailsAndroid"] = [] + var android_product = GodotIapPlugin._product_from_dict(subscription_data) + _assert_true( + android_product is Types.ProductSubscriptionAndroid, + "Android subscriptions should use ProductSubscriptionAndroid" + ) + _assert_equal(android_product.subscription_offers.size(), 1, "Android offers should be preserved") + GodotIapPlugin._platform = original_platform + + # ============================================ # Purchase Tests (Mock Mode) # ============================================ diff --git a/libraries/godot-iap/Example/tests/test_types_only.gd b/libraries/godot-iap/Example/tests/test_types_only.gd index bfd627db4..c6e313773 100644 --- a/libraries/godot-iap/Example/tests/test_types_only.gd +++ b/libraries/godot-iap/Example/tests/test_types_only.gd @@ -31,6 +31,7 @@ func _init() -> void: func _run_all_tests() -> void: # ProductRequest tests _test_product_request() + _test_product_nested_arrays() # PurchaseAndroid tests _test_purchase_android() @@ -81,13 +82,86 @@ func _test_product_request() -> void: _assert_equal(dict["skus"][0], "product_1", "to_dict should preserve sku") _assert_equal(dict["type"], "in-app", "to_dict should convert type to string") - # Test from_dict (skus need to be typed Array[String]) - var sku_arr: Array[String] = ["sku_from_dict"] - var from_dict_data = {"skus": sku_arr, "type": "subs"} + # JSON.parse_string returns an untyped Array; from_dict must rebuild Array[String]. + var from_dict_data = {"skus": ["sku_from_dict"], "type": "subs"} var parsed = Types.ProductRequest.from_dict(from_dict_data) _assert_equal(parsed.skus[0], "sku_from_dict", "from_dict should parse skus") +func _test_product_nested_arrays() -> void: + print("Testing nested product arrays...") + + var offer_data = { + "id": "intro", + "displayPrice": "Free", + "price": 0.0, + "type": "introductory", + "offerTagsAndroid": ["launch"] + } + var ios_product = Types.ProductIOS.from_dict({ + "id": "premium.ios", + "title": "Premium", + "description": "Premium subscription", + "type": "subs", + "platform": "ios", + "subscriptionOffers": [offer_data], + "pricingTermsIOS": [{ + "billingDisplayPrice": "$9.99", + "billingPlanType": "monthly", + "billingPrice": 9.99, + "subscriptionOffers": [offer_data] + }] + }) + _assert_equal(ios_product.subscription_offers.size(), 1, "ProductIOS should parse subscription offers") + _assert_equal(ios_product.subscription_offers[0].id, "intro", "ProductIOS should preserve offer fields") + _assert_equal(ios_product.pricing_terms_ios.size(), 1, "ProductIOS should parse pricing terms") + _assert_equal( + ios_product.pricing_terms_ios[0].subscription_offers[0].offer_tags_android[0], + "launch", + "Nested scalar arrays should remain typed" + ) + + var android_product = Types.ProductSubscriptionAndroid.from_dict({ + "id": "premium.android", + "title": "Premium", + "description": "Premium subscription", + "type": "subs", + "platform": "android", + "nameAndroid": "Premium", + "subscriptionOffers": [{ + "id": "commitment", + "displayPrice": "$9.99", + "price": 9.99, + "type": "promotional", + "installmentPlanDetailsAndroid": { + "commitmentPaymentsCount": 12, + "subsequentCommitmentPaymentsCount": 0 + } + }], + "subscriptionOfferDetailsAndroid": [{ + "basePlanId": "annual", + "offerTags": ["commitment"], + "offerToken": "token", + "pricingPhases": {"pricingPhaseList": []}, + "installmentPlanDetails": { + "commitmentPaymentsCount": 12, + "subsequentCommitmentPaymentsCount": 0 + } + }] + }) + _assert_equal(android_product.subscription_offers.size(), 1, "Android should parse standardized offers") + _assert_equal( + android_product.subscription_offers[0].installment_plan_details_android.commitment_payments_count, + 12, + "Android standardized installment details should survive" + ) + _assert_equal( + android_product.subscription_offer_details_android[0].installment_plan_details.commitment_payments_count, + 12, + "Android legacy installment details should survive" + ) + + # ============================================ # PurchaseAndroid Tests # ============================================ diff --git a/libraries/godot-iap/addons/godot-iap/godot_iap.gd b/libraries/godot-iap/addons/godot-iap/godot_iap.gd index 0138fe7e5..7f1fe43e0 100644 --- a/libraries/godot-iap/addons/godot-iap/godot_iap.gd +++ b/libraries/godot-iap/addons/godot-iap/godot_iap.gd @@ -293,11 +293,8 @@ func _apply_purchase_updated_listener_options_ios() -> void: ## [code]type[/code] ([code]ProductQueryType.IN_APP[/code], [code]SUBS[/code], or [code]ALL[/code]). ## ## Returns an Array — typed as [Array] because GDScript can't express heterogeneous element -## types. The wrapper maps results to platform-specific objects: -## [Array][[Types.ProductAndroid]] on Android and [Array][[Types.ProductIOS]] on iOS, -## regardless of whether the request was IN_APP, SUBS, or ALL. Use the platform-specific -## fields ([code]subscription_offer_details_android[/code], [code]subscription_*_ios[/code]) -## to distinguish subscriptions from one-time products at the call site. +## types. The wrapper maps one-time products to [Types.ProductAndroid] / [Types.ProductIOS] +## and subscriptions to [Types.ProductSubscriptionAndroid] / [Types.ProductSubscriptionIOS]. ## ## [codeblock] ## var request = ProductRequest.new() @@ -318,13 +315,31 @@ func fetch_products(request) -> Array: if result.has("products"): for product_dict in result["products"]: if product_dict is Dictionary: - if _platform == "Android": - products.append(Types.ProductAndroid.from_dict(_normalize_android_product_dict(product_dict))) - elif _platform == "iOS": - products.append(Types.ProductIOS.from_dict(product_dict)) + var product = _product_from_dict(product_dict) + if product != null: + products.append(product) return products + +func _product_from_dict(product_dict: Dictionary) -> Variant: + var raw_type = product_dict.get("type", "") + var is_subscription = false + if raw_type is String: + is_subscription = raw_type.to_lower() in ["subs", "subscription", "subscriptions"] + elif raw_type is int: + is_subscription = raw_type == Types.ProductType.SUBS + + if _platform == "Android": + if is_subscription: + return Types.ProductSubscriptionAndroid.from_dict(product_dict) + return Types.ProductAndroid.from_dict(product_dict) + if _platform == "iOS": + if is_subscription: + return Types.ProductSubscriptionIOS.from_dict(product_dict) + return Types.ProductIOS.from_dict(product_dict) + return null + ## Internal: Fetch products with raw Dictionary (for backward compatibility) func _fetch_products_raw(request: Dictionary) -> Dictionary: print("[GodotIap] _fetch_products_raw called with: ", request) @@ -597,14 +612,6 @@ func _normalize_android_purchase_dict(purchase_dict: Dictionary) -> Dictionary: return normalized -func _normalize_android_product_dict(product_dict: Dictionary) -> Dictionary: - var normalized := product_dict.duplicate() - normalized.erase("discountOffers") - normalized.erase("subscriptionOffers") - normalized.erase("oneTimePurchaseOfferDetailsAndroid") - normalized.erase("subscriptionOfferDetailsAndroid") - return normalized - ## Internal: Get available purchases raw func _get_available_purchases_raw() -> Array: if _native_plugin: diff --git a/libraries/godot-iap/addons/godot-iap/types.gd b/libraries/godot-iap/addons/godot-iap/types.gd index c6e7cb9de..956f8fd79 100644 --- a/libraries/godot-iap/addons/godot-iap/types.gd +++ b/libraries/godot-iap/addons/godot-iap/types.gd @@ -484,7 +484,7 @@ class AdvancedCommerceInfoIOS: static func from_dict(data: Dictionary) -> AdvancedCommerceInfoIOS: var obj = AdvancedCommerceInfoIOS.new() if data.has("items") and data["items"] != null: - var arr = [] + var arr: Array[AdvancedCommerceItemIOS] = [] for item in data["items"]: if item is Dictionary: arr.append(AdvancedCommerceItemIOS.from_dict(item)) @@ -569,7 +569,7 @@ class AdvancedCommerceItemIOS: else: obj.details = data["details"] if data.has("refunds") and data["refunds"] != null: - var arr = [] + var arr: Array[AdvancedCommerceRefundIOS] = [] for item in data["refunds"]: if item is Dictionary: arr.append(AdvancedCommerceRefundIOS.from_dict(item)) @@ -942,7 +942,10 @@ class DiscountOffer: if data.has("offerTokenAndroid") and data["offerTokenAndroid"] != null: obj.offer_token_android = data["offerTokenAndroid"] if data.has("offerTagsAndroid") and data["offerTagsAndroid"] != null: - obj.offer_tags_android = data["offerTagsAndroid"] + var arr: Array[String] = [] + for item in data["offerTagsAndroid"]: + arr.append(str(item)) + obj.offer_tags_android = arr if data.has("fullPriceMicrosAndroid") and data["fullPriceMicrosAndroid"] != null: obj.full_price_micros_android = data["fullPriceMicrosAndroid"] if data.has("percentageDiscountAndroid") and data["percentageDiscountAndroid"] != null: @@ -1261,7 +1264,10 @@ class PendingPurchaseUpdateAndroid: static func from_dict(data: Dictionary) -> PendingPurchaseUpdateAndroid: var obj = PendingPurchaseUpdateAndroid.new() if data.has("products") and data["products"] != null: - obj.products = data["products"] + var arr: Array[String] = [] + for item in data["products"]: + arr.append(str(item)) + obj.products = arr if data.has("purchaseToken") and data["purchaseToken"] != null: obj.purchase_token = data["purchaseToken"] return obj @@ -1333,7 +1339,7 @@ class PricingPhasesAndroid: static func from_dict(data: Dictionary) -> PricingPhasesAndroid: var obj = PricingPhasesAndroid.new() if data.has("pricingPhaseList") and data["pricingPhaseList"] != null: - var arr = [] + var arr: Array[PricingPhaseAndroid] = [] for item in data["pricingPhaseList"]: if item is Dictionary: arr.append(PricingPhaseAndroid.from_dict(item)) @@ -1418,7 +1424,7 @@ class ProductAndroid: else: obj.product_status_android = enum_str if data.has("discountOffers") and data["discountOffers"] != null: - var arr = [] + var arr: Array[DiscountOffer] = [] for item in data["discountOffers"]: if item is Dictionary: arr.append(DiscountOffer.from_dict(item)) @@ -1426,7 +1432,7 @@ class ProductAndroid: arr.append(item) obj.discount_offers = arr if data.has("subscriptionOffers") and data["subscriptionOffers"] != null: - var arr = [] + var arr: Array[SubscriptionOffer] = [] for item in data["subscriptionOffers"]: if item is Dictionary: arr.append(SubscriptionOffer.from_dict(item)) @@ -1434,7 +1440,7 @@ class ProductAndroid: arr.append(item) obj.subscription_offers = arr if data.has("oneTimePurchaseOfferDetailsAndroid") and data["oneTimePurchaseOfferDetailsAndroid"] != null: - var arr = [] + var arr: Array[ProductAndroidOneTimePurchaseOfferDetail] = [] for item in data["oneTimePurchaseOfferDetailsAndroid"]: if item is Dictionary: arr.append(ProductAndroidOneTimePurchaseOfferDetail.from_dict(item)) @@ -1442,7 +1448,7 @@ class ProductAndroid: arr.append(item) obj.one_time_purchase_offer_details_android = arr if data.has("subscriptionOfferDetailsAndroid") and data["subscriptionOfferDetailsAndroid"] != null: - var arr = [] + var arr: Array[ProductSubscriptionAndroidOfferDetails] = [] for item in data["subscriptionOfferDetailsAndroid"]: if item is Dictionary: arr.append(ProductSubscriptionAndroidOfferDetails.from_dict(item)) @@ -1552,7 +1558,10 @@ class ProductAndroidOneTimePurchaseOfferDetail: if data.has("offerToken") and data["offerToken"] != null: obj.offer_token = data["offerToken"] if data.has("offerTags") and data["offerTags"] != null: - obj.offer_tags = data["offerTags"] + var arr: Array[String] = [] + for item in data["offerTags"]: + arr.append(str(item)) + obj.offer_tags = arr if data.has("priceCurrencyCode") and data["priceCurrencyCode"] != null: obj.price_currency_code = data["priceCurrencyCode"] if data.has("formattedPrice") and data["formattedPrice"] != null: @@ -1690,7 +1699,7 @@ class ProductIOS: else: obj.type_ios = enum_str if data.has("subscriptionOffers") and data["subscriptionOffers"] != null: - var arr = [] + var arr: Array[SubscriptionOffer] = [] for item in data["subscriptionOffers"]: if item is Dictionary: arr.append(SubscriptionOffer.from_dict(item)) @@ -1698,7 +1707,7 @@ class ProductIOS: arr.append(item) obj.subscription_offers = arr if data.has("pricingTermsIOS") and data["pricingTermsIOS"] != null: - var arr = [] + var arr: Array[SubscriptionPricingTermsIOS] = [] for item in data["pricingTermsIOS"]: if item is Dictionary: arr.append(SubscriptionPricingTermsIOS.from_dict(item)) @@ -1828,7 +1837,7 @@ class ProductSubscriptionAndroid: else: obj.product_status_android = enum_str if data.has("discountOffers") and data["discountOffers"] != null: - var arr = [] + var arr: Array[DiscountOffer] = [] for item in data["discountOffers"]: if item is Dictionary: arr.append(DiscountOffer.from_dict(item)) @@ -1836,7 +1845,7 @@ class ProductSubscriptionAndroid: arr.append(item) obj.discount_offers = arr if data.has("subscriptionOffers") and data["subscriptionOffers"] != null: - var arr = [] + var arr: Array[SubscriptionOffer] = [] for item in data["subscriptionOffers"]: if item is Dictionary: arr.append(SubscriptionOffer.from_dict(item)) @@ -1844,7 +1853,7 @@ class ProductSubscriptionAndroid: arr.append(item) obj.subscription_offers = arr if data.has("oneTimePurchaseOfferDetailsAndroid") and data["oneTimePurchaseOfferDetailsAndroid"] != null: - var arr = [] + var arr: Array[ProductAndroidOneTimePurchaseOfferDetail] = [] for item in data["oneTimePurchaseOfferDetailsAndroid"]: if item is Dictionary: arr.append(ProductAndroidOneTimePurchaseOfferDetail.from_dict(item)) @@ -1852,7 +1861,7 @@ class ProductSubscriptionAndroid: arr.append(item) obj.one_time_purchase_offer_details_android = arr if data.has("subscriptionOfferDetailsAndroid") and data["subscriptionOfferDetailsAndroid"] != null: - var arr = [] + var arr: Array[ProductSubscriptionAndroidOfferDetails] = [] for item in data["subscriptionOfferDetailsAndroid"]: if item is Dictionary: arr.append(ProductSubscriptionAndroidOfferDetails.from_dict(item)) @@ -1948,7 +1957,10 @@ class ProductSubscriptionAndroidOfferDetails: if data.has("offerToken") and data["offerToken"] != null: obj.offer_token = data["offerToken"] if data.has("offerTags") and data["offerTags"] != null: - obj.offer_tags = data["offerTags"] + var arr: Array[String] = [] + for item in data["offerTags"]: + arr.append(str(item)) + obj.offer_tags = arr if data.has("pricingPhases") and data["pricingPhases"] != null: if data["pricingPhases"] is Dictionary: obj.pricing_phases = PricingPhasesAndroid.from_dict(data["pricingPhases"]) @@ -2054,7 +2066,7 @@ class ProductSubscriptionIOS: else: obj.type_ios = enum_str if data.has("subscriptionOffers") and data["subscriptionOffers"] != null: - var arr = [] + var arr: Array[SubscriptionOffer] = [] for item in data["subscriptionOffers"]: if item is Dictionary: arr.append(SubscriptionOffer.from_dict(item)) @@ -2062,7 +2074,7 @@ class ProductSubscriptionIOS: arr.append(item) obj.subscription_offers = arr if data.has("pricingTermsIOS") and data["pricingTermsIOS"] != null: - var arr = [] + var arr: Array[SubscriptionPricingTermsIOS] = [] for item in data["pricingTermsIOS"]: if item is Dictionary: arr.append(SubscriptionPricingTermsIOS.from_dict(item)) @@ -2077,7 +2089,7 @@ class ProductSubscriptionIOS: else: obj.subscription_info_ios = data["subscriptionInfoIOS"] if data.has("discountsIOS") and data["discountsIOS"] != null: - var arr = [] + var arr: Array[DiscountIOS] = [] for item in data["discountsIOS"]: if item is Dictionary: arr.append(DiscountIOS.from_dict(item)) @@ -2232,7 +2244,10 @@ class PurchaseAndroid: if data.has("productId") and data["productId"] != null: obj.product_id = data["productId"] if data.has("ids") and data["ids"] != null: - obj.ids = data["ids"] + var arr: Array[String] = [] + for item in data["ids"]: + arr.append(str(item)) + obj.ids = arr if data.has("transactionId") and data["transactionId"] != null: obj.transaction_id = data["transactionId"] if data.has("transactionDate") and data["transactionDate"] != null: @@ -2365,7 +2380,10 @@ class PurchaseError: if data.has("responseCode") and data["responseCode"] != null: obj.response_code = data["responseCode"] if data.has("productIds") and data["productIds"] != null: - obj.product_ids = data["productIds"] + var arr: Array[String] = [] + for item in data["productIds"]: + arr.append(str(item)) + obj.product_ids = arr if data.has("productType") and data["productType"] != null: obj.product_type = data["productType"] if data.has("isEmptyProductList") and data["isEmptyProductList"] != null: @@ -2442,7 +2460,10 @@ class PurchaseIOS: if data.has("productId") and data["productId"] != null: obj.product_id = data["productId"] if data.has("ids") and data["ids"] != null: - obj.ids = data["ids"] + var arr: Array[String] = [] + for item in data["ids"]: + arr.append(str(item)) + obj.ids = arr if data.has("transactionDate") and data["transactionDate"] != null: obj.transaction_date = data["transactionDate"] if data.has("purchaseToken") and data["purchaseToken"] != null: @@ -2905,7 +2926,7 @@ class SubscriptionInfoIOS: else: obj.introductory_offer = data["introductoryOffer"] if data.has("pricingTerms") and data["pricingTerms"] != null: - var arr = [] + var arr: Array[SubscriptionPricingTermsIOS] = [] for item in data["pricingTerms"]: if item is Dictionary: arr.append(SubscriptionPricingTermsIOS.from_dict(item)) @@ -2913,7 +2934,7 @@ class SubscriptionInfoIOS: arr.append(item) obj.pricing_terms = arr if data.has("promotionalOffers") and data["promotionalOffers"] != null: - var arr = [] + var arr: Array[SubscriptionOfferIOS] = [] for item in data["promotionalOffers"]: if item is Dictionary: arr.append(SubscriptionOfferIOS.from_dict(item)) @@ -3049,7 +3070,10 @@ class SubscriptionOffer: if data.has("offerTokenAndroid") and data["offerTokenAndroid"] != null: obj.offer_token_android = data["offerTokenAndroid"] if data.has("offerTagsAndroid") and data["offerTagsAndroid"] != null: - obj.offer_tags_android = data["offerTagsAndroid"] + var arr: Array[String] = [] + for item in data["offerTagsAndroid"]: + arr.append(str(item)) + obj.offer_tags_android = arr if data.has("pricingPhasesAndroid") and data["pricingPhasesAndroid"] != null: if data["pricingPhasesAndroid"] is Dictionary: obj.pricing_phases_android = PricingPhasesAndroid.from_dict(data["pricingPhasesAndroid"]) @@ -3253,7 +3277,7 @@ class SubscriptionPricingTermsIOS: else: obj.commitment_info = data["commitmentInfo"] if data.has("subscriptionOffers") and data["subscriptionOffers"] != null: - var arr = [] + var arr: Array[SubscriptionOffer] = [] for item in data["subscriptionOffers"]: if item is Dictionary: arr.append(SubscriptionOffer.from_dict(item)) @@ -3352,7 +3376,10 @@ class UserChoiceBillingDetails: if data.has("externalTransactionToken") and data["externalTransactionToken"] != null: obj.external_transaction_token = data["externalTransactionToken"] if data.has("products") and data["products"] != null: - obj.products = data["products"] + var arr: Array[String] = [] + for item in data["products"]: + arr.append(str(item)) + obj.products = arr return obj func to_dict() -> Dictionary: @@ -3560,7 +3587,7 @@ class VerifyPurchaseWithProviderResult: else: obj.iapkit = data["iapkit"] if data.has("errors") and data["errors"] != null: - var arr = [] + var arr: Array[VerifyPurchaseWithProviderError] = [] for item in data["errors"]: if item is Dictionary: arr.append(VerifyPurchaseWithProviderError.from_dict(item)) @@ -3976,7 +4003,10 @@ class ProductRequest: static func from_dict(data: Dictionary) -> ProductRequest: var obj = ProductRequest.new() if data.has("skus") and data["skus"] != null: - obj.skus = data["skus"] + var arr: Array[String] = [] + for item in data["skus"]: + arr.append(str(item)) + obj.skus = arr if data.has("type") and data["type"] != null: var enum_str = data["type"] if enum_str is String and PRODUCT_QUERY_TYPE_FROM_STRING.has(enum_str): @@ -4040,7 +4070,10 @@ class PurchaseInput: if data.has("productId") and data["productId"] != null: obj.product_id = data["productId"] if data.has("ids") and data["ids"] != null: - obj.ids = data["ids"] + var arr: Array[String] = [] + for item in data["ids"]: + arr.append(str(item)) + obj.ids = arr if data.has("transactionDate") and data["transactionDate"] != null: obj.transaction_date = data["transactionDate"] if data.has("purchaseToken") and data["purchaseToken"] != null: @@ -4163,7 +4196,10 @@ class RequestPurchaseAndroidProps: static func from_dict(data: Dictionary) -> RequestPurchaseAndroidProps: var obj = RequestPurchaseAndroidProps.new() if data.has("skus") and data["skus"] != null: - obj.skus = data["skus"] + var arr: Array[String] = [] + for item in data["skus"]: + arr.append(str(item)) + obj.skus = arr if data.has("obfuscatedAccountId") and data["obfuscatedAccountId"] != null: obj.obfuscated_account_id = data["obfuscatedAccountId"] if data.has("obfuscatedProfileId") and data["obfuscatedProfileId"] != null: @@ -4385,7 +4421,10 @@ class RequestSubscriptionAndroidProps: static func from_dict(data: Dictionary) -> RequestSubscriptionAndroidProps: var obj = RequestSubscriptionAndroidProps.new() if data.has("skus") and data["skus"] != null: - obj.skus = data["skus"] + var arr: Array[String] = [] + for item in data["skus"]: + arr.append(str(item)) + obj.skus = arr if data.has("obfuscatedAccountId") and data["obfuscatedAccountId"] != null: obj.obfuscated_account_id = data["obfuscatedAccountId"] if data.has("obfuscatedProfileId") and data["obfuscatedProfileId"] != null: @@ -4397,7 +4436,7 @@ class RequestSubscriptionAndroidProps: if data.has("replacementMode") and data["replacementMode"] != null: obj.replacement_mode = data["replacementMode"] if data.has("subscriptionOffers") and data["subscriptionOffers"] != null: - var arr = [] + var arr: Array[AndroidSubscriptionOfferInput] = [] for item in data["subscriptionOffers"]: if item is Dictionary: arr.append(AndroidSubscriptionOfferInput.from_dict(item)) @@ -5542,7 +5581,10 @@ class Query: static func from_dict(data: Dictionary) -> Args: var obj = Args.new() if data.has("subscriptionIds") and data["subscriptionIds"] != null: - obj.subscription_ids = data["subscriptionIds"] + var arr: Array[String] = [] + for item in data["subscriptionIds"]: + arr.append(str(item)) + obj.subscription_ids = arr return obj func to_dict() -> Dictionary: @@ -5562,7 +5604,10 @@ class Query: static func from_dict(data: Dictionary) -> Args: var obj = Args.new() if data.has("subscriptionIds") and data["subscriptionIds"] != null: - obj.subscription_ids = data["subscriptionIds"] + var arr: Array[String] = [] + for item in data["subscriptionIds"]: + arr.append(str(item)) + obj.subscription_ids = arr return obj func to_dict() -> Dictionary: diff --git a/libraries/kmp-iap/gradle/wrapper/gradle-wrapper.properties b/libraries/kmp-iap/gradle/wrapper/gradle-wrapper.properties index 302725038..42f40c92d 100644 --- a/libraries/kmp-iap/gradle/wrapper/gradle-wrapper.properties +++ b/libraries/kmp-iap/gradle/wrapper/gradle-wrapper.properties @@ -2,6 +2,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip -networkTimeout=10000 +networkTimeout=120000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/Helper.kt b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/Helper.kt index 55a8bad5a..50bc2fdea 100644 --- a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/Helper.kt +++ b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/Helper.kt @@ -55,6 +55,78 @@ import dev.hyo.openiap.LaunchExternalLinkParamsAndroid as OpenIapLaunchExternalL private val billingPeriodRegex = Regex("""P(\d+)([DWMY])""") +internal data class UnfetchedProductInfo( + val productId: String, + val productType: String, + val statusCode: Int, +) + +internal data class ProductQueryOutcome( + val productDetails: List, + val unfetchedProducts: List, + val succeeded: Boolean, +) + +internal fun productStatusFromUnfetchedStatus(statusCode: Int): ProductStatusAndroid = + when (statusCode) { + 3 -> ProductStatusAndroid.NotFound + 4 -> ProductStatusAndroid.NoOffersAvailable + else -> ProductStatusAndroid.Unknown + } + +internal fun QueryProductDetailsResult.unfetchedProductsCompat(): List = + runCatching { + val items = javaClass.getMethod("getUnfetchedProductList").invoke(this) as? List<*> + ?: return@runCatching emptyList() + items.mapNotNull { item -> + item ?: return@mapNotNull null + val itemClass = item.javaClass + val productId = itemClass.getMethod("getProductId").invoke(item) as? String + ?: return@mapNotNull null + val productType = itemClass.getMethod("getProductType").invoke(item) as? String + ?: return@mapNotNull null + val statusCode = itemClass.getMethod("getStatusCode").invoke(item) as? Int + ?: return@mapNotNull null + UnfetchedProductInfo(productId, productType, statusCode) + } + }.getOrDefault(emptyList()) + +internal fun unavailableInAppProduct( + productId: String, + status: ProductStatusAndroid, +): ProductAndroid = ProductAndroid( + currency = "", + description = "", + displayName = null, + displayPrice = "", + id = productId, + nameAndroid = "", + platform = IapPlatform.Android, + price = null, + productStatusAndroid = status, + title = "", + type = ProductType.InApp, +) + +internal fun unavailableSubscriptionProduct( + productId: String, + status: ProductStatusAndroid, +): ProductSubscriptionAndroid = ProductSubscriptionAndroid( + currency = "", + description = "", + displayName = null, + displayPrice = "", + id = productId, + nameAndroid = "", + platform = IapPlatform.Android, + price = null, + productStatusAndroid = status, + subscriptionOfferDetailsAndroid = emptyList(), + subscriptionOffers = emptyList(), + title = "", + type = ProductType.Subs, +) + internal fun emitFailureAndThrow( errorFlow: MutableSharedFlow, error: PurchaseError @@ -320,7 +392,7 @@ internal fun ProductDetails.toProduct(): Product { oneTimePurchaseOfferDetailsAndroid = oneTimeOfferDetails, platform = IapPlatform.Android, price = priceValue, - productStatusAndroid = getProductStatus(), + productStatusAndroid = ProductStatusAndroid.Ok, subscriptionOfferDetailsAndroid = subscriptionOfferDetails, subscriptionOffers = subscriptionOffers, title = title, @@ -351,16 +423,6 @@ internal fun ProductDetails.toSubscriptionProduct(): ProductSubscriptionAndroid? ) } -private fun ProductDetails.getProductStatus(): ProductStatusAndroid? = runCatching { - val statusMethod = this::class.java.getMethod("getProductStatus") - when (statusMethod.invoke(this) as? Int) { - 0 -> ProductStatusAndroid.Ok - 1 -> ProductStatusAndroid.NotFound - 2 -> ProductStatusAndroid.NoOffersAvailable - else -> ProductStatusAndroid.Unknown - } -}.getOrNull() - private fun ProductDetails.OneTimePurchaseOfferDetails.toOfferDetail(): ProductAndroidOneTimePurchaseOfferDetail { val discountInfo = runCatching { discountDisplayInfo }.getOrNull() diff --git a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt index 7827ad7f5..b07fc4be7 100644 --- a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt +++ b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt @@ -52,6 +52,7 @@ import io.github.hyochan.kmpiap.openiap.Product import io.github.hyochan.kmpiap.openiap.ProductOrSubscription import io.github.hyochan.kmpiap.openiap.ProductQueryType import io.github.hyochan.kmpiap.openiap.ProductRequest +import io.github.hyochan.kmpiap.openiap.ProductStatusAndroid import io.github.hyochan.kmpiap.openiap.Purchase import io.github.hyochan.kmpiap.openiap.PurchaseAndroid import io.github.hyochan.kmpiap.openiap.IapPlatform @@ -816,20 +817,12 @@ internal class InAppPurchaseAndroid : KmpInAppPurchase, Application.ActivityLife val includeInApp = queryType == ProductQueryType.InApp || queryType == ProductQueryType.All val includeSubs = queryType == ProductQueryType.Subs || queryType == ProductQueryType.All - suspend fun query(productType: String): List { - val ordered = mutableListOf() - val missing = mutableListOf() - - params.skus.forEach { sku -> - val cached = cachedProductDetails[sku] - if (cached != null && cached.productType == productType) { - ordered += cached - } else { - missing += sku - } + suspend fun query(productType: String): ProductQueryOutcome { + val missing = params.skus.distinct().filter { sku -> + cachedProductDetails[sku]?.productType != productType } - if (missing.isNotEmpty()) { + val queryOutcome = if (missing.isNotEmpty()) { val queryParams = QueryProductDetailsParams.newBuilder() .setProductList( missing.map { sku -> @@ -841,48 +834,134 @@ internal class InAppPurchaseAndroid : KmpInAppPurchase, Application.ActivityLife ) .build() - val queried = suspendCancellableCoroutine> { continuation -> + suspendCancellableCoroutine { continuation -> client.queryProductDetailsAsync(queryParams) { billingResult: BillingResult, result: QueryProductDetailsResult -> if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { result.productDetailsList.forEach { detail -> cachedProductDetails[detail.productId] = detail } - continuation.resume(result.productDetailsList) + continuation.resume( + ProductQueryOutcome( + productDetails = result.productDetailsList, + unfetchedProducts = result.unfetchedProductsCompat(), + succeeded = true, + ) + ) } else { - continuation.resume(emptyList()) + continuation.resume( + ProductQueryOutcome( + productDetails = emptyList(), + unfetchedProducts = emptyList(), + succeeded = false, + ) + ) } } } + } else { + ProductQueryOutcome( + productDetails = emptyList(), + unfetchedProducts = emptyList(), + succeeded = true, + ) + } - missing.forEach { sku -> - cachedProductDetails[sku]?.takeIf { it.productType == productType }?.let { ordered += it } + return queryOutcome.copy( + productDetails = params.skus.mapNotNull { sku -> + cachedProductDetails[sku]?.takeIf { it.productType == productType } } + ) + } - queried.filter { detail -> detail.productType == productType && detail.productId in params.skus } - .forEach { detail -> - if (!ordered.contains(detail)) ordered += detail - } + val inAppResult = if (includeInApp) { + query(BillingClient.ProductType.INAPP) + } else { + ProductQueryOutcome(emptyList(), emptyList(), true) + } + val subscriptionsResult = if (includeSubs) { + query(BillingClient.ProductType.SUBS) + } else { + ProductQueryOutcome(emptyList(), emptyList(), true) + } + + fun ProductQueryOutcome.statusFor(productId: String) = unfetchedProducts + .firstOrNull { it.productId == productId } + ?.let { productStatusFromUnfetchedStatus(it.statusCode) } + + fun unavailableAllProduct(productId: String): ProductOrSubscription? { + val statuses = listOfNotNull( + inAppResult.unfetchedProducts.firstOrNull { it.productId == productId }, + subscriptionsResult.unfetchedProducts.firstOrNull { it.productId == productId }, + ) + val noOffers = statuses.firstOrNull { + productStatusFromUnfetchedStatus(it.statusCode) == + ProductStatusAndroid.NoOffersAvailable + } + val selected = noOffers ?: statuses.firstOrNull() + val status = when { + noOffers != null -> + ProductStatusAndroid.NoOffersAvailable + inAppResult.succeeded && subscriptionsResult.succeeded && + statuses.size == 2 && statuses.all { + productStatusFromUnfetchedStatus(it.statusCode) == + ProductStatusAndroid.NotFound + } -> ProductStatusAndroid.NotFound + inAppResult.succeeded || subscriptionsResult.succeeded -> + ProductStatusAndroid.Unknown + else -> return null } - return ordered + return if (selected?.productType == BillingClient.ProductType.SUBS) { + ProductOrSubscription.ProductSubscriptionItem( + unavailableSubscriptionProduct(productId, status) + ) + } else { + ProductOrSubscription.ProductItem(unavailableInAppProduct(productId, status)) + } } - val inAppDetails = if (includeInApp) query(BillingClient.ProductType.INAPP) else emptyList() - val subsDetails = if (includeSubs) query(BillingClient.ProductType.SUBS) else emptyList() - return@withContext when (queryType) { - ProductQueryType.InApp -> FetchProductsResultProducts(inAppDetails.map { it.toProduct() }) - ProductQueryType.Subs -> FetchProductsResultSubscriptions(subsDetails.mapNotNull { it.toSubscriptionProduct() }) - ProductQueryType.All -> { - // Preserve the mixed OpenIAP `all` union by keeping in-app - // products and subscriptions in their distinct variants. - val combined = buildList { - addAll(inAppDetails.map { ProductOrSubscription.ProductItem(it.toProduct()) }) - addAll( - subsDetails.mapNotNull { detail -> - detail.toSubscriptionProduct()?.let { - ProductOrSubscription.ProductSubscriptionItem(it) + ProductQueryType.InApp -> { + val detailsById = inAppResult.productDetails.associateBy { it.productId } + FetchProductsResultProducts( + params.skus.mapNotNull { productId -> + detailsById[productId]?.toProduct() + ?: if (inAppResult.succeeded) { + unavailableInAppProduct( + productId, + inAppResult.statusFor(productId) + ?: ProductStatusAndroid.Unknown, + ) + } else { + null } - } - ) + } + ) + } + ProductQueryType.Subs -> { + val detailsById = subscriptionsResult.productDetails.associateBy { it.productId } + FetchProductsResultSubscriptions( + params.skus.mapNotNull { productId -> + detailsById[productId]?.toSubscriptionProduct() + ?: if (subscriptionsResult.succeeded) { + unavailableSubscriptionProduct( + productId, + subscriptionsResult.statusFor(productId) + ?: ProductStatusAndroid.Unknown, + ) + } else { + null + } + } + ) + } + ProductQueryType.All -> { + val inAppById = inAppResult.productDetails.associateBy { it.productId } + val subscriptionsById = subscriptionsResult.productDetails.associateBy { it.productId } + val combined = params.skus.mapNotNull { productId -> + inAppById[productId]?.let { + ProductOrSubscription.ProductItem(it.toProduct()) + } ?: subscriptionsById[productId]?.toSubscriptionProduct()?.let { + ProductOrSubscription.ProductSubscriptionItem(it) + } ?: unavailableAllProduct(productId) } FetchProductsResultAll(combined) } diff --git a/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/ProductStatusAndroidTest.kt b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/ProductStatusAndroidTest.kt new file mode 100644 index 000000000..61570d38c --- /dev/null +++ b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/ProductStatusAndroidTest.kt @@ -0,0 +1,33 @@ +package io.github.hyochan.kmpiap + +import io.github.hyochan.kmpiap.openiap.ProductStatusAndroid +import io.github.hyochan.kmpiap.openiap.ProductType +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ProductStatusAndroidTest { + @Test + fun `maps Billing 8 unfetched status codes`() { + assertEquals(ProductStatusAndroid.NotFound, productStatusFromUnfetchedStatus(3)) + assertEquals(ProductStatusAndroid.NoOffersAvailable, productStatusFromUnfetchedStatus(4)) + assertEquals(ProductStatusAndroid.Unknown, productStatusFromUnfetchedStatus(2)) + assertEquals(ProductStatusAndroid.Unknown, productStatusFromUnfetchedStatus(0)) + } + + @Test + fun `creates typed placeholders for unfetched products`() { + val inApp = unavailableInAppProduct("missing.inapp", ProductStatusAndroid.NotFound) + val subscription = unavailableSubscriptionProduct( + "ineligible.subscription", + ProductStatusAndroid.NoOffersAvailable, + ) + + assertEquals(ProductType.InApp, inApp.type) + assertEquals(ProductStatusAndroid.NotFound, inApp.productStatusAndroid) + assertEquals(ProductType.Subs, subscription.type) + assertEquals(ProductStatusAndroid.NoOffersAvailable, subscription.productStatusAndroid) + assertTrue(subscription.subscriptionOfferDetailsAndroid.isEmpty()) + assertTrue(subscription.subscriptionOffers.isEmpty()) + } +} diff --git a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt index 7610db94e..c1c3405e7 100644 --- a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt +++ b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt @@ -1145,7 +1145,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { return try { // OpenIAP returns NSDictionary which can be cast to Map val dict = (data as? Map<*, *>) ?: return null - val map = dict.mapKeys { it.key.toString() } + val map = normalizeBridgeMap(dict) ?: return null val platform = map["platform"] as? String if (platform == "ios" || platform == "iOS") { @@ -1201,8 +1201,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { } private fun mapFromAny(data: Any?): Map? { - val dict = data as? Map<*, *> ?: return null - return dict.entries.associate { (key, value) -> key.toString() to value } + return normalizeBridgeMap(data) } private fun convertAnyToTransactionCommitmentInfoIOS(data: Any?): TransactionCommitmentInfoIOS? { @@ -1228,6 +1227,20 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { return decoded.ifEmpty { null } } + private fun convertAnyToSubscriptionInfoIOS(data: Any?): SubscriptionInfoIOS? = + normalizeBridgeMap(data)?.let { map -> + runCatching { SubscriptionInfoIOS.fromJson(map) }.getOrNull() + } + + private fun convertAnyListToDiscountsIOS(data: Any?): List? { + val list = data as? List<*> ?: return null + return list.mapNotNull { item -> + normalizeBridgeMap(item)?.let { map -> + runCatching { DiscountIOS.fromJson(map) }.getOrNull() + } + }.ifEmpty { null } + } + @Suppress("UNCHECKED_CAST") private fun convertAnyListToPurchases(data: Any?): List { if (data == null) return emptyList() @@ -1272,7 +1285,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { return@mapNotNull null } - val map = dict.mapKeys { it.key.toString() } + val map = normalizeIosProductPayload(dict) ?: return@mapNotNull null runCatching { ProductIOS.fromJson(map) }.getOrNull()?.let { return@mapNotNull mergeLegacySubscriptionOffers(it, map) } @@ -1295,7 +1308,9 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { pricingTermsIOS = convertAnyListToSubscriptionPricingTermsIOS( map["pricingTermsIOS"] ), - subscriptionInfoIOS = null, // Complex object, handle separately if needed + subscriptionInfoIOS = convertAnyToSubscriptionInfoIOS( + map["subscriptionInfoIOS"] + ), subscriptionOffers = subscriptionOffers.ifEmpty { null }, title = map["title"] as? String ?: "", type = (map["type"] as? String)?.let { ProductType.fromJson(it) } @@ -1318,7 +1333,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { val list = data as? List<*> ?: return emptyList() list.mapNotNull { item -> val dict = (item as? Map<*, *>) ?: return@mapNotNull null - val map = dict.mapKeys { it.key.toString() } + val map = normalizeIosProductPayload(dict) ?: return@mapNotNull null runCatching { ProductSubscriptionIOS.fromJson(map) }.getOrNull()?.let { return@mapNotNull mergeLegacySubscriptionOffers(it, map) } @@ -1330,7 +1345,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { currency = map["currency"] as? String ?: "", debugDescription = map["debugDescription"] as? String, description = map["description"] as? String ?: "", - discountsIOS = null, // Complex array (deprecated, use subscriptionOffers) + discountsIOS = convertAnyListToDiscountsIOS(map["discountsIOS"]), displayName = map["displayName"] as? String, displayNameIOS = map["displayNameIOS"] as? String ?: "", displayPrice = map["displayPrice"] as? String ?: "", @@ -1338,8 +1353,12 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { introductoryPriceAsAmountIOS = map["introductoryPriceAsAmountIOS"] as? String, introductoryPriceIOS = map["introductoryPriceIOS"] as? String, introductoryPriceNumberOfPeriodsIOS = map["introductoryPriceNumberOfPeriodsIOS"] as? String, - introductoryPricePaymentModeIOS = PaymentModeIOS.Empty, // Complex enum - introductoryPriceSubscriptionPeriodIOS = null, // Complex enum + introductoryPricePaymentModeIOS = (map["introductoryPricePaymentModeIOS"] as? String) + ?.let { runCatching { PaymentModeIOS.fromJson(it) }.getOrNull() } + ?: PaymentModeIOS.Empty, + introductoryPriceSubscriptionPeriodIOS = + (map["introductoryPriceSubscriptionPeriodIOS"] as? String) + ?.let { runCatching { SubscriptionPeriodIOS.fromJson(it) }.getOrNull() }, isFamilyShareableIOS = map["isFamilyShareableIOS"] as? Boolean ?: false, jsonRepresentationIOS = map["jsonRepresentationIOS"] as? String ?: "", platform = IapPlatform.Ios, @@ -1347,14 +1366,19 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { pricingTermsIOS = convertAnyListToSubscriptionPricingTermsIOS( map["pricingTermsIOS"] ), - subscriptionInfoIOS = null, // Complex object + subscriptionInfoIOS = convertAnyToSubscriptionInfoIOS( + map["subscriptionInfoIOS"] + ), subscriptionOffers = subscriptionOffers.ifEmpty { null }, subscriptionGroupIdIOS = map["subscriptionGroupIdIOS"] as? String, subscriptionPeriodNumberIOS = map["subscriptionPeriodNumberIOS"] as? String, - subscriptionPeriodUnitIOS = null, // Complex enum + subscriptionPeriodUnitIOS = (map["subscriptionPeriodUnitIOS"] as? String) + ?.let { runCatching { SubscriptionPeriodIOS.fromJson(it) }.getOrNull() }, title = map["title"] as? String ?: "", type = ProductType.Subs, - typeIOS = ProductTypeIOS.AutoRenewableSubscription + typeIOS = (map["typeIOS"] as? String) + ?.let { runCatching { ProductTypeIOS.fromJson(it) }.getOrNull() } + ?: ProductTypeIOS.AutoRenewableSubscription ) } } catch (e: Exception) { @@ -1369,7 +1393,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { return list.mapNotNull { item -> runCatching { val dict = (item as? Map<*, *>) ?: return@runCatching null - val map = dict.mapKeys { it.key.toString() } + val map = normalizeIosProductPayload(dict) ?: return@runCatching null // Native iOS may return either generated union JSON or raw StoreKit maps; // decode the union first, then recover by product type for legacy payloads. @@ -1448,7 +1472,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { return try { val dict = (data as? Map<*, *>) ?: return null - val map = dict.mapKeys { it.key.toString() } + val map = normalizeIosProductPayload(dict) ?: return null runCatching { ProductIOS.fromJson(map) }.getOrNull()?.let { return mergeLegacySubscriptionOffers(it, map) as? ProductIOS ?: it } @@ -1468,7 +1492,9 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { pricingTermsIOS = convertAnyListToSubscriptionPricingTermsIOS( map["pricingTermsIOS"] ), - subscriptionInfoIOS = null, // Complex object + subscriptionInfoIOS = convertAnyToSubscriptionInfoIOS( + map["subscriptionInfoIOS"] + ), subscriptionOffers = subscriptionOffersFrom(map).ifEmpty { null }, title = map["title"] as? String ?: "", type = (map["type"] as? String)?.let { ProductType.fromJson(it) } @@ -1662,7 +1688,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { return try { val dict = (data as? Map<*, *>) ?: return null - val map = dict.mapKeys { it.key.toString() } + val map = normalizeBridgeMap(dict) ?: return null // Determine payment mode val paymentModeString = map["paymentMode"] as? String diff --git a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/IosProductPayloadNormalizer.kt b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/IosProductPayloadNormalizer.kt new file mode 100644 index 000000000..c5989b82f --- /dev/null +++ b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/IosProductPayloadNormalizer.kt @@ -0,0 +1,34 @@ +package io.github.hyochan.kmpiap + +internal fun normalizeBridgeMap(data: Any?): Map? { + val source = data as? Map<*, *> ?: return null + return source.entries.associate { (key, value) -> + key.toString() to normalizeBridgeValue(value) + } +} + +internal fun normalizeIosProductPayload(data: Any?): Map? { + val normalized = normalizeBridgeMap(data)?.toMutableMap() ?: return null + + fun applyAlias(canonical: String, legacy: String) { + val canonicalValue = normalized[canonical] + val isMissing = canonical !in normalized || canonicalValue == null || + (canonicalValue is List<*> && canonicalValue.isEmpty()) + if (isMissing && normalized[legacy] != null) { + normalized[canonical] = normalized[legacy] + } + } + + applyAlias("subscriptionInfoIOS", "subscription") + applyAlias("subscriptionOffers", "offers") + applyAlias("discountsIOS", "discounts") + return normalized +} + +private fun normalizeBridgeValue(value: Any?): Any? = when (value) { + is Map<*, *> -> value.entries.associate { (key, nested) -> + key.toString() to normalizeBridgeValue(nested) + } + is List<*> -> value.map(::normalizeBridgeValue) + else -> value +} diff --git a/libraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/IosProductPayloadNormalizerTest.kt b/libraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/IosProductPayloadNormalizerTest.kt new file mode 100644 index 000000000..065f9afbc --- /dev/null +++ b/libraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/IosProductPayloadNormalizerTest.kt @@ -0,0 +1,53 @@ +package io.github.hyochan.kmpiap + +import io.github.hyochan.kmpiap.openiap.ProductSubscriptionIOS +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class IosProductPayloadNormalizerTest { + @Test + fun `normalizes legacy product aliases before generated decoding`() { + val payload: Map = mapOf( + "id" to "premium.monthly", + "type" to "subs", + "typeIOS" to "auto-renewable-subscription", + "subscriptionInfoIOS" to null, + "subscription" to mapOf( + "subscriptionGroupId" to "group-1", + "subscriptionPeriod" to mapOf( + "unit" to "month", + "value" to 1, + ), + ), + "subscriptionOffers" to emptyList(), + "offers" to listOf( + mapOf( + "displayPrice" to "Free", + "id" to "intro", + "price" to 0.0, + "type" to "introductory", + ) + ), + "discountsIOS" to emptyList(), + "discounts" to listOf( + mapOf( + "identifier" to "legacy-discount", + "numberOfPeriods" to 1, + "paymentMode" to "free-trial", + "price" to "Free", + "priceAmount" to 0.0, + "subscriptionPeriod" to "P1M", + "type" to "introductory", + ) + ), + ) + + val normalized = assertNotNull(normalizeIosProductPayload(payload)) + val product = ProductSubscriptionIOS.fromJson(normalized) + + assertEquals("group-1", product.subscriptionInfoIOS?.subscriptionGroupId) + assertEquals("intro", product.subscriptionOffers?.single()?.id) + assertEquals("legacy-discount", product.discountsIOS?.single()?.identifier) + } +} diff --git a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt index cfaaec648..fb63b69cf 100644 --- a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt +++ b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt @@ -924,111 +924,21 @@ class HybridRnIap : HybridRnIapSpec() { } private fun serializeSubscriptionOffers(offers: List): String { - val array = JSONArray() - offers.forEach { offer -> - val offerJson = JSONObject() - offerJson.put("basePlanId", offer.basePlanId) - offerJson.put("offerId", offer.offerId) - offerJson.put("offerTags", JSONArray(offer.offerTags)) - offerJson.put("offerToken", offer.offerToken) - - val phasesArray = JSONArray() - offer.pricingPhases.pricingPhaseList.forEach { phase -> - val phaseJson = JSONObject() - phaseJson.put("billingCycleCount", phase.billingCycleCount) - phaseJson.put("billingPeriod", phase.billingPeriod) - phaseJson.put("formattedPrice", phase.formattedPrice) - phaseJson.put("priceAmountMicros", phase.priceAmountMicros) - phaseJson.put("priceCurrencyCode", phase.priceCurrencyCode) - phaseJson.put("recurrenceMode", phase.recurrenceMode) - phasesArray.put(phaseJson) - } - - val pricingPhasesJson = JSONObject() - pricingPhasesJson.put("pricingPhaseList", phasesArray) - offerJson.put("pricingPhases", pricingPhasesJson) - - array.put(offerJson) - } - return array.toString() + return JSONArray(legacySubscriptionOfferMaps(offers)).toString() } /** * Serialize standardized SubscriptionOffer list to JSON string (OpenIAP 1.3.10+) */ private fun serializeStandardizedSubscriptionOffers(offers: List): String { - val array = JSONArray() - offers.forEach { offer -> - val offerJson = JSONObject() - offerJson.put("id", offer.id) - offerJson.put("displayPrice", offer.displayPrice) - offerJson.put("price", offer.price) - offerJson.put("type", offer.type.rawValue) - offer.currency?.let { offerJson.put("currency", it) } - offer.basePlanIdAndroid?.let { offerJson.put("basePlanIdAndroid", it) } - offer.offerTokenAndroid?.let { offerJson.put("offerTokenAndroid", it) } - offer.offerTagsAndroid?.let { offerJson.put("offerTagsAndroid", JSONArray(it)) } - offer.paymentMode?.let { offerJson.put("paymentMode", it.rawValue) } - offer.periodCount?.let { offerJson.put("periodCount", it) } - offer.numberOfPeriodsIOS?.let { offerJson.put("numberOfPeriodsIOS", it) } - offer.period?.let { period -> - val periodJson = JSONObject() - periodJson.put("unit", period.unit.rawValue) - periodJson.put("value", period.value) - offerJson.put("period", periodJson) - } - offer.pricingPhasesAndroid?.let { phases -> - val phasesJson = JSONObject() - val phaseList = JSONArray() - phases.pricingPhaseList.forEach { phase -> - val phaseJson = JSONObject() - phaseJson.put("billingCycleCount", phase.billingCycleCount) - phaseJson.put("billingPeriod", phase.billingPeriod) - phaseJson.put("formattedPrice", phase.formattedPrice) - phaseJson.put("priceAmountMicros", phase.priceAmountMicros) - phaseJson.put("priceCurrencyCode", phase.priceCurrencyCode) - phaseJson.put("recurrenceMode", phase.recurrenceMode) - phaseList.put(phaseJson) - } - phasesJson.put("pricingPhaseList", phaseList) - offerJson.put("pricingPhasesAndroid", phasesJson) - } - array.put(offerJson) - } - return array.toString() + return JSONArray(subscriptionOfferMaps(offers)).toString() } /** * Serialize standardized DiscountOffer list to JSON string (OpenIAP 1.3.10+) */ private fun serializeStandardizedDiscountOffers(offers: List): String { - val array = JSONArray() - offers.forEach { offer -> - val offerJson = JSONObject() - offerJson.put("currency", offer.currency) - offerJson.put("displayPrice", offer.displayPrice) - offerJson.put("price", offer.price) - offer.id?.let { offerJson.put("id", it) } - offer.offerTagsAndroid?.let { offerJson.put("offerTagsAndroid", JSONArray(it)) } - offer.offerTokenAndroid?.let { offerJson.put("offerTokenAndroid", it) } - offer.discountAmountMicrosAndroid?.let { offerJson.put("discountAmountMicrosAndroid", it) } - offer.formattedDiscountAmountAndroid?.let { offerJson.put("formattedDiscountAmountAndroid", it) } - offer.fullPriceMicrosAndroid?.let { offerJson.put("fullPriceMicrosAndroid", it) } - offer.limitedQuantityInfoAndroid?.let { info -> - val infoJson = JSONObject() - infoJson.put("maximumQuantity", info.maximumQuantity) - infoJson.put("remainingQuantity", info.remainingQuantity) - offerJson.put("limitedQuantityInfoAndroid", infoJson) - } - offer.validTimeWindowAndroid?.let { window -> - val windowJson = JSONObject() - windowJson.put("startTimeMillis", window.startTimeMillis) - windowJson.put("endTimeMillis", window.endTimeMillis) - offerJson.put("validTimeWindowAndroid", windowJson) - } - array.put(offerJson) - } - return array.toString() + return JSONArray(discountOfferMaps(offers)).toString() } private fun convertToNitroProduct(product: ProductCommon): NitroProduct { @@ -1053,6 +963,7 @@ class HybridRnIap : HybridRnIapSpec() { offerToken = otp.offerToken, offerTags = otp.offerTags.toTypedArray(), fullPriceMicros = otp.fullPriceMicros.wrapVariant(), + purchaseOptionId = otp.purchaseOptionId.wrapVariant(), discountDisplayInfo = otp.discountDisplayInfo?.let { discount -> Variant_NullType_NitroDiscountDisplayInfoAndroid.Second( NitroDiscountDisplayInfoAndroid( diff --git a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/ProductSerialization.kt b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/ProductSerialization.kt new file mode 100644 index 000000000..ce5c2dcb4 --- /dev/null +++ b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/ProductSerialization.kt @@ -0,0 +1,30 @@ +package com.margelo.nitro.iap + +import dev.hyo.openiap.DiscountOffer +import dev.hyo.openiap.ProductSubscriptionAndroidOfferDetails +import dev.hyo.openiap.SubscriptionOffer + +internal fun legacySubscriptionOfferMaps( + offers: List, +): List> = offers.map { it.toJson().withoutTypeNames() } + +internal fun subscriptionOfferMaps( + offers: List, +): List> = offers.map { it.toJson().withoutTypeNames() } + +internal fun discountOfferMaps( + offers: List, +): List> = offers.map { it.toJson().withoutTypeNames() } + +private fun Map.withoutTypeNames(): Map = + entries + .filter { it.key != "__typename" } + .associate { (key, value) -> key to value.withoutTypeNames() } + +private fun Any?.withoutTypeNames(): Any? = when (this) { + is Map<*, *> -> entries + .filter { it.key != "__typename" } + .associate { (key, value) -> key.toString() to value.withoutTypeNames() } + is List<*> -> map { it.withoutTypeNames() } + else -> this +} diff --git a/libraries/react-native-iap/android/src/test/java/com/margelo/nitro/iap/ProductSerializationTest.kt b/libraries/react-native-iap/android/src/test/java/com/margelo/nitro/iap/ProductSerializationTest.kt new file mode 100644 index 000000000..de2a81606 --- /dev/null +++ b/libraries/react-native-iap/android/src/test/java/com/margelo/nitro/iap/ProductSerializationTest.kt @@ -0,0 +1,94 @@ +package com.margelo.nitro.iap + +import dev.hyo.openiap.DiscountOffer +import dev.hyo.openiap.DiscountOfferType +import dev.hyo.openiap.InstallmentPlanDetailsAndroid +import dev.hyo.openiap.PaymentMode +import dev.hyo.openiap.PreorderDetailsAndroid +import dev.hyo.openiap.PricingPhasesAndroid +import dev.hyo.openiap.ProductSubscriptionAndroidOfferDetails +import dev.hyo.openiap.RentalDetailsAndroid +import dev.hyo.openiap.SubscriptionOffer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test + +class ProductSerializationTest { + @Test + fun `legacy subscription offer keeps installment details`() { + val maps = legacySubscriptionOfferMaps( + listOf( + ProductSubscriptionAndroidOfferDetails( + basePlanId = "annual", + installmentPlanDetails = InstallmentPlanDetailsAndroid(12, 12), + offerId = "commitment", + offerTags = listOf("annual"), + offerToken = "token", + pricingPhases = PricingPhasesAndroid(emptyList()), + ) + ) + ) + + val installment = maps.single()["installmentPlanDetails"] as Map<*, *> + assertEquals(false, maps.single().containsKey("__typename")) + assertEquals(false, installment.containsKey("__typename")) + assertEquals(12, installment["commitmentPaymentsCount"]) + assertEquals(12, installment["subsequentCommitmentPaymentsCount"]) + } + + @Test + fun `standardized subscription offer keeps Android metadata`() { + val maps = subscriptionOfferMaps( + listOf( + SubscriptionOffer( + basePlanIdAndroid = "annual", + currency = "USD", + displayPrice = "\$9.99", + id = "commitment", + installmentPlanDetailsAndroid = InstallmentPlanDetailsAndroid(12, 0), + offerTagsAndroid = listOf("commitment"), + offerTokenAndroid = "token", + paymentMode = PaymentMode.PayAsYouGo, + price = 9.99, + type = DiscountOfferType.Promotional, + ) + ) + ) + + val map = maps.single() + assertEquals("promotional", map["type"]) + assertNotNull(map["installmentPlanDetailsAndroid"]) + } + + @Test + fun `standardized discount offer keeps every public Android field`() { + val maps = discountOfferMaps( + listOf( + DiscountOffer( + currency = "USD", + displayPrice = "\$4.99", + id = "half-off", + percentageDiscountAndroid = 50, + preorderDetailsAndroid = PreorderDetailsAndroid( + preorderPresaleEndTimeMillis = "1000", + preorderReleaseTimeMillis = "2000", + ), + price = 4.99, + purchaseOptionIdAndroid = "purchase-option", + rentalDetailsAndroid = RentalDetailsAndroid( + rentalExpirationPeriod = "P1D", + rentalPeriod = "P7D", + ), + type = DiscountOfferType.OneTime, + ) + ) + ) + + val map = maps.single() + assertEquals("one-time", map["type"]) + assertEquals(50, map["percentageDiscountAndroid"]) + assertEquals("purchase-option", map["purchaseOptionIdAndroid"]) + assertNotNull(map["preorderDetailsAndroid"]) + assertNotNull(map["rentalDetailsAndroid"]) + } +} diff --git a/libraries/react-native-iap/src/__tests__/utils/type-bridge.test.ts b/libraries/react-native-iap/src/__tests__/utils/type-bridge.test.ts index 65f6cfb7b..b8104c564 100644 --- a/libraries/react-native-iap/src/__tests__/utils/type-bridge.test.ts +++ b/libraries/react-native-iap/src/__tests__/utils/type-bridge.test.ts @@ -234,6 +234,10 @@ describe('type-bridge utilities', () => { offerTokenAndroid: 'token123', offerTagsAndroid: ['monthly', 'default'], paymentMode: 'pay-as-you-go', + installmentPlanDetailsAndroid: { + commitmentPaymentsCount: 12, + subsequentCommitmentPaymentsCount: 0, + }, period: { unit: 'month', value: 1, @@ -261,6 +265,10 @@ describe('type-bridge utilities', () => { expect(Array.isArray(result.subscriptionOffers)).toBe(true); expect(result.subscriptionOffers[0].basePlanIdAndroid).toBe('monthly'); expect(result.subscriptionOffers[0].offerTokenAndroid).toBe('token123'); + expect( + result.subscriptionOffers[0].installmentPlanDetailsAndroid + .commitmentPaymentsCount, + ).toBe(12); }); it('converts Android product with standardized discountOffers', () => { @@ -274,17 +282,38 @@ describe('type-bridge utilities', () => { currency: 'USD', price: 9.99, platform: 'android', + oneTimePurchaseOfferDetailsAndroid: [ + { + formattedPrice: '$4.99', + offerTags: ['sale'], + offerToken: 'discount_token123', + priceAmountMicros: '4990000', + priceCurrencyCode: 'USD', + purchaseOptionId: 'legacy-purchase-option', + }, + ], discountOffers: JSON.stringify([ { id: 'discount_50off', currency: 'USD', displayPrice: '$4.99', price: 4.99, + type: 'one-time', offerTokenAndroid: 'discount_token123', offerTagsAndroid: ['sale', 'limited'], discountAmountMicrosAndroid: '5000000', formattedDiscountAmountAndroid: '$5.00 OFF', fullPriceMicrosAndroid: '9990000', + percentageDiscountAndroid: 50, + purchaseOptionIdAndroid: 'purchase-option', + preorderDetailsAndroid: { + preorderPresaleEndTimeMillis: '1000', + preorderReleaseTimeMillis: '2000', + }, + rentalDetailsAndroid: { + rentalExpirationPeriod: 'P1D', + rentalPeriod: 'P7D', + }, }, ]), } as NitroProduct; @@ -301,6 +330,21 @@ describe('type-bridge utilities', () => { expect(result.discountOffers[0].formattedDiscountAmountAndroid).toBe( '$5.00 OFF', ); + expect(result.discountOffers[0].type).toBe('one-time'); + expect(result.discountOffers[0].percentageDiscountAndroid).toBe(50); + expect(result.discountOffers[0].purchaseOptionIdAndroid).toBe( + 'purchase-option', + ); + expect(result.discountOffers[0].preorderDetailsAndroid).toEqual({ + preorderPresaleEndTimeMillis: '1000', + preorderReleaseTimeMillis: '2000', + }); + expect(result.discountOffers[0].rentalDetailsAndroid.rentalPeriod).toBe( + 'P7D', + ); + expect( + result.oneTimePurchaseOfferDetailsAndroid[0].purchaseOptionId, + ).toBe('legacy-purchase-option'); }); it('handles missing subscriptionOffers gracefully', () => { diff --git a/libraries/react-native-iap/src/specs/RnIap.nitro.ts b/libraries/react-native-iap/src/specs/RnIap.nitro.ts index c7a90df94..005425b07 100644 --- a/libraries/react-native-iap/src/specs/RnIap.nitro.ts +++ b/libraries/react-native-iap/src/specs/RnIap.nitro.ts @@ -505,6 +505,7 @@ export interface NitroOneTimePurchaseOfferDetail { preorderDetailsAndroid?: NitroPreorderDetailsAndroid | null; priceAmountMicros: string; priceCurrencyCode: string; + purchaseOptionId?: string | null; rentalDetailsAndroid?: NitroRentalDetailsAndroid | null; validTimeWindow?: NitroValidTimeWindowAndroid | null; } diff --git a/packages/apple/Sources/Helpers/StoreKitTypesBridge.swift b/packages/apple/Sources/Helpers/StoreKitTypesBridge.swift index c83bee127..75ac8350a 100644 --- a/packages/apple/Sources/Helpers/StoreKitTypesBridge.swift +++ b/packages/apple/Sources/Helpers/StoreKitTypesBridge.swift @@ -29,6 +29,9 @@ enum StoreKitTypesBridge { price: NSDecimalNumber(decimal: product.price).doubleValue, pricingTermsIOS: makeSubscriptionPricingTerms(from: product.subscription), subscriptionInfoIOS: makeSubscriptionInfo(from: product.subscription), + subscriptionOffers: product.subscription.map(makeStandardizedSubscriptionOffers).flatMap { + $0.isEmpty ? nil : $0 + }, title: product.displayName, type: productType(from: product.type), typeIOS: productTypeIOS(from: product.type) @@ -36,6 +39,10 @@ enum StoreKitTypesBridge { } static func productSubscriptionIOS(from product: StoreKit.Product) async -> ProductSubscriptionIOS? { + if product.type == .nonRenewable { + return nonRenewingSubscriptionIOS(from: product) + } + guard let subscription = product.subscription else { return nil } // Compute discounts once for reuse @@ -105,6 +112,37 @@ enum StoreKitTypesBridge { ) } + private static func nonRenewingSubscriptionIOS(from product: StoreKit.Product) -> ProductSubscriptionIOS { + ProductSubscriptionIOS( + currency: currencyCode(from: product) ?? "", + debugDescription: product.description, + description: product.description, + discountsIOS: nil, + displayName: product.displayName, + displayNameIOS: product.displayName, + displayPrice: product.displayPrice, + id: product.id, + introductoryPriceAsAmountIOS: nil, + introductoryPriceIOS: nil, + introductoryPriceNumberOfPeriodsIOS: nil, + introductoryPricePaymentModeIOS: .empty, + introductoryPriceSubscriptionPeriodIOS: nil, + isFamilyShareableIOS: product.isFamilyShareable, + jsonRepresentationIOS: String(data: product.jsonRepresentation, encoding: .utf8) ?? "", + platform: .ios, + price: NSDecimalNumber(decimal: product.price).doubleValue, + pricingTermsIOS: nil, + subscriptionGroupIdIOS: nil, + subscriptionInfoIOS: nil, + subscriptionOffers: nil, + subscriptionPeriodNumberIOS: nil, + subscriptionPeriodUnitIOS: nil, + title: product.displayName, + type: .subs, + typeIOS: .nonRenewingSubscription + ) + } + static func purchase(from transaction: StoreKit.Transaction, jwsRepresentation: String?) async -> Purchase { .purchaseIos(await purchaseIOS(from: transaction, jwsRepresentation: jwsRepresentation)) } diff --git a/packages/apple/Sources/Models/OpenIapSerialization.swift b/packages/apple/Sources/Models/OpenIapSerialization.swift index 6971b87b4..66a80dbfa 100644 --- a/packages/apple/Sources/Models/OpenIapSerialization.swift +++ b/packages/apple/Sources/Models/OpenIapSerialization.swift @@ -213,29 +213,18 @@ public enum OpenIapSerialization { return encoded case .all(let items): - let allItems = items ?? [] - let iosProducts = allItems.compactMap { item -> ProductIOS? in - guard case .product(let product) = item, - case .productIos(let value) = product - else { return nil } - return value - } - let iosSubscriptions = allItems.compactMap { item -> ProductSubscriptionIOS? in - guard case .productSubscription(.productSubscriptionIos(let value)) = item - else { return nil } - return value - } - iosProducts.forEach { - logger?("Product: \($0.id) - \($0.title) - \($0.displayPrice)") - } - iosSubscriptions.forEach { - logger?("Subscription: \($0.id) - \($0.title) - \($0.displayPrice)") + return (items ?? []).compactMap { item in + switch item { + case .product(.productIos(let product)): + logger?("Product: \(product.id) - \(product.title) - \(product.displayPrice)") + return encode(product) + case .productSubscription(.productSubscriptionIos(let subscription)): + logger?("Subscription: \(subscription.id) - \(subscription.title) - \(subscription.displayPrice)") + return encode(subscription) + default: + return nil + } } - - // Combine both products and subscriptions - let productEncoded = iosProducts.map { encode($0) } - let subscriptionEncoded = iosSubscriptions.map { encode($0) } - return productEncoded + subscriptionEncoded } } diff --git a/packages/apple/Sources/OpenIapModule+ObjC.swift b/packages/apple/Sources/OpenIapModule+ObjC.swift index ec82c854c..c78987275 100644 --- a/packages/apple/Sources/OpenIapModule+ObjC.swift +++ b/packages/apple/Sources/OpenIapModule+ObjC.swift @@ -43,48 +43,7 @@ import StoreKit let productType = type.flatMap { ProductQueryType(rawValue: $0) } let request = ProductRequest(skus: skus, type: productType) let result = try await fetchProducts(request) - - switch result { - case .products(let products): - // Extract ProductIOS from Product enum and convert to dictionaries - let productIOS = (products ?? []).compactMap { product -> ProductIOS? in - guard case let .productIos(value) = product else { return nil } - return value - } - print("[OpenIAP] Fetched \(productIOS.count) products") - let dictionaries = productIOS.map { OpenIapSerialization.encode($0) } - completion(dictionaries, nil) - - case .subscriptions(let subscriptions): - // Extract ProductSubscriptionIOS from ProductSubscription enum and convert to dictionaries - let subscriptionIOS = (subscriptions ?? []).compactMap { subscription -> ProductSubscriptionIOS? in - guard case let .productSubscriptionIos(value) = subscription else { return nil } - return value - } - print("[OpenIAP] Fetched \(subscriptionIOS.count) subscriptions") - let dictionaries = subscriptionIOS.map { OpenIapSerialization.encode($0) } - completion(dictionaries, nil) - - case .all(let items): - // Extract both products and subscriptions from ProductOrSubscription union - let allItems = items ?? [] - let productIOS = allItems.compactMap { item -> ProductIOS? in - guard case .product(let product) = item, - case .productIos(let value) = product - else { return nil } - return value - } - let subscriptionIOS = allItems.compactMap { item -> ProductSubscriptionIOS? in - guard case .productSubscription(.productSubscriptionIos(let value)) = item - else { return nil } - return value - } - print("[OpenIAP] Fetched \(productIOS.count) products and \(subscriptionIOS.count) subscriptions") - // Combine both into a single array of dictionaries - let productDictionaries = productIOS.map { OpenIapSerialization.encode($0) } - let subscriptionDictionaries = subscriptionIOS.map { OpenIapSerialization.encode($0) } - completion(productDictionaries + subscriptionDictionaries, nil) - } + completion(OpenIapSerialization.products(result), nil) } catch { completion(nil, error) } diff --git a/packages/apple/Sources/OpenIapModule.swift b/packages/apple/Sources/OpenIapModule.swift index ddbc78e99..df5182bf6 100644 --- a/packages/apple/Sources/OpenIapModule.swift +++ b/packages/apple/Sources/OpenIapModule.swift @@ -149,73 +149,30 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { throw purchaseError } - // Only process products that were actually requested, not all cached products + // Preserve the concrete product variant so subscription-only fields are + // not discarded when callers request a mixed result. var productEntries: [OpenIAP.Product] = [] var subscriptionEntries: [OpenIAP.ProductSubscription] = [] + var allEntries: [OpenIAP.ProductOrSubscription] = [] for product in fetchedProducts { - productEntries.append(await StoreKitTypesBridge.product(from: product)) if let subscription = await StoreKitTypesBridge.productSubscription(from: product) { subscriptionEntries.append(subscription) + allEntries.append(.productSubscription(subscription)) + } else { + let productEntry = await StoreKitTypesBridge.product(from: product) + productEntries.append(productEntry) + allEntries.append(.product(productEntry)) } } switch params.type ?? .inApp { case .subs: - // Return products that are subscriptions (both auto-renewable and non-renewing) - // Auto-renewable subscriptions have product.subscription != nil and use subscriptionEntries - // Non-renewing subscriptions have product.type == .nonRenewable but no subscription metadata - let autoRenewableSubs = subscriptionEntries.filter { sub in - fetchedProducts.contains { product in - product.id == sub.id && product.subscription != nil - } - } - - // Include non-renewing subscriptions as ProductSubscriptionIOS - // Note: Non-renewing subscriptions in StoreKit 2 don't have subscription metadata - // (no discounts, intro offers, subscription period, or subscription group). - // This is a StoreKit limitation, not missing data - we include all available product info. - let nonRenewingSubs: [ProductSubscription] = fetchedProducts.compactMap { product in - guard product.type == .nonRenewable else { return nil } - return .productSubscriptionIos(ProductSubscriptionIOS( - currency: StoreKitTypesBridge.currencyCode(from: product) ?? "", - debugDescription: product.description, - description: product.description, - discountsIOS: nil, // StoreKit: Non-renewing subscriptions don't support discounts - displayName: product.displayName, - displayNameIOS: product.displayName, - displayPrice: product.displayPrice, - id: product.id, - introductoryPriceAsAmountIOS: nil, // StoreKit: Non-renewing subscriptions don't support intro offers - introductoryPriceIOS: nil, - introductoryPriceNumberOfPeriodsIOS: nil, - introductoryPricePaymentModeIOS: .empty, - introductoryPriceSubscriptionPeriodIOS: nil, - isFamilyShareableIOS: product.isFamilyShareable, - jsonRepresentationIOS: String(data: product.jsonRepresentation, encoding: .utf8) ?? "", - platform: .ios, - price: NSDecimalNumber(decimal: product.price).doubleValue, - pricingTermsIOS: nil, - subscriptionGroupIdIOS: nil, - subscriptionInfoIOS: nil, // StoreKit: Non-renewing subscriptions have no subscription metadata - subscriptionPeriodNumberIOS: nil, - subscriptionPeriodUnitIOS: nil, - title: product.displayName, - type: .subs, - typeIOS: .nonRenewingSubscription - )) - } - - let allSubs = autoRenewableSubs + nonRenewingSubs - return .subscriptions(allSubs.isEmpty ? nil : allSubs) + return .subscriptions(subscriptionEntries.isEmpty ? nil : subscriptionEntries) case .inApp: - let inApp = productEntries.compactMap { entry -> OpenIAP.Product? in - guard case let .productIos(value) = entry, value.type == .inApp else { return nil } - return entry - } - return .products(inApp.isEmpty ? nil : inApp) - case .all: return .products(productEntries.isEmpty ? nil : productEntries) + case .all: + return .all(allEntries.isEmpty ? nil : allEntries) } } diff --git a/packages/apple/Tests/OpenIapTests.swift b/packages/apple/Tests/OpenIapTests.swift index 5ecb78084..5c36a2e6d 100644 --- a/packages/apple/Tests/OpenIapTests.swift +++ b/packages/apple/Tests/OpenIapTests.swift @@ -898,6 +898,24 @@ final class OpenIapTests: XCTestCase { XCTAssertEqual(decoded.subscriptionOffers?.first?.id, "intro_weekly") } + func testMixedProductSerializationPreservesVariantsAndOrder() { + let subscription = makeSampleSubscription() + var product = makeSampleProduct() + product.type = .inApp + product.typeIOS = .consumable + let result = FetchProductsResult.all([ + .productSubscription(.productSubscriptionIos(subscription)), + .product(.productIos(product)), + ]) + + let payload = OpenIapSerialization.products(result) + + XCTAssertEqual(payload.compactMap { $0["id"] as? String }, [subscription.id, product.id]) + XCTAssertEqual(payload[0]["type"] as? String, ProductType.subs.rawValue) + XCTAssertEqual(payload[0]["subscriptionGroupIdIOS"] as? String, "group") + XCTAssertEqual(payload[1]["type"] as? String, ProductType.inApp.rawValue) + } + // MARK: - Helpers private func makeSampleProduct() -> ProductIOS { diff --git a/packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapModule.kt index a984c4a2f..ca7ffea8a 100644 --- a/packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapModule.kt +++ b/packages/google/openiap/src/amazon/java/dev/hyo/openiap/OpenIapModule.kt @@ -1027,6 +1027,7 @@ class OpenIapModule( private fun AmazonProduct.toInAppProduct(): ProductAndroid { return ProductAndroid( currency = "", + debugDescription = description, description = description.orEmpty(), displayName = title, displayPrice = price.orEmpty(), @@ -1074,6 +1075,7 @@ class OpenIapModule( ) return ProductSubscriptionAndroid( currency = "", + debugDescription = description, description = description.orEmpty(), displayName = title, displayPrice = price.orEmpty(), diff --git a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/utils/BillingConverters.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/utils/BillingConverters.kt index 03e2bf365..23ff6dca4 100644 --- a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/utils/BillingConverters.kt +++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/utils/BillingConverters.kt @@ -3,17 +3,67 @@ package dev.hyo.openiap.utils import com.meta.horizon.billingclient.api.ProductDetails as HorizonProductDetails import com.meta.horizon.billingclient.api.Purchase as HorizonPurchase import dev.hyo.openiap.ActiveSubscription +import dev.hyo.openiap.DiscountOfferType import dev.hyo.openiap.IapPlatform import dev.hyo.openiap.IapStore +import dev.hyo.openiap.PaymentMode import dev.hyo.openiap.PricingPhaseAndroid import dev.hyo.openiap.PricingPhasesAndroid import dev.hyo.openiap.ProductAndroid import dev.hyo.openiap.ProductAndroidOneTimePurchaseOfferDetail +import dev.hyo.openiap.ProductStatusAndroid import dev.hyo.openiap.ProductSubscriptionAndroid import dev.hyo.openiap.ProductSubscriptionAndroidOfferDetails import dev.hyo.openiap.ProductType import dev.hyo.openiap.PurchaseAndroid import dev.hyo.openiap.PurchaseState +import dev.hyo.openiap.SubscriptionOffer +import dev.hyo.openiap.SubscriptionPeriod +import dev.hyo.openiap.SubscriptionPeriodUnit + +private val billingPeriodRegex = Regex("""^P(\d+)([DWMY])$""") + +internal fun ProductSubscriptionAndroidOfferDetails.toHorizonSubscriptionOffer(): SubscriptionOffer { + val firstPhase = pricingPhases.pricingPhaseList.firstOrNull() + val period = firstPhase?.billingPeriod?.let { billingPeriod -> + billingPeriodRegex.matchEntire(billingPeriod)?.let { match -> + val unit = when (match.groupValues[2]) { + "D" -> SubscriptionPeriodUnit.Day + "W" -> SubscriptionPeriodUnit.Week + "M" -> SubscriptionPeriodUnit.Month + "Y" -> SubscriptionPeriodUnit.Year + else -> SubscriptionPeriodUnit.Unknown + } + SubscriptionPeriod(unit = unit, value = match.groupValues[1].toInt()) + } + } + val paymentMode = firstPhase?.let { phase -> + when { + phase.priceAmountMicros == "0" -> PaymentMode.FreeTrial + phase.recurrenceMode == 3 -> PaymentMode.PayUpFront + else -> PaymentMode.PayAsYouGo + } + } + + return SubscriptionOffer( + basePlanIdAndroid = basePlanId, + currency = firstPhase?.priceCurrencyCode, + displayPrice = firstPhase?.formattedPrice.orEmpty(), + id = offerId ?: basePlanId, + offerTagsAndroid = offerTags, + offerTokenAndroid = offerToken, + paymentMode = paymentMode, + period = period, + periodCount = firstPhase?.billingCycleCount, + price = firstPhase?.priceAmountMicros?.toDoubleOrNull()?.div(1_000_000.0) ?: 0.0, + pricingPhasesAndroid = pricingPhases, + type = if (offerId == null) { + DiscountOfferType.Introductory + } else { + DiscountOfferType.Promotional + }, + ) +} internal object HorizonBillingConverters { @@ -55,8 +105,9 @@ internal object HorizonBillingConverters { oneTimePurchaseOfferDetailsAndroid = offerDetailsList, platform = IapPlatform.Android, price = priceAmountMicros.toDouble() / 1_000_000.0, + productStatusAndroid = ProductStatusAndroid.Ok, subscriptionOfferDetailsAndroid = null, - subscriptionOffers = null, // Horizon doesn't support standardized offers yet + subscriptionOffers = null, title = title, type = ProductType.InApp ) @@ -121,8 +172,9 @@ internal object HorizonBillingConverters { oneTimePurchaseOfferDetailsAndroid = oneTimeOfferDetailsList, platform = IapPlatform.Android, price = firstPhase?.priceAmountMicros?.toDouble()?.div(1_000_000.0), + productStatusAndroid = ProductStatusAndroid.Ok, subscriptionOfferDetailsAndroid = pricingDetails, - subscriptionOffers = emptyList(), // Horizon doesn't support standardized offers yet + subscriptionOffers = pricingDetails.map { it.toHorizonSubscriptionOffer() }, title = title, type = ProductType.Subs ) 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 ef5e55b0b..6d8db762b 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 @@ -23,6 +23,7 @@ import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability import com.google.gson.Gson import dev.hyo.openiap.helpers.ProductManager +import dev.hyo.openiap.helpers.ProductQueryResult import dev.hyo.openiap.MutationAcknowledgePurchaseAndroidHandler import dev.hyo.openiap.MutationConsumePurchaseAndroidHandler import dev.hyo.openiap.MutationDeepLinkToSubscriptionsHandler @@ -54,6 +55,7 @@ import dev.hyo.openiap.helpers.onPurchaseUpdated import dev.hyo.openiap.helpers.onSubscriptionBillingIssue import dev.hyo.openiap.helpers.queryAlreadyOwnedPurchases import dev.hyo.openiap.helpers.queryProductDetails +import dev.hyo.openiap.helpers.queryProductDetailsWithStatus import dev.hyo.openiap.helpers.queryPurchases import dev.hyo.openiap.helpers.resolveBasePlanIdForOfferToken import dev.hyo.openiap.helpers.resumeGuard @@ -66,6 +68,9 @@ import dev.hyo.openiap.listener.OpenIapUserChoiceBillingListener import dev.hyo.openiap.utils.BillingConverters.toInAppProduct import dev.hyo.openiap.utils.BillingConverters.toPurchase import dev.hyo.openiap.utils.BillingConverters.toSubscriptionProduct +import dev.hyo.openiap.utils.BillingConverters.productStatusFromUnfetchedStatus +import dev.hyo.openiap.utils.BillingConverters.unavailableInAppProduct +import dev.hyo.openiap.utils.BillingConverters.unavailableSubscriptionProduct import dev.hyo.openiap.utils.fromBillingState import dev.hyo.openiap.utils.toActiveSubscription import dev.hyo.openiap.utils.verifyPurchaseWithGooglePlay @@ -199,6 +204,71 @@ class OpenIapModule( } } + private data class UnfetchedStatus( + val productType: String, + val status: ProductStatusAndroid, + ) + + private fun ProductQueryResult.unfetchedStatus(productId: String): UnfetchedStatus? = + unfetchedProducts.firstOrNull { it.productId == productId }?.let { + UnfetchedStatus( + productType = it.productType, + status = productStatusFromUnfetchedStatus(it.statusCode), + ) + } + + private fun ProductQueryResult.toInAppProducts(skus: List): List { + val detailsById = productDetails.associateBy { it.productId } + return skus.map { productId -> + detailsById[productId]?.toInAppProduct() + ?: unavailableInAppProduct( + productId, + unfetchedStatus(productId)?.status ?: ProductStatusAndroid.Unknown, + ) + } + } + + private fun ProductQueryResult.toSubscriptionProducts( + skus: List, + ): List { + val detailsById = productDetails.associateBy { it.productId } + return skus.map { productId -> + detailsById[productId]?.toSubscriptionProduct() + ?: unavailableSubscriptionProduct( + productId, + unfetchedStatus(productId)?.status ?: ProductStatusAndroid.Unknown, + ) + } + } + + private fun unavailableAllProduct( + productId: String, + inAppResult: ProductQueryResult?, + subscriptionsResult: ProductQueryResult?, + ): ProductOrSubscription { + val statuses = listOfNotNull( + inAppResult?.unfetchedStatus(productId), + subscriptionsResult?.unfetchedStatus(productId), + ) + val noOffers = statuses.firstOrNull { it.status == ProductStatusAndroid.NoOffersAvailable } + val selected = noOffers ?: statuses.firstOrNull() + val status = when { + noOffers != null -> ProductStatusAndroid.NoOffersAvailable + inAppResult != null && subscriptionsResult != null && + statuses.size == 2 && statuses.all { it.status == ProductStatusAndroid.NotFound } -> + ProductStatusAndroid.NotFound + else -> ProductStatusAndroid.Unknown + } + + return if (selected?.productType == BillingClient.ProductType.SUBS) { + ProductOrSubscription.ProductSubscriptionItem( + unavailableSubscriptionProduct(productId, status) + ) + } else { + ProductOrSubscription.ProductItem(unavailableInAppProduct(productId, status)) + } + } + override val fetchProducts: QueryFetchProductsHandler = { params -> withContext(Dispatchers.IO) { val client = billingClient ?: throw OpenIapError.NotPrepared @@ -209,63 +279,61 @@ class OpenIapModule( when (queryType) { ProductQueryType.InApp -> { - val inAppProducts = queryProductDetails(client, productManager, params.skus, BillingClient.ProductType.INAPP) - .map { it.toInAppProduct() } - FetchProductsResultProducts(inAppProducts) + val result = queryProductDetailsWithStatus( + client, + productManager, + params.skus, + BillingClient.ProductType.INAPP, + ) + FetchProductsResultProducts(result.toInAppProducts(params.skus)) } ProductQueryType.Subs -> { - val subscriptionProducts = queryProductDetails(client, productManager, params.skus, BillingClient.ProductType.SUBS) - .map { it.toSubscriptionProduct() } - FetchProductsResultSubscriptions(subscriptionProducts) + val result = queryProductDetailsWithStatus( + client, + productManager, + params.skus, + BillingClient.ProductType.SUBS, + ) + FetchProductsResultSubscriptions(result.toSubscriptionProducts(params.skus)) } ProductQueryType.All -> { - // Query both types and combine results - val allItems = mutableListOf() - val processedIds = mutableSetOf() var firstQueryError: Throwable? = null - // First, get all INAPP products - val inAppDetails = runCatching { - queryProductDetails(client, productManager, params.skus, BillingClient.ProductType.INAPP) + val inAppResult = runCatching { + queryProductDetailsWithStatus( + client, + productManager, + params.skus, + BillingClient.ProductType.INAPP, + ) }.onFailure { error -> firstQueryError = firstQueryError ?: error - }.getOrDefault(emptyList()) - - for (detail in inAppDetails) { - val product = detail.toInAppProduct() - allItems.add(ProductOrSubscription.ProductItem(product)) - processedIds.add(detail.productId) - } - - // Then, get subscription products (only add if not already processed as INAPP) - val subsDetails = runCatching { - queryProductDetails(client, productManager, params.skus, BillingClient.ProductType.SUBS) + }.getOrNull() + + val subscriptionsResult = runCatching { + queryProductDetailsWithStatus( + client, + productManager, + params.skus, + BillingClient.ProductType.SUBS, + ) }.onFailure { error -> firstQueryError = firstQueryError ?: error - }.getOrDefault(emptyList()) + }.getOrNull() - for (detail in subsDetails) { - if (detail.productId !in processedIds) { - val subProduct = detail.toSubscriptionProduct() - allItems.add(ProductOrSubscription.ProductSubscriptionItem(subProduct)) - } + if (inAppResult == null && subscriptionsResult == null) { + throw firstQueryError ?: OpenIapError.QueryProduct } - if (allItems.isEmpty()) { - firstQueryError?.let { throw it } - } - - // Return products in the order they were requested if SKUs provided - val orderedItems = if (params.skus.isNotEmpty()) { - val itemMap = allItems.associateBy { item -> - when (item) { - is ProductOrSubscription.ProductItem -> item.value.id - is ProductOrSubscription.ProductSubscriptionItem -> item.value.id - } - } - params.skus.mapNotNull { itemMap[it] } - } else { - allItems + val inAppById = inAppResult?.productDetails.orEmpty().associateBy { it.productId } + val subscriptionsById = subscriptionsResult?.productDetails.orEmpty() + .associateBy { it.productId } + val orderedItems = params.skus.map { productId -> + inAppById[productId]?.let { + ProductOrSubscription.ProductItem(it.toInAppProduct()) + } ?: subscriptionsById[productId]?.let { + ProductOrSubscription.ProductSubscriptionItem(it.toSubscriptionProduct()) + } ?: unavailableAllProduct(productId, inAppResult, subscriptionsResult) } FetchProductsResultAll(orderedItems) @@ -306,7 +374,7 @@ class OpenIapModule( val productIdsNeedingDetails = filtered .map { it.productId } .distinct() - .filter { productManager.get(it) == null } + .filter { productManager.get(it, BillingClient.ProductType.SUBS) == null } // Batch query missing ProductDetails to minimize API calls if (productIdsNeedingDetails.isNotEmpty()) { @@ -324,7 +392,10 @@ class OpenIapModule( // Now enrich purchases with cached ProductDetails filtered.map { purchase -> - val productDetails = productManager.get(purchase.productId) + val productDetails = productManager.get( + purchase.productId, + BillingClient.ProductType.SUBS, + ) val offers = productDetails?.subscriptionOfferDetails.orEmpty() if (offers.size > 1) { OpenIapLog.w("Multiple offers (${offers.size}) found for ${purchase.productId}, using first basePlanId (may be inaccurate)", TAG) @@ -929,7 +1000,7 @@ class OpenIapModule( val detailsBySku = mutableMapOf() for (sku in androidArgs.skus) { - productManager.get(sku)?.takeIf { it.productType == desiredType }?.let { detailsBySku[sku] = it } + productManager.get(sku, desiredType)?.let { detailsBySku[sku] = it } } val missing = androidArgs.skus.filter { !detailsBySku.containsKey(it) } 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 fa9f91166..9de3cc5da 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 @@ -143,3 +143,14 @@ internal suspend fun queryProductDetails( if (!billingClient.isReady) throw OpenIapError.NotPrepared return productManager.getOrQuery(billingClient, skus, productType) } + +internal suspend fun queryProductDetailsWithStatus( + client: BillingClient?, + productManager: ProductManager, + skus: List, + productType: String, +): ProductQueryResult { + val billingClient = client ?: throw OpenIapError.NotPrepared + if (!billingClient.isReady) throw OpenIapError.NotPrepared + return productManager.getOrQueryWithStatus(billingClient, skus, productType) +} diff --git a/packages/google/openiap/src/play/java/dev/hyo/openiap/helpers/ProductManager.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/helpers/ProductManager.kt index 62aa76fb2..ec61b74fa 100644 --- a/packages/google/openiap/src/play/java/dev/hyo/openiap/helpers/ProductManager.kt +++ b/packages/google/openiap/src/play/java/dev/hyo/openiap/helpers/ProductManager.kt @@ -3,12 +3,18 @@ package dev.hyo.openiap.helpers import com.android.billingclient.api.BillingClient import com.android.billingclient.api.QueryProductDetailsParams import com.android.billingclient.api.ProductDetails +import com.android.billingclient.api.UnfetchedProduct import dev.hyo.openiap.OpenIapError import dev.hyo.openiap.OpenIapLog import kotlinx.coroutines.suspendCancellableCoroutine import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicBoolean +internal data class ProductQueryResult( + val productDetails: List, + val unfetchedProducts: List, +) + /** * Manages ProductDetails caching and queries. * @@ -18,12 +24,20 @@ import java.util.concurrent.atomic.AtomicBoolean * appears to have incomplete data (defensive programming). */ internal class ProductManager { - private val cache = ConcurrentHashMap() + private data class CacheKey(val productId: String, val productType: String) + private val cache = ConcurrentHashMap() + + fun get(productId: String, productType: String): ProductDetails? = + cache[CacheKey(productId, productType)] - fun get(productId: String): ProductDetails? = cache[productId] + fun get(productId: String): ProductDetails? = + get(productId, BillingClient.ProductType.SUBS) + ?: get(productId, BillingClient.ProductType.INAPP) fun putAll(details: Collection) { - for (detail in details) { cache[detail.productId] = detail } + for (detail in details) { + cache[CacheKey(detail.productId, detail.productType)] = detail + } } fun clear() = cache.clear() @@ -42,14 +56,25 @@ internal class ProductManager { client: BillingClient, productIds: List, productType: String, - ): List { - if (productIds.isEmpty()) return emptyList() + ): List = getOrQueryWithStatus(client, productIds, productType).productDetails + + /** + * Returns fetched details together with Billing 8 per-product failures. + * Unfetched products are not cached because eligibility can change between queries. + */ + suspend fun getOrQueryWithStatus( + client: BillingClient, + productIds: List, + productType: String, + ): ProductQueryResult { + if (productIds.isEmpty()) return ProductQueryResult(emptyList(), emptyList()) // Check which products are missing or have incomplete data val needsQuery = mutableListOf() for (productId in productIds.distinct()) { - val cached = cache[productId] + val key = CacheKey(productId, productType) + val cached = cache[key] if (cached == null) { needsQuery.add(productId) } else { @@ -67,13 +92,16 @@ internal class ProductManager { if (!isComplete) { OpenIapLog.w("Cached ProductDetails for '$productId' has incomplete data, will re-query", "ProductManager") needsQuery.add(productId) - cache.remove(productId) + cache.remove(key) } } } if (needsQuery.isEmpty()) { - return productIds.mapNotNull { cache[it] } + return ProductQueryResult( + productDetails = productIds.mapNotNull { cache[CacheKey(it, productType)] }, + unfetchedProducts = emptyList(), + ) } val productList = needsQuery.map { sku -> @@ -94,7 +122,7 @@ internal class ProductManager { // Always update cache even if coroutine was cancelled if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { - val list = result.productDetailsList ?: emptyList() + val list = result.productDetailsList.orEmpty() putAll(list) } @@ -111,7 +139,14 @@ internal class ProductManager { return@queryProductDetailsAsync } // Preserve requested order and include cached + newly-fetched - resumer.resume(productIds.mapNotNull { cache[it] }) + resumer.resume( + ProductQueryResult( + productDetails = productIds.mapNotNull { + cache[CacheKey(it, productType)] + }, + unfetchedProducts = result.unfetchedProductList.orEmpty(), + ) + ) } } } diff --git a/packages/google/openiap/src/play/java/dev/hyo/openiap/utils/BillingConverters.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/utils/BillingConverters.kt index d0acafd31..37e637780 100644 --- a/packages/google/openiap/src/play/java/dev/hyo/openiap/utils/BillingConverters.kt +++ b/packages/google/openiap/src/play/java/dev/hyo/openiap/utils/BillingConverters.kt @@ -32,26 +32,52 @@ import dev.hyo.openiap.SubscriptionPeriodUnit import dev.hyo.openiap.ValidTimeWindowAndroid import com.android.billingclient.api.BillingClient import com.android.billingclient.api.ProductDetails +import com.android.billingclient.api.UnfetchedProduct import com.android.billingclient.api.Purchase as BillingPurchase internal object BillingConverters { - /** - * Gets the product status from ProductDetails (Billing Library 8.0+). - * Returns null for older billing library versions. - */ - private fun ProductDetails.getProductStatus(): ProductStatusAndroid? { - return runCatching { - // ProductDetails.productStatus is available in Billing Library 8.0+ - val statusMethod = this::class.java.getMethod("getProductStatus") - val status = statusMethod.invoke(this) as? Int - when (status) { - 0 -> ProductStatusAndroid.Ok // ProductDetails.ProductStatus.OK - 1 -> ProductStatusAndroid.NotFound // ProductDetails.ProductStatus.NOT_FOUND - 2 -> ProductStatusAndroid.NoOffersAvailable // ProductDetails.ProductStatus.NO_OFFERS_AVAILABLE - else -> ProductStatusAndroid.Unknown - } - }.getOrNull() - } + internal fun productStatusFromUnfetchedStatus(statusCode: Int): ProductStatusAndroid = + when (statusCode) { + UnfetchedProduct.StatusCode.PRODUCT_NOT_FOUND -> ProductStatusAndroid.NotFound + UnfetchedProduct.StatusCode.NO_ELIGIBLE_OFFER -> ProductStatusAndroid.NoOffersAvailable + else -> ProductStatusAndroid.Unknown + } + + internal fun unavailableInAppProduct( + productId: String, + status: ProductStatusAndroid, + ): ProductAndroid = ProductAndroid( + currency = "", + description = "", + displayName = null, + displayPrice = "", + id = productId, + nameAndroid = "", + platform = IapPlatform.Android, + price = null, + productStatusAndroid = status, + title = "", + type = ProductType.InApp, + ) + + internal fun unavailableSubscriptionProduct( + productId: String, + status: ProductStatusAndroid, + ): ProductSubscriptionAndroid = ProductSubscriptionAndroid( + currency = "", + description = "", + displayName = null, + displayPrice = "", + id = productId, + nameAndroid = "", + platform = IapPlatform.Android, + price = null, + productStatusAndroid = status, + subscriptionOfferDetailsAndroid = emptyList(), + subscriptionOffers = emptyList(), + title = "", + type = ProductType.Subs, + ) /** * Converts a ProductDetails.OneTimePurchaseOfferDetails to ProductAndroidOneTimePurchaseOfferDetail @@ -319,7 +345,7 @@ internal object BillingConverters { oneTimePurchaseOfferDetailsAndroid = offerDetailsList, platform = IapPlatform.Android, price = priceAmountMicros.toDouble() / 1_000_000.0, - productStatusAndroid = getProductStatus(), + productStatusAndroid = ProductStatusAndroid.Ok, subscriptionOfferDetailsAndroid = null, subscriptionOffers = null, title = title, @@ -395,7 +421,7 @@ internal object BillingConverters { oneTimePurchaseOfferDetailsAndroid = oneTimeOfferDetailsList, platform = IapPlatform.Android, price = basePhase?.priceAmountMicros?.toDouble()?.div(1_000_000.0), - productStatusAndroid = getProductStatus(), + productStatusAndroid = ProductStatusAndroid.Ok, subscriptionOfferDetailsAndroid = pricingDetails, subscriptionOffers = subscriptionOffers, title = title, diff --git a/packages/google/openiap/src/testHorizon/java/dev/hyo/openiap/HorizonStandardizedOfferTest.kt b/packages/google/openiap/src/testHorizon/java/dev/hyo/openiap/HorizonStandardizedOfferTest.kt new file mode 100644 index 000000000..015c87e2b --- /dev/null +++ b/packages/google/openiap/src/testHorizon/java/dev/hyo/openiap/HorizonStandardizedOfferTest.kt @@ -0,0 +1,40 @@ +package dev.hyo.openiap + +import dev.hyo.openiap.utils.toHorizonSubscriptionOffer +import org.junit.Assert.assertEquals +import org.junit.Test + +class HorizonStandardizedOfferTest { + @Test + fun `legacy offer maps to standardized metadata`() { + val legacy = ProductSubscriptionAndroidOfferDetails( + basePlanId = "annual", + offerId = "intro", + offerTags = listOf("eligible"), + offerToken = "token", + pricingPhases = PricingPhasesAndroid( + listOf( + PricingPhaseAndroid( + billingCycleCount = 3, + billingPeriod = "P1M", + formattedPrice = "\$1.99", + priceAmountMicros = "1990000", + priceCurrencyCode = "USD", + recurrenceMode = 2, + ) + ) + ), + ) + + val offer = legacy.toHorizonSubscriptionOffer() + + assertEquals("intro", offer.id) + assertEquals(DiscountOfferType.Promotional, offer.type) + assertEquals(PaymentMode.PayAsYouGo, offer.paymentMode) + assertEquals(SubscriptionPeriodUnit.Month, offer.period?.unit) + assertEquals(1, offer.period?.value) + assertEquals(3, offer.periodCount) + assertEquals("token", offer.offerTokenAndroid) + assertEquals(1.99, offer.price, 0.0001) + } +} diff --git a/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/ProductStatusAndroidTest.kt b/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/ProductStatusAndroidTest.kt new file mode 100644 index 000000000..f5c87b168 --- /dev/null +++ b/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/ProductStatusAndroidTest.kt @@ -0,0 +1,49 @@ +package dev.hyo.openiap + +import com.android.billingclient.api.UnfetchedProduct +import dev.hyo.openiap.utils.BillingConverters.productStatusFromUnfetchedStatus +import dev.hyo.openiap.utils.BillingConverters.unavailableInAppProduct +import dev.hyo.openiap.utils.BillingConverters.unavailableSubscriptionProduct +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ProductStatusAndroidTest { + @Test + fun `maps Billing 8 unfetched status codes`() { + assertEquals( + ProductStatusAndroid.NotFound, + productStatusFromUnfetchedStatus(UnfetchedProduct.StatusCode.PRODUCT_NOT_FOUND), + ) + assertEquals( + ProductStatusAndroid.NoOffersAvailable, + productStatusFromUnfetchedStatus(UnfetchedProduct.StatusCode.NO_ELIGIBLE_OFFER), + ) + assertEquals( + ProductStatusAndroid.Unknown, + productStatusFromUnfetchedStatus(UnfetchedProduct.StatusCode.INVALID_PRODUCT_ID_FORMAT), + ) + assertEquals( + ProductStatusAndroid.Unknown, + productStatusFromUnfetchedStatus(UnfetchedProduct.StatusCode.UNKNOWN), + ) + } + + @Test + fun `creates typed placeholders for unfetched products`() { + val inApp = unavailableInAppProduct("missing.inapp", ProductStatusAndroid.NotFound) + val subscription = unavailableSubscriptionProduct( + "ineligible.subscription", + ProductStatusAndroid.NoOffersAvailable, + ) + + assertEquals("missing.inapp", inApp.id) + assertEquals(ProductType.InApp, inApp.type) + assertEquals(ProductStatusAndroid.NotFound, inApp.productStatusAndroid) + assertEquals("ineligible.subscription", subscription.id) + assertEquals(ProductType.Subs, subscription.type) + assertEquals(ProductStatusAndroid.NoOffersAvailable, subscription.productStatusAndroid) + assertTrue(subscription.subscriptionOffers.isEmpty()) + assertTrue(subscription.subscriptionOfferDetailsAndroid.isEmpty()) + } +} diff --git a/packages/gql/codegen/plugins/gdscript.ts b/packages/gql/codegen/plugins/gdscript.ts index 10ad8913c..0f7c0e47f 100644 --- a/packages/gql/codegen/plugins/gdscript.ts +++ b/packages/gql/codegen/plugins/gdscript.ts @@ -403,15 +403,8 @@ export class GDScriptPlugin extends CodegenPlugin { this.emit(`\t\tif data.has("${graphqlName}") and data["${graphqlName}"] != null:`); - if (this.isObjectOrInput(type) && type.kind === 'list') { - const elementTypeName = type.elementType!.name!; - this.emit(`\t\t\tvar arr = []`); - this.emit(`\t\t\tfor item in data["${graphqlName}"]:`); - this.emit(`\t\t\t\tif item is Dictionary:`); - this.emit(`\t\t\t\t\tarr.append(${elementTypeName}.from_dict(item))`); - this.emit(`\t\t\t\telse:`); - this.emit(`\t\t\t\t\tarr.append(item)`); - this.emit(`\t\t\tobj.${fieldName} = arr`); + if (type.kind === 'list') { + this.generateListFromDictAssignment(type, graphqlName, fieldName); } else if (this.isObjectOrInput(type)) { const typeName = type.name!; this.emit(`\t\t\tif data["${graphqlName}"] is Dictionary:`); @@ -425,6 +418,47 @@ export class GDScriptPlugin extends CodegenPlugin { } } + private generateListFromDictAssignment( + type: IRType, + graphqlName: string, + fieldName: string, + indent = '\t\t\t' + ): void { + const elementType = type.elementType!; + const elementTypeName = elementType.name!; + const gdElementType = this.mapType(elementType); + this.emit(`${indent}var arr: Array[${gdElementType}] = []`); + this.emit(`${indent}for item in data["${graphqlName}"]:`); + if (this.isObjectOrInput(elementType)) { + this.emit(`${indent}\tif item is Dictionary:`); + this.emit(`${indent}\t\tarr.append(${elementTypeName}.from_dict(item))`); + this.emit(`${indent}\telse:`); + this.emit(`${indent}\t\tarr.append(item)`); + } else if (elementType.kind === 'enum' || this.enumNames.has(elementTypeName)) { + const enumReverseLookup = toConstantCase(elementTypeName) + '_FROM_STRING'; + const fallback = this.getEnumUnknownFallback(elementTypeName); + this.emit(`${indent}\tvar parsed_item = item`); + if (fallback) { + this.emit(`${indent}\tif item is String:`); + this.emit(`${indent}\t\tparsed_item = ${enumReverseLookup}.get(item, ${fallback})`); + } else { + this.emit(`${indent}\tif item is String and ${enumReverseLookup}.has(item):`); + this.emit(`${indent}\t\tparsed_item = ${enumReverseLookup}[item]`); + } + this.emit(`${indent}\tarr.append(parsed_item)`); + } else { + let appendExpression = 'item'; + switch (gdElementType) { + case 'String': appendExpression = 'str(item)'; break; + case 'int': appendExpression = 'int(item)'; break; + case 'float': appendExpression = 'float(item)'; break; + case 'bool': appendExpression = 'bool(item)'; break; + } + this.emit(`${indent}\tarr.append(${appendExpression})`); + } + this.emit(`${indent}obj.${fieldName} = arr`); + } + private generateToDictField(field: IRField, fieldName: string): void { const graphqlName = field.name; const type = field.type; @@ -549,15 +583,8 @@ export class GDScriptPlugin extends CodegenPlugin { this.emit(`\t\tif data.has("${graphqlName}") and data["${graphqlName}"] != null:`); - if (this.isObjectOrInput(type) && type.kind === 'list') { - const elementTypeName = type.elementType!.name!; - this.emit(`\t\t\tvar arr = []`); - this.emit(`\t\t\tfor item in data["${graphqlName}"]:`); - this.emit(`\t\t\t\tif item is Dictionary:`); - this.emit(`\t\t\t\t\tarr.append(${elementTypeName}.from_dict(item))`); - this.emit(`\t\t\t\telse:`); - this.emit(`\t\t\t\t\tarr.append(item)`); - this.emit(`\t\t\tobj.${fieldName} = arr`); + if (type.kind === 'list') { + this.generateListFromDictAssignment(type, graphqlName, fieldName); } else if (this.isObjectOrInput(type)) { const typeName = type.name!; this.emit(`\t\t\tif data["${graphqlName}"] is Dictionary:`); @@ -647,7 +674,9 @@ export class GDScriptPlugin extends CodegenPlugin { for (const arg of field.args) { const argSnakeName = this.escapeKeyword(toSnakeCase(arg.name)); this.emit(`\t\t\t\tif data.has("${arg.name}") and data["${arg.name}"] != null:`); - if (arg.type.kind === 'enum') { + if (arg.type.kind === 'list') { + this.generateListFromDictAssignment(arg.type, arg.name, argSnakeName, '\t\t\t\t\t'); + } else if (arg.type.kind === 'enum') { this.emitEnumFromDictAssignment( '\t\t\t\t\t', `obj.${argSnakeName}`, diff --git a/packages/gql/src/generated-gdscript.test.ts b/packages/gql/src/generated-gdscript.test.ts new file mode 100644 index 000000000..3c646ff8e --- /dev/null +++ b/packages/gql/src/generated-gdscript.test.ts @@ -0,0 +1,34 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +const generated = readFileSync(new URL('./generated/types.gd', import.meta.url), 'utf8'); + +function classSource(className: string, nextClassName: string): string { + const start = generated.indexOf(`class ${className}:`); + const end = generated.indexOf(`class ${nextClassName}:`, start + 1); + return generated.slice(start, end); +} + +describe('generated GDScript list decoding', () => { + it('builds typed scalar arrays from JSON arrays', () => { + const source = classSource('ProductRequest', 'PromotionalOfferJWSInputIOS'); + + expect(source).toContain('var arr: Array[String] = []'); + expect(source).toContain('arr.append(str(item))'); + }); + + it('builds typed nested model arrays before assignment', () => { + const source = classSource('ProductIOS', 'ProductSubscriptionAndroid'); + + expect(source).toContain('var arr: Array[SubscriptionOffer] = []'); + expect(source).toContain('arr.append(SubscriptionOffer.from_dict(item))'); + expect(source).toContain('var arr: Array[SubscriptionPricingTermsIOS] = []'); + }); + + it('rebuilds list arguments in generated operation helpers', () => { + const source = classSource('Query', 'Mutation'); + + expect(source).toContain('var arr: Array[String] = []'); + expect(source).toContain('obj.subscription_ids = arr'); + }); +}); diff --git a/packages/gql/src/generated/types.gd b/packages/gql/src/generated/types.gd index c6e7cb9de..956f8fd79 100644 --- a/packages/gql/src/generated/types.gd +++ b/packages/gql/src/generated/types.gd @@ -484,7 +484,7 @@ class AdvancedCommerceInfoIOS: static func from_dict(data: Dictionary) -> AdvancedCommerceInfoIOS: var obj = AdvancedCommerceInfoIOS.new() if data.has("items") and data["items"] != null: - var arr = [] + var arr: Array[AdvancedCommerceItemIOS] = [] for item in data["items"]: if item is Dictionary: arr.append(AdvancedCommerceItemIOS.from_dict(item)) @@ -569,7 +569,7 @@ class AdvancedCommerceItemIOS: else: obj.details = data["details"] if data.has("refunds") and data["refunds"] != null: - var arr = [] + var arr: Array[AdvancedCommerceRefundIOS] = [] for item in data["refunds"]: if item is Dictionary: arr.append(AdvancedCommerceRefundIOS.from_dict(item)) @@ -942,7 +942,10 @@ class DiscountOffer: if data.has("offerTokenAndroid") and data["offerTokenAndroid"] != null: obj.offer_token_android = data["offerTokenAndroid"] if data.has("offerTagsAndroid") and data["offerTagsAndroid"] != null: - obj.offer_tags_android = data["offerTagsAndroid"] + var arr: Array[String] = [] + for item in data["offerTagsAndroid"]: + arr.append(str(item)) + obj.offer_tags_android = arr if data.has("fullPriceMicrosAndroid") and data["fullPriceMicrosAndroid"] != null: obj.full_price_micros_android = data["fullPriceMicrosAndroid"] if data.has("percentageDiscountAndroid") and data["percentageDiscountAndroid"] != null: @@ -1261,7 +1264,10 @@ class PendingPurchaseUpdateAndroid: static func from_dict(data: Dictionary) -> PendingPurchaseUpdateAndroid: var obj = PendingPurchaseUpdateAndroid.new() if data.has("products") and data["products"] != null: - obj.products = data["products"] + var arr: Array[String] = [] + for item in data["products"]: + arr.append(str(item)) + obj.products = arr if data.has("purchaseToken") and data["purchaseToken"] != null: obj.purchase_token = data["purchaseToken"] return obj @@ -1333,7 +1339,7 @@ class PricingPhasesAndroid: static func from_dict(data: Dictionary) -> PricingPhasesAndroid: var obj = PricingPhasesAndroid.new() if data.has("pricingPhaseList") and data["pricingPhaseList"] != null: - var arr = [] + var arr: Array[PricingPhaseAndroid] = [] for item in data["pricingPhaseList"]: if item is Dictionary: arr.append(PricingPhaseAndroid.from_dict(item)) @@ -1418,7 +1424,7 @@ class ProductAndroid: else: obj.product_status_android = enum_str if data.has("discountOffers") and data["discountOffers"] != null: - var arr = [] + var arr: Array[DiscountOffer] = [] for item in data["discountOffers"]: if item is Dictionary: arr.append(DiscountOffer.from_dict(item)) @@ -1426,7 +1432,7 @@ class ProductAndroid: arr.append(item) obj.discount_offers = arr if data.has("subscriptionOffers") and data["subscriptionOffers"] != null: - var arr = [] + var arr: Array[SubscriptionOffer] = [] for item in data["subscriptionOffers"]: if item is Dictionary: arr.append(SubscriptionOffer.from_dict(item)) @@ -1434,7 +1440,7 @@ class ProductAndroid: arr.append(item) obj.subscription_offers = arr if data.has("oneTimePurchaseOfferDetailsAndroid") and data["oneTimePurchaseOfferDetailsAndroid"] != null: - var arr = [] + var arr: Array[ProductAndroidOneTimePurchaseOfferDetail] = [] for item in data["oneTimePurchaseOfferDetailsAndroid"]: if item is Dictionary: arr.append(ProductAndroidOneTimePurchaseOfferDetail.from_dict(item)) @@ -1442,7 +1448,7 @@ class ProductAndroid: arr.append(item) obj.one_time_purchase_offer_details_android = arr if data.has("subscriptionOfferDetailsAndroid") and data["subscriptionOfferDetailsAndroid"] != null: - var arr = [] + var arr: Array[ProductSubscriptionAndroidOfferDetails] = [] for item in data["subscriptionOfferDetailsAndroid"]: if item is Dictionary: arr.append(ProductSubscriptionAndroidOfferDetails.from_dict(item)) @@ -1552,7 +1558,10 @@ class ProductAndroidOneTimePurchaseOfferDetail: if data.has("offerToken") and data["offerToken"] != null: obj.offer_token = data["offerToken"] if data.has("offerTags") and data["offerTags"] != null: - obj.offer_tags = data["offerTags"] + var arr: Array[String] = [] + for item in data["offerTags"]: + arr.append(str(item)) + obj.offer_tags = arr if data.has("priceCurrencyCode") and data["priceCurrencyCode"] != null: obj.price_currency_code = data["priceCurrencyCode"] if data.has("formattedPrice") and data["formattedPrice"] != null: @@ -1690,7 +1699,7 @@ class ProductIOS: else: obj.type_ios = enum_str if data.has("subscriptionOffers") and data["subscriptionOffers"] != null: - var arr = [] + var arr: Array[SubscriptionOffer] = [] for item in data["subscriptionOffers"]: if item is Dictionary: arr.append(SubscriptionOffer.from_dict(item)) @@ -1698,7 +1707,7 @@ class ProductIOS: arr.append(item) obj.subscription_offers = arr if data.has("pricingTermsIOS") and data["pricingTermsIOS"] != null: - var arr = [] + var arr: Array[SubscriptionPricingTermsIOS] = [] for item in data["pricingTermsIOS"]: if item is Dictionary: arr.append(SubscriptionPricingTermsIOS.from_dict(item)) @@ -1828,7 +1837,7 @@ class ProductSubscriptionAndroid: else: obj.product_status_android = enum_str if data.has("discountOffers") and data["discountOffers"] != null: - var arr = [] + var arr: Array[DiscountOffer] = [] for item in data["discountOffers"]: if item is Dictionary: arr.append(DiscountOffer.from_dict(item)) @@ -1836,7 +1845,7 @@ class ProductSubscriptionAndroid: arr.append(item) obj.discount_offers = arr if data.has("subscriptionOffers") and data["subscriptionOffers"] != null: - var arr = [] + var arr: Array[SubscriptionOffer] = [] for item in data["subscriptionOffers"]: if item is Dictionary: arr.append(SubscriptionOffer.from_dict(item)) @@ -1844,7 +1853,7 @@ class ProductSubscriptionAndroid: arr.append(item) obj.subscription_offers = arr if data.has("oneTimePurchaseOfferDetailsAndroid") and data["oneTimePurchaseOfferDetailsAndroid"] != null: - var arr = [] + var arr: Array[ProductAndroidOneTimePurchaseOfferDetail] = [] for item in data["oneTimePurchaseOfferDetailsAndroid"]: if item is Dictionary: arr.append(ProductAndroidOneTimePurchaseOfferDetail.from_dict(item)) @@ -1852,7 +1861,7 @@ class ProductSubscriptionAndroid: arr.append(item) obj.one_time_purchase_offer_details_android = arr if data.has("subscriptionOfferDetailsAndroid") and data["subscriptionOfferDetailsAndroid"] != null: - var arr = [] + var arr: Array[ProductSubscriptionAndroidOfferDetails] = [] for item in data["subscriptionOfferDetailsAndroid"]: if item is Dictionary: arr.append(ProductSubscriptionAndroidOfferDetails.from_dict(item)) @@ -1948,7 +1957,10 @@ class ProductSubscriptionAndroidOfferDetails: if data.has("offerToken") and data["offerToken"] != null: obj.offer_token = data["offerToken"] if data.has("offerTags") and data["offerTags"] != null: - obj.offer_tags = data["offerTags"] + var arr: Array[String] = [] + for item in data["offerTags"]: + arr.append(str(item)) + obj.offer_tags = arr if data.has("pricingPhases") and data["pricingPhases"] != null: if data["pricingPhases"] is Dictionary: obj.pricing_phases = PricingPhasesAndroid.from_dict(data["pricingPhases"]) @@ -2054,7 +2066,7 @@ class ProductSubscriptionIOS: else: obj.type_ios = enum_str if data.has("subscriptionOffers") and data["subscriptionOffers"] != null: - var arr = [] + var arr: Array[SubscriptionOffer] = [] for item in data["subscriptionOffers"]: if item is Dictionary: arr.append(SubscriptionOffer.from_dict(item)) @@ -2062,7 +2074,7 @@ class ProductSubscriptionIOS: arr.append(item) obj.subscription_offers = arr if data.has("pricingTermsIOS") and data["pricingTermsIOS"] != null: - var arr = [] + var arr: Array[SubscriptionPricingTermsIOS] = [] for item in data["pricingTermsIOS"]: if item is Dictionary: arr.append(SubscriptionPricingTermsIOS.from_dict(item)) @@ -2077,7 +2089,7 @@ class ProductSubscriptionIOS: else: obj.subscription_info_ios = data["subscriptionInfoIOS"] if data.has("discountsIOS") and data["discountsIOS"] != null: - var arr = [] + var arr: Array[DiscountIOS] = [] for item in data["discountsIOS"]: if item is Dictionary: arr.append(DiscountIOS.from_dict(item)) @@ -2232,7 +2244,10 @@ class PurchaseAndroid: if data.has("productId") and data["productId"] != null: obj.product_id = data["productId"] if data.has("ids") and data["ids"] != null: - obj.ids = data["ids"] + var arr: Array[String] = [] + for item in data["ids"]: + arr.append(str(item)) + obj.ids = arr if data.has("transactionId") and data["transactionId"] != null: obj.transaction_id = data["transactionId"] if data.has("transactionDate") and data["transactionDate"] != null: @@ -2365,7 +2380,10 @@ class PurchaseError: if data.has("responseCode") and data["responseCode"] != null: obj.response_code = data["responseCode"] if data.has("productIds") and data["productIds"] != null: - obj.product_ids = data["productIds"] + var arr: Array[String] = [] + for item in data["productIds"]: + arr.append(str(item)) + obj.product_ids = arr if data.has("productType") and data["productType"] != null: obj.product_type = data["productType"] if data.has("isEmptyProductList") and data["isEmptyProductList"] != null: @@ -2442,7 +2460,10 @@ class PurchaseIOS: if data.has("productId") and data["productId"] != null: obj.product_id = data["productId"] if data.has("ids") and data["ids"] != null: - obj.ids = data["ids"] + var arr: Array[String] = [] + for item in data["ids"]: + arr.append(str(item)) + obj.ids = arr if data.has("transactionDate") and data["transactionDate"] != null: obj.transaction_date = data["transactionDate"] if data.has("purchaseToken") and data["purchaseToken"] != null: @@ -2905,7 +2926,7 @@ class SubscriptionInfoIOS: else: obj.introductory_offer = data["introductoryOffer"] if data.has("pricingTerms") and data["pricingTerms"] != null: - var arr = [] + var arr: Array[SubscriptionPricingTermsIOS] = [] for item in data["pricingTerms"]: if item is Dictionary: arr.append(SubscriptionPricingTermsIOS.from_dict(item)) @@ -2913,7 +2934,7 @@ class SubscriptionInfoIOS: arr.append(item) obj.pricing_terms = arr if data.has("promotionalOffers") and data["promotionalOffers"] != null: - var arr = [] + var arr: Array[SubscriptionOfferIOS] = [] for item in data["promotionalOffers"]: if item is Dictionary: arr.append(SubscriptionOfferIOS.from_dict(item)) @@ -3049,7 +3070,10 @@ class SubscriptionOffer: if data.has("offerTokenAndroid") and data["offerTokenAndroid"] != null: obj.offer_token_android = data["offerTokenAndroid"] if data.has("offerTagsAndroid") and data["offerTagsAndroid"] != null: - obj.offer_tags_android = data["offerTagsAndroid"] + var arr: Array[String] = [] + for item in data["offerTagsAndroid"]: + arr.append(str(item)) + obj.offer_tags_android = arr if data.has("pricingPhasesAndroid") and data["pricingPhasesAndroid"] != null: if data["pricingPhasesAndroid"] is Dictionary: obj.pricing_phases_android = PricingPhasesAndroid.from_dict(data["pricingPhasesAndroid"]) @@ -3253,7 +3277,7 @@ class SubscriptionPricingTermsIOS: else: obj.commitment_info = data["commitmentInfo"] if data.has("subscriptionOffers") and data["subscriptionOffers"] != null: - var arr = [] + var arr: Array[SubscriptionOffer] = [] for item in data["subscriptionOffers"]: if item is Dictionary: arr.append(SubscriptionOffer.from_dict(item)) @@ -3352,7 +3376,10 @@ class UserChoiceBillingDetails: if data.has("externalTransactionToken") and data["externalTransactionToken"] != null: obj.external_transaction_token = data["externalTransactionToken"] if data.has("products") and data["products"] != null: - obj.products = data["products"] + var arr: Array[String] = [] + for item in data["products"]: + arr.append(str(item)) + obj.products = arr return obj func to_dict() -> Dictionary: @@ -3560,7 +3587,7 @@ class VerifyPurchaseWithProviderResult: else: obj.iapkit = data["iapkit"] if data.has("errors") and data["errors"] != null: - var arr = [] + var arr: Array[VerifyPurchaseWithProviderError] = [] for item in data["errors"]: if item is Dictionary: arr.append(VerifyPurchaseWithProviderError.from_dict(item)) @@ -3976,7 +4003,10 @@ class ProductRequest: static func from_dict(data: Dictionary) -> ProductRequest: var obj = ProductRequest.new() if data.has("skus") and data["skus"] != null: - obj.skus = data["skus"] + var arr: Array[String] = [] + for item in data["skus"]: + arr.append(str(item)) + obj.skus = arr if data.has("type") and data["type"] != null: var enum_str = data["type"] if enum_str is String and PRODUCT_QUERY_TYPE_FROM_STRING.has(enum_str): @@ -4040,7 +4070,10 @@ class PurchaseInput: if data.has("productId") and data["productId"] != null: obj.product_id = data["productId"] if data.has("ids") and data["ids"] != null: - obj.ids = data["ids"] + var arr: Array[String] = [] + for item in data["ids"]: + arr.append(str(item)) + obj.ids = arr if data.has("transactionDate") and data["transactionDate"] != null: obj.transaction_date = data["transactionDate"] if data.has("purchaseToken") and data["purchaseToken"] != null: @@ -4163,7 +4196,10 @@ class RequestPurchaseAndroidProps: static func from_dict(data: Dictionary) -> RequestPurchaseAndroidProps: var obj = RequestPurchaseAndroidProps.new() if data.has("skus") and data["skus"] != null: - obj.skus = data["skus"] + var arr: Array[String] = [] + for item in data["skus"]: + arr.append(str(item)) + obj.skus = arr if data.has("obfuscatedAccountId") and data["obfuscatedAccountId"] != null: obj.obfuscated_account_id = data["obfuscatedAccountId"] if data.has("obfuscatedProfileId") and data["obfuscatedProfileId"] != null: @@ -4385,7 +4421,10 @@ class RequestSubscriptionAndroidProps: static func from_dict(data: Dictionary) -> RequestSubscriptionAndroidProps: var obj = RequestSubscriptionAndroidProps.new() if data.has("skus") and data["skus"] != null: - obj.skus = data["skus"] + var arr: Array[String] = [] + for item in data["skus"]: + arr.append(str(item)) + obj.skus = arr if data.has("obfuscatedAccountId") and data["obfuscatedAccountId"] != null: obj.obfuscated_account_id = data["obfuscatedAccountId"] if data.has("obfuscatedProfileId") and data["obfuscatedProfileId"] != null: @@ -4397,7 +4436,7 @@ class RequestSubscriptionAndroidProps: if data.has("replacementMode") and data["replacementMode"] != null: obj.replacement_mode = data["replacementMode"] if data.has("subscriptionOffers") and data["subscriptionOffers"] != null: - var arr = [] + var arr: Array[AndroidSubscriptionOfferInput] = [] for item in data["subscriptionOffers"]: if item is Dictionary: arr.append(AndroidSubscriptionOfferInput.from_dict(item)) @@ -5542,7 +5581,10 @@ class Query: static func from_dict(data: Dictionary) -> Args: var obj = Args.new() if data.has("subscriptionIds") and data["subscriptionIds"] != null: - obj.subscription_ids = data["subscriptionIds"] + var arr: Array[String] = [] + for item in data["subscriptionIds"]: + arr.append(str(item)) + obj.subscription_ids = arr return obj func to_dict() -> Dictionary: @@ -5562,7 +5604,10 @@ class Query: static func from_dict(data: Dictionary) -> Args: var obj = Args.new() if data.has("subscriptionIds") and data["subscriptionIds"] != null: - obj.subscription_ids = data["subscriptionIds"] + var arr: Array[String] = [] + for item in data["subscriptionIds"]: + arr.append(str(item)) + obj.subscription_ids = arr return obj func to_dict() -> Dictionary: From 98c22eb8efb36c7cf2ee72355e3677ac115f72dc Mon Sep 17 00:00:00 2001 From: Hyo Date: Fri, 10 Jul 2026 15:14:36 +0900 Subject: [PATCH 13/15] fix(kmp): guard Billing 8 metadata access --- .../kotlin/io/github/hyochan/kmpiap/Helper.kt | 45 +++++++++++++------ .../kmpiap/ProductStatusAndroidTest.kt | 39 ++++++++++++++++ 2 files changed, 71 insertions(+), 13 deletions(-) diff --git a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/Helper.kt b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/Helper.kt index 50bc2fdea..7a5525b7d 100644 --- a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/Helper.kt +++ b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/Helper.kt @@ -74,23 +74,42 @@ internal fun productStatusFromUnfetchedStatus(statusCode: Int): ProductStatusAnd else -> ProductStatusAndroid.Unknown } +internal fun unfetchedProductInfoFrom(items: List<*>): List { + val sample = items.firstOrNull { it != null } ?: return emptyList() + + return runCatching { + val itemClass = sample.javaClass + val getProductId = itemClass.getMethod("getProductId") + val getProductType = itemClass.getMethod("getProductType") + val getStatusCode = itemClass.getMethod("getStatusCode") + + items.mapNotNull { item -> + item ?: return@mapNotNull null + runCatching { + val productId = getProductId.invoke(item) as? String + val productType = getProductType.invoke(item) as? String + val statusCode = getStatusCode.invoke(item) as? Int + + if (productId == null || productType == null || statusCode == null) { + null + } else { + UnfetchedProductInfo(productId, productType, statusCode) + } + }.getOrNull() + } + }.getOrDefault(emptyList()) +} + internal fun QueryProductDetailsResult.unfetchedProductsCompat(): List = runCatching { val items = javaClass.getMethod("getUnfetchedProductList").invoke(this) as? List<*> ?: return@runCatching emptyList() - items.mapNotNull { item -> - item ?: return@mapNotNull null - val itemClass = item.javaClass - val productId = itemClass.getMethod("getProductId").invoke(item) as? String - ?: return@mapNotNull null - val productType = itemClass.getMethod("getProductType").invoke(item) as? String - ?: return@mapNotNull null - val statusCode = itemClass.getMethod("getStatusCode").invoke(item) as? Int - ?: return@mapNotNull null - UnfetchedProductInfo(productId, productType, statusCode) - } + unfetchedProductInfoFrom(items) }.getOrDefault(emptyList()) +internal fun billingStringOrEmpty(block: () -> String?): String = + runCatching(block).getOrNull().orEmpty() + internal fun unavailableInAppProduct( productId: String, status: ProductStatusAndroid, @@ -448,7 +467,7 @@ private fun ProductDetails.OneTimePurchaseOfferDetails.toOfferDetail(): ProductA }, offerId = runCatching { offerId }.getOrNull(), offerTags = runCatching { offerTags.orEmpty() }.getOrElse { emptyList() }, - offerToken = offerToken.orEmpty(), + offerToken = billingStringOrEmpty { this.offerToken }, preorderDetailsAndroid = runCatching { preorderDetails }.getOrNull()?.let { details -> PreorderDetailsAndroid( preorderPresaleEndTimeMillis = details.preorderPresaleEndTimeMillis.toString(), @@ -482,7 +501,7 @@ private fun ProductDetails.OneTimePurchaseOfferDetails.toDiscountOffer(): Discou price = priceAmountMicros.toDouble() / 1_000_000.0, currency = priceCurrencyCode, type = DiscountOfferType.OneTime, - offerTokenAndroid = offerToken, + offerTokenAndroid = billingStringOrEmpty { this.offerToken }, offerTagsAndroid = runCatching { offerTags.orEmpty() }.getOrElse { emptyList() }, fullPriceMicrosAndroid = runCatching { fullPriceMicros?.toString() }.getOrNull(), percentageDiscountAndroid = runCatching { discountInfo?.percentageDiscount }.getOrNull(), diff --git a/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/ProductStatusAndroidTest.kt b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/ProductStatusAndroidTest.kt index 61570d38c..2631d7471 100644 --- a/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/ProductStatusAndroidTest.kt +++ b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/ProductStatusAndroidTest.kt @@ -6,6 +6,18 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue +internal class TestUnfetchedProduct( + private val productIdValue: String, + private val productTypeValue: String, + private val statusCodeValue: Int, +) { + fun getProductId(): String = productIdValue + + fun getProductType(): String = productTypeValue + + fun getStatusCode(): Int = statusCodeValue +} + class ProductStatusAndroidTest { @Test fun `maps Billing 8 unfetched status codes`() { @@ -30,4 +42,31 @@ class ProductStatusAndroidTest { assertTrue(subscription.subscriptionOfferDetailsAndroid.isEmpty()) assertTrue(subscription.subscriptionOffers.isEmpty()) } + + @Test + fun `reads unfetched products with one reflected accessor lookup`() { + val products = unfetchedProductInfoFrom( + listOf( + null, + TestUnfetchedProduct("missing.inapp", "inapp", 3), + Any(), + TestUnfetchedProduct("ineligible.subscription", "subs", 4), + ) + ) + + assertEquals( + listOf( + UnfetchedProductInfo("missing.inapp", "inapp", 3), + UnfetchedProductInfo("ineligible.subscription", "subs", 4), + ), + products, + ) + } + + @Test + fun `falls back when a Billing 8 string getter is unavailable`() { + assertEquals("offer-token", billingStringOrEmpty { "offer-token" }) + assertEquals("", billingStringOrEmpty { null }) + assertEquals("", billingStringOrEmpty { throw NoSuchMethodError("Billing 8 API") }) + } } From d7051c445dee0c2742c703abc351d0d57e37ea67 Mon Sep 17 00:00:00 2001 From: Hyo Date: Fri, 10 Jul 2026 15:39:26 +0900 Subject: [PATCH 14/15] fix(libraries): harden product metadata handling --- .github/workflows/ci-godot-iap.yml | 1 + .../Example/tests/test_types_only.gd | 8 +- libraries/godot-iap/addons/godot-iap/types.gd | 465 ++++++++++-------- .../kotlin/io/github/hyochan/kmpiap/Helper.kt | 73 ++- .../hyochan/kmpiap/InAppPurchaseAndroid.kt | 49 +- .../kmpiap/ProductStatusAndroidTest.kt | 84 ++++ .../github/hyochan/kmpiap/InAppPurchaseIOS.kt | 8 +- ...izer.kt => ProductPayloadNormalizerIOS.kt} | 3 +- ....kt => ProductPayloadNormalizerTestIOS.kt} | 6 +- .../java/com/margelo/nitro/iap/HybridRnIap.kt | 64 +-- .../margelo/nitro/iap/ProductSerialization.kt | 68 +++ .../nitro/iap/ProductSerializationTest.kt | 29 ++ .../java/dev/hyo/openiap/OpenIapModule.kt | 13 +- .../hyo/openiap/ProductStatusAndroidTest.kt | 30 ++ packages/gql/codegen/plugins/gdscript.ts | 59 ++- packages/gql/src/generated-gdscript.test.ts | 80 +++ packages/gql/src/generated/types.gd | 465 ++++++++++-------- 17 files changed, 954 insertions(+), 551 deletions(-) rename libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/{IosProductPayloadNormalizer.kt => ProductPayloadNormalizerIOS.kt} (89%) rename libraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/{IosProductPayloadNormalizerTest.kt => ProductPayloadNormalizerTestIOS.kt} (91%) diff --git a/.github/workflows/ci-godot-iap.yml b/.github/workflows/ci-godot-iap.yml index 013a04781..2df26f077 100644 --- a/.github/workflows/ci-godot-iap.yml +++ b/.github/workflows/ci-godot-iap.yml @@ -31,6 +31,7 @@ jobs: - uses: actions/checkout@v7 with: fetch-depth: 1 + persist-credentials: false - name: Setup Godot uses: chickensoft-games/setup-godot@v2.4.1 diff --git a/libraries/godot-iap/Example/tests/test_types_only.gd b/libraries/godot-iap/Example/tests/test_types_only.gd index c6e313773..abdff9805 100644 --- a/libraries/godot-iap/Example/tests/test_types_only.gd +++ b/libraries/godot-iap/Example/tests/test_types_only.gd @@ -83,9 +83,13 @@ func _test_product_request() -> void: _assert_equal(dict["type"], "in-app", "to_dict should convert type to string") # JSON.parse_string returns an untyped Array; from_dict must rebuild Array[String]. - var from_dict_data = {"skus": ["sku_from_dict"], "type": "subs"} + var from_dict_data = { + "skus": ["sku_from_dict", null, {"invalid": true}], + "type": "subs" + } var parsed = Types.ProductRequest.from_dict(from_dict_data) _assert_equal(parsed.skus[0], "sku_from_dict", "from_dict should parse skus") + _assert_equal(parsed.skus.size(), 1, "from_dict should skip malformed scalar list items") func _test_product_nested_arrays() -> void: @@ -104,7 +108,7 @@ func _test_product_nested_arrays() -> void: "description": "Premium subscription", "type": "subs", "platform": "ios", - "subscriptionOffers": [offer_data], + "subscriptionOffers": [offer_data, "invalid", null], "pricingTermsIOS": [{ "billingDisplayPrice": "$9.99", "billingPlanType": "monthly", diff --git a/libraries/godot-iap/addons/godot-iap/types.gd b/libraries/godot-iap/addons/godot-iap/types.gd index 956f8fd79..f36b8f261 100644 --- a/libraries/godot-iap/addons/godot-iap/types.gd +++ b/libraries/godot-iap/addons/godot-iap/types.gd @@ -484,13 +484,14 @@ class AdvancedCommerceInfoIOS: static func from_dict(data: Dictionary) -> AdvancedCommerceInfoIOS: var obj = AdvancedCommerceInfoIOS.new() if data.has("items") and data["items"] != null: - var arr: Array[AdvancedCommerceItemIOS] = [] - for item in data["items"]: - if item is Dictionary: - arr.append(AdvancedCommerceItemIOS.from_dict(item)) - else: - arr.append(item) - obj.items = arr + if data["items"] is Array: + var arr: Array[AdvancedCommerceItemIOS] = [] + for item in data["items"]: + if item is Dictionary: + arr.append(AdvancedCommerceItemIOS.from_dict(item)) + elif item is AdvancedCommerceItemIOS: + arr.append(item) + obj.items = arr if data.has("requestReferenceId") and data["requestReferenceId"] != null: obj.request_reference_id = data["requestReferenceId"] if data.has("taxCode") and data["taxCode"] != null: @@ -569,13 +570,14 @@ class AdvancedCommerceItemIOS: else: obj.details = data["details"] if data.has("refunds") and data["refunds"] != null: - var arr: Array[AdvancedCommerceRefundIOS] = [] - for item in data["refunds"]: - if item is Dictionary: - arr.append(AdvancedCommerceRefundIOS.from_dict(item)) - else: - arr.append(item) - obj.refunds = arr + if data["refunds"] is Array: + var arr: Array[AdvancedCommerceRefundIOS] = [] + for item in data["refunds"]: + if item is Dictionary: + arr.append(AdvancedCommerceRefundIOS.from_dict(item)) + elif item is AdvancedCommerceRefundIOS: + arr.append(item) + obj.refunds = arr if data.has("revocationDate") and data["revocationDate"] != null: obj.revocation_date = data["revocationDate"] return obj @@ -942,10 +944,12 @@ class DiscountOffer: if data.has("offerTokenAndroid") and data["offerTokenAndroid"] != null: obj.offer_token_android = data["offerTokenAndroid"] if data.has("offerTagsAndroid") and data["offerTagsAndroid"] != null: - var arr: Array[String] = [] - for item in data["offerTagsAndroid"]: - arr.append(str(item)) - obj.offer_tags_android = arr + if data["offerTagsAndroid"] is Array: + var arr: Array[String] = [] + for item in data["offerTagsAndroid"]: + if item is String: + arr.append(str(item)) + obj.offer_tags_android = arr if data.has("fullPriceMicrosAndroid") and data["fullPriceMicrosAndroid"] != null: obj.full_price_micros_android = data["fullPriceMicrosAndroid"] if data.has("percentageDiscountAndroid") and data["percentageDiscountAndroid"] != null: @@ -1264,10 +1268,12 @@ class PendingPurchaseUpdateAndroid: static func from_dict(data: Dictionary) -> PendingPurchaseUpdateAndroid: var obj = PendingPurchaseUpdateAndroid.new() if data.has("products") and data["products"] != null: - var arr: Array[String] = [] - for item in data["products"]: - arr.append(str(item)) - obj.products = arr + if data["products"] is Array: + var arr: Array[String] = [] + for item in data["products"]: + if item is String: + arr.append(str(item)) + obj.products = arr if data.has("purchaseToken") and data["purchaseToken"] != null: obj.purchase_token = data["purchaseToken"] return obj @@ -1339,13 +1345,14 @@ class PricingPhasesAndroid: static func from_dict(data: Dictionary) -> PricingPhasesAndroid: var obj = PricingPhasesAndroid.new() if data.has("pricingPhaseList") and data["pricingPhaseList"] != null: - var arr: Array[PricingPhaseAndroid] = [] - for item in data["pricingPhaseList"]: - if item is Dictionary: - arr.append(PricingPhaseAndroid.from_dict(item)) - else: - arr.append(item) - obj.pricing_phase_list = arr + if data["pricingPhaseList"] is Array: + var arr: Array[PricingPhaseAndroid] = [] + for item in data["pricingPhaseList"]: + if item is Dictionary: + arr.append(PricingPhaseAndroid.from_dict(item)) + elif item is PricingPhaseAndroid: + arr.append(item) + obj.pricing_phase_list = arr return obj func to_dict() -> Dictionary: @@ -1424,37 +1431,41 @@ class ProductAndroid: else: obj.product_status_android = enum_str if data.has("discountOffers") and data["discountOffers"] != null: - var arr: Array[DiscountOffer] = [] - for item in data["discountOffers"]: - if item is Dictionary: - arr.append(DiscountOffer.from_dict(item)) - else: - arr.append(item) - obj.discount_offers = arr + if data["discountOffers"] is Array: + var arr: Array[DiscountOffer] = [] + for item in data["discountOffers"]: + if item is Dictionary: + arr.append(DiscountOffer.from_dict(item)) + elif item is DiscountOffer: + arr.append(item) + obj.discount_offers = arr if data.has("subscriptionOffers") and data["subscriptionOffers"] != null: - var arr: Array[SubscriptionOffer] = [] - for item in data["subscriptionOffers"]: - if item is Dictionary: - arr.append(SubscriptionOffer.from_dict(item)) - else: - arr.append(item) - obj.subscription_offers = arr + if data["subscriptionOffers"] is Array: + var arr: Array[SubscriptionOffer] = [] + for item in data["subscriptionOffers"]: + if item is Dictionary: + arr.append(SubscriptionOffer.from_dict(item)) + elif item is SubscriptionOffer: + arr.append(item) + obj.subscription_offers = arr if data.has("oneTimePurchaseOfferDetailsAndroid") and data["oneTimePurchaseOfferDetailsAndroid"] != null: - var arr: Array[ProductAndroidOneTimePurchaseOfferDetail] = [] - for item in data["oneTimePurchaseOfferDetailsAndroid"]: - if item is Dictionary: - arr.append(ProductAndroidOneTimePurchaseOfferDetail.from_dict(item)) - else: - arr.append(item) - obj.one_time_purchase_offer_details_android = arr + if data["oneTimePurchaseOfferDetailsAndroid"] is Array: + var arr: Array[ProductAndroidOneTimePurchaseOfferDetail] = [] + for item in data["oneTimePurchaseOfferDetailsAndroid"]: + if item is Dictionary: + arr.append(ProductAndroidOneTimePurchaseOfferDetail.from_dict(item)) + elif item is ProductAndroidOneTimePurchaseOfferDetail: + arr.append(item) + obj.one_time_purchase_offer_details_android = arr if data.has("subscriptionOfferDetailsAndroid") and data["subscriptionOfferDetailsAndroid"] != null: - var arr: Array[ProductSubscriptionAndroidOfferDetails] = [] - for item in data["subscriptionOfferDetailsAndroid"]: - if item is Dictionary: - arr.append(ProductSubscriptionAndroidOfferDetails.from_dict(item)) - else: - arr.append(item) - obj.subscription_offer_details_android = arr + if data["subscriptionOfferDetailsAndroid"] is Array: + var arr: Array[ProductSubscriptionAndroidOfferDetails] = [] + for item in data["subscriptionOfferDetailsAndroid"]: + if item is Dictionary: + arr.append(ProductSubscriptionAndroidOfferDetails.from_dict(item)) + elif item is ProductSubscriptionAndroidOfferDetails: + arr.append(item) + obj.subscription_offer_details_android = arr return obj func to_dict() -> Dictionary: @@ -1558,10 +1569,12 @@ class ProductAndroidOneTimePurchaseOfferDetail: if data.has("offerToken") and data["offerToken"] != null: obj.offer_token = data["offerToken"] if data.has("offerTags") and data["offerTags"] != null: - var arr: Array[String] = [] - for item in data["offerTags"]: - arr.append(str(item)) - obj.offer_tags = arr + if data["offerTags"] is Array: + var arr: Array[String] = [] + for item in data["offerTags"]: + if item is String: + arr.append(str(item)) + obj.offer_tags = arr if data.has("priceCurrencyCode") and data["priceCurrencyCode"] != null: obj.price_currency_code = data["priceCurrencyCode"] if data.has("formattedPrice") and data["formattedPrice"] != null: @@ -1699,21 +1712,23 @@ class ProductIOS: else: obj.type_ios = enum_str if data.has("subscriptionOffers") and data["subscriptionOffers"] != null: - var arr: Array[SubscriptionOffer] = [] - for item in data["subscriptionOffers"]: - if item is Dictionary: - arr.append(SubscriptionOffer.from_dict(item)) - else: - arr.append(item) - obj.subscription_offers = arr + if data["subscriptionOffers"] is Array: + var arr: Array[SubscriptionOffer] = [] + for item in data["subscriptionOffers"]: + if item is Dictionary: + arr.append(SubscriptionOffer.from_dict(item)) + elif item is SubscriptionOffer: + arr.append(item) + obj.subscription_offers = arr if data.has("pricingTermsIOS") and data["pricingTermsIOS"] != null: - var arr: Array[SubscriptionPricingTermsIOS] = [] - for item in data["pricingTermsIOS"]: - if item is Dictionary: - arr.append(SubscriptionPricingTermsIOS.from_dict(item)) - else: - arr.append(item) - obj.pricing_terms_ios = arr + if data["pricingTermsIOS"] is Array: + var arr: Array[SubscriptionPricingTermsIOS] = [] + for item in data["pricingTermsIOS"]: + if item is Dictionary: + arr.append(SubscriptionPricingTermsIOS.from_dict(item)) + elif item is SubscriptionPricingTermsIOS: + arr.append(item) + obj.pricing_terms_ios = arr if data.has("subscriptionInfoIOS") and data["subscriptionInfoIOS"] != null: if data["subscriptionInfoIOS"] is Dictionary: obj.subscription_info_ios = SubscriptionInfoIOS.from_dict(data["subscriptionInfoIOS"]) @@ -1837,37 +1852,41 @@ class ProductSubscriptionAndroid: else: obj.product_status_android = enum_str if data.has("discountOffers") and data["discountOffers"] != null: - var arr: Array[DiscountOffer] = [] - for item in data["discountOffers"]: - if item is Dictionary: - arr.append(DiscountOffer.from_dict(item)) - else: - arr.append(item) - obj.discount_offers = arr + if data["discountOffers"] is Array: + var arr: Array[DiscountOffer] = [] + for item in data["discountOffers"]: + if item is Dictionary: + arr.append(DiscountOffer.from_dict(item)) + elif item is DiscountOffer: + arr.append(item) + obj.discount_offers = arr if data.has("subscriptionOffers") and data["subscriptionOffers"] != null: - var arr: Array[SubscriptionOffer] = [] - for item in data["subscriptionOffers"]: - if item is Dictionary: - arr.append(SubscriptionOffer.from_dict(item)) - else: - arr.append(item) - obj.subscription_offers = arr + if data["subscriptionOffers"] is Array: + var arr: Array[SubscriptionOffer] = [] + for item in data["subscriptionOffers"]: + if item is Dictionary: + arr.append(SubscriptionOffer.from_dict(item)) + elif item is SubscriptionOffer: + arr.append(item) + obj.subscription_offers = arr if data.has("oneTimePurchaseOfferDetailsAndroid") and data["oneTimePurchaseOfferDetailsAndroid"] != null: - var arr: Array[ProductAndroidOneTimePurchaseOfferDetail] = [] - for item in data["oneTimePurchaseOfferDetailsAndroid"]: - if item is Dictionary: - arr.append(ProductAndroidOneTimePurchaseOfferDetail.from_dict(item)) - else: - arr.append(item) - obj.one_time_purchase_offer_details_android = arr + if data["oneTimePurchaseOfferDetailsAndroid"] is Array: + var arr: Array[ProductAndroidOneTimePurchaseOfferDetail] = [] + for item in data["oneTimePurchaseOfferDetailsAndroid"]: + if item is Dictionary: + arr.append(ProductAndroidOneTimePurchaseOfferDetail.from_dict(item)) + elif item is ProductAndroidOneTimePurchaseOfferDetail: + arr.append(item) + obj.one_time_purchase_offer_details_android = arr if data.has("subscriptionOfferDetailsAndroid") and data["subscriptionOfferDetailsAndroid"] != null: - var arr: Array[ProductSubscriptionAndroidOfferDetails] = [] - for item in data["subscriptionOfferDetailsAndroid"]: - if item is Dictionary: - arr.append(ProductSubscriptionAndroidOfferDetails.from_dict(item)) - else: - arr.append(item) - obj.subscription_offer_details_android = arr + if data["subscriptionOfferDetailsAndroid"] is Array: + var arr: Array[ProductSubscriptionAndroidOfferDetails] = [] + for item in data["subscriptionOfferDetailsAndroid"]: + if item is Dictionary: + arr.append(ProductSubscriptionAndroidOfferDetails.from_dict(item)) + elif item is ProductSubscriptionAndroidOfferDetails: + arr.append(item) + obj.subscription_offer_details_android = arr return obj func to_dict() -> Dictionary: @@ -1957,10 +1976,12 @@ class ProductSubscriptionAndroidOfferDetails: if data.has("offerToken") and data["offerToken"] != null: obj.offer_token = data["offerToken"] if data.has("offerTags") and data["offerTags"] != null: - var arr: Array[String] = [] - for item in data["offerTags"]: - arr.append(str(item)) - obj.offer_tags = arr + if data["offerTags"] is Array: + var arr: Array[String] = [] + for item in data["offerTags"]: + if item is String: + arr.append(str(item)) + obj.offer_tags = arr if data.has("pricingPhases") and data["pricingPhases"] != null: if data["pricingPhases"] is Dictionary: obj.pricing_phases = PricingPhasesAndroid.from_dict(data["pricingPhases"]) @@ -2066,21 +2087,23 @@ class ProductSubscriptionIOS: else: obj.type_ios = enum_str if data.has("subscriptionOffers") and data["subscriptionOffers"] != null: - var arr: Array[SubscriptionOffer] = [] - for item in data["subscriptionOffers"]: - if item is Dictionary: - arr.append(SubscriptionOffer.from_dict(item)) - else: - arr.append(item) - obj.subscription_offers = arr + if data["subscriptionOffers"] is Array: + var arr: Array[SubscriptionOffer] = [] + for item in data["subscriptionOffers"]: + if item is Dictionary: + arr.append(SubscriptionOffer.from_dict(item)) + elif item is SubscriptionOffer: + arr.append(item) + obj.subscription_offers = arr if data.has("pricingTermsIOS") and data["pricingTermsIOS"] != null: - var arr: Array[SubscriptionPricingTermsIOS] = [] - for item in data["pricingTermsIOS"]: - if item is Dictionary: - arr.append(SubscriptionPricingTermsIOS.from_dict(item)) - else: - arr.append(item) - obj.pricing_terms_ios = arr + if data["pricingTermsIOS"] is Array: + var arr: Array[SubscriptionPricingTermsIOS] = [] + for item in data["pricingTermsIOS"]: + if item is Dictionary: + arr.append(SubscriptionPricingTermsIOS.from_dict(item)) + elif item is SubscriptionPricingTermsIOS: + arr.append(item) + obj.pricing_terms_ios = arr if data.has("subscriptionGroupIdIOS") and data["subscriptionGroupIdIOS"] != null: obj.subscription_group_id_ios = data["subscriptionGroupIdIOS"] if data.has("subscriptionInfoIOS") and data["subscriptionInfoIOS"] != null: @@ -2089,13 +2112,14 @@ class ProductSubscriptionIOS: else: obj.subscription_info_ios = data["subscriptionInfoIOS"] if data.has("discountsIOS") and data["discountsIOS"] != null: - var arr: Array[DiscountIOS] = [] - for item in data["discountsIOS"]: - if item is Dictionary: - arr.append(DiscountIOS.from_dict(item)) - else: - arr.append(item) - obj.discounts_ios = arr + if data["discountsIOS"] is Array: + var arr: Array[DiscountIOS] = [] + for item in data["discountsIOS"]: + if item is Dictionary: + arr.append(DiscountIOS.from_dict(item)) + elif item is DiscountIOS: + arr.append(item) + obj.discounts_ios = arr if data.has("introductoryPriceIOS") and data["introductoryPriceIOS"] != null: obj.introductory_price_ios = data["introductoryPriceIOS"] if data.has("introductoryPriceAsAmountIOS") and data["introductoryPriceAsAmountIOS"] != null: @@ -2244,10 +2268,12 @@ class PurchaseAndroid: if data.has("productId") and data["productId"] != null: obj.product_id = data["productId"] if data.has("ids") and data["ids"] != null: - var arr: Array[String] = [] - for item in data["ids"]: - arr.append(str(item)) - obj.ids = arr + if data["ids"] is Array: + var arr: Array[String] = [] + for item in data["ids"]: + if item is String: + arr.append(str(item)) + obj.ids = arr if data.has("transactionId") and data["transactionId"] != null: obj.transaction_id = data["transactionId"] if data.has("transactionDate") and data["transactionDate"] != null: @@ -2380,10 +2406,12 @@ class PurchaseError: if data.has("responseCode") and data["responseCode"] != null: obj.response_code = data["responseCode"] if data.has("productIds") and data["productIds"] != null: - var arr: Array[String] = [] - for item in data["productIds"]: - arr.append(str(item)) - obj.product_ids = arr + if data["productIds"] is Array: + var arr: Array[String] = [] + for item in data["productIds"]: + if item is String: + arr.append(str(item)) + obj.product_ids = arr if data.has("productType") and data["productType"] != null: obj.product_type = data["productType"] if data.has("isEmptyProductList") and data["isEmptyProductList"] != null: @@ -2460,10 +2488,12 @@ class PurchaseIOS: if data.has("productId") and data["productId"] != null: obj.product_id = data["productId"] if data.has("ids") and data["ids"] != null: - var arr: Array[String] = [] - for item in data["ids"]: - arr.append(str(item)) - obj.ids = arr + if data["ids"] is Array: + var arr: Array[String] = [] + for item in data["ids"]: + if item is String: + arr.append(str(item)) + obj.ids = arr if data.has("transactionDate") and data["transactionDate"] != null: obj.transaction_date = data["transactionDate"] if data.has("purchaseToken") and data["purchaseToken"] != null: @@ -2926,21 +2956,23 @@ class SubscriptionInfoIOS: else: obj.introductory_offer = data["introductoryOffer"] if data.has("pricingTerms") and data["pricingTerms"] != null: - var arr: Array[SubscriptionPricingTermsIOS] = [] - for item in data["pricingTerms"]: - if item is Dictionary: - arr.append(SubscriptionPricingTermsIOS.from_dict(item)) - else: - arr.append(item) - obj.pricing_terms = arr + if data["pricingTerms"] is Array: + var arr: Array[SubscriptionPricingTermsIOS] = [] + for item in data["pricingTerms"]: + if item is Dictionary: + arr.append(SubscriptionPricingTermsIOS.from_dict(item)) + elif item is SubscriptionPricingTermsIOS: + arr.append(item) + obj.pricing_terms = arr if data.has("promotionalOffers") and data["promotionalOffers"] != null: - var arr: Array[SubscriptionOfferIOS] = [] - for item in data["promotionalOffers"]: - if item is Dictionary: - arr.append(SubscriptionOfferIOS.from_dict(item)) - else: - arr.append(item) - obj.promotional_offers = arr + if data["promotionalOffers"] is Array: + var arr: Array[SubscriptionOfferIOS] = [] + for item in data["promotionalOffers"]: + if item is Dictionary: + arr.append(SubscriptionOfferIOS.from_dict(item)) + elif item is SubscriptionOfferIOS: + arr.append(item) + obj.promotional_offers = arr if data.has("subscriptionGroupId") and data["subscriptionGroupId"] != null: obj.subscription_group_id = data["subscriptionGroupId"] if data.has("subscriptionPeriod") and data["subscriptionPeriod"] != null: @@ -3070,10 +3102,12 @@ class SubscriptionOffer: if data.has("offerTokenAndroid") and data["offerTokenAndroid"] != null: obj.offer_token_android = data["offerTokenAndroid"] if data.has("offerTagsAndroid") and data["offerTagsAndroid"] != null: - var arr: Array[String] = [] - for item in data["offerTagsAndroid"]: - arr.append(str(item)) - obj.offer_tags_android = arr + if data["offerTagsAndroid"] is Array: + var arr: Array[String] = [] + for item in data["offerTagsAndroid"]: + if item is String: + arr.append(str(item)) + obj.offer_tags_android = arr if data.has("pricingPhasesAndroid") and data["pricingPhasesAndroid"] != null: if data["pricingPhasesAndroid"] is Dictionary: obj.pricing_phases_android = PricingPhasesAndroid.from_dict(data["pricingPhasesAndroid"]) @@ -3277,13 +3311,14 @@ class SubscriptionPricingTermsIOS: else: obj.commitment_info = data["commitmentInfo"] if data.has("subscriptionOffers") and data["subscriptionOffers"] != null: - var arr: Array[SubscriptionOffer] = [] - for item in data["subscriptionOffers"]: - if item is Dictionary: - arr.append(SubscriptionOffer.from_dict(item)) - else: - arr.append(item) - obj.subscription_offers = arr + if data["subscriptionOffers"] is Array: + var arr: Array[SubscriptionOffer] = [] + for item in data["subscriptionOffers"]: + if item is Dictionary: + arr.append(SubscriptionOffer.from_dict(item)) + elif item is SubscriptionOffer: + arr.append(item) + obj.subscription_offers = arr return obj func to_dict() -> Dictionary: @@ -3376,10 +3411,12 @@ class UserChoiceBillingDetails: if data.has("externalTransactionToken") and data["externalTransactionToken"] != null: obj.external_transaction_token = data["externalTransactionToken"] if data.has("products") and data["products"] != null: - var arr: Array[String] = [] - for item in data["products"]: - arr.append(str(item)) - obj.products = arr + if data["products"] is Array: + var arr: Array[String] = [] + for item in data["products"]: + if item is String: + arr.append(str(item)) + obj.products = arr return obj func to_dict() -> Dictionary: @@ -3587,13 +3624,14 @@ class VerifyPurchaseWithProviderResult: else: obj.iapkit = data["iapkit"] if data.has("errors") and data["errors"] != null: - var arr: Array[VerifyPurchaseWithProviderError] = [] - for item in data["errors"]: - if item is Dictionary: - arr.append(VerifyPurchaseWithProviderError.from_dict(item)) - else: - arr.append(item) - obj.errors = arr + if data["errors"] is Array: + var arr: Array[VerifyPurchaseWithProviderError] = [] + for item in data["errors"]: + if item is Dictionary: + arr.append(VerifyPurchaseWithProviderError.from_dict(item)) + elif item is VerifyPurchaseWithProviderError: + arr.append(item) + obj.errors = arr return obj func to_dict() -> Dictionary: @@ -4003,10 +4041,12 @@ class ProductRequest: static func from_dict(data: Dictionary) -> ProductRequest: var obj = ProductRequest.new() if data.has("skus") and data["skus"] != null: - var arr: Array[String] = [] - for item in data["skus"]: - arr.append(str(item)) - obj.skus = arr + if data["skus"] is Array: + var arr: Array[String] = [] + for item in data["skus"]: + if item is String: + arr.append(str(item)) + obj.skus = arr if data.has("type") and data["type"] != null: var enum_str = data["type"] if enum_str is String and PRODUCT_QUERY_TYPE_FROM_STRING.has(enum_str): @@ -4070,10 +4110,12 @@ class PurchaseInput: if data.has("productId") and data["productId"] != null: obj.product_id = data["productId"] if data.has("ids") and data["ids"] != null: - var arr: Array[String] = [] - for item in data["ids"]: - arr.append(str(item)) - obj.ids = arr + if data["ids"] is Array: + var arr: Array[String] = [] + for item in data["ids"]: + if item is String: + arr.append(str(item)) + obj.ids = arr if data.has("transactionDate") and data["transactionDate"] != null: obj.transaction_date = data["transactionDate"] if data.has("purchaseToken") and data["purchaseToken"] != null: @@ -4196,10 +4238,12 @@ class RequestPurchaseAndroidProps: static func from_dict(data: Dictionary) -> RequestPurchaseAndroidProps: var obj = RequestPurchaseAndroidProps.new() if data.has("skus") and data["skus"] != null: - var arr: Array[String] = [] - for item in data["skus"]: - arr.append(str(item)) - obj.skus = arr + if data["skus"] is Array: + var arr: Array[String] = [] + for item in data["skus"]: + if item is String: + arr.append(str(item)) + obj.skus = arr if data.has("obfuscatedAccountId") and data["obfuscatedAccountId"] != null: obj.obfuscated_account_id = data["obfuscatedAccountId"] if data.has("obfuscatedProfileId") and data["obfuscatedProfileId"] != null: @@ -4421,10 +4465,12 @@ class RequestSubscriptionAndroidProps: static func from_dict(data: Dictionary) -> RequestSubscriptionAndroidProps: var obj = RequestSubscriptionAndroidProps.new() if data.has("skus") and data["skus"] != null: - var arr: Array[String] = [] - for item in data["skus"]: - arr.append(str(item)) - obj.skus = arr + if data["skus"] is Array: + var arr: Array[String] = [] + for item in data["skus"]: + if item is String: + arr.append(str(item)) + obj.skus = arr if data.has("obfuscatedAccountId") and data["obfuscatedAccountId"] != null: obj.obfuscated_account_id = data["obfuscatedAccountId"] if data.has("obfuscatedProfileId") and data["obfuscatedProfileId"] != null: @@ -4436,13 +4482,14 @@ class RequestSubscriptionAndroidProps: if data.has("replacementMode") and data["replacementMode"] != null: obj.replacement_mode = data["replacementMode"] if data.has("subscriptionOffers") and data["subscriptionOffers"] != null: - var arr: Array[AndroidSubscriptionOfferInput] = [] - for item in data["subscriptionOffers"]: - if item is Dictionary: - arr.append(AndroidSubscriptionOfferInput.from_dict(item)) - else: - arr.append(item) - obj.subscription_offers = arr + if data["subscriptionOffers"] is Array: + var arr: Array[AndroidSubscriptionOfferInput] = [] + for item in data["subscriptionOffers"]: + if item is Dictionary: + arr.append(AndroidSubscriptionOfferInput.from_dict(item)) + elif item is AndroidSubscriptionOfferInput: + arr.append(item) + obj.subscription_offers = arr if data.has("subscriptionProductReplacementParams") and data["subscriptionProductReplacementParams"] != null: if data["subscriptionProductReplacementParams"] is Dictionary: obj.subscription_product_replacement_params = SubscriptionProductReplacementParamsAndroid.from_dict(data["subscriptionProductReplacementParams"]) @@ -5581,10 +5628,12 @@ class Query: static func from_dict(data: Dictionary) -> Args: var obj = Args.new() if data.has("subscriptionIds") and data["subscriptionIds"] != null: - var arr: Array[String] = [] - for item in data["subscriptionIds"]: - arr.append(str(item)) - obj.subscription_ids = arr + if data["subscriptionIds"] is Array: + var arr: Array[String] = [] + for item in data["subscriptionIds"]: + if item is String: + arr.append(str(item)) + obj.subscription_ids = arr return obj func to_dict() -> Dictionary: @@ -5604,10 +5653,12 @@ class Query: static func from_dict(data: Dictionary) -> Args: var obj = Args.new() if data.has("subscriptionIds") and data["subscriptionIds"] != null: - var arr: Array[String] = [] - for item in data["subscriptionIds"]: - arr.append(str(item)) - obj.subscription_ids = arr + if data["subscriptionIds"] is Array: + var arr: Array[String] = [] + for item in data["subscriptionIds"]: + if item is String: + arr.append(str(item)) + obj.subscription_ids = arr return obj func to_dict() -> Dictionary: diff --git a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/Helper.kt b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/Helper.kt index 7a5525b7d..648aabd84 100644 --- a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/Helper.kt +++ b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/Helper.kt @@ -17,7 +17,6 @@ import io.github.hyochan.kmpiap.openiap.DiscountOffer import io.github.hyochan.kmpiap.openiap.ErrorCode import io.github.hyochan.kmpiap.openiap.ExternalLinkLaunchModeAndroid import io.github.hyochan.kmpiap.openiap.ExternalLinkTypeAndroid -import io.github.hyochan.kmpiap.openiap.FetchProductsResult import io.github.hyochan.kmpiap.openiap.IapPlatform import io.github.hyochan.kmpiap.openiap.IapStore import io.github.hyochan.kmpiap.openiap.InstallmentPlanDetailsAndroid @@ -26,7 +25,7 @@ import io.github.hyochan.kmpiap.openiap.LimitedQuantityInfoAndroid import io.github.hyochan.kmpiap.openiap.Product import io.github.hyochan.kmpiap.openiap.ProductAndroid import io.github.hyochan.kmpiap.openiap.ProductAndroidOneTimePurchaseOfferDetail -import io.github.hyochan.kmpiap.openiap.ProductRequest +import io.github.hyochan.kmpiap.openiap.ProductQueryType import io.github.hyochan.kmpiap.openiap.ProductStatusAndroid import io.github.hyochan.kmpiap.openiap.ProductSubscriptionAndroid import io.github.hyochan.kmpiap.openiap.ProductSubscriptionAndroidOfferDetails @@ -46,6 +45,7 @@ import io.github.hyochan.kmpiap.openiap.SubscriptionPeriod import io.github.hyochan.kmpiap.openiap.SubscriptionPeriodUnit import io.github.hyochan.kmpiap.openiap.SubscriptionReplacementModeAndroid import io.github.hyochan.kmpiap.openiap.ValidTimeWindowAndroid +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableSharedFlow import com.android.billingclient.api.BillingFlowParams import dev.hyo.openiap.BillingProgramAndroid as OpenIapBillingProgram @@ -67,6 +67,48 @@ internal data class ProductQueryOutcome( val succeeded: Boolean, ) +internal data class ProductQueryOutcomes( + val inApp: ProductQueryOutcome, + val subscriptions: ProductQueryOutcome, +) + +internal data class ProductCacheKey( + val productId: String, + val productType: String, +) + +internal suspend fun collectProductQueryOutcomes( + queryType: ProductQueryType, + queryInApp: suspend () -> ProductQueryOutcome, + querySubscriptions: suspend () -> ProductQueryOutcome, +): ProductQueryOutcomes { + val emptySuccess = ProductQueryOutcome(emptyList(), emptyList(), true) + + return when (queryType) { + ProductQueryType.InApp -> ProductQueryOutcomes(queryInApp(), emptySuccess) + ProductQueryType.Subs -> ProductQueryOutcomes(emptySuccess, querySubscriptions()) + ProductQueryType.All -> { + var firstError: Throwable? = null + + suspend fun capture(block: suspend () -> ProductQueryOutcome): ProductQueryOutcome = + try { + block() + } catch (error: Throwable) { + if (error is CancellationException || error is Error) throw error + firstError = firstError ?: error + ProductQueryOutcome(emptyList(), emptyList(), false) + } + + val inApp = capture(queryInApp) + val subscriptions = capture(querySubscriptions) + if (!inApp.succeeded && !subscriptions.succeeded) { + throw checkNotNull(firstError) + } + ProductQueryOutcomes(inApp, subscriptions) + } + } +} + internal fun productStatusFromUnfetchedStatus(statusCode: Int): ProductStatusAndroid = when (statusCode) { 3 -> ProductStatusAndroid.NotFound @@ -79,9 +121,9 @@ internal fun unfetchedProductInfoFrom(items: List<*>): List item ?: return@mapNotNull null @@ -154,16 +196,7 @@ internal fun emitFailureAndThrow( throw PurchaseException(error) } -internal fun mapFetchResultToProductsHelper( - params: ProductRequest, - @Suppress("UNUSED_PARAMETER") - result: FetchProductsResult, - cache: Map -): List = params.skus.flatMap { sku -> - cache[sku]?.let { listOf(it.toProduct()) } ?: emptyList() -} - -internal fun clearProductCache(cache: MutableMap) { +internal fun clearProductCache(cache: MutableMap) { cache.clear() } @@ -276,12 +309,12 @@ internal suspend fun loadProductDetails( client: BillingClient, productType: String, skus: List, - cache: MutableMap, + cache: MutableMap, errorFlow: MutableSharedFlow ): Map? { val details = mutableMapOf() skus.forEach { sku -> - cache[sku]?.takeIf { it.productType == productType }?.let { details[sku] = it } + cache[ProductCacheKey(sku, productType)]?.let { details[sku] = it } } val missing = skus.filterNot(details::containsKey) @@ -300,7 +333,9 @@ internal suspend fun loadProductDetails( val success = suspendCancellableCoroutine { continuation -> client.queryProductDetailsAsync(params) { billingResult: BillingResult, queryResult: QueryProductDetailsResult -> if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { - queryResult.productDetailsList.forEach { detail -> cache[detail.productId] = detail } + queryResult.productDetailsList.forEach { detail -> + cache[ProductCacheKey(detail.productId, detail.productType)] = detail + } continuation.resume(true) } else { continuation.resume(false) @@ -316,7 +351,7 @@ internal suspend fun loadProductDetails( } skus.forEach { sku -> - cache[sku]?.takeIf { it.productType == productType }?.let { details[sku] = it } + cache[ProductCacheKey(sku, productType)]?.let { details[sku] = it } } } diff --git a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt index b07fc4be7..f89bd048c 100644 --- a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt +++ b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt @@ -141,7 +141,7 @@ internal class InAppPurchaseAndroid : KmpInAppPurchase, Application.ActivityLife private var context: Context? = null private var currentActivity: Activity? = null private var activityCallbacksDisposer: (() -> Unit)? = null - private val cachedProductDetails = ConcurrentHashMap() + private val cachedProductDetails = ConcurrentHashMap() private var currentPurchaseCallback: ((Result>) -> Unit)? = null private val mainHandler by lazy(LazyThreadSafetyMode.NONE) { Handler(Looper.getMainLooper()) @@ -183,11 +183,6 @@ internal class InAppPurchaseAndroid : KmpInAppPurchase, Application.ActivityLife private fun failWith(error: PurchaseError): Nothing = emitFailureAndThrow(_purchaseErrorListener, error) - private fun mapFetchResultToProducts( - params: ProductRequest, - result: FetchProductsResult - ): List = mapFetchResultToProductsHelper(params, result, cachedProductDetails) - // --------------------------------------------------------------------- // Mutation handlers // --------------------------------------------------------------------- @@ -814,12 +809,10 @@ internal class InAppPurchaseAndroid : KmpInAppPurchase, Application.ActivityLife ) val queryType = params.type ?: ProductQueryType.InApp - val includeInApp = queryType == ProductQueryType.InApp || queryType == ProductQueryType.All - val includeSubs = queryType == ProductQueryType.Subs || queryType == ProductQueryType.All suspend fun query(productType: String): ProductQueryOutcome { val missing = params.skus.distinct().filter { sku -> - cachedProductDetails[sku]?.productType != productType + !cachedProductDetails.containsKey(ProductCacheKey(sku, productType)) } val queryOutcome = if (missing.isNotEmpty()) { @@ -837,7 +830,11 @@ internal class InAppPurchaseAndroid : KmpInAppPurchase, Application.ActivityLife suspendCancellableCoroutine { continuation -> client.queryProductDetailsAsync(queryParams) { billingResult: BillingResult, result: QueryProductDetailsResult -> if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { - result.productDetailsList.forEach { detail -> cachedProductDetails[detail.productId] = detail } + result.productDetailsList.forEach { detail -> + cachedProductDetails[ + ProductCacheKey(detail.productId, detail.productType) + ] = detail + } continuation.resume( ProductQueryOutcome( productDetails = result.productDetailsList, @@ -846,13 +843,14 @@ internal class InAppPurchaseAndroid : KmpInAppPurchase, Application.ActivityLife ) ) } else { - continuation.resume( - ProductQueryOutcome( - productDetails = emptyList(), - unfetchedProducts = emptyList(), - succeeded = false, - ) + val error = PurchaseError( + code = mapBillingResponseCode(billingResult.responseCode), + message = billingResult.debugMessage + .takeIf { it.isNotBlank() } + ?: "Failed to query product details", ) + _purchaseErrorListener.tryEmit(error) + continuation.resumeWithException(PurchaseException(error)) } } } @@ -866,21 +864,18 @@ internal class InAppPurchaseAndroid : KmpInAppPurchase, Application.ActivityLife return queryOutcome.copy( productDetails = params.skus.mapNotNull { sku -> - cachedProductDetails[sku]?.takeIf { it.productType == productType } + cachedProductDetails[ProductCacheKey(sku, productType)] } ) } - val inAppResult = if (includeInApp) { - query(BillingClient.ProductType.INAPP) - } else { - ProductQueryOutcome(emptyList(), emptyList(), true) - } - val subscriptionsResult = if (includeSubs) { - query(BillingClient.ProductType.SUBS) - } else { - ProductQueryOutcome(emptyList(), emptyList(), true) - } + val outcomes = collectProductQueryOutcomes( + queryType = queryType, + queryInApp = { query(BillingClient.ProductType.INAPP) }, + querySubscriptions = { query(BillingClient.ProductType.SUBS) }, + ) + val inAppResult = outcomes.inApp + val subscriptionsResult = outcomes.subscriptions fun ProductQueryOutcome.statusFor(productId: String) = unfetchedProducts .firstOrNull { it.productId == productId } diff --git a/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/ProductStatusAndroidTest.kt b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/ProductStatusAndroidTest.kt index 2631d7471..b408ba9f8 100644 --- a/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/ProductStatusAndroidTest.kt +++ b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/ProductStatusAndroidTest.kt @@ -1,9 +1,18 @@ package io.github.hyochan.kmpiap +import com.android.billingclient.api.BillingClient +import io.github.hyochan.kmpiap.openiap.ErrorCode +import io.github.hyochan.kmpiap.openiap.ProductQueryType import io.github.hyochan.kmpiap.openiap.ProductStatusAndroid import io.github.hyochan.kmpiap.openiap.ProductType +import io.github.hyochan.kmpiap.openiap.PurchaseError +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertSame import kotlin.test.assertTrue internal class TestUnfetchedProduct( @@ -43,6 +52,17 @@ class ProductStatusAndroidTest { assertTrue(subscription.subscriptionOffers.isEmpty()) } + @Test + fun `keeps in-app and subscription cache entries for the same sku separate`() { + val inAppKey = ProductCacheKey("shared.sku", BillingClient.ProductType.INAPP) + val subscriptionKey = ProductCacheKey("shared.sku", BillingClient.ProductType.SUBS) + val cache = mapOf(inAppKey to "in-app", subscriptionKey to "subscription") + + assertEquals(2, cache.size) + assertEquals("in-app", cache[inAppKey]) + assertEquals("subscription", cache[subscriptionKey]) + } + @Test fun `reads unfetched products with one reflected accessor lookup`() { val products = unfetchedProductInfoFrom( @@ -69,4 +89,68 @@ class ProductStatusAndroidTest { assertEquals("", billingStringOrEmpty { null }) assertEquals("", billingStringOrEmpty { throw NoSuchMethodError("Billing 8 API") }) } + + @Test + fun `single product query propagates failures`() = runTest { + val failure = PurchaseException( + PurchaseError(code = ErrorCode.ServiceError, message = "Service unavailable") + ) + + val thrown = assertFailsWith { + collectProductQueryOutcomes( + queryType = ProductQueryType.InApp, + queryInApp = { throw failure }, + querySubscriptions = { error("Subscription query must not run") }, + ) + } + + assertSame(failure, thrown) + } + + @Test + fun `all query preserves a successful product kind`() = runTest { + val outcomes = collectProductQueryOutcomes( + queryType = ProductQueryType.All, + queryInApp = { ProductQueryOutcome(emptyList(), emptyList(), true) }, + querySubscriptions = { throw IllegalStateException("Subscriptions unavailable") }, + ) + + assertTrue(outcomes.inApp.succeeded) + assertFalse(outcomes.subscriptions.succeeded) + } + + @Test + fun `all query rethrows its first failure when both product kinds fail`() = runTest { + val firstFailure = IllegalStateException("In-app unavailable") + + val thrown = assertFailsWith { + collectProductQueryOutcomes( + queryType = ProductQueryType.All, + queryInApp = { throw firstFailure }, + querySubscriptions = { throw IllegalArgumentException("Subscriptions unavailable") }, + ) + } + + assertSame(firstFailure, thrown) + } + + @Test + fun `all query propagates cancellation before starting the next query`() = runTest { + val cancellation = CancellationException("Cancelled") + var subscriptionQueryStarted = false + + val thrown = assertFailsWith { + collectProductQueryOutcomes( + queryType = ProductQueryType.All, + queryInApp = { throw cancellation }, + querySubscriptions = { + subscriptionQueryStarted = true + ProductQueryOutcome(emptyList(), emptyList(), true) + }, + ) + } + + assertSame(cancellation, thrown) + assertFalse(subscriptionQueryStarted) + } } diff --git a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt index c1c3405e7..9f6cb81a4 100644 --- a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt +++ b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt @@ -1285,7 +1285,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { return@mapNotNull null } - val map = normalizeIosProductPayload(dict) ?: return@mapNotNull null + val map = normalizeProductPayloadIOS(dict) ?: return@mapNotNull null runCatching { ProductIOS.fromJson(map) }.getOrNull()?.let { return@mapNotNull mergeLegacySubscriptionOffers(it, map) } @@ -1333,7 +1333,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { val list = data as? List<*> ?: return emptyList() list.mapNotNull { item -> val dict = (item as? Map<*, *>) ?: return@mapNotNull null - val map = normalizeIosProductPayload(dict) ?: return@mapNotNull null + val map = normalizeProductPayloadIOS(dict) ?: return@mapNotNull null runCatching { ProductSubscriptionIOS.fromJson(map) }.getOrNull()?.let { return@mapNotNull mergeLegacySubscriptionOffers(it, map) } @@ -1393,7 +1393,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { return list.mapNotNull { item -> runCatching { val dict = (item as? Map<*, *>) ?: return@runCatching null - val map = normalizeIosProductPayload(dict) ?: return@runCatching null + val map = normalizeProductPayloadIOS(dict) ?: return@runCatching null // Native iOS may return either generated union JSON or raw StoreKit maps; // decode the union first, then recover by product type for legacy payloads. @@ -1472,7 +1472,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { return try { val dict = (data as? Map<*, *>) ?: return null - val map = normalizeIosProductPayload(dict) ?: return null + val map = normalizeProductPayloadIOS(dict) ?: return null runCatching { ProductIOS.fromJson(map) }.getOrNull()?.let { return mergeLegacySubscriptionOffers(it, map) as? ProductIOS ?: it } diff --git a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/IosProductPayloadNormalizer.kt b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/ProductPayloadNormalizerIOS.kt similarity index 89% rename from libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/IosProductPayloadNormalizer.kt rename to libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/ProductPayloadNormalizerIOS.kt index c5989b82f..262cf9e6a 100644 --- a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/IosProductPayloadNormalizer.kt +++ b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/ProductPayloadNormalizerIOS.kt @@ -7,12 +7,13 @@ internal fun normalizeBridgeMap(data: Any?): Map? { } } -internal fun normalizeIosProductPayload(data: Any?): Map? { +internal fun normalizeProductPayloadIOS(data: Any?): Map? { val normalized = normalizeBridgeMap(data)?.toMutableMap() ?: return null fun applyAlias(canonical: String, legacy: String) { val canonicalValue = normalized[canonical] val isMissing = canonical !in normalized || canonicalValue == null || + (canonicalValue is Map<*, *> && canonicalValue.isEmpty()) || (canonicalValue is List<*> && canonicalValue.isEmpty()) if (isMissing && normalized[legacy] != null) { normalized[canonical] = normalized[legacy] diff --git a/libraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/IosProductPayloadNormalizerTest.kt b/libraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/ProductPayloadNormalizerTestIOS.kt similarity index 91% rename from libraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/IosProductPayloadNormalizerTest.kt rename to libraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/ProductPayloadNormalizerTestIOS.kt index 065f9afbc..4a71160e0 100644 --- a/libraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/IosProductPayloadNormalizerTest.kt +++ b/libraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/ProductPayloadNormalizerTestIOS.kt @@ -5,14 +5,14 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull -class IosProductPayloadNormalizerTest { +class ProductPayloadNormalizerTestIOS { @Test fun `normalizes legacy product aliases before generated decoding`() { val payload: Map = mapOf( "id" to "premium.monthly", "type" to "subs", "typeIOS" to "auto-renewable-subscription", - "subscriptionInfoIOS" to null, + "subscriptionInfoIOS" to emptyMap(), "subscription" to mapOf( "subscriptionGroupId" to "group-1", "subscriptionPeriod" to mapOf( @@ -43,7 +43,7 @@ class IosProductPayloadNormalizerTest { ), ) - val normalized = assertNotNull(normalizeIosProductPayload(payload)) + val normalized = assertNotNull(normalizeProductPayloadIOS(payload)) val product = ProductSubscriptionIOS.fromJson(normalized) assertEquals("group-1", product.subscriptionInfoIOS?.subscriptionGroupId) diff --git a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt index fb63b69cf..67b117aae 100644 --- a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt +++ b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt @@ -954,65 +954,9 @@ class HybridRnIap : HybridRnIapSpec() { } val subscriptionOffersJson = subscriptionOffers.takeIf { it.isNotEmpty() }?.let { serializeSubscriptionOffers(it) } - val oneTimeOffersNitro = oneTimeOffers?.map { otp -> - NitroOneTimePurchaseOfferDetail( - formattedPrice = otp.formattedPrice, - priceAmountMicros = otp.priceAmountMicros, - priceCurrencyCode = otp.priceCurrencyCode, - offerId = otp.offerId.wrapVariant(), - offerToken = otp.offerToken, - offerTags = otp.offerTags.toTypedArray(), - fullPriceMicros = otp.fullPriceMicros.wrapVariant(), - purchaseOptionId = otp.purchaseOptionId.wrapVariant(), - discountDisplayInfo = otp.discountDisplayInfo?.let { discount -> - Variant_NullType_NitroDiscountDisplayInfoAndroid.Second( - NitroDiscountDisplayInfoAndroid( - percentageDiscount = discount.percentageDiscount?.toDouble().wrapVariant(), - discountAmount = discount.discountAmount?.let { amount -> - Variant_NullType_NitroDiscountAmountAndroid.Second( - NitroDiscountAmountAndroid( - discountAmountMicros = amount.discountAmountMicros, - formattedDiscountAmount = amount.formattedDiscountAmount - ) - ) - } - ) - ) - }, - validTimeWindow = otp.validTimeWindow?.let { window -> - Variant_NullType_NitroValidTimeWindowAndroid.Second( - NitroValidTimeWindowAndroid( - startTimeMillis = window.startTimeMillis, - endTimeMillis = window.endTimeMillis - ) - ) - }, - limitedQuantityInfo = otp.limitedQuantityInfo?.let { info -> - Variant_NullType_NitroLimitedQuantityInfoAndroid.Second( - NitroLimitedQuantityInfoAndroid( - maximumQuantity = info.maximumQuantity.toDouble(), - remainingQuantity = info.remainingQuantity.toDouble() - ) - ) - }, - preorderDetailsAndroid = otp.preorderDetailsAndroid?.let { preorder -> - Variant_NullType_NitroPreorderDetailsAndroid.Second( - NitroPreorderDetailsAndroid( - preorderPresaleEndTimeMillis = preorder.preorderPresaleEndTimeMillis, - preorderReleaseTimeMillis = preorder.preorderReleaseTimeMillis - ) - ) - }, - rentalDetailsAndroid = otp.rentalDetailsAndroid?.let { rental -> - Variant_NullType_NitroRentalDetailsAndroid.Second( - NitroRentalDetailsAndroid( - rentalExpirationPeriod = rental.rentalExpirationPeriod?.let { Variant_NullType_String.Second(it) }, - rentalPeriod = rental.rentalPeriod - ) - ) - } - ) - }?.toTypedArray() + val oneTimeOffersNitro = oneTimeOffers + ?.map { it.toNitroOneTimePurchaseOfferDetail() } + ?.toTypedArray() var originalPriceAndroid: String? = null var originalPriceAmountMicrosAndroid: Double? = null @@ -1088,7 +1032,7 @@ class HybridRnIap : HybridRnIapSpec() { id = product.id, title = product.title, description = product.description, - debugDescription = product.debugDescription.wrapVariant(), + debugDescription = product.nitroDebugDescription(), type = product.type.rawValue, displayName = product.displayName.wrapVariant(), displayPrice = product.displayPrice, diff --git a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/ProductSerialization.kt b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/ProductSerialization.kt index ce5c2dcb4..614a4aae0 100644 --- a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/ProductSerialization.kt +++ b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/ProductSerialization.kt @@ -1,6 +1,8 @@ package com.margelo.nitro.iap import dev.hyo.openiap.DiscountOffer +import dev.hyo.openiap.ProductAndroidOneTimePurchaseOfferDetail +import dev.hyo.openiap.ProductCommon import dev.hyo.openiap.ProductSubscriptionAndroidOfferDetails import dev.hyo.openiap.SubscriptionOffer @@ -16,6 +18,72 @@ internal fun discountOfferMaps( offers: List, ): List> = offers.map { it.toJson().withoutTypeNames() } +internal fun ProductAndroidOneTimePurchaseOfferDetail.toNitroOneTimePurchaseOfferDetail(): NitroOneTimePurchaseOfferDetail = + NitroOneTimePurchaseOfferDetail( + formattedPrice = formattedPrice, + priceAmountMicros = priceAmountMicros, + priceCurrencyCode = priceCurrencyCode, + offerId = offerId?.let { Variant_NullType_String.Second(it) }, + offerToken = offerToken, + offerTags = offerTags.toTypedArray(), + fullPriceMicros = fullPriceMicros?.let { Variant_NullType_String.Second(it) }, + purchaseOptionId = purchaseOptionId?.let { Variant_NullType_String.Second(it) }, + discountDisplayInfo = discountDisplayInfo?.let { discount -> + Variant_NullType_NitroDiscountDisplayInfoAndroid.Second( + NitroDiscountDisplayInfoAndroid( + percentageDiscount = discount.percentageDiscount?.toDouble()?.let { + Variant_NullType_Double.Second(it) + }, + discountAmount = discount.discountAmount?.let { amount -> + Variant_NullType_NitroDiscountAmountAndroid.Second( + NitroDiscountAmountAndroid( + discountAmountMicros = amount.discountAmountMicros, + formattedDiscountAmount = amount.formattedDiscountAmount, + ) + ) + }, + ) + ) + }, + validTimeWindow = validTimeWindow?.let { window -> + Variant_NullType_NitroValidTimeWindowAndroid.Second( + NitroValidTimeWindowAndroid( + startTimeMillis = window.startTimeMillis, + endTimeMillis = window.endTimeMillis, + ) + ) + }, + limitedQuantityInfo = limitedQuantityInfo?.let { info -> + Variant_NullType_NitroLimitedQuantityInfoAndroid.Second( + NitroLimitedQuantityInfoAndroid( + maximumQuantity = info.maximumQuantity.toDouble(), + remainingQuantity = info.remainingQuantity.toDouble(), + ) + ) + }, + preorderDetailsAndroid = preorderDetailsAndroid?.let { preorder -> + Variant_NullType_NitroPreorderDetailsAndroid.Second( + NitroPreorderDetailsAndroid( + preorderPresaleEndTimeMillis = preorder.preorderPresaleEndTimeMillis, + preorderReleaseTimeMillis = preorder.preorderReleaseTimeMillis, + ) + ) + }, + rentalDetailsAndroid = rentalDetailsAndroid?.let { rental -> + Variant_NullType_NitroRentalDetailsAndroid.Second( + NitroRentalDetailsAndroid( + rentalExpirationPeriod = rental.rentalExpirationPeriod?.let { + Variant_NullType_String.Second(it) + }, + rentalPeriod = rental.rentalPeriod, + ) + ) + }, + ) + +internal fun ProductCommon.nitroDebugDescription(): Variant_NullType_String? = + debugDescription?.let { Variant_NullType_String.Second(it) } + private fun Map.withoutTypeNames(): Map = entries .filter { it.key != "__typename" } diff --git a/libraries/react-native-iap/android/src/test/java/com/margelo/nitro/iap/ProductSerializationTest.kt b/libraries/react-native-iap/android/src/test/java/com/margelo/nitro/iap/ProductSerializationTest.kt index de2a81606..8220c20e5 100644 --- a/libraries/react-native-iap/android/src/test/java/com/margelo/nitro/iap/ProductSerializationTest.kt +++ b/libraries/react-native-iap/android/src/test/java/com/margelo/nitro/iap/ProductSerializationTest.kt @@ -6,6 +6,8 @@ import dev.hyo.openiap.InstallmentPlanDetailsAndroid import dev.hyo.openiap.PaymentMode import dev.hyo.openiap.PreorderDetailsAndroid import dev.hyo.openiap.PricingPhasesAndroid +import dev.hyo.openiap.ProductAndroid +import dev.hyo.openiap.ProductAndroidOneTimePurchaseOfferDetail import dev.hyo.openiap.ProductSubscriptionAndroidOfferDetails import dev.hyo.openiap.RentalDetailsAndroid import dev.hyo.openiap.SubscriptionOffer @@ -91,4 +93,31 @@ class ProductSerializationTest { assertNotNull(map["preorderDetailsAndroid"]) assertNotNull(map["rentalDetailsAndroid"]) } + + @Test + fun `native product metadata reaches Nitro bridge models`() { + val offer = ProductAndroidOneTimePurchaseOfferDetail( + formattedPrice = "\$4.99", + offerTags = listOf("discount"), + offerToken = "offer-token", + priceAmountMicros = "4990000", + priceCurrencyCode = "USD", + purchaseOptionId = "purchase-option", + ) + val product = ProductAndroid( + currency = "USD", + debugDescription = "native debug metadata", + description = "Premium access", + displayPrice = "\$4.99", + id = "premium", + nameAndroid = "Premium", + oneTimePurchaseOfferDetailsAndroid = listOf(offer), + title = "Premium", + ) + + val nitroOffer = offer.toNitroOneTimePurchaseOfferDetail() + + assertEquals("purchase-option", nitroOffer.purchaseOptionId?.asSecondOrNull()) + assertEquals("native debug metadata", product.nitroDebugDescription()?.asSecondOrNull()) + } } 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 6d8db762b..1fefe8a3d 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 @@ -75,6 +75,7 @@ import dev.hyo.openiap.utils.fromBillingState import dev.hyo.openiap.utils.toActiveSubscription import dev.hyo.openiap.utils.verifyPurchaseWithGooglePlay import dev.hyo.openiap.utils.verifyPurchaseWithIapkit +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext @@ -84,6 +85,14 @@ import java.util.concurrent.atomic.AtomicReference // AlternativeBillingMode moved to main source set (shared between Play and Horizon) +internal fun recordRecoverableProductQueryFailure( + firstError: Throwable?, + error: Throwable, +): Throwable { + if (error is CancellationException || error is Error) throw error + return firstError ?: error +} + /** * Main OpenIapModule implementation for Android * @@ -307,7 +316,7 @@ class OpenIapModule( BillingClient.ProductType.INAPP, ) }.onFailure { error -> - firstQueryError = firstQueryError ?: error + firstQueryError = recordRecoverableProductQueryFailure(firstQueryError, error) }.getOrNull() val subscriptionsResult = runCatching { @@ -318,7 +327,7 @@ class OpenIapModule( BillingClient.ProductType.SUBS, ) }.onFailure { error -> - firstQueryError = firstQueryError ?: error + firstQueryError = recordRecoverableProductQueryFailure(firstQueryError, error) }.getOrNull() if (inAppResult == null && subscriptionsResult == null) { diff --git a/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/ProductStatusAndroidTest.kt b/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/ProductStatusAndroidTest.kt index f5c87b168..2ac4a33aa 100644 --- a/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/ProductStatusAndroidTest.kt +++ b/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/ProductStatusAndroidTest.kt @@ -4,8 +4,11 @@ import com.android.billingclient.api.UnfetchedProduct import dev.hyo.openiap.utils.BillingConverters.productStatusFromUnfetchedStatus import dev.hyo.openiap.utils.BillingConverters.unavailableInAppProduct import dev.hyo.openiap.utils.BillingConverters.unavailableSubscriptionProduct +import kotlinx.coroutines.CancellationException import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame import org.junit.Assert.assertTrue +import org.junit.Assert.fail import org.junit.Test class ProductStatusAndroidTest { @@ -46,4 +49,31 @@ class ProductStatusAndroidTest { assertTrue(subscription.subscriptionOffers.isEmpty()) assertTrue(subscription.subscriptionOfferDetailsAndroid.isEmpty()) } + + @Test + fun `records only the first recoverable all-query failure`() { + val first = IllegalStateException("In-app query failed") + val second = IllegalArgumentException("Subscription query failed") + + assertSame(first, recordRecoverableProductQueryFailure(null, first)) + assertSame(first, recordRecoverableProductQueryFailure(first, second)) + } + + @Test + fun `rethrows cancellation and fatal errors from all queries`() { + val cancellation = CancellationException("Cancelled") + val fatal = AssertionError("Fatal") + + assertRethrown(cancellation) + assertRethrown(fatal) + } + + private fun assertRethrown(expected: Throwable) { + try { + recordRecoverableProductQueryFailure(null, expected) + fail("Expected ${expected::class.simpleName} to be rethrown") + } catch (actual: Throwable) { + assertSame(expected, actual) + } + } } diff --git a/packages/gql/codegen/plugins/gdscript.ts b/packages/gql/codegen/plugins/gdscript.ts index 0f7c0e47f..1f3156a34 100644 --- a/packages/gql/codegen/plugins/gdscript.ts +++ b/packages/gql/codegen/plugins/gdscript.ts @@ -427,36 +427,57 @@ export class GDScriptPlugin extends CodegenPlugin { const elementType = type.elementType!; const elementTypeName = elementType.name!; const gdElementType = this.mapType(elementType); - this.emit(`${indent}var arr: Array[${gdElementType}] = []`); - this.emit(`${indent}for item in data["${graphqlName}"]:`); + const listIndent = `${indent}\t`; + const itemIndent = `${listIndent}\t`; + this.emit(`${indent}if data["${graphqlName}"] is Array:`); + this.emit(`${listIndent}var arr: Array[${gdElementType}] = []`); + this.emit(`${listIndent}for item in data["${graphqlName}"]:`); if (this.isObjectOrInput(elementType)) { - this.emit(`${indent}\tif item is Dictionary:`); - this.emit(`${indent}\t\tarr.append(${elementTypeName}.from_dict(item))`); - this.emit(`${indent}\telse:`); - this.emit(`${indent}\t\tarr.append(item)`); + this.emit(`${itemIndent}if item is Dictionary:`); + this.emit(`${itemIndent}\tarr.append(${elementTypeName}.from_dict(item))`); + this.emit(`${itemIndent}elif item is ${elementTypeName}:`); + this.emit(`${itemIndent}\tarr.append(item)`); } else if (elementType.kind === 'enum' || this.enumNames.has(elementTypeName)) { const enumReverseLookup = toConstantCase(elementTypeName) + '_FROM_STRING'; const fallback = this.getEnumUnknownFallback(elementTypeName); - this.emit(`${indent}\tvar parsed_item = item`); if (fallback) { - this.emit(`${indent}\tif item is String:`); - this.emit(`${indent}\t\tparsed_item = ${enumReverseLookup}.get(item, ${fallback})`); + this.emit(`${itemIndent}if item is String:`); + this.emit(`${itemIndent}\tarr.append(${enumReverseLookup}.get(item, ${fallback}))`); } else { - this.emit(`${indent}\tif item is String and ${enumReverseLookup}.has(item):`); - this.emit(`${indent}\t\tparsed_item = ${enumReverseLookup}[item]`); + this.emit(`${itemIndent}if item is String and ${enumReverseLookup}.has(item):`); + this.emit(`${itemIndent}\tarr.append(${enumReverseLookup}[item])`); } - this.emit(`${indent}\tarr.append(parsed_item)`); + this.emit(`${itemIndent}elif item is int:`); + this.emit(`${itemIndent}\tarr.append(item)`); } else { - let appendExpression = 'item'; switch (gdElementType) { - case 'String': appendExpression = 'str(item)'; break; - case 'int': appendExpression = 'int(item)'; break; - case 'float': appendExpression = 'float(item)'; break; - case 'bool': appendExpression = 'bool(item)'; break; + case 'String': + this.emit(`${itemIndent}if item is String:`); + this.emit(`${itemIndent}\tarr.append(str(item))`); + break; + case 'int': + this.emit(`${itemIndent}if item is int:`); + this.emit(`${itemIndent}\tarr.append(item)`); + this.emit(`${itemIndent}elif item is float:`); + this.emit(`${itemIndent}\tarr.append(int(item))`); + this.emit(`${itemIndent}elif item is String and item.is_valid_int():`); + this.emit(`${itemIndent}\tarr.append(int(item))`); + break; + case 'float': + this.emit(`${itemIndent}if item is int or item is float:`); + this.emit(`${itemIndent}\tarr.append(float(item))`); + this.emit(`${itemIndent}elif item is String and item.is_valid_float():`); + this.emit(`${itemIndent}\tarr.append(float(item))`); + break; + case 'bool': + this.emit(`${itemIndent}if item is bool:`); + this.emit(`${itemIndent}\tarr.append(bool(item))`); + break; + default: + this.emit(`${itemIndent}arr.append(item)`); } - this.emit(`${indent}\tarr.append(${appendExpression})`); } - this.emit(`${indent}obj.${fieldName} = arr`); + this.emit(`${listIndent}obj.${fieldName} = arr`); } private generateToDictField(field: IRField, fieldName: string): void { diff --git a/packages/gql/src/generated-gdscript.test.ts b/packages/gql/src/generated-gdscript.test.ts index 3c646ff8e..e22868d5c 100644 --- a/packages/gql/src/generated-gdscript.test.ts +++ b/packages/gql/src/generated-gdscript.test.ts @@ -1,5 +1,7 @@ import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; +import type { IRSchema } from '../codegen/core/types.js'; +import { GDScriptPlugin } from '../codegen/plugins/gdscript.js'; const generated = readFileSync(new URL('./generated/types.gd', import.meta.url), 'utf8'); @@ -14,6 +16,7 @@ describe('generated GDScript list decoding', () => { const source = classSource('ProductRequest', 'PromotionalOfferJWSInputIOS'); expect(source).toContain('var arr: Array[String] = []'); + expect(source).toContain('if item is String:'); expect(source).toContain('arr.append(str(item))'); }); @@ -22,6 +25,7 @@ describe('generated GDScript list decoding', () => { expect(source).toContain('var arr: Array[SubscriptionOffer] = []'); expect(source).toContain('arr.append(SubscriptionOffer.from_dict(item))'); + expect(source).toContain('elif item is SubscriptionOffer:'); expect(source).toContain('var arr: Array[SubscriptionPricingTermsIOS] = []'); }); @@ -31,4 +35,80 @@ describe('generated GDScript list decoding', () => { expect(source).toContain('var arr: Array[String] = []'); expect(source).toContain('obj.subscription_ids = arr'); }); + + it('validates enum list values before appending to a typed array', () => { + const schema: IRSchema = { + enums: [ + { + name: 'TestStatus', + values: [ + { name: 'Unknown', rawValue: 'unknown', legacyAliases: [] }, + { name: 'Active', rawValue: 'active', legacyAliases: [] }, + ], + isErrorCode: false, + }, + { + name: 'StrictStatus', + values: [{ name: 'Active', rawValue: 'active', legacyAliases: [] }], + isErrorCode: false, + }, + ], + interfaces: [], + objects: [ + { + name: 'EnumListHolder', + fields: [ + { + name: 'statuses', + type: { + kind: 'list', + nullable: false, + elementType: { + kind: 'enum', + name: 'TestStatus', + nullable: false, + }, + }, + isOverride: false, + }, + { + name: 'strictStatuses', + type: { + kind: 'list', + nullable: false, + elementType: { + kind: 'enum', + name: 'StrictStatus', + nullable: false, + }, + }, + isOverride: false, + }, + ], + interfaces: [], + unions: [], + isResultUnion: false, + isSingleFieldArgs: false, + }, + ], + inputs: [], + unions: [], + operations: [], + metadata: { + unionWrapperNames: new Set(), + futureFieldNames: new Set(), + platformDefaults: new Map(), + singleFieldObjects: new Map(), + unionMembership: new Map(), + inputsWithRequiredFields: new Set(), + }, + }; + const source = new GDScriptPlugin({ outputPath: 'types.gd' }).generate(schema); + + expect(source).toContain('if data["statuses"] is Array:'); + expect(source).toContain('arr.append(TEST_STATUS_FROM_STRING.get(item, TestStatus.UNKNOWN))'); + expect(source).toContain('if item is String and STRICT_STATUS_FROM_STRING.has(item):'); + expect(source).toContain('arr.append(STRICT_STATUS_FROM_STRING[item])'); + expect(source).toContain('elif item is int:'); + }); }); diff --git a/packages/gql/src/generated/types.gd b/packages/gql/src/generated/types.gd index 956f8fd79..f36b8f261 100644 --- a/packages/gql/src/generated/types.gd +++ b/packages/gql/src/generated/types.gd @@ -484,13 +484,14 @@ class AdvancedCommerceInfoIOS: static func from_dict(data: Dictionary) -> AdvancedCommerceInfoIOS: var obj = AdvancedCommerceInfoIOS.new() if data.has("items") and data["items"] != null: - var arr: Array[AdvancedCommerceItemIOS] = [] - for item in data["items"]: - if item is Dictionary: - arr.append(AdvancedCommerceItemIOS.from_dict(item)) - else: - arr.append(item) - obj.items = arr + if data["items"] is Array: + var arr: Array[AdvancedCommerceItemIOS] = [] + for item in data["items"]: + if item is Dictionary: + arr.append(AdvancedCommerceItemIOS.from_dict(item)) + elif item is AdvancedCommerceItemIOS: + arr.append(item) + obj.items = arr if data.has("requestReferenceId") and data["requestReferenceId"] != null: obj.request_reference_id = data["requestReferenceId"] if data.has("taxCode") and data["taxCode"] != null: @@ -569,13 +570,14 @@ class AdvancedCommerceItemIOS: else: obj.details = data["details"] if data.has("refunds") and data["refunds"] != null: - var arr: Array[AdvancedCommerceRefundIOS] = [] - for item in data["refunds"]: - if item is Dictionary: - arr.append(AdvancedCommerceRefundIOS.from_dict(item)) - else: - arr.append(item) - obj.refunds = arr + if data["refunds"] is Array: + var arr: Array[AdvancedCommerceRefundIOS] = [] + for item in data["refunds"]: + if item is Dictionary: + arr.append(AdvancedCommerceRefundIOS.from_dict(item)) + elif item is AdvancedCommerceRefundIOS: + arr.append(item) + obj.refunds = arr if data.has("revocationDate") and data["revocationDate"] != null: obj.revocation_date = data["revocationDate"] return obj @@ -942,10 +944,12 @@ class DiscountOffer: if data.has("offerTokenAndroid") and data["offerTokenAndroid"] != null: obj.offer_token_android = data["offerTokenAndroid"] if data.has("offerTagsAndroid") and data["offerTagsAndroid"] != null: - var arr: Array[String] = [] - for item in data["offerTagsAndroid"]: - arr.append(str(item)) - obj.offer_tags_android = arr + if data["offerTagsAndroid"] is Array: + var arr: Array[String] = [] + for item in data["offerTagsAndroid"]: + if item is String: + arr.append(str(item)) + obj.offer_tags_android = arr if data.has("fullPriceMicrosAndroid") and data["fullPriceMicrosAndroid"] != null: obj.full_price_micros_android = data["fullPriceMicrosAndroid"] if data.has("percentageDiscountAndroid") and data["percentageDiscountAndroid"] != null: @@ -1264,10 +1268,12 @@ class PendingPurchaseUpdateAndroid: static func from_dict(data: Dictionary) -> PendingPurchaseUpdateAndroid: var obj = PendingPurchaseUpdateAndroid.new() if data.has("products") and data["products"] != null: - var arr: Array[String] = [] - for item in data["products"]: - arr.append(str(item)) - obj.products = arr + if data["products"] is Array: + var arr: Array[String] = [] + for item in data["products"]: + if item is String: + arr.append(str(item)) + obj.products = arr if data.has("purchaseToken") and data["purchaseToken"] != null: obj.purchase_token = data["purchaseToken"] return obj @@ -1339,13 +1345,14 @@ class PricingPhasesAndroid: static func from_dict(data: Dictionary) -> PricingPhasesAndroid: var obj = PricingPhasesAndroid.new() if data.has("pricingPhaseList") and data["pricingPhaseList"] != null: - var arr: Array[PricingPhaseAndroid] = [] - for item in data["pricingPhaseList"]: - if item is Dictionary: - arr.append(PricingPhaseAndroid.from_dict(item)) - else: - arr.append(item) - obj.pricing_phase_list = arr + if data["pricingPhaseList"] is Array: + var arr: Array[PricingPhaseAndroid] = [] + for item in data["pricingPhaseList"]: + if item is Dictionary: + arr.append(PricingPhaseAndroid.from_dict(item)) + elif item is PricingPhaseAndroid: + arr.append(item) + obj.pricing_phase_list = arr return obj func to_dict() -> Dictionary: @@ -1424,37 +1431,41 @@ class ProductAndroid: else: obj.product_status_android = enum_str if data.has("discountOffers") and data["discountOffers"] != null: - var arr: Array[DiscountOffer] = [] - for item in data["discountOffers"]: - if item is Dictionary: - arr.append(DiscountOffer.from_dict(item)) - else: - arr.append(item) - obj.discount_offers = arr + if data["discountOffers"] is Array: + var arr: Array[DiscountOffer] = [] + for item in data["discountOffers"]: + if item is Dictionary: + arr.append(DiscountOffer.from_dict(item)) + elif item is DiscountOffer: + arr.append(item) + obj.discount_offers = arr if data.has("subscriptionOffers") and data["subscriptionOffers"] != null: - var arr: Array[SubscriptionOffer] = [] - for item in data["subscriptionOffers"]: - if item is Dictionary: - arr.append(SubscriptionOffer.from_dict(item)) - else: - arr.append(item) - obj.subscription_offers = arr + if data["subscriptionOffers"] is Array: + var arr: Array[SubscriptionOffer] = [] + for item in data["subscriptionOffers"]: + if item is Dictionary: + arr.append(SubscriptionOffer.from_dict(item)) + elif item is SubscriptionOffer: + arr.append(item) + obj.subscription_offers = arr if data.has("oneTimePurchaseOfferDetailsAndroid") and data["oneTimePurchaseOfferDetailsAndroid"] != null: - var arr: Array[ProductAndroidOneTimePurchaseOfferDetail] = [] - for item in data["oneTimePurchaseOfferDetailsAndroid"]: - if item is Dictionary: - arr.append(ProductAndroidOneTimePurchaseOfferDetail.from_dict(item)) - else: - arr.append(item) - obj.one_time_purchase_offer_details_android = arr + if data["oneTimePurchaseOfferDetailsAndroid"] is Array: + var arr: Array[ProductAndroidOneTimePurchaseOfferDetail] = [] + for item in data["oneTimePurchaseOfferDetailsAndroid"]: + if item is Dictionary: + arr.append(ProductAndroidOneTimePurchaseOfferDetail.from_dict(item)) + elif item is ProductAndroidOneTimePurchaseOfferDetail: + arr.append(item) + obj.one_time_purchase_offer_details_android = arr if data.has("subscriptionOfferDetailsAndroid") and data["subscriptionOfferDetailsAndroid"] != null: - var arr: Array[ProductSubscriptionAndroidOfferDetails] = [] - for item in data["subscriptionOfferDetailsAndroid"]: - if item is Dictionary: - arr.append(ProductSubscriptionAndroidOfferDetails.from_dict(item)) - else: - arr.append(item) - obj.subscription_offer_details_android = arr + if data["subscriptionOfferDetailsAndroid"] is Array: + var arr: Array[ProductSubscriptionAndroidOfferDetails] = [] + for item in data["subscriptionOfferDetailsAndroid"]: + if item is Dictionary: + arr.append(ProductSubscriptionAndroidOfferDetails.from_dict(item)) + elif item is ProductSubscriptionAndroidOfferDetails: + arr.append(item) + obj.subscription_offer_details_android = arr return obj func to_dict() -> Dictionary: @@ -1558,10 +1569,12 @@ class ProductAndroidOneTimePurchaseOfferDetail: if data.has("offerToken") and data["offerToken"] != null: obj.offer_token = data["offerToken"] if data.has("offerTags") and data["offerTags"] != null: - var arr: Array[String] = [] - for item in data["offerTags"]: - arr.append(str(item)) - obj.offer_tags = arr + if data["offerTags"] is Array: + var arr: Array[String] = [] + for item in data["offerTags"]: + if item is String: + arr.append(str(item)) + obj.offer_tags = arr if data.has("priceCurrencyCode") and data["priceCurrencyCode"] != null: obj.price_currency_code = data["priceCurrencyCode"] if data.has("formattedPrice") and data["formattedPrice"] != null: @@ -1699,21 +1712,23 @@ class ProductIOS: else: obj.type_ios = enum_str if data.has("subscriptionOffers") and data["subscriptionOffers"] != null: - var arr: Array[SubscriptionOffer] = [] - for item in data["subscriptionOffers"]: - if item is Dictionary: - arr.append(SubscriptionOffer.from_dict(item)) - else: - arr.append(item) - obj.subscription_offers = arr + if data["subscriptionOffers"] is Array: + var arr: Array[SubscriptionOffer] = [] + for item in data["subscriptionOffers"]: + if item is Dictionary: + arr.append(SubscriptionOffer.from_dict(item)) + elif item is SubscriptionOffer: + arr.append(item) + obj.subscription_offers = arr if data.has("pricingTermsIOS") and data["pricingTermsIOS"] != null: - var arr: Array[SubscriptionPricingTermsIOS] = [] - for item in data["pricingTermsIOS"]: - if item is Dictionary: - arr.append(SubscriptionPricingTermsIOS.from_dict(item)) - else: - arr.append(item) - obj.pricing_terms_ios = arr + if data["pricingTermsIOS"] is Array: + var arr: Array[SubscriptionPricingTermsIOS] = [] + for item in data["pricingTermsIOS"]: + if item is Dictionary: + arr.append(SubscriptionPricingTermsIOS.from_dict(item)) + elif item is SubscriptionPricingTermsIOS: + arr.append(item) + obj.pricing_terms_ios = arr if data.has("subscriptionInfoIOS") and data["subscriptionInfoIOS"] != null: if data["subscriptionInfoIOS"] is Dictionary: obj.subscription_info_ios = SubscriptionInfoIOS.from_dict(data["subscriptionInfoIOS"]) @@ -1837,37 +1852,41 @@ class ProductSubscriptionAndroid: else: obj.product_status_android = enum_str if data.has("discountOffers") and data["discountOffers"] != null: - var arr: Array[DiscountOffer] = [] - for item in data["discountOffers"]: - if item is Dictionary: - arr.append(DiscountOffer.from_dict(item)) - else: - arr.append(item) - obj.discount_offers = arr + if data["discountOffers"] is Array: + var arr: Array[DiscountOffer] = [] + for item in data["discountOffers"]: + if item is Dictionary: + arr.append(DiscountOffer.from_dict(item)) + elif item is DiscountOffer: + arr.append(item) + obj.discount_offers = arr if data.has("subscriptionOffers") and data["subscriptionOffers"] != null: - var arr: Array[SubscriptionOffer] = [] - for item in data["subscriptionOffers"]: - if item is Dictionary: - arr.append(SubscriptionOffer.from_dict(item)) - else: - arr.append(item) - obj.subscription_offers = arr + if data["subscriptionOffers"] is Array: + var arr: Array[SubscriptionOffer] = [] + for item in data["subscriptionOffers"]: + if item is Dictionary: + arr.append(SubscriptionOffer.from_dict(item)) + elif item is SubscriptionOffer: + arr.append(item) + obj.subscription_offers = arr if data.has("oneTimePurchaseOfferDetailsAndroid") and data["oneTimePurchaseOfferDetailsAndroid"] != null: - var arr: Array[ProductAndroidOneTimePurchaseOfferDetail] = [] - for item in data["oneTimePurchaseOfferDetailsAndroid"]: - if item is Dictionary: - arr.append(ProductAndroidOneTimePurchaseOfferDetail.from_dict(item)) - else: - arr.append(item) - obj.one_time_purchase_offer_details_android = arr + if data["oneTimePurchaseOfferDetailsAndroid"] is Array: + var arr: Array[ProductAndroidOneTimePurchaseOfferDetail] = [] + for item in data["oneTimePurchaseOfferDetailsAndroid"]: + if item is Dictionary: + arr.append(ProductAndroidOneTimePurchaseOfferDetail.from_dict(item)) + elif item is ProductAndroidOneTimePurchaseOfferDetail: + arr.append(item) + obj.one_time_purchase_offer_details_android = arr if data.has("subscriptionOfferDetailsAndroid") and data["subscriptionOfferDetailsAndroid"] != null: - var arr: Array[ProductSubscriptionAndroidOfferDetails] = [] - for item in data["subscriptionOfferDetailsAndroid"]: - if item is Dictionary: - arr.append(ProductSubscriptionAndroidOfferDetails.from_dict(item)) - else: - arr.append(item) - obj.subscription_offer_details_android = arr + if data["subscriptionOfferDetailsAndroid"] is Array: + var arr: Array[ProductSubscriptionAndroidOfferDetails] = [] + for item in data["subscriptionOfferDetailsAndroid"]: + if item is Dictionary: + arr.append(ProductSubscriptionAndroidOfferDetails.from_dict(item)) + elif item is ProductSubscriptionAndroidOfferDetails: + arr.append(item) + obj.subscription_offer_details_android = arr return obj func to_dict() -> Dictionary: @@ -1957,10 +1976,12 @@ class ProductSubscriptionAndroidOfferDetails: if data.has("offerToken") and data["offerToken"] != null: obj.offer_token = data["offerToken"] if data.has("offerTags") and data["offerTags"] != null: - var arr: Array[String] = [] - for item in data["offerTags"]: - arr.append(str(item)) - obj.offer_tags = arr + if data["offerTags"] is Array: + var arr: Array[String] = [] + for item in data["offerTags"]: + if item is String: + arr.append(str(item)) + obj.offer_tags = arr if data.has("pricingPhases") and data["pricingPhases"] != null: if data["pricingPhases"] is Dictionary: obj.pricing_phases = PricingPhasesAndroid.from_dict(data["pricingPhases"]) @@ -2066,21 +2087,23 @@ class ProductSubscriptionIOS: else: obj.type_ios = enum_str if data.has("subscriptionOffers") and data["subscriptionOffers"] != null: - var arr: Array[SubscriptionOffer] = [] - for item in data["subscriptionOffers"]: - if item is Dictionary: - arr.append(SubscriptionOffer.from_dict(item)) - else: - arr.append(item) - obj.subscription_offers = arr + if data["subscriptionOffers"] is Array: + var arr: Array[SubscriptionOffer] = [] + for item in data["subscriptionOffers"]: + if item is Dictionary: + arr.append(SubscriptionOffer.from_dict(item)) + elif item is SubscriptionOffer: + arr.append(item) + obj.subscription_offers = arr if data.has("pricingTermsIOS") and data["pricingTermsIOS"] != null: - var arr: Array[SubscriptionPricingTermsIOS] = [] - for item in data["pricingTermsIOS"]: - if item is Dictionary: - arr.append(SubscriptionPricingTermsIOS.from_dict(item)) - else: - arr.append(item) - obj.pricing_terms_ios = arr + if data["pricingTermsIOS"] is Array: + var arr: Array[SubscriptionPricingTermsIOS] = [] + for item in data["pricingTermsIOS"]: + if item is Dictionary: + arr.append(SubscriptionPricingTermsIOS.from_dict(item)) + elif item is SubscriptionPricingTermsIOS: + arr.append(item) + obj.pricing_terms_ios = arr if data.has("subscriptionGroupIdIOS") and data["subscriptionGroupIdIOS"] != null: obj.subscription_group_id_ios = data["subscriptionGroupIdIOS"] if data.has("subscriptionInfoIOS") and data["subscriptionInfoIOS"] != null: @@ -2089,13 +2112,14 @@ class ProductSubscriptionIOS: else: obj.subscription_info_ios = data["subscriptionInfoIOS"] if data.has("discountsIOS") and data["discountsIOS"] != null: - var arr: Array[DiscountIOS] = [] - for item in data["discountsIOS"]: - if item is Dictionary: - arr.append(DiscountIOS.from_dict(item)) - else: - arr.append(item) - obj.discounts_ios = arr + if data["discountsIOS"] is Array: + var arr: Array[DiscountIOS] = [] + for item in data["discountsIOS"]: + if item is Dictionary: + arr.append(DiscountIOS.from_dict(item)) + elif item is DiscountIOS: + arr.append(item) + obj.discounts_ios = arr if data.has("introductoryPriceIOS") and data["introductoryPriceIOS"] != null: obj.introductory_price_ios = data["introductoryPriceIOS"] if data.has("introductoryPriceAsAmountIOS") and data["introductoryPriceAsAmountIOS"] != null: @@ -2244,10 +2268,12 @@ class PurchaseAndroid: if data.has("productId") and data["productId"] != null: obj.product_id = data["productId"] if data.has("ids") and data["ids"] != null: - var arr: Array[String] = [] - for item in data["ids"]: - arr.append(str(item)) - obj.ids = arr + if data["ids"] is Array: + var arr: Array[String] = [] + for item in data["ids"]: + if item is String: + arr.append(str(item)) + obj.ids = arr if data.has("transactionId") and data["transactionId"] != null: obj.transaction_id = data["transactionId"] if data.has("transactionDate") and data["transactionDate"] != null: @@ -2380,10 +2406,12 @@ class PurchaseError: if data.has("responseCode") and data["responseCode"] != null: obj.response_code = data["responseCode"] if data.has("productIds") and data["productIds"] != null: - var arr: Array[String] = [] - for item in data["productIds"]: - arr.append(str(item)) - obj.product_ids = arr + if data["productIds"] is Array: + var arr: Array[String] = [] + for item in data["productIds"]: + if item is String: + arr.append(str(item)) + obj.product_ids = arr if data.has("productType") and data["productType"] != null: obj.product_type = data["productType"] if data.has("isEmptyProductList") and data["isEmptyProductList"] != null: @@ -2460,10 +2488,12 @@ class PurchaseIOS: if data.has("productId") and data["productId"] != null: obj.product_id = data["productId"] if data.has("ids") and data["ids"] != null: - var arr: Array[String] = [] - for item in data["ids"]: - arr.append(str(item)) - obj.ids = arr + if data["ids"] is Array: + var arr: Array[String] = [] + for item in data["ids"]: + if item is String: + arr.append(str(item)) + obj.ids = arr if data.has("transactionDate") and data["transactionDate"] != null: obj.transaction_date = data["transactionDate"] if data.has("purchaseToken") and data["purchaseToken"] != null: @@ -2926,21 +2956,23 @@ class SubscriptionInfoIOS: else: obj.introductory_offer = data["introductoryOffer"] if data.has("pricingTerms") and data["pricingTerms"] != null: - var arr: Array[SubscriptionPricingTermsIOS] = [] - for item in data["pricingTerms"]: - if item is Dictionary: - arr.append(SubscriptionPricingTermsIOS.from_dict(item)) - else: - arr.append(item) - obj.pricing_terms = arr + if data["pricingTerms"] is Array: + var arr: Array[SubscriptionPricingTermsIOS] = [] + for item in data["pricingTerms"]: + if item is Dictionary: + arr.append(SubscriptionPricingTermsIOS.from_dict(item)) + elif item is SubscriptionPricingTermsIOS: + arr.append(item) + obj.pricing_terms = arr if data.has("promotionalOffers") and data["promotionalOffers"] != null: - var arr: Array[SubscriptionOfferIOS] = [] - for item in data["promotionalOffers"]: - if item is Dictionary: - arr.append(SubscriptionOfferIOS.from_dict(item)) - else: - arr.append(item) - obj.promotional_offers = arr + if data["promotionalOffers"] is Array: + var arr: Array[SubscriptionOfferIOS] = [] + for item in data["promotionalOffers"]: + if item is Dictionary: + arr.append(SubscriptionOfferIOS.from_dict(item)) + elif item is SubscriptionOfferIOS: + arr.append(item) + obj.promotional_offers = arr if data.has("subscriptionGroupId") and data["subscriptionGroupId"] != null: obj.subscription_group_id = data["subscriptionGroupId"] if data.has("subscriptionPeriod") and data["subscriptionPeriod"] != null: @@ -3070,10 +3102,12 @@ class SubscriptionOffer: if data.has("offerTokenAndroid") and data["offerTokenAndroid"] != null: obj.offer_token_android = data["offerTokenAndroid"] if data.has("offerTagsAndroid") and data["offerTagsAndroid"] != null: - var arr: Array[String] = [] - for item in data["offerTagsAndroid"]: - arr.append(str(item)) - obj.offer_tags_android = arr + if data["offerTagsAndroid"] is Array: + var arr: Array[String] = [] + for item in data["offerTagsAndroid"]: + if item is String: + arr.append(str(item)) + obj.offer_tags_android = arr if data.has("pricingPhasesAndroid") and data["pricingPhasesAndroid"] != null: if data["pricingPhasesAndroid"] is Dictionary: obj.pricing_phases_android = PricingPhasesAndroid.from_dict(data["pricingPhasesAndroid"]) @@ -3277,13 +3311,14 @@ class SubscriptionPricingTermsIOS: else: obj.commitment_info = data["commitmentInfo"] if data.has("subscriptionOffers") and data["subscriptionOffers"] != null: - var arr: Array[SubscriptionOffer] = [] - for item in data["subscriptionOffers"]: - if item is Dictionary: - arr.append(SubscriptionOffer.from_dict(item)) - else: - arr.append(item) - obj.subscription_offers = arr + if data["subscriptionOffers"] is Array: + var arr: Array[SubscriptionOffer] = [] + for item in data["subscriptionOffers"]: + if item is Dictionary: + arr.append(SubscriptionOffer.from_dict(item)) + elif item is SubscriptionOffer: + arr.append(item) + obj.subscription_offers = arr return obj func to_dict() -> Dictionary: @@ -3376,10 +3411,12 @@ class UserChoiceBillingDetails: if data.has("externalTransactionToken") and data["externalTransactionToken"] != null: obj.external_transaction_token = data["externalTransactionToken"] if data.has("products") and data["products"] != null: - var arr: Array[String] = [] - for item in data["products"]: - arr.append(str(item)) - obj.products = arr + if data["products"] is Array: + var arr: Array[String] = [] + for item in data["products"]: + if item is String: + arr.append(str(item)) + obj.products = arr return obj func to_dict() -> Dictionary: @@ -3587,13 +3624,14 @@ class VerifyPurchaseWithProviderResult: else: obj.iapkit = data["iapkit"] if data.has("errors") and data["errors"] != null: - var arr: Array[VerifyPurchaseWithProviderError] = [] - for item in data["errors"]: - if item is Dictionary: - arr.append(VerifyPurchaseWithProviderError.from_dict(item)) - else: - arr.append(item) - obj.errors = arr + if data["errors"] is Array: + var arr: Array[VerifyPurchaseWithProviderError] = [] + for item in data["errors"]: + if item is Dictionary: + arr.append(VerifyPurchaseWithProviderError.from_dict(item)) + elif item is VerifyPurchaseWithProviderError: + arr.append(item) + obj.errors = arr return obj func to_dict() -> Dictionary: @@ -4003,10 +4041,12 @@ class ProductRequest: static func from_dict(data: Dictionary) -> ProductRequest: var obj = ProductRequest.new() if data.has("skus") and data["skus"] != null: - var arr: Array[String] = [] - for item in data["skus"]: - arr.append(str(item)) - obj.skus = arr + if data["skus"] is Array: + var arr: Array[String] = [] + for item in data["skus"]: + if item is String: + arr.append(str(item)) + obj.skus = arr if data.has("type") and data["type"] != null: var enum_str = data["type"] if enum_str is String and PRODUCT_QUERY_TYPE_FROM_STRING.has(enum_str): @@ -4070,10 +4110,12 @@ class PurchaseInput: if data.has("productId") and data["productId"] != null: obj.product_id = data["productId"] if data.has("ids") and data["ids"] != null: - var arr: Array[String] = [] - for item in data["ids"]: - arr.append(str(item)) - obj.ids = arr + if data["ids"] is Array: + var arr: Array[String] = [] + for item in data["ids"]: + if item is String: + arr.append(str(item)) + obj.ids = arr if data.has("transactionDate") and data["transactionDate"] != null: obj.transaction_date = data["transactionDate"] if data.has("purchaseToken") and data["purchaseToken"] != null: @@ -4196,10 +4238,12 @@ class RequestPurchaseAndroidProps: static func from_dict(data: Dictionary) -> RequestPurchaseAndroidProps: var obj = RequestPurchaseAndroidProps.new() if data.has("skus") and data["skus"] != null: - var arr: Array[String] = [] - for item in data["skus"]: - arr.append(str(item)) - obj.skus = arr + if data["skus"] is Array: + var arr: Array[String] = [] + for item in data["skus"]: + if item is String: + arr.append(str(item)) + obj.skus = arr if data.has("obfuscatedAccountId") and data["obfuscatedAccountId"] != null: obj.obfuscated_account_id = data["obfuscatedAccountId"] if data.has("obfuscatedProfileId") and data["obfuscatedProfileId"] != null: @@ -4421,10 +4465,12 @@ class RequestSubscriptionAndroidProps: static func from_dict(data: Dictionary) -> RequestSubscriptionAndroidProps: var obj = RequestSubscriptionAndroidProps.new() if data.has("skus") and data["skus"] != null: - var arr: Array[String] = [] - for item in data["skus"]: - arr.append(str(item)) - obj.skus = arr + if data["skus"] is Array: + var arr: Array[String] = [] + for item in data["skus"]: + if item is String: + arr.append(str(item)) + obj.skus = arr if data.has("obfuscatedAccountId") and data["obfuscatedAccountId"] != null: obj.obfuscated_account_id = data["obfuscatedAccountId"] if data.has("obfuscatedProfileId") and data["obfuscatedProfileId"] != null: @@ -4436,13 +4482,14 @@ class RequestSubscriptionAndroidProps: if data.has("replacementMode") and data["replacementMode"] != null: obj.replacement_mode = data["replacementMode"] if data.has("subscriptionOffers") and data["subscriptionOffers"] != null: - var arr: Array[AndroidSubscriptionOfferInput] = [] - for item in data["subscriptionOffers"]: - if item is Dictionary: - arr.append(AndroidSubscriptionOfferInput.from_dict(item)) - else: - arr.append(item) - obj.subscription_offers = arr + if data["subscriptionOffers"] is Array: + var arr: Array[AndroidSubscriptionOfferInput] = [] + for item in data["subscriptionOffers"]: + if item is Dictionary: + arr.append(AndroidSubscriptionOfferInput.from_dict(item)) + elif item is AndroidSubscriptionOfferInput: + arr.append(item) + obj.subscription_offers = arr if data.has("subscriptionProductReplacementParams") and data["subscriptionProductReplacementParams"] != null: if data["subscriptionProductReplacementParams"] is Dictionary: obj.subscription_product_replacement_params = SubscriptionProductReplacementParamsAndroid.from_dict(data["subscriptionProductReplacementParams"]) @@ -5581,10 +5628,12 @@ class Query: static func from_dict(data: Dictionary) -> Args: var obj = Args.new() if data.has("subscriptionIds") and data["subscriptionIds"] != null: - var arr: Array[String] = [] - for item in data["subscriptionIds"]: - arr.append(str(item)) - obj.subscription_ids = arr + if data["subscriptionIds"] is Array: + var arr: Array[String] = [] + for item in data["subscriptionIds"]: + if item is String: + arr.append(str(item)) + obj.subscription_ids = arr return obj func to_dict() -> Dictionary: @@ -5604,10 +5653,12 @@ class Query: static func from_dict(data: Dictionary) -> Args: var obj = Args.new() if data.has("subscriptionIds") and data["subscriptionIds"] != null: - var arr: Array[String] = [] - for item in data["subscriptionIds"]: - arr.append(str(item)) - obj.subscription_ids = arr + if data["subscriptionIds"] is Array: + var arr: Array[String] = [] + for item in data["subscriptionIds"]: + if item is String: + arr.append(str(item)) + obj.subscription_ids = arr return obj func to_dict() -> Dictionary: From 213ed8bbddb57132e807ef7d2b58e11a9b5677ab Mon Sep 17 00:00:00 2001 From: Hyo Date: Fri, 10 Jul 2026 15:54:48 +0900 Subject: [PATCH 15/15] perf(libraries): parallelize mixed product queries --- .../kotlin/io/github/hyochan/kmpiap/Helper.kt | 25 ++++-- .../kmpiap/ProductStatusAndroidTest.kt | 54 ++++++++++-- packages/apple/Sources/OpenIapModule.swift | 44 ++++++++-- .../java/dev/hyo/openiap/OpenIapModule.kt | 84 +++++++++++++------ .../hyo/openiap/ProductStatusAndroidTest.kt | 60 +++++++++++++ 5 files changed, 215 insertions(+), 52 deletions(-) diff --git a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/Helper.kt b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/Helper.kt index 648aabd84..4ce86137c 100644 --- a/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/Helper.kt +++ b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/Helper.kt @@ -46,6 +46,9 @@ import io.github.hyochan.kmpiap.openiap.SubscriptionPeriodUnit import io.github.hyochan.kmpiap.openiap.SubscriptionReplacementModeAndroid import io.github.hyochan.kmpiap.openiap.ValidTimeWindowAndroid import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableSharedFlow import com.android.billingclient.api.BillingFlowParams import dev.hyo.openiap.BillingProgramAndroid as OpenIapBillingProgram @@ -88,19 +91,25 @@ internal suspend fun collectProductQueryOutcomes( ProductQueryType.InApp -> ProductQueryOutcomes(queryInApp(), emptySuccess) ProductQueryType.Subs -> ProductQueryOutcomes(emptySuccess, querySubscriptions()) ProductQueryType.All -> { - var firstError: Throwable? = null - - suspend fun capture(block: suspend () -> ProductQueryOutcome): ProductQueryOutcome = + suspend fun capture(block: suspend () -> ProductQueryOutcome): Result = try { - block() + Result.success(block()) } catch (error: Throwable) { if (error is CancellationException || error is Error) throw error - firstError = firstError ?: error - ProductQueryOutcome(emptyList(), emptyList(), false) + Result.failure(error) } - val inApp = capture(queryInApp) - val subscriptions = capture(querySubscriptions) + val (inAppResult, subscriptionsResult) = coroutineScope { + awaitAll( + async { capture(queryInApp) }, + async { capture(querySubscriptions) }, + ) + } + val firstError = inAppResult.exceptionOrNull() + ?: subscriptionsResult.exceptionOrNull() + val failedOutcome = ProductQueryOutcome(emptyList(), emptyList(), false) + val inApp = inAppResult.getOrElse { failedOutcome } + val subscriptions = subscriptionsResult.getOrElse { failedOutcome } if (!inApp.succeeded && !subscriptions.succeeded) { throw checkNotNull(firstError) } diff --git a/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/ProductStatusAndroidTest.kt b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/ProductStatusAndroidTest.kt index b408ba9f8..7b12a20d9 100644 --- a/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/ProductStatusAndroidTest.kt +++ b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/ProductStatusAndroidTest.kt @@ -7,7 +7,10 @@ import io.github.hyochan.kmpiap.openiap.ProductStatusAndroid import io.github.hyochan.kmpiap.openiap.ProductType import io.github.hyochan.kmpiap.openiap.PurchaseError import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith @@ -135,22 +138,57 @@ class ProductStatusAndroidTest { } @Test - fun `all query propagates cancellation before starting the next query`() = runTest { - val cancellation = CancellationException("Cancelled") - var subscriptionQueryStarted = false + fun `all query runs both product kinds concurrently`() = runTest { + val inAppStarted = CompletableDeferred() + val subscriptionsStarted = CompletableDeferred() - val thrown = assertFailsWith { + val outcomes = withTimeout(1_000) { collectProductQueryOutcomes( queryType = ProductQueryType.All, - queryInApp = { throw cancellation }, + queryInApp = { + inAppStarted.complete(Unit) + subscriptionsStarted.await() + ProductQueryOutcome(emptyList(), emptyList(), true) + }, querySubscriptions = { - subscriptionQueryStarted = true + subscriptionsStarted.complete(Unit) + inAppStarted.await() ProductQueryOutcome(emptyList(), emptyList(), true) }, ) } - assertSame(cancellation, thrown) - assertFalse(subscriptionQueryStarted) + assertTrue(outcomes.inApp.succeeded) + assertTrue(outcomes.subscriptions.succeeded) + } + + @Test + fun `all query propagates cancellation and cancels its sibling`() = runTest { + val cancellation = CancellationException("Cancelled") + val subscriptionQueryStarted = CompletableDeferred() + var subscriptionQueryCancelled = false + + val thrown = assertFailsWith { + withTimeout(1_000) { + collectProductQueryOutcomes( + queryType = ProductQueryType.All, + queryInApp = { + subscriptionQueryStarted.await() + throw cancellation + }, + querySubscriptions = { + subscriptionQueryStarted.complete(Unit) + try { + awaitCancellation() + } finally { + subscriptionQueryCancelled = true + } + }, + ) + } + } + + assertEquals(cancellation.message, thrown.message) + assertTrue(subscriptionQueryCancelled) } } diff --git a/packages/apple/Sources/OpenIapModule.swift b/packages/apple/Sources/OpenIapModule.swift index df5182bf6..4b3f10223 100644 --- a/packages/apple/Sources/OpenIapModule.swift +++ b/packages/apple/Sources/OpenIapModule.swift @@ -1,5 +1,10 @@ import Foundation import StoreKit + +private struct IndexedProductEntry: @unchecked Sendable { + let index: Int + let entry: OpenIAP.ProductOrSubscription +} // UIKit: Required for UIApplication, UIWindowScene on iOS/tvOS/visionOS #if canImport(UIKit) import UIKit @@ -151,18 +156,39 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { // Preserve the concrete product variant so subscription-only fields are // not discarded when callers request a mixed result. + let allEntries = await withTaskGroup(of: IndexedProductEntry.self) { group in + for (index, product) in fetchedProducts.enumerated() { + group.addTask { + if let subscription = await StoreKitTypesBridge.productSubscription(from: product) { + return IndexedProductEntry( + index: index, + entry: .productSubscription(subscription) + ) + } + + let productEntry = await StoreKitTypesBridge.product(from: product) + return IndexedProductEntry(index: index, entry: .product(productEntry)) + } + } + + var entries = Array( + repeating: nil, + count: fetchedProducts.count + ) + for await result in group { + entries[result.index] = result.entry + } + return entries.compactMap { $0 } + } + var productEntries: [OpenIAP.Product] = [] var subscriptionEntries: [OpenIAP.ProductSubscription] = [] - var allEntries: [OpenIAP.ProductOrSubscription] = [] - - for product in fetchedProducts { - if let subscription = await StoreKitTypesBridge.productSubscription(from: product) { + for entry in allEntries { + switch entry { + case .product(let product): + productEntries.append(product) + case .productSubscription(let subscription): subscriptionEntries.append(subscription) - allEntries.append(.productSubscription(subscription)) - } else { - let productEntry = await StoreKitTypesBridge.product(from: product) - productEntries.append(productEntry) - allEntries.append(.product(productEntry)) } } 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 1fefe8a3d..2c5411979 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 @@ -77,6 +77,9 @@ import dev.hyo.openiap.utils.verifyPurchaseWithGooglePlay import dev.hyo.openiap.utils.verifyPurchaseWithIapkit import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import java.lang.ref.WeakReference @@ -93,6 +96,40 @@ internal fun recordRecoverableProductQueryFailure( return firstError ?: error } +internal data class AllProductQueryResults( + val inApp: T?, + val subscriptions: T?, +) + +internal suspend fun collectAllProductQueryResults( + queryInApp: suspend () -> T, + querySubscriptions: suspend () -> T, +): AllProductQueryResults { + suspend fun capture(query: suspend () -> T): Result = + try { + Result.success(query()) + } catch (error: Throwable) { + Result.failure(recordRecoverableProductQueryFailure(null, error)) + } + + val (inAppResult, subscriptionsResult) = coroutineScope { + awaitAll( + async { capture(queryInApp) }, + async { capture(querySubscriptions) }, + ) + } + val firstError = inAppResult.exceptionOrNull() + ?: subscriptionsResult.exceptionOrNull() + if (inAppResult.isFailure && subscriptionsResult.isFailure) { + throw checkNotNull(firstError) + } + + return AllProductQueryResults( + inApp = inAppResult.getOrNull(), + subscriptions = subscriptionsResult.getOrNull(), + ) +} + /** * Main OpenIapModule implementation for Android * @@ -306,33 +343,26 @@ class OpenIapModule( FetchProductsResultSubscriptions(result.toSubscriptionProducts(params.skus)) } ProductQueryType.All -> { - var firstQueryError: Throwable? = null - - val inAppResult = runCatching { - queryProductDetailsWithStatus( - client, - productManager, - params.skus, - BillingClient.ProductType.INAPP, - ) - }.onFailure { error -> - firstQueryError = recordRecoverableProductQueryFailure(firstQueryError, error) - }.getOrNull() - - val subscriptionsResult = runCatching { - queryProductDetailsWithStatus( - client, - productManager, - params.skus, - BillingClient.ProductType.SUBS, - ) - }.onFailure { error -> - firstQueryError = recordRecoverableProductQueryFailure(firstQueryError, error) - }.getOrNull() - - if (inAppResult == null && subscriptionsResult == null) { - throw firstQueryError ?: OpenIapError.QueryProduct - } + val results = collectAllProductQueryResults( + queryInApp = { + queryProductDetailsWithStatus( + client, + productManager, + params.skus, + BillingClient.ProductType.INAPP, + ) + }, + querySubscriptions = { + queryProductDetailsWithStatus( + client, + productManager, + params.skus, + BillingClient.ProductType.SUBS, + ) + }, + ) + val inAppResult = results.inApp + val subscriptionsResult = results.subscriptions val inAppById = inAppResult?.productDetails.orEmpty().associateBy { it.productId } val subscriptionsById = subscriptionsResult?.productDetails.orEmpty() diff --git a/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/ProductStatusAndroidTest.kt b/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/ProductStatusAndroidTest.kt index 2ac4a33aa..6eb8871bb 100644 --- a/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/ProductStatusAndroidTest.kt +++ b/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/ProductStatusAndroidTest.kt @@ -5,6 +5,10 @@ import dev.hyo.openiap.utils.BillingConverters.productStatusFromUnfetchedStatus import dev.hyo.openiap.utils.BillingConverters.unavailableInAppProduct import dev.hyo.openiap.utils.BillingConverters.unavailableSubscriptionProduct import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeout import org.junit.Assert.assertEquals import org.junit.Assert.assertSame import org.junit.Assert.assertTrue @@ -59,6 +63,62 @@ class ProductStatusAndroidTest { assertSame(first, recordRecoverableProductQueryFailure(first, second)) } + @Test + fun `all query runs both product kinds concurrently and preserves partial success`() = runTest { + val inAppStarted = CompletableDeferred() + val subscriptionsStarted = CompletableDeferred() + + val results = withTimeout(1_000) { + collectAllProductQueryResults( + queryInApp = { + inAppStarted.complete(Unit) + subscriptionsStarted.await() + "in-app" + }, + querySubscriptions = { + subscriptionsStarted.complete(Unit) + inAppStarted.await() + throw IllegalStateException("Subscriptions unavailable") + }, + ) + } + + assertEquals("in-app", results.inApp) + assertEquals(null, results.subscriptions) + } + + @Test + fun `all query propagates cancellation and cancels its sibling`() = runTest { + val cancellation = CancellationException("Cancelled") + val subscriptionQueryStarted = CompletableDeferred() + var subscriptionQueryCancelled = false + + val thrown = try { + withTimeout(1_000) { + collectAllProductQueryResults( + queryInApp = { + subscriptionQueryStarted.await() + throw cancellation + }, + querySubscriptions = { + subscriptionQueryStarted.complete(Unit) + try { + awaitCancellation() + } finally { + subscriptionQueryCancelled = true + } + }, + ) + } + throw AssertionError("Expected CancellationException") + } catch (error: CancellationException) { + error + } + + assertEquals(cancellation.message, thrown.message) + assertTrue(subscriptionQueryCancelled) + } + @Test fun `rethrows cancellation and fatal errors from all queries`() { val cancellation = CancellationException("Cancelled")