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 bb9bfff7c6e..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,6 +182,53 @@ extension InAppPurchasePlugin: InAppPurchase2API { } } + /// Checks if the user is eligible for an 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( 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..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 @@ -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..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 @@ -355,6 +355,33 @@ class InAppPurchaseStoreKitPlatform extends InAppPurchasePlatform { return _useStoreKit2; } + /// 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. + /// + /// [PlatformException.code] can be one of: + /// - `storekit2_not_enabled` + /// - `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..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,6 +400,18 @@ class SK2Product { return result.convertFromPigeon(); } + /// Checks if the user is eligible for an introductory offer. + /// The product must be an auto-renewable subscription. + 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(); 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 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<