diff --git a/.github/workflows/ci-godot-iap.yml b/.github/workflows/ci-godot-iap.yml index 2803dc10b..2df26f077 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,11 +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 + persist-credentials: false + + - 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: | @@ -42,3 +51,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 d9e8a2c9f..3e52e5830 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; @@ -118,10 +120,13 @@ 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(), @@ -136,16 +141,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 +163,9 @@ gentype.ProductCommon parseProductFromNative( json['oneTimePurchaseOfferDetailsAndroid'], ), price: priceValue, + productStatusAndroid: _parseProductStatusAndroid( + json['productStatusAndroid'], + ), ); } @@ -173,9 +185,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 +211,22 @@ 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, + productStatusAndroid: _parseProductStatusAndroid( + json['productStatusAndroid'], + ), subscriptionOfferDetailsAndroid: androidOffers.isEmpty ? null : androidOffers, + subscriptionOffers: + _parseStandardizedSubscriptionOffers(json['subscriptionOffers']) ?? + (androidOffers.isEmpty + ? null + : _parseSubscriptionOffers(androidOffers)), ); } @@ -534,6 +561,85 @@ 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, +) { + 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 { + parsed.add(fromJson(map)); + } catch (_) { + continue; + } + } + 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, +) { + 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; + if (value is gentype.ProductStatusAndroid) return value; + try { + return gentype.ProductStatusAndroid.fromJson(value.toString()); + } catch (_) { + return null; + } +} + +gentype.InstallmentPlanDetailsAndroid? _parseInstallmentPlanDetailsAndroid( + dynamic value, +) { + if (value is gentype.InstallmentPlanDetailsAndroid) return 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, ) { @@ -574,9 +680,11 @@ List _parseOfferDetails( // Skip invalid items return null; } - return gentype.ProductSubscriptionAndroidOfferDetails( basePlanId: e['basePlanId'] as String? ?? '', + installmentPlanDetails: _parseInstallmentPlanDetailsAndroid( + e['installmentPlanDetails'], + ), offerId: e['offerId'] as String?, offerToken: e['offerToken'] as String? ?? '', offerTags: (e['offerTags'] as List?) @@ -728,22 +836,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; @@ -757,6 +867,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; @@ -765,6 +877,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; @@ -795,7 +909,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, @@ -803,6 +920,22 @@ List _parseSubscriptionOffers( }).toList(); } +gentype.SubscriptionPeriod? _parseBillingPeriod(String billingPeriod) { + final match = _billingPeriodRegExp.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(); @@ -893,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, @@ -931,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 a4d304347..de80dd281 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', @@ -114,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', () { @@ -133,7 +190,31 @@ void main() { 'formattedPrice': '\$2.99', 'priceAmountMicros': '2990000', 'priceCurrencyCode': 'USD', + 'purchaseOptionId': 'single-purchase-option', }, + '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', + }, + { + 'currency': 'USD', + 'displayPrice': '\$1.99', + 'id': 'discount_001', + 'offerTokenAndroid': 'discount-token', + 'price': 1.99, + 'type': 'one-time', + }, + ], + 'productStatusAndroid': types.ProductStatusAndroid.Ok, }, 'inapp', fallbackIsIOS: false, @@ -145,6 +226,24 @@ 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, + 'typed-token', + ); + expect( + androidProduct.discountOffers![1].offerTokenAndroid, + 'discount-token', + ); + expect( + androidProduct.productStatusAndroid, + types.ProductStatusAndroid.Ok, + ); }, ); @@ -309,6 +408,10 @@ void main() { 'subscriptionOfferDetailsAndroid': >[ { 'basePlanId': 'base', + 'installmentPlanDetails': { + 'commitmentPaymentsCount': '12', + 'subsequentCommitmentPaymentsCount': 0.0, + }, 'offerToken': 'token', 'offerTags': ['tag'], 'pricingPhases': { @@ -325,6 +428,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 +452,75 @@ 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, + ); + }, + ); + + 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'); }, ); @@ -361,6 +545,7 @@ void main() { 'offerToken': 'offer-token', 'offerId': 'offer-id', 'fullPriceMicros': '2990000', + 'purchaseOptionId': 'purchase-option', 'discountDisplayInfo': { 'discountAmount': { 'discountAmountMicros': '100000', @@ -401,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'); @@ -447,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( { @@ -587,32 +802,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, - ); + '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/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..abdff9805 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,11 +82,88 @@ 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", 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: + 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, "invalid", null], + "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" + ) # ============================================ 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..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 = [] - 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 = [] - 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,7 +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: - obj.offer_tags_android = data["offerTagsAndroid"] + 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: @@ -1261,7 +1268,12 @@ 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"] + 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 @@ -1333,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 = [] - 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: @@ -1418,37 +1431,41 @@ class ProductAndroid: else: obj.product_status_android = enum_str if data.has("discountOffers") and data["discountOffers"] != null: - var arr = [] - 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 = [] - 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 = [] - 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 = [] - 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: @@ -1552,7 +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: - obj.offer_tags = data["offerTags"] + 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: @@ -1690,21 +1712,23 @@ class ProductIOS: else: obj.type_ios = enum_str if data.has("subscriptionOffers") and data["subscriptionOffers"] != null: - var arr = [] - 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 = [] - 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"]) @@ -1828,37 +1852,41 @@ class ProductSubscriptionAndroid: else: obj.product_status_android = enum_str if data.has("discountOffers") and data["discountOffers"] != null: - var arr = [] - 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 = [] - 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 = [] - 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 = [] - 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: @@ -1948,7 +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: - obj.offer_tags = data["offerTags"] + 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"]) @@ -2054,21 +2087,23 @@ class ProductSubscriptionIOS: else: obj.type_ios = enum_str if data.has("subscriptionOffers") and data["subscriptionOffers"] != null: - var arr = [] - 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 = [] - 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: @@ -2077,13 +2112,14 @@ class ProductSubscriptionIOS: else: obj.subscription_info_ios = data["subscriptionInfoIOS"] if data.has("discountsIOS") and data["discountsIOS"] != null: - var arr = [] - 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: @@ -2232,7 +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: - obj.ids = data["ids"] + 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: @@ -2365,7 +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: - obj.product_ids = data["productIds"] + 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: @@ -2442,7 +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: - obj.ids = data["ids"] + 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: @@ -2905,21 +2956,23 @@ class SubscriptionInfoIOS: else: obj.introductory_offer = data["introductoryOffer"] if data.has("pricingTerms") and data["pricingTerms"] != null: - var arr = [] - 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 = [] - 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: @@ -3049,7 +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: - obj.offer_tags_android = data["offerTagsAndroid"] + 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"]) @@ -3253,13 +3311,14 @@ class SubscriptionPricingTermsIOS: else: obj.commitment_info = data["commitmentInfo"] if data.has("subscriptionOffers") and data["subscriptionOffers"] != null: - var arr = [] - 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: @@ -3352,7 +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: - obj.products = data["products"] + 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: @@ -3560,13 +3624,14 @@ class VerifyPurchaseWithProviderResult: else: obj.iapkit = data["iapkit"] if data.has("errors") and data["errors"] != null: - var arr = [] - 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: @@ -3976,7 +4041,12 @@ 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"] + 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): @@ -4040,7 +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: - obj.ids = data["ids"] + 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: @@ -4163,7 +4238,12 @@ 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"] + 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: @@ -4385,7 +4465,12 @@ 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"] + 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: @@ -4397,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 = [] - 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"]) @@ -5542,7 +5628,12 @@ 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"] + 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: @@ -5562,7 +5653,12 @@ 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"] + 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/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/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 9a377f149..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 @@ -11,20 +11,26 @@ 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.ProductQueryType +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 +40,15 @@ 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.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 @@ -42,6 +56,147 @@ 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 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 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 -> { + suspend fun capture(block: suspend () -> ProductQueryOutcome): Result = + try { + Result.success(block()) + } catch (error: Throwable) { + if (error is CancellationException || error is Error) throw error + Result.failure(error) + } + + 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) + } + ProductQueryOutcomes(inApp, subscriptions) + } + } +} + +internal fun productStatusFromUnfetchedStatus(statusCode: Int): ProductStatusAndroid = + when (statusCode) { + 3 -> ProductStatusAndroid.NotFound + 4 -> ProductStatusAndroid.NoOffersAvailable + 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").apply { isAccessible = true } + val getProductType = itemClass.getMethod("getProductType").apply { isAccessible = true } + val getStatusCode = itemClass.getMethod("getStatusCode").apply { isAccessible = true } + + 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() + unfetchedProductInfoFrom(items) + }.getOrDefault(emptyList()) + +internal fun billingStringOrEmpty(block: () -> String?): String = + runCatching(block).getOrNull().orEmpty() + +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 @@ -50,16 +205,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() } @@ -172,12 +318,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) @@ -196,7 +342,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) @@ -212,7 +360,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 } } } @@ -261,6 +409,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 +430,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 = ProductStatusAndroid.Ok, + subscriptionOfferDetailsAndroid = subscriptionOfferDetails, + subscriptionOffers = subscriptionOffers, title = title, type = productType ) @@ -312,6 +470,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 +478,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 +486,117 @@ internal fun ProductDetails.toSubscriptionProduct(): ProductSubscriptionAndroid? ) } +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 = billingStringOrEmpty { this.offerToken }, + 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 = billingStringOrEmpty { this.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 +623,54 @@ 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 match = billingPeriodRegex.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/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt b/libraries/kmp-iap/library/src/androidMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseAndroid.kt index 7827ad7f5..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 @@ -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 @@ -140,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()) @@ -182,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 // --------------------------------------------------------------------- @@ -813,23 +809,13 @@ 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): 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.containsKey(ProductCacheKey(sku, productType)) } - if (missing.isNotEmpty()) { + val queryOutcome = if (missing.isNotEmpty()) { val queryParams = QueryProductDetailsParams.newBuilder() .setProductList( missing.map { sku -> @@ -841,48 +827,136 @@ 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) + result.productDetailsList.forEach { detail -> + cachedProductDetails[ + ProductCacheKey(detail.productId, detail.productType) + ] = detail + } + continuation.resume( + ProductQueryOutcome( + productDetails = result.productDetailsList, + unfetchedProducts = result.unfetchedProductsCompat(), + succeeded = true, + ) + ) } else { - continuation.resume(emptyList()) + 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)) } } } + } 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[ProductCacheKey(sku, productType)] } + ) + } - queried.filter { detail -> detail.productType == productType && detail.productId in params.skus } - .forEach { detail -> - if (!ordered.contains(detail)) ordered += detail - } + 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 } + ?.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..7b12a20d9 --- /dev/null +++ b/libraries/kmp-iap/library/src/androidUnitTest/kotlin/io/github/hyochan/kmpiap/ProductStatusAndroidTest.kt @@ -0,0 +1,194 @@ +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.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 +import kotlin.test.assertFalse +import kotlin.test.assertSame +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`() { + 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()) + } + + @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( + 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") }) + } + + @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 runs both product kinds concurrently`() = runTest { + val inAppStarted = CompletableDeferred() + val subscriptionsStarted = CompletableDeferred() + + val outcomes = withTimeout(1_000) { + collectProductQueryOutcomes( + queryType = ProductQueryType.All, + queryInApp = { + inAppStarted.complete(Unit) + subscriptionsStarted.await() + ProductQueryOutcome(emptyList(), emptyList(), true) + }, + querySubscriptions = { + subscriptionsStarted.complete(Unit) + inAppStarted.await() + ProductQueryOutcome(emptyList(), emptyList(), true) + }, + ) + } + + 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/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( 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..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 @@ -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,12 +1285,13 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { return@mapNotNull null } - val map = dict.mapKeys { it.key.toString() } + val map = normalizeProductPayloadIOS(dict) ?: return@mapNotNull null + 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 ?: "", @@ -1294,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) } @@ -1317,18 +1333,19 @@ 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 = normalizeProductPayloadIOS(dict) ?: return@mapNotNull null + 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 ?: "", 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 ?: "", @@ -1336,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, @@ -1345,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) { @@ -1367,11 +1393,13 @@ 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 = 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. - 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 { @@ -1387,13 +1415,67 @@ 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 = subscriptionOffersFrom(map) + 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 = subscriptionOffersFrom(map) + 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 return try { val dict = (data as? Map<*, *>) ?: return null - val map = dict.mapKeys { it.key.toString() } + val map = normalizeProductPayloadIOS(dict) ?: return null + runCatching { ProductIOS.fromJson(map) }.getOrNull()?.let { + return mergeLegacySubscriptionOffers(it, map) as? ProductIOS ?: it + } ProductIOS( currency = map["currency"] as? String ?: "", @@ -1410,10 +1492,10 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { pricingTermsIOS = convertAnyListToSubscriptionPricingTermsIOS( map["pricingTermsIOS"] ), - subscriptionInfoIOS = null, // Complex object - subscriptionOffers = convertAnyListToSubscriptionOffers( - map["subscriptionOffers"] ?: map["offers"] - ).ifEmpty { null }, + subscriptionInfoIOS = convertAnyToSubscriptionInfoIOS( + map["subscriptionInfoIOS"] + ), + subscriptionOffers = subscriptionOffersFrom(map).ifEmpty { null }, title = map["title"] as? String ?: "", type = (map["type"] as? String)?.let { ProductType.fromJson(it) } ?: ProductType.InApp, @@ -1606,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/ProductPayloadNormalizerIOS.kt b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/ProductPayloadNormalizerIOS.kt new file mode 100644 index 000000000..262cf9e6a --- /dev/null +++ b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/ProductPayloadNormalizerIOS.kt @@ -0,0 +1,35 @@ +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 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] + } + } + + 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/ProductPayloadNormalizerTestIOS.kt b/libraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/ProductPayloadNormalizerTestIOS.kt new file mode 100644 index 000000000..4a71160e0 --- /dev/null +++ b/libraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/ProductPayloadNormalizerTestIOS.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 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 emptyMap(), + "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(normalizeProductPayloadIOS(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 867f9d6c9..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 @@ -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 { @@ -1044,64 +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(), - 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 @@ -1177,6 +1032,7 @@ class HybridRnIap : HybridRnIapSpec() { id = product.id, title = product.title, description = product.description, + debugDescription = product.nitroDebugDescription(), type = product.type.rawValue, displayName = product.displayName.wrapVariant(), displayPrice = product.displayPrice, @@ -1186,6 +1042,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/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..614a4aae0 --- /dev/null +++ b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/ProductSerialization.kt @@ -0,0 +1,98 @@ +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 + +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() } + +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" } + .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..8220c20e5 --- /dev/null +++ b/libraries/react-native-iap/android/src/test/java/com/margelo/nitro/iap/ProductSerializationTest.kt @@ -0,0 +1,123 @@ +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.ProductAndroid +import dev.hyo.openiap.ProductAndroidOneTimePurchaseOfferDetail +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"]) + } + + @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/libraries/react-native-iap/ios/RnIapHelper.swift b/libraries/react-native-iap/ios/RnIapHelper.swift index a635f65af..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) } @@ -145,6 +147,26 @@ enum RnIapHelper { } } + var pricingTermsIOS: Variant_NullType_String? = nil + if let pricingTermsArray = dictionary["pricingTermsIOS"] as? [[String: Any]], !pricingTermsArray.isEmpty { + if let json = serializeToJSON(pricingTermsArray) { + pricingTermsIOS = .second(json) + } else { + NSLog("⚠️ [RnIapHelper] Failed to serialize pricingTermsIOS") + } + } + + var subscriptionInfoIOS: Variant_NullType_String? = nil + 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 { + 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 +191,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 +201,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 +442,7 @@ enum RnIapHelper { id: id, title: id, description: "", + debugDescription: nil, type: "inapp", displayName: nil, displayPrice: nil, @@ -426,6 +452,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..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 @@ -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'); @@ -157,6 +179,40 @@ describe('type-bridge utilities', () => { expect(result.subscriptionOffers[1].type).toBe('promotional'); }); + it('rejects invalid iOS metadata JSON payloads', () => { + 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(); + + 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', () => { const nitroProduct: NitroProduct = { id: 'com.example.android.subs', @@ -178,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, @@ -205,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', () => { @@ -218,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; @@ -245,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 668afe716..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; } @@ -602,6 +603,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 +614,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..127e48a9f 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,31 @@ export function convertNitroProductToProduct( iosProduct.discountsIOS = null; } + if (nitroProduct.pricingTermsIOS) { + try { + const parsed = JSON.parse(nitroProduct.pricingTermsIOS); + iosProduct.pricingTermsIOS = Array.isArray(parsed) ? parsed : null; + } catch { + iosProduct.pricingTermsIOS = null; + } + } else { + iosProduct.pricingTermsIOS = null; + } + + if (nitroProduct.subscriptionInfoIOS) { + try { + const parsed = JSON.parse(nitroProduct.subscriptionInfoIOS); + iosProduct.subscriptionInfoIOS = + typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) + ? parsed + : null; + } catch { + iosProduct.subscriptionInfoIOS = null; + } + } else { + iosProduct.subscriptionInfoIOS = null; + } + // Parse standardized subscriptionOffers (cross-platform, OpenIAP 1.3.10+) if (nitroProduct.subscriptionOffers) { try { 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..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 @@ -149,73 +154,51 @@ 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. + 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] = [] - - for product in fetchedProducts { - productEntries.append(await StoreKitTypesBridge.product(from: product)) - 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) } } 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..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 @@ -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,11 +68,18 @@ 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 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 @@ -79,6 +88,48 @@ 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 +} + +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 * @@ -199,6 +250,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 +325,54 @@ 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) - }.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) - }.onFailure { error -> - firstQueryError = firstQueryError ?: error - }.getOrDefault(emptyList()) - - for (detail in subsDetails) { - if (detail.productId !in processedIds) { - val subProduct = detail.toSubscriptionProduct() - allItems.add(ProductOrSubscription.ProductSubscriptionItem(subProduct)) - } - } - - 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 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() + .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 +413,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 +431,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 +1039,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..6eb8871bb --- /dev/null +++ b/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/ProductStatusAndroidTest.kt @@ -0,0 +1,139 @@ +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 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 +import org.junit.Assert.fail +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()) + } + + @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 `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") + 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 10ad8913c..1f3156a34 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,68 @@ 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); + 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(`${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); + if (fallback) { + this.emit(`${itemIndent}if item is String:`); + this.emit(`${itemIndent}\tarr.append(${enumReverseLookup}.get(item, ${fallback}))`); + } else { + this.emit(`${itemIndent}if item is String and ${enumReverseLookup}.has(item):`); + this.emit(`${itemIndent}\tarr.append(${enumReverseLookup}[item])`); + } + this.emit(`${itemIndent}elif item is int:`); + this.emit(`${itemIndent}\tarr.append(item)`); + } else { + switch (gdElementType) { + 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(`${listIndent}obj.${fieldName} = arr`); + } + private generateToDictField(field: IRField, fieldName: string): void { const graphqlName = field.name; const type = field.type; @@ -549,15 +604,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 +695,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..e22868d5c --- /dev/null +++ b/packages/gql/src/generated-gdscript.test.ts @@ -0,0 +1,114 @@ +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'); + +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('if item is 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('elif item is SubscriptionOffer:'); + 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'); + }); + + 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 c6e7cb9de..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 = [] - 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 = [] - 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,7 +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: - obj.offer_tags_android = data["offerTagsAndroid"] + 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: @@ -1261,7 +1268,12 @@ 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"] + 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 @@ -1333,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 = [] - 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: @@ -1418,37 +1431,41 @@ class ProductAndroid: else: obj.product_status_android = enum_str if data.has("discountOffers") and data["discountOffers"] != null: - var arr = [] - 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 = [] - 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 = [] - 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 = [] - 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: @@ -1552,7 +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: - obj.offer_tags = data["offerTags"] + 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: @@ -1690,21 +1712,23 @@ class ProductIOS: else: obj.type_ios = enum_str if data.has("subscriptionOffers") and data["subscriptionOffers"] != null: - var arr = [] - 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 = [] - 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"]) @@ -1828,37 +1852,41 @@ class ProductSubscriptionAndroid: else: obj.product_status_android = enum_str if data.has("discountOffers") and data["discountOffers"] != null: - var arr = [] - 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 = [] - 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 = [] - 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 = [] - 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: @@ -1948,7 +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: - obj.offer_tags = data["offerTags"] + 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"]) @@ -2054,21 +2087,23 @@ class ProductSubscriptionIOS: else: obj.type_ios = enum_str if data.has("subscriptionOffers") and data["subscriptionOffers"] != null: - var arr = [] - 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 = [] - 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: @@ -2077,13 +2112,14 @@ class ProductSubscriptionIOS: else: obj.subscription_info_ios = data["subscriptionInfoIOS"] if data.has("discountsIOS") and data["discountsIOS"] != null: - var arr = [] - 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: @@ -2232,7 +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: - obj.ids = data["ids"] + 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: @@ -2365,7 +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: - obj.product_ids = data["productIds"] + 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: @@ -2442,7 +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: - obj.ids = data["ids"] + 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: @@ -2905,21 +2956,23 @@ class SubscriptionInfoIOS: else: obj.introductory_offer = data["introductoryOffer"] if data.has("pricingTerms") and data["pricingTerms"] != null: - var arr = [] - 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 = [] - 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: @@ -3049,7 +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: - obj.offer_tags_android = data["offerTagsAndroid"] + 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"]) @@ -3253,13 +3311,14 @@ class SubscriptionPricingTermsIOS: else: obj.commitment_info = data["commitmentInfo"] if data.has("subscriptionOffers") and data["subscriptionOffers"] != null: - var arr = [] - 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: @@ -3352,7 +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: - obj.products = data["products"] + 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: @@ -3560,13 +3624,14 @@ class VerifyPurchaseWithProviderResult: else: obj.iapkit = data["iapkit"] if data.has("errors") and data["errors"] != null: - var arr = [] - 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: @@ -3976,7 +4041,12 @@ 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"] + 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): @@ -4040,7 +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: - obj.ids = data["ids"] + 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: @@ -4163,7 +4238,12 @@ 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"] + 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: @@ -4385,7 +4465,12 @@ 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"] + 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: @@ -4397,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 = [] - 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"]) @@ -5542,7 +5628,12 @@ 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"] + 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: @@ -5562,7 +5653,12 @@ 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"] + 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: