Skip to content
Open
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,8 @@
## 0.4.12

* Adds `showManageSubscriptions` to `InAppPurchaseStoreKitPlatformAddition`, which presents the
App Store sheet for managing subscriptions. Requires StoreKit 2 and iOS 15+

## 0.4.11+1

* Fixes StoreKit 2 restore transactions not grouping purchases into a single event.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,51 @@ extension InAppPurchasePlugin: InAppPurchase2API {
#endif
}

/// Wrapper method around StoreKit2's showManageSubscriptions(in:) method
/// https://developer.apple.com/documentation/storekit/appstore/showmanagesubscriptions(in:)
/// Presents the App Store sheet that lets the user manage their subscriptions.
func showManageSubscriptions(completion: @escaping (Result<Void, Error>) -> Void) {
#if os(iOS)
if #available(iOS 15.0, *) {
guard let windowScene = self.registrar?.viewController?.view.window?.windowScene else {
let error = PigeonError(
code: "storekit2_missing_key_window_scene",
message: "Failed to fetch key window scene",
details: "registrar.viewController.view.window.windowScene returned nil."
)
completion(.failure(error))
return
}
Task { @MainActor in
do {
try await AppStore.showManageSubscriptions(in: windowScene)
completion(.success(()))
} catch {
completion(.failure(error))
}
}
Comment on lines +410 to +426

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Accessing UIKit properties such as viewController, view, window, and windowScene must be done on the main thread (@MainActor). Currently, the guard statement retrieves the windowScene before entering the @MainActor task block, which can lead to thread-safety issues or crashes if this method is invoked from a background thread. Moving the entire guard statement inside the @MainActor task ensures safe UI-related operations.

Suggested change
guard let windowScene = self.registrar?.viewController?.view.window?.windowScene else {
let error = PigeonError(
code: "storekit2_missing_key_window_scene",
message: "Failed to fetch key window scene",
details: "registrar.viewController.view.window.windowScene returned nil."
)
completion(.failure(error))
return
}
Task { @MainActor in
do {
try await AppStore.showManageSubscriptions(in: windowScene)
completion(.success(()))
} catch {
completion(.failure(error))
}
}
Task { @MainActor in
guard let windowScene = self.registrar?.viewController?.view.window?.windowScene else {
let error = PigeonError(
code: "storekit2_missing_key_window_scene",
message: "Failed to fetch key window scene",
details: "registrar.viewController.view.window.windowScene returned nil."
)
completion(.failure(error))
return
}
do {
try await AppStore.showManageSubscriptions(in: windowScene)
completion(.success(()))
} catch {
completion(.failure(error))
}
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I think this is more correct but I was reflecting the pattern in presentOfferCodeRedeemSheet. Happy to update this one or both

} else {
completion(
.failure(
PigeonError(
code: "storekit2_unsupported_platform_version",
message: "Managing subscriptions requires iOS 15+",
details: nil
)))
}
#elseif os(macOS)
// StoreKit does not provide showManageSubscriptions on macOS. Subscriptions are
// managed through the App Store app instead.
completion(
.failure(
PigeonError(
code: "storekit2_unsupported_platform",
message: "Managing subscriptions is not supported on macOS",
details: nil
)))
#endif
}

/// Wrapper method around StoreKit2's sync() method
/// https://developer.apple.com/documentation/storekit/appstore/sync()
/// When called, a system prompt will ask users to enter their authentication details
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -760,6 +760,7 @@ protocol InAppPurchase2API {
func countryCode(completion: @escaping (Result<String, Error>) -> Void)
func sync(completion: @escaping (Result<Void, Error>) -> Void)
func presentOfferCodeRedeemSheet(completion: @escaping (Result<Void, Error>) -> Void)
func showManageSubscriptions(completion: @escaping (Result<Void, Error>) -> Void)
}

/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
Expand Down Expand Up @@ -1027,6 +1028,24 @@ class InAppPurchase2APISetup {
} else {
presentOfferCodeRedeemSheetChannel.setMessageHandler(nil)
}
let showManageSubscriptionsChannel = FlutterBasicMessageChannel(
name:
"dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.showManageSubscriptions\(channelSuffix)",
binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
showManageSubscriptionsChannel.setMessageHandler { _, reply in
api.showManageSubscriptions { result in
switch result {
case .success:
reply(wrapResult(nil))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
showManageSubscriptionsChannel.setMessageHandler(nil)
}
}
}
/// Generated protocol from Pigeon that represents Flutter messages that can be called from Swift.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ class _MyAppState extends State<_MyApp> {
_buildProductList(),
_buildConsumableBox(),
_buildCodeRedemptionButton(),
_buildShowManageSubscriptionsButton(),
_buildRestoreButton(),
],
),
Expand Down Expand Up @@ -419,6 +420,29 @@ class _MyAppState extends State<_MyApp> {
);
}

Widget _buildShowManageSubscriptionsButton() {
if (_loading) {
return Container();
}

return Padding(
padding: const EdgeInsets.all(4.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
TextButton(
style: TextButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Colors.white,
),
onPressed: () => _iapStoreKitPlatformAddition.showManageSubscriptions(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The showManageSubscriptions method returns a Future<void> and can throw an exception (for example, on unsupported platforms like macOS or iOS versions below 15). Since this future is not awaited or caught in the onPressed callback, any thrown error will result in an unhandled asynchronous exception. It is highly recommended to catch the error and display a user-friendly message (e.g., using a SnackBar) to handle these expected platform limitations gracefully.

            onPressed: () async {
              try {
                await _iapStoreKitPlatformAddition.showManageSubscriptions();
              } catch (e) {
                if (mounted) {
                  ScaffoldMessenger.of(context).showSnackBar(
                    SnackBar(content: Text('Error: $e')),
                  );
                }
              }
            },

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I think this is ok for the internal example

child: const Text('Manage subscriptions'),
),
],
),
);
}

Widget _buildRestoreButton() {
if (_loading) {
return Container();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -636,4 +636,19 @@ final class InAppPurchase2PluginTests: XCTestCase {
waitForExpectations(timeout: 1.0)
}

func testShowManageSubscriptionsFailsGracefullyWhenNoWindow() {
let expectation = self.expectation(
description: "Should fail gracefully when without key window")

plugin.registrar = nil

plugin.showManageSubscriptions { result in
if case .failure = result {
expectation.fulfill()
}
}

waitForExpectations(timeout: 1.0)
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ class InAppPurchaseStoreKitPlatformAddition extends InAppPurchasePlatformAdditio
return SKPaymentQueueWrapper().presentCodeRedemptionSheet();
}

/// Presents the App Store sheet that lets the user manage their
/// subscriptions.
///
/// Available on devices running iOS 15 and iPadOS 15 and later.
/// StoreKit 2 only; StoreKit 1 has no equivalent API. Not supported on macOS,
/// where subscriptions are managed through the App Store app instead.
Future<void> showManageSubscriptions() {
return AppStore().showManageSubscriptions();
}

/// Retry loading purchase data after an initial failure.
///
/// If no results, a `null` value is returned.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1023,6 +1023,20 @@ class InAppPurchase2API {

_extractReplyValueOrThrow(pigeonVar_replyList, pigeonVar_channelName, isNullValid: true);
}

Future<void> showManageSubscriptions() async {
final pigeonVar_channelName =
'dev.flutter.pigeon.in_app_purchase_storekit.InAppPurchase2API.showManageSubscriptions$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;

_extractReplyValueOrThrow(pigeonVar_replyList, pigeonVar_channelName, isNullValid: true);
}
}

abstract class InAppPurchase2CallbackAPI {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,11 @@ final class AppStore {
Future<void> presentOfferCodeRedeemSheet() {
return hostApi2.presentOfferCodeRedeemSheet();
}

/// Dart wrapper for StoreKit2's showManageSubscriptions(in:)
/// Presents the App Store sheet that lets users manage their subscriptions.
/// https://developer.apple.com/documentation/storekit/appstore/showmanagesubscriptions(in:)
Future<void> showManageSubscriptions() {
return hostApi2.showManageSubscriptions();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,9 @@ abstract class InAppPurchase2API {

@async
void presentOfferCodeRedeemSheet();

@async
void showManageSubscriptions();
}

@FlutterApi()
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.11+1
version: 0.4.12

environment:
sdk: ^3.10.0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,7 @@ class FakeStoreKit2Platform implements InAppPurchase2API {
SK2ProductPurchaseOptionsMessage? lastPurchaseOptions;
Map<String, Set<String>> eligibleWinBackOffers = <String, Set<String>>{};
Map<String, bool> eligibleIntroductoryOffers = <String, bool>{};
int showManageSubscriptionsCallCount = 0;

/// Simulates purchase result for testing non-success scenarios.
/// Set to userCancelled, pending, or unverified to test those cases.
Expand All @@ -350,6 +351,7 @@ class FakeStoreKit2Platform implements InAppPurchase2API {
eligibleWinBackOffers = <String, Set<String>>{};
eligibleIntroductoryOffers = <String, bool>{};
simulatedPurchaseResult = SK2ProductPurchaseResultMessage.success;
showManageSubscriptionsCallCount = 0;
transactionsList = <SK2TransactionMessage>[
SK2TransactionMessage(
id: 123,
Expand Down Expand Up @@ -557,6 +559,11 @@ class FakeStoreKit2Platform implements InAppPurchase2API {

@override
Future<void> presentOfferCodeRedeemSheet() async {}

@override
Future<void> showManageSubscriptions() async {
showManageSubscriptionsCallCount++;
}
}

SK2TransactionMessage createPendingTransaction(String id, {int quantity = 1}) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,13 @@ void main() {
});
});

group('manage subscriptions', () {
test('forwards the call to the platform', () async {
await InAppPurchaseStoreKitPlatformAddition().showManageSubscriptions();
expect(fakeStoreKit2Platform.showManageSubscriptionsCallCount, 1);
});
});

group('win back offers eligibility', () {
late FakeStoreKit2Platform fakeStoreKit2Platform;

Expand Down
Loading