Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Bool, Error>) -> 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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -691,6 +691,8 @@ protocol InAppPurchase2API {
completion: @escaping (Result<SK2ProductPurchaseResultMessage, Error>) -> Void)
func isWinBackOfferEligible(
productId: String, offerId: String, completion: @escaping (Result<Bool, Error>) -> Void)
func isIntroductoryOfferEligible(
productId: String, completion: @escaping (Result<Bool, Error>) -> Void)
func transactions(completion: @escaping (Result<[SK2TransactionMessage], Error>) -> Void)
func finish(id: Int64, completion: @escaping (Result<Void, Error>) -> Void)
func startListeningToTransactions() throws
Expand Down Expand Up @@ -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
Copy link
Contributor

@LongCatIsLooong LongCatIsLooong Jul 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @LouiseHsu it looks like the extension where isIntroductoryOfferEligible is defined has this availability annotation:

Does this mean if the developer's app's target iOS version is < 15.0 they'll get a compile time error from the plugin code (even if they're not using these new APIs at all?)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, availability annotation on extensions will compile fine on lower device version, it just cannot reference any code inside that extension!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah it looks like the InAppPurchase2API calls are all placed inside an available check:

if #available(iOS 15.0, macOS 12.0, *) {
    InAppPurchase2APISetup.setUp(binaryMessenger: messenger, api: instance)
}

So I guess if the application target's min iOS version is lower than 15 the caller would get a runtime error if they try to access methods defined in that extension, since the method channel is not set up?

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)",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool> 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -942,6 +942,37 @@ class InAppPurchase2API {
}
}

Future<bool> isIntroductoryOfferEligible(String productId) async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.isIntroductoryOfferEligible$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel =
BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture =
pigeonVar_channel.send(<Object?>[productId]);
final List<Object?>? pigeonVar_replyList =
await pigeonVar_sendFuture as List<Object?>?;
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<List<SK2TransactionMessage>> transactions() async {
final String pigeonVar_channelName =
'dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.transactions$pigeonVar_messageChannelSuffix';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool> 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<bool> isWinBackOfferEligible(
String productId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,9 @@ abstract class InAppPurchase2API {
@async
bool isWinBackOfferEligible(String productId, String offerId);

@async
bool isIntroductoryOfferEligible(String productId);

@async
List<SK2TransactionMessage> transactions();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,7 @@ class FakeStoreKit2Platform implements TestInAppPurchase2Api {
bool isListenerRegistered = false;
SK2ProductPurchaseOptionsMessage? lastPurchaseOptions;
Map<String, Set<String>> eligibleWinBackOffers = <String, Set<String>>{};
Map<String, bool> eligibleIntroductoryOffers = <String, bool>{};

void reset() {
validProductIDs = <String>{'123', '456'};
Expand All @@ -318,6 +319,7 @@ class FakeStoreKit2Platform implements TestInAppPurchase2Api {
validProducts[validID] = product;
}
eligibleWinBackOffers = <String, Set<String>>{};
eligibleIntroductoryOffers = <String, bool>{};
}

SK2TransactionMessage createRestoredTransaction(
Expand Down Expand Up @@ -434,6 +436,29 @@ class FakeStoreKit2Platform implements TestInAppPurchase2Api {

return eligibleWinBackOffers[productId]?.contains(offerId) ?? false;
}

@override
Future<bool> 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}) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <String>{'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: <SK2SubscriptionOffer>[],
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 = <String>{'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: <SK2SubscriptionOffer>[],
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<PlatformException>().having(
(PlatformException e) => e.code,
'code',
'storekit2_failed_to_fetch_product',
)),
);
});

test('should throw subscription error for non-subscription product',
() async {
fakeStoreKit2Platform.validProductIDs = <String>{'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<PlatformException>().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<PlatformException>().having(
(PlatformException e) => e.code,
'code',
'storekit2_not_enabled',
)),
);
});
});
}
Loading