From dec92bac8640fca7216040f1dcc41f683c8797e4 Mon Sep 17 00:00:00 2001 From: Pataridze Givi Date: Thu, 26 Jun 2025 07:53:14 +0400 Subject: [PATCH 01/10] isIntroductoryEligible implementation --- .../InAppPurchasePlugin+StoreKit2.swift | 50 +++++++++++++++++++ .../StoreKit2/sk2_pigeon.g.swift | 22 ++++++++ .../in_app_purchase_storekit_platform.dart | 28 +++++++++++ .../lib/src/sk2_pigeon.g.dart | 31 ++++++++++++ .../sk2_product_wrapper.dart | 11 ++++ .../pigeons/sk2_pigeon.dart | 3 ++ 6 files changed, 145 insertions(+) diff --git a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift index bb9bfff7c6e..cf082f9a2f8 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift +++ b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift @@ -182,6 +182,56 @@ extension InAppPurchasePlugin: InAppPurchase2API { } } + func isIntroductoryOfferEligible( + productId: String, + completion: @escaping (Result) -> Void + ) { + if #available(iOS 15.0, macOS 12.0, *) { + Task { + do { + guard let product = try await Product.products(for: [productId]).first else { + completion( + .failure( + PigeonError( + code: "storekit2_failed_to_fetch_product", + message: "Storekit has failed to fetch this product.", + details: "Product ID: \(productId)"))) + return + } + + guard let subscription = product.subscription else { + completion( + .failure( + PigeonError( + code: "storekit2_not_subscription", + message: "Product is not a subscription", + details: "Product ID: \(productId)"))) + return + } + + let isEligible = try await subscription.isEligibleForIntroOffer + + completion(.success(isEligible)) + + } catch { + completion( + .failure( + PigeonError( + code: "storekit2_eligibility_check_failed", + message: "Failed to check offer eligibility: \(error.localizedDescription)", + details: "Product ID: \(productId), Error: \(error)"))) + } + } + } else { + completion( + .failure( + PigeonError( + code: "storekit2_unsupported_platform_version", + message: "Win back offers require iOS 18+ or macOS 15.0+", + details: nil))) + } + } + /// Wrapper method around StoreKit2's transactions() method /// https://developer.apple.com/documentation/storekit/product/3851116-products func transactions( diff --git a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/sk2_pigeon.g.swift b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/sk2_pigeon.g.swift index ce95ec1e29c..8f03a6ce7b6 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/sk2_pigeon.g.swift +++ b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/sk2_pigeon.g.swift @@ -691,6 +691,8 @@ protocol InAppPurchase2API { completion: @escaping (Result) -> Void) func isWinBackOfferEligible( productId: String, offerId: String, completion: @escaping (Result) -> Void) + func isIntroductoryOfferEligible( + productId: String, completion: @escaping (Result) -> Void) func transactions(completion: @escaping (Result<[SK2TransactionMessage], Error>) -> Void) func finish(id: Int64, completion: @escaping (Result) -> Void) func startListeningToTransactions() throws @@ -787,6 +789,26 @@ class InAppPurchase2APISetup { } else { isWinBackOfferEligibleChannel.setMessageHandler(nil) } + let isIntroductoryOfferEligibleChannel = FlutterBasicMessageChannel( + name: + "dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.isIntroductoryOfferEligible\(channelSuffix)", + binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + isIntroductoryOfferEligibleChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let productIdArg = args[0] as! String + api.isIntroductoryOfferEligible(productId: productIdArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + isIntroductoryOfferEligibleChannel.setMessageHandler(nil) + } let transactionsChannel = FlutterBasicMessageChannel( name: "dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.transactions\(channelSuffix)", diff --git a/packages/in_app_purchase/in_app_purchase_storekit/lib/src/in_app_purchase_storekit_platform.dart b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/in_app_purchase_storekit_platform.dart index 94e3104c380..4b039de5665 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/lib/src/in_app_purchase_storekit_platform.dart +++ b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/in_app_purchase_storekit_platform.dart @@ -355,6 +355,34 @@ class InAppPurchaseStoreKitPlatform extends InAppPurchasePlatform { return _useStoreKit2; } + /// Checks if the user is eligible for a introductory offer (StoreKit2 only). + /// + /// Throws [PlatformException] if StoreKit2 is not enabled, if the product is not found, + /// if the product is not a subscription, or if any error occurs during the eligibility check. + /// + /// [PlatformException.code] can be one of: + /// - `storekit2_not_enabled` + /// - `storekit2_unsupported_platform_version` + /// - `storekit2_failed_to_fetch_product` + /// - `storekit2_not_subscription` + /// - `storekit2_eligibility_check_failed` + Future isIntroductoryOfferEligible( + String productId, + ) async { + if (!_useStoreKit2) { + throw PlatformException( + code: 'storekit2_not_enabled', + message: 'Win back offers require StoreKit2 which is not enabled.', + ); + } + + final bool eligibility = await SK2Product.isIntroductoryOfferEligible( + productId, + ); + + return eligibility; + } + /// Checks if the user is eligible for a specific win back offer (StoreKit2 only). /// /// Throws [PlatformException] if StoreKit2 is not enabled, if the product is not found, diff --git a/packages/in_app_purchase/in_app_purchase_storekit/lib/src/sk2_pigeon.g.dart b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/sk2_pigeon.g.dart index 1a095c63fcd..fa19079811f 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/lib/src/sk2_pigeon.g.dart +++ b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/sk2_pigeon.g.dart @@ -942,6 +942,37 @@ class InAppPurchase2API { } } + Future isIntroductoryOfferEligible(String productId) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.isIntroductoryOfferEligible$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = + pigeonVar_channel.send([productId]); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as bool?)!; + } + } + Future> transactions() async { final String pigeonVar_channelName = 'dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.transactions$pigeonVar_messageChannelSuffix'; diff --git a/packages/in_app_purchase/in_app_purchase_storekit/lib/src/store_kit_2_wrappers/sk2_product_wrapper.dart b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/store_kit_2_wrappers/sk2_product_wrapper.dart index 124f2cd6fef..4242b364a21 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/lib/src/store_kit_2_wrappers/sk2_product_wrapper.dart +++ b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/store_kit_2_wrappers/sk2_product_wrapper.dart @@ -400,6 +400,17 @@ class SK2Product { return result.convertFromPigeon(); } + /// Checks if the user is eligible for a introductory offer. + static Future isIntroductoryOfferEligible( + String productId, + ) async { + final bool result = await _hostApi.isIntroductoryOfferEligible( + productId, + ); + + return result; + } + /// Checks if the user is eligible for a specific win back offer. static Future isWinBackOfferEligible( String productId, diff --git a/packages/in_app_purchase/in_app_purchase_storekit/pigeons/sk2_pigeon.dart b/packages/in_app_purchase/in_app_purchase_storekit/pigeons/sk2_pigeon.dart index 7a2a1d9620f..1afb97dbdcf 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/pigeons/sk2_pigeon.dart +++ b/packages/in_app_purchase/in_app_purchase_storekit/pigeons/sk2_pigeon.dart @@ -225,6 +225,9 @@ abstract class InAppPurchase2API { @async bool isWinBackOfferEligible(String productId, String offerId); + @async + bool isIntroductoryOfferEligible(String productId); + @async List transactions(); From 614e68e7faa82f2dbb314ac8f29d1cfa876dfccf Mon Sep 17 00:00:00 2001 From: Pataridze Givi Date: Thu, 26 Jun 2025 08:28:36 +0400 Subject: [PATCH 02/10] Added comments --- .../in_app_purchase/in_app_purchase_storekit/CHANGELOG.md | 4 ++++ .../StoreKit2/InAppPurchasePlugin+StoreKit2.swift | 7 +++++++ .../in_app_purchase/in_app_purchase_storekit/pubspec.yaml | 2 +- 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/in_app_purchase/in_app_purchase_storekit/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_storekit/CHANGELOG.md index f952dbc0617..606fd82a618 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_storekit/CHANGELOG.md @@ -1,3 +1,7 @@ +## 0.4.3 + +* Adds **Introductory Offer Eligibility** support for StoreKit2 + ## 0.4.2 * Add [jwsRepresentation](https://developer.apple.com/documentation/storekit/verificationresult/jwsrepresentation-21vgo) to `SK2PurchaseDetails` as `serverVerificationData` for secure server-side purchase verification. Use this JSON Web Signature (JWS) value to perform your own JWS verification on your server. diff --git a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift index cf082f9a2f8..aa51adb0cfb 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift +++ b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift @@ -182,6 +182,13 @@ extension InAppPurchasePlugin: InAppPurchase2API { } } + /// Checks if the user is eligible for a introductory offer. + /// + /// - Parameters: + /// - productId: The product ID associated with the offer. + /// - completion: Returns `Bool` for eligibility or `Error` on failure. + /// + /// - Availability: iOS 15.0+, macOS 12.0+ func isIntroductoryOfferEligible( productId: String, completion: @escaping (Result) -> Void diff --git a/packages/in_app_purchase/in_app_purchase_storekit/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_storekit/pubspec.yaml index b353b6fe3dc..b04206ec231 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_storekit/pubspec.yaml @@ -2,7 +2,7 @@ name: in_app_purchase_storekit description: An implementation for the iOS and macOS platforms of the Flutter `in_app_purchase` plugin. This uses the StoreKit Framework. repository: https://github.com/flutter/packages/tree/main/packages/in_app_purchase/in_app_purchase_storekit issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 -version: 0.4.2 +version: 0.4.3 environment: sdk: ^3.6.0 From fe4873bf3b3b629006554c15834feebfef55cf5f Mon Sep 17 00:00:00 2001 From: Pataridze Givi Date: Thu, 26 Jun 2025 09:41:57 +0400 Subject: [PATCH 03/10] Added tests --- .../test/fakes/fake_storekit_platform.dart | 25 ++++ ...app_purchase_storekit_2_platform_test.dart | 115 ++++++++++++++++++ .../test/sk2_test_api.g.dart | 37 +++++- 3 files changed, 176 insertions(+), 1 deletion(-) diff --git a/packages/in_app_purchase/in_app_purchase_storekit/test/fakes/fake_storekit_platform.dart b/packages/in_app_purchase/in_app_purchase_storekit/test/fakes/fake_storekit_platform.dart index d82a41c4e91..3e417378fe7 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/test/fakes/fake_storekit_platform.dart +++ b/packages/in_app_purchase/in_app_purchase_storekit/test/fakes/fake_storekit_platform.dart @@ -301,6 +301,7 @@ class FakeStoreKit2Platform implements TestInAppPurchase2Api { bool isListenerRegistered = false; SK2ProductPurchaseOptionsMessage? lastPurchaseOptions; Map> eligibleWinBackOffers = >{}; + Map eligibleIntroductoryOffers = {}; void reset() { validProductIDs = {'123', '456'}; @@ -318,6 +319,7 @@ class FakeStoreKit2Platform implements TestInAppPurchase2Api { validProducts[validID] = product; } eligibleWinBackOffers = >{}; + eligibleIntroductoryOffers = {}; } SK2TransactionMessage createRestoredTransaction( @@ -434,6 +436,29 @@ class FakeStoreKit2Platform implements TestInAppPurchase2Api { return eligibleWinBackOffers[productId]?.contains(offerId) ?? false; } + + @override + Future isIntroductoryOfferEligible( + String productId, + ) async { + if (!validProductIDs.contains(productId)) { + throw PlatformException( + code: 'storekit2_failed_to_fetch_product', + message: 'StoreKit failed to fetch product', + details: 'Product ID: $productId', + ); + } + + if (validProducts[productId]?.type != SK2ProductType.autoRenewable) { + throw PlatformException( + code: 'storekit2_not_subscription', + message: 'Product is not a subscription', + details: 'Product ID: $productId', + ); + } + + return eligibleIntroductoryOffers[productId] ?? false; + } } SK2TransactionMessage createPendingTransaction(String id, {int quantity = 1}) { diff --git a/packages/in_app_purchase/in_app_purchase_storekit/test/in_app_purchase_storekit_2_platform_test.dart b/packages/in_app_purchase/in_app_purchase_storekit/test/in_app_purchase_storekit_2_platform_test.dart index 20da75f241b..b9228ccd6c3 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/test/in_app_purchase_storekit_2_platform_test.dart +++ b/packages/in_app_purchase/in_app_purchase_storekit/test/in_app_purchase_storekit_2_platform_test.dart @@ -434,4 +434,119 @@ void main() { ); }); }); + + group('introductory offers eligibility', () { + late FakeStoreKit2Platform fakeStoreKit2Platform; + + setUp(() async { + fakeStoreKit2Platform = FakeStoreKit2Platform(); + fakeStoreKit2Platform.reset(); + TestInAppPurchase2Api.setUp(fakeStoreKit2Platform); + await InAppPurchaseStoreKitPlatform.enableStoreKit2(); + }); + + test('should return true when introductory offer is eligible', () async { + fakeStoreKit2Platform.validProductIDs = {'sub1'}; + fakeStoreKit2Platform.eligibleIntroductoryOffers['sub1'] = true; + fakeStoreKit2Platform.validProducts['sub1'] = SK2Product( + id: 'sub1', + displayName: 'Subscription', + displayPrice: r'$9.99', + description: 'Monthly subscription', + price: 9.99, + type: SK2ProductType.autoRenewable, + subscription: const SK2SubscriptionInfo( + subscriptionGroupID: 'group1', + promotionalOffers: [], + subscriptionPeriod: SK2SubscriptionPeriod( + value: 1, + unit: SK2SubscriptionPeriodUnit.month, + ), + ), + priceLocale: SK2PriceLocale(currencyCode: 'USD', currencySymbol: r'$'), + ); + + final bool result = + await iapStoreKitPlatform.isIntroductoryOfferEligible('sub1'); + + expect(result, isTrue); + }); + + test('should return false when introductory offer is not eligible', + () async { + fakeStoreKit2Platform.validProductIDs = {'sub1'}; + fakeStoreKit2Platform.eligibleIntroductoryOffers['sub1'] = false; + fakeStoreKit2Platform.validProducts['sub1'] = SK2Product( + id: 'sub1', + displayName: 'Subscription', + displayPrice: r'$9.99', + description: 'Monthly subscription', + price: 9.99, + type: SK2ProductType.autoRenewable, + subscription: const SK2SubscriptionInfo( + subscriptionGroupID: 'group1', + promotionalOffers: [], + subscriptionPeriod: SK2SubscriptionPeriod( + value: 1, + unit: SK2SubscriptionPeriodUnit.month, + ), + ), + priceLocale: SK2PriceLocale(currencyCode: 'USD', currencySymbol: r'$'), + ); + + final bool result = + await iapStoreKitPlatform.isIntroductoryOfferEligible('sub1'); + + expect(result, isFalse); + }); + + test('should throw product not found error for invalid product', () async { + expect( + () => + iapStoreKitPlatform.isIntroductoryOfferEligible('invalid_product'), + throwsA(isA().having( + (PlatformException e) => e.code, + 'code', + 'storekit2_failed_to_fetch_product', + )), + ); + }); + + test('should throw subscription error for non-subscription product', + () async { + fakeStoreKit2Platform.validProductIDs = {'consumable1'}; + fakeStoreKit2Platform.validProducts['consumable1'] = SK2Product( + id: 'consumable1', + displayName: 'Coins', + displayPrice: r'$0.99', + description: 'Game currency', + price: 0.99, + type: SK2ProductType.consumable, + priceLocale: SK2PriceLocale(currencyCode: 'USD', currencySymbol: r'$'), + ); + + expect( + () => iapStoreKitPlatform.isIntroductoryOfferEligible('consumable1'), + throwsA(isA().having( + (PlatformException e) => e.code, + 'code', + 'storekit2_not_subscription', + )), + ); + }); + + test('should throw platform exception when StoreKit2 is not supported', + () async { + await InAppPurchaseStoreKitPlatform.enableStoreKit1(); + + expect( + () => iapStoreKitPlatform.isIntroductoryOfferEligible('sub1'), + throwsA(isA().having( + (PlatformException e) => e.code, + 'code', + 'storekit2_not_enabled', + )), + ); + }); + }); } diff --git a/packages/in_app_purchase/in_app_purchase_storekit/test/sk2_test_api.g.dart b/packages/in_app_purchase/in_app_purchase_storekit/test/sk2_test_api.g.dart index cc6fb75e5fe..8dd95cfdd3e 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/test/sk2_test_api.g.dart +++ b/packages/in_app_purchase/in_app_purchase_storekit/test/sk2_test_api.g.dart @@ -7,10 +7,10 @@ // ignore_for_file: avoid_relative_lib_imports import 'dart:async'; import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List; + import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; - import 'package:in_app_purchase_storekit/src/sk2_pigeon.g.dart'; class _PigeonCodec extends StandardMessageCodec { @@ -136,6 +136,8 @@ abstract class TestInAppPurchase2Api { Future isWinBackOfferEligible(String productId, String offerId); + Future isIntroductoryOfferEligible(String productId); + Future> transactions(); Future finish(int id); @@ -288,6 +290,39 @@ abstract class TestInAppPurchase2Api { }); } } + { + final BasicMessageChannel< + Object?> pigeonVar_channel = BasicMessageChannel< + Object?>( + 'dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.isIntroductoryOfferEligible$messageChannelSuffix', + pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + _testBinaryMessengerBinding!.defaultBinaryMessenger + .setMockDecodedMessageHandler(pigeonVar_channel, null); + } else { + _testBinaryMessengerBinding!.defaultBinaryMessenger + .setMockDecodedMessageHandler(pigeonVar_channel, + (Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.isIntroductoryOfferEligible was null.'); + final List args = (message as List?)!; + final String? arg_productId = (args[0] as String?); + assert(arg_productId != null, + 'Argument for dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.isIntroductoryOfferEligible was null, expected non-null String.'); + try { + final bool output = + await api.isIntroductoryOfferEligible(arg_productId!); + return [output]; + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse( + error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } { final BasicMessageChannel< Object?> pigeonVar_channel = BasicMessageChannel< From 52fa72783edf3d95785b47baba147d6e3174f206 Mon Sep 17 00:00:00 2001 From: Pataridze Givi Date: Thu, 26 Jun 2025 10:30:21 +0400 Subject: [PATCH 04/10] Fix format --- .../InAppPurchasePlugin+StoreKit2.swift | 120 ++++++++---------- .../StoreKit2/sk2_pigeon.g.swift | 34 ++--- 2 files changed, 72 insertions(+), 82 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift index aa51adb0cfb..33e65f5081e 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift +++ b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift @@ -131,7 +131,60 @@ extension InAppPurchasePlugin: InAppPurchase2API { offerId: String, completion: @escaping (Result) -> Void ) { - if #available(iOS 18.0, macOS 15.0, *) { + Task { + do { + guard let product = try await Product.products(for: [productId]).first else { + completion( + .failure( + PigeonError( + code: "storekit2_failed_to_fetch_product", + message: "Storekit has failed to fetch this product.", + details: "Product ID: \(productId)"))) + return + } + + guard let subscription = product.subscription else { + completion( + .failure( + PigeonError( + code: "storekit2_not_subscription", + message: "Product is not a subscription", + details: "Product ID: \(productId)"))) + return + } + + let isEligible = try await subscription.status.contains { status in + if case .verified(let renewalInfo) = status.renewalInfo { + return renewalInfo.eligibleWinBackOfferIDs.contains(offerId) + } + return false + } + + completion(.success(isEligible)) + + } catch { + completion( + .failure( + PigeonError( + code: "storekit2_eligibility_check_failed", + message: "Failed to check offer eligibility: \(error.localizedDescription)", + details: "Product ID: \(productId), Error: \(error)"))) + } + } + } + + /// Checks if the user is eligible for a introductory offer. + /// + /// - Parameters: + /// - productId: The product ID associated with the offer. + /// - completion: Returns `Bool` for eligibility or `Error` on failure. + /// + /// - Availability: iOS 15.0+, macOS 12.0+ + func isIntroductoryOfferEligible( + productId: String, + completion: @escaping (Result) -> Void + ) { + if #available(iOS 15.0, macOS 12.0, *) { Task { do { guard let product = try await Product.products(for: [productId]).first else { @@ -154,15 +207,9 @@ extension InAppPurchasePlugin: InAppPurchase2API { return } - let isEligible = try await subscription.status.contains { status in - if case .verified(let renewalInfo) = status.renewalInfo { - return renewalInfo.eligibleWinBackOfferIDs.contains(offerId) - } - return false - } + let isEligible = await subscription.isEligibleForIntroOffer completion(.success(isEligible)) - } catch { completion( .failure( @@ -182,63 +229,6 @@ extension InAppPurchasePlugin: InAppPurchase2API { } } - /// Checks if the user is eligible for a introductory offer. - /// - /// - Parameters: - /// - productId: The product ID associated with the offer. - /// - completion: Returns `Bool` for eligibility or `Error` on failure. - /// - /// - Availability: iOS 15.0+, macOS 12.0+ - func isIntroductoryOfferEligible( - productId: String, - completion: @escaping (Result) -> Void - ) { - if #available(iOS 15.0, macOS 12.0, *) { - Task { - do { - guard let product = try await Product.products(for: [productId]).first else { - completion( - .failure( - PigeonError( - code: "storekit2_failed_to_fetch_product", - message: "Storekit has failed to fetch this product.", - details: "Product ID: \(productId)"))) - return - } - - guard let subscription = product.subscription else { - completion( - .failure( - PigeonError( - code: "storekit2_not_subscription", - message: "Product is not a subscription", - details: "Product ID: \(productId)"))) - return - } - - let isEligible = try await subscription.isEligibleForIntroOffer - - completion(.success(isEligible)) - - } catch { - completion( - .failure( - PigeonError( - code: "storekit2_eligibility_check_failed", - message: "Failed to check offer eligibility: \(error.localizedDescription)", - details: "Product ID: \(productId), Error: \(error)"))) - } - } - } else { - completion( - .failure( - PigeonError( - code: "storekit2_unsupported_platform_version", - message: "Win back offers require iOS 18+ or macOS 15.0+", - details: nil))) - } - } - /// Wrapper method around StoreKit2's transactions() method /// https://developer.apple.com/documentation/storekit/product/3851116-products func transactions( diff --git a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/sk2_pigeon.g.swift b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/sk2_pigeon.g.swift index 8f03a6ce7b6..bf07f4e9324 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/sk2_pigeon.g.swift +++ b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/sk2_pigeon.g.swift @@ -790,25 +790,25 @@ class InAppPurchase2APISetup { isWinBackOfferEligibleChannel.setMessageHandler(nil) } let isIntroductoryOfferEligibleChannel = FlutterBasicMessageChannel( - name: - "dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.isIntroductoryOfferEligible\(channelSuffix)", - binaryMessenger: binaryMessenger, codec: codec) - if let api = api { - isIntroductoryOfferEligibleChannel.setMessageHandler { message, reply in - let args = message as! [Any?] - let productIdArg = args[0] as! String - api.isIntroductoryOfferEligible(productId: productIdArg) { result in - switch result { - case .success(let res): - reply(wrapResult(res)) - case .failure(let error): - reply(wrapError(error)) - } - } + name: + "dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.isIntroductoryOfferEligible\(channelSuffix)", + binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + isIntroductoryOfferEligibleChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let productIdArg = args[0] as! String + api.isIntroductoryOfferEligible(productId: productIdArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) } - } else { - isIntroductoryOfferEligibleChannel.setMessageHandler(nil) } + } + } else { + isIntroductoryOfferEligibleChannel.setMessageHandler(nil) + } let transactionsChannel = FlutterBasicMessageChannel( name: "dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.transactions\(channelSuffix)", From 0804bb3f2bf17866ed35b3ef16e1e3286c8ffe40 Mon Sep 17 00:00:00 2001 From: Pataridze Givi Date: Thu, 26 Jun 2025 10:40:10 +0400 Subject: [PATCH 05/10] Fix --- .../InAppPurchasePlugin+StoreKit2.swift | 110 +++++++++--------- 1 file changed, 55 insertions(+), 55 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift index 33e65f5081e..167cf20f43d 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift +++ b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift @@ -131,60 +131,7 @@ extension InAppPurchasePlugin: InAppPurchase2API { offerId: String, completion: @escaping (Result) -> Void ) { - Task { - do { - guard let product = try await Product.products(for: [productId]).first else { - completion( - .failure( - PigeonError( - code: "storekit2_failed_to_fetch_product", - message: "Storekit has failed to fetch this product.", - details: "Product ID: \(productId)"))) - return - } - - guard let subscription = product.subscription else { - completion( - .failure( - PigeonError( - code: "storekit2_not_subscription", - message: "Product is not a subscription", - details: "Product ID: \(productId)"))) - return - } - - let isEligible = try await subscription.status.contains { status in - if case .verified(let renewalInfo) = status.renewalInfo { - return renewalInfo.eligibleWinBackOfferIDs.contains(offerId) - } - return false - } - - completion(.success(isEligible)) - - } catch { - completion( - .failure( - PigeonError( - code: "storekit2_eligibility_check_failed", - message: "Failed to check offer eligibility: \(error.localizedDescription)", - details: "Product ID: \(productId), Error: \(error)"))) - } - } - } - - /// Checks if the user is eligible for a introductory offer. - /// - /// - Parameters: - /// - productId: The product ID associated with the offer. - /// - completion: Returns `Bool` for eligibility or `Error` on failure. - /// - /// - Availability: iOS 15.0+, macOS 12.0+ - func isIntroductoryOfferEligible( - productId: String, - completion: @escaping (Result) -> Void - ) { - if #available(iOS 15.0, macOS 12.0, *) { + if #available(iOS 18.0, macOS 15.0, *) { Task { do { guard let product = try await Product.products(for: [productId]).first else { @@ -207,9 +154,15 @@ extension InAppPurchasePlugin: InAppPurchase2API { return } - let isEligible = await subscription.isEligibleForIntroOffer + let isEligible = try await subscription.status.contains { status in + if case .verified(let renewalInfo) = status.renewalInfo { + return renewalInfo.eligibleWinBackOfferIDs.contains(offerId) + } + return false + } completion(.success(isEligible)) + } catch { completion( .failure( @@ -229,6 +182,53 @@ extension InAppPurchasePlugin: InAppPurchase2API { } } + /// Checks if the user is eligible for a introductory offer. + /// + /// - Parameters: + /// - productId: The product ID associated with the offer. + /// - completion: Returns `Bool` for eligibility or `Error` on failure. + /// + /// - Availability: iOS 15.0+, macOS 12.0+ + func isIntroductoryOfferEligible( + productId: String, + completion: @escaping (Result) -> Void + ) { + Task { + do { + guard let product = try await Product.products(for: [productId]).first else { + completion( + .failure( + PigeonError( + code: "storekit2_failed_to_fetch_product", + message: "Storekit has failed to fetch this product.", + details: "Product ID: \(productId)"))) + return + } + + guard let subscription = product.subscription else { + completion( + .failure( + PigeonError( + code: "storekit2_not_subscription", + message: "Product is not a subscription", + details: "Product ID: \(productId)"))) + return + } + + let isEligible = await subscription.isEligibleForIntroOffer + + completion(.success(isEligible)) + } catch { + completion( + .failure( + PigeonError( + code: "storekit2_eligibility_check_failed", + message: "Failed to check offer eligibility: \(error.localizedDescription)", + details: "Product ID: \(productId), Error: \(error)"))) + } + } + } + /// Wrapper method around StoreKit2's transactions() method /// https://developer.apple.com/documentation/storekit/product/3851116-products func transactions( From 8c03e8aee38dc1f2edb2cd94762b499715e03463 Mon Sep 17 00:00:00 2001 From: Pataridze Givi Date: Fri, 27 Jun 2025 14:09:04 +0400 Subject: [PATCH 06/10] Added comment --- .../lib/src/store_kit_2_wrappers/sk2_product_wrapper.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/in_app_purchase/in_app_purchase_storekit/lib/src/store_kit_2_wrappers/sk2_product_wrapper.dart b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/store_kit_2_wrappers/sk2_product_wrapper.dart index 4242b364a21..7170c3e50b6 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/lib/src/store_kit_2_wrappers/sk2_product_wrapper.dart +++ b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/store_kit_2_wrappers/sk2_product_wrapper.dart @@ -400,7 +400,8 @@ class SK2Product { return result.convertFromPigeon(); } - /// Checks if the user is eligible for a introductory offer. + /// Checks if the user is eligible for an introductory offer. + /// The product must be an auto-renewable subscription. static Future isIntroductoryOfferEligible( String productId, ) async { From d2f58365bf253442344ed77d1316eb3ba6e48a8b Mon Sep 17 00:00:00 2001 From: Pataridze Givi Date: Sat, 28 Jun 2025 08:33:47 +0400 Subject: [PATCH 07/10] Added #available for iOS/macOS --- .../InAppPurchasePlugin+StoreKit2.swift | 63 +++++++++++-------- 1 file changed, 36 insertions(+), 27 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift index 167cf20f43d..2d7e6e54842 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift +++ b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift @@ -193,39 +193,48 @@ extension InAppPurchasePlugin: InAppPurchase2API { productId: String, completion: @escaping (Result) -> Void ) { - Task { - do { - guard let product = try await Product.products(for: [productId]).first else { - completion( - .failure( - PigeonError( - code: "storekit2_failed_to_fetch_product", - message: "Storekit has failed to fetch this product.", - details: "Product ID: \(productId)"))) - return - } + if #available(iOS 15.0, macOS 12.0, *) { + Task { + do { + guard let product = try await Product.products(for: [productId]).first else { + completion( + .failure( + PigeonError( + code: "storekit2_failed_to_fetch_product", + message: "Storekit has failed to fetch this product.", + details: "Product ID: \(productId)"))) + return + } + + guard let subscription = product.subscription else { + completion( + .failure( + PigeonError( + code: "storekit2_not_subscription", + message: "Product is not a subscription", + details: "Product ID: \(productId)"))) + return + } + + let isEligible = await subscription.isEligibleForIntroOffer - guard let subscription = product.subscription else { + completion(.success(isEligible)) + } catch { completion( .failure( PigeonError( - code: "storekit2_not_subscription", - message: "Product is not a subscription", - details: "Product ID: \(productId)"))) - return + code: "storekit2_eligibility_check_failed", + message: "Failed to check offer eligibility: \(error.localizedDescription)", + details: "Product ID: \(productId), Error: \(error)"))) } - - let isEligible = await subscription.isEligibleForIntroOffer - - completion(.success(isEligible)) - } catch { - completion( - .failure( - PigeonError( - code: "storekit2_eligibility_check_failed", - message: "Failed to check offer eligibility: \(error.localizedDescription)", - details: "Product ID: \(productId), Error: \(error)"))) } + } else { + completion( + .failure( + PigeonError( + code: "storekit2_unsupported_platform_version", + message: "Win back offers require iOS 18+ or macOS 15.0+", + details: nil))) } } From aba0a610e9e431f96da04d6c010c745f9bba33df Mon Sep 17 00:00:00 2001 From: Pataridze Givi Date: Wed, 2 Jul 2025 08:26:54 +0400 Subject: [PATCH 08/10] Fix according PR comments --- .../InAppPurchasePlugin+StoreKit2.swift | 63 ++++++++----------- 1 file changed, 27 insertions(+), 36 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift index 2d7e6e54842..167cf20f43d 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift +++ b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift @@ -193,48 +193,39 @@ extension InAppPurchasePlugin: InAppPurchase2API { productId: String, completion: @escaping (Result) -> Void ) { - if #available(iOS 15.0, macOS 12.0, *) { - Task { - do { - guard let product = try await Product.products(for: [productId]).first else { - completion( - .failure( - PigeonError( - code: "storekit2_failed_to_fetch_product", - message: "Storekit has failed to fetch this product.", - details: "Product ID: \(productId)"))) - return - } - - guard let subscription = product.subscription else { - completion( - .failure( - PigeonError( - code: "storekit2_not_subscription", - message: "Product is not a subscription", - details: "Product ID: \(productId)"))) - return - } - - let isEligible = await subscription.isEligibleForIntroOffer + Task { + do { + guard let product = try await Product.products(for: [productId]).first else { + completion( + .failure( + PigeonError( + code: "storekit2_failed_to_fetch_product", + message: "Storekit has failed to fetch this product.", + details: "Product ID: \(productId)"))) + return + } - completion(.success(isEligible)) - } catch { + guard let subscription = product.subscription else { completion( .failure( PigeonError( - code: "storekit2_eligibility_check_failed", - message: "Failed to check offer eligibility: \(error.localizedDescription)", - details: "Product ID: \(productId), Error: \(error)"))) + code: "storekit2_not_subscription", + message: "Product is not a subscription", + details: "Product ID: \(productId)"))) + return } + + let isEligible = await subscription.isEligibleForIntroOffer + + completion(.success(isEligible)) + } catch { + completion( + .failure( + PigeonError( + code: "storekit2_eligibility_check_failed", + message: "Failed to check offer eligibility: \(error.localizedDescription)", + details: "Product ID: \(productId), Error: \(error)"))) } - } else { - completion( - .failure( - PigeonError( - code: "storekit2_unsupported_platform_version", - message: "Win back offers require iOS 18+ or macOS 15.0+", - details: nil))) } } From 96fc32656c838182a9a3d6752c6654d3ec507ee3 Mon Sep 17 00:00:00 2001 From: Pataridze Givi Date: Thu, 3 Jul 2025 07:54:57 +0400 Subject: [PATCH 09/10] Fixed typos --- .../StoreKit2/InAppPurchasePlugin+StoreKit2.swift | 2 +- .../lib/src/in_app_purchase_storekit_platform.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift index 167cf20f43d..899f7262bda 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift +++ b/packages/in_app_purchase/in_app_purchase_storekit/darwin/in_app_purchase_storekit/Sources/in_app_purchase_storekit/StoreKit2/InAppPurchasePlugin+StoreKit2.swift @@ -182,7 +182,7 @@ extension InAppPurchasePlugin: InAppPurchase2API { } } - /// Checks if the user is eligible for a introductory offer. + /// Checks if the user is eligible for an introductory offer. /// /// - Parameters: /// - productId: The product ID associated with the offer. diff --git a/packages/in_app_purchase/in_app_purchase_storekit/lib/src/in_app_purchase_storekit_platform.dart b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/in_app_purchase_storekit_platform.dart index 4b039de5665..00401226704 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/lib/src/in_app_purchase_storekit_platform.dart +++ b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/in_app_purchase_storekit_platform.dart @@ -355,7 +355,7 @@ class InAppPurchaseStoreKitPlatform extends InAppPurchasePlatform { return _useStoreKit2; } - /// Checks if the user is eligible for a introductory offer (StoreKit2 only). + /// Checks if the user is eligible for an introductory offer (StoreKit2 only). /// /// Throws [PlatformException] if StoreKit2 is not enabled, if the product is not found, /// if the product is not a subscription, or if any error occurs during the eligibility check. From 272adf474c61ee238b9640ff2aa056c33f4ef054 Mon Sep 17 00:00:00 2001 From: Pataridze Givi Date: Thu, 3 Jul 2025 09:43:36 +0400 Subject: [PATCH 10/10] Updated comment --- .../lib/src/in_app_purchase_storekit_platform.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/in_app_purchase/in_app_purchase_storekit/lib/src/in_app_purchase_storekit_platform.dart b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/in_app_purchase_storekit_platform.dart index 00401226704..9b785b795f9 100644 --- a/packages/in_app_purchase/in_app_purchase_storekit/lib/src/in_app_purchase_storekit_platform.dart +++ b/packages/in_app_purchase/in_app_purchase_storekit/lib/src/in_app_purchase_storekit_platform.dart @@ -362,7 +362,6 @@ class InAppPurchaseStoreKitPlatform extends InAppPurchasePlatform { /// /// [PlatformException.code] can be one of: /// - `storekit2_not_enabled` - /// - `storekit2_unsupported_platform_version` /// - `storekit2_failed_to_fetch_product` /// - `storekit2_not_subscription` /// - `storekit2_eligibility_check_failed`