Skip to content

[in_app_purchase ][iOS] When upgrading in_app_purchase 3.1.13 to 3.2.0, a fatal crash occurs if I manually call SKPaymentQueueWrapper().finishTransaction #154763

Closed
flutter/packages
#8071

Description

@himotou

What package does this bug report belong to?

in_app_purchase

What target platforms are you seeing this bug on?

iOS

Have you already upgraded your packages?

Yes

Dependency versions

pubspec.lock
 in_app_purchase:
    dependency: "direct main"
    description:
      name: in_app_purchase
      sha256: "960f26a08d9351fb8f89f08901f8a829d41b04d45a694b8f776121d9e41dcad6"
      url: "https://pub.flutter-io.cn"
    source: hosted
    version: "3.2.0"
  in_app_purchase_android:
    dependency: transitive
    description:
      name: in_app_purchase_android
      sha256: "4c3f46f736a31902ec5392a75f41ea1ad292f27db00ed51153f3912d076d0efa"
      url: "https://pub.flutter-io.cn"
    source: hosted
    version: "0.3.6+5"
  in_app_purchase_platform_interface:
    dependency: transitive
    description:
      name: in_app_purchase_platform_interface
      sha256: "1d353d38251da5b9fea6635c0ebfc6bb17a2d28d0e86ea5e083bf64244f1fb4c"
      url: "https://pub.flutter-io.cn"
    source: hosted
    version: "1.4.0"
  in_app_purchase_storekit:
    dependency: "direct main"
    description:
      name: in_app_purchase_storekit
      sha256: e9a408da11d055f9af9849859e1251b2b0a2f84f256fa3bf1285c5614a397079
      url: "https://pub.flutter-io.cn"
    source: hosted
    version: "0.3.18+1"

Steps to reproduce

I used "in_app_purchase: ^3.1.13 in_app_purchase_storekit: ^0.3.8+1" before, which has been running stably for more than half a year. Recently, the SDK was upgraded and the plug-in version was updated to "in_app_purchase: ^3.2.0 in_app_purchase_storekit: ^0.3.18+1", and then the app crashed when it was invoked to pay.

Expected results

After upgrading the version, I did not make any code changes and expected the payment to continue to work normally.

Actual results

在唤起支付后,程序崩溃。以下是报错内容:

*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: 'Cannot finish a purchasing transaction'
*** First throw call stack:
(0x190da0f20 0x188c2b2b8 0x190e9f6dc 0x1ba2127bc 0x101a33a2c 0x101a31888 0x101a42890 0x101a42b78 0x101a3d214 0x10599cb9c 0x10541c58c 0x10277cb98 0x10277e7bc 0x10278ed58 0x10278e90c 0x190d73710 0x190d70914 0x190d6fcd8 0x1d57bd1a8 0x1933a9ae8 0x19345dd98 0x1003d8e24 0x1b4547154)
libc++abi: terminating due to uncaught exception of type NSException

Code sample

Code sample
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_evdo_app/entity/my.dart';
import 'package:flutter_evdo_app/generated/locales.g.dart';
import 'package:flutter_evdo_app/http/apis/my.dart';
import 'package:flutter_evdo_app/utils/function.dart';
import 'package:flutter_evdo_app/utils/shared_preferences.dart';
import 'package:flutter_evdo_app/values/defines.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
import 'package:in_app_purchase/in_app_purchase.dart';
import 'package:in_app_purchase_storekit/in_app_purchase_storekit.dart';
import 'package:in_app_purchase_storekit/store_kit_wrappers.dart';

class ApplePurchaseUtils {
  static void pay(
      {required PurchaseParam purchaseParam,
      required InAppPurchase inAppPurchase,
      required String order}) async {
    inAppPurchase.buyNonConsumable(purchaseParam: purchaseParam);
    var transactions = await SKPaymentQueueWrapper().transactions();
    for (var skPaymentTransactionWrapper in transactions) {
      SKPaymentQueueWrapper().finishTransaction(skPaymentTransactionWrapper);
    }
    SharedPreferencesUtils.setString(Defines.appleOrderInfo, order);
  }

  static dispose({required InAppPurchase inAppPurchase}) {
    //苹果取消代理
    final InAppPurchaseStoreKitPlatformAddition iosPlatformAddition =
        inAppPurchase
            .getPlatformAddition<InAppPurchaseStoreKitPlatformAddition>();
    iosPlatformAddition.setDelegate(null);
    ApplePurchaseUtils.subscription?.cancel();
    ApplePurchaseUtils.subscription = null;
  }

  static PurchaseParam? getPurchaseParam(
      {required List<ProductDetails> productDetails,
      required String productId}) {
    if (productDetails.isEmpty) {
      SmartDialog.showToast("${LocaleKeys.member_noAvailableProducts.tr}!");
      return null;
    }

    List<ProductDetails>? payProducts =
        productDetails.where((element) => element.id == productId).toList();
    if (payProducts.isEmpty) {
      SmartDialog.showToast(
          "${LocaleKeys.member_subscriptionPackageException.tr}!");
      return null;
    }
    PurchaseParam purchaseParam = PurchaseParam(
      productDetails: payProducts[0],
    );
    return purchaseParam;
  }

  static void _listenToPurchaseUpdated(
      List<PurchaseDetails> purchaseDetailsList, ParamVoidCallback onSuccess,
      {required InAppPurchase inAppPurchase}) async {
    for (final PurchaseDetails purchaseDetails in purchaseDetailsList) {
      if (purchaseDetails.status == PurchaseStatus.pending) {
        SmartDialog.showLoading();
      } else {
        SmartDialog.dismiss();
        if (purchaseDetails.status == PurchaseStatus.error) {
          SmartDialog.showToast("${LocaleKeys.member_paymentException.tr}!");
        } else if (purchaseDetails.status == PurchaseStatus.purchased ||
            purchaseDetails.status == PurchaseStatus.restored) {
          AppStorePurchaseDetails appstoreDetail =
              purchaseDetails as AppStorePurchaseDetails;
          String appleOrderReceipt =
              appstoreDetail.verificationData.serverVerificationData;
          SharedPreferencesUtils.setString(
              Defines.appleOrderReceipt, appleOrderReceipt);
          String orderSn = await SharedPreferencesUtils.getString(
              Defines.appleOrderInfo, '');
          debugPrint("订单状态:${purchaseDetails.status}");
          if (orderSn.isEmpty) return;
          await MyApi.applePayVerify(
              orderSn, appleOrderReceipt, Defines.isDebug);
          SharedPreferencesUtils.setString(Defines.appleOrderInfo, "");
          SharedPreferencesUtils.setString(Defines.appleOrderReceipt, "");
          SmartDialog.showToast(LocaleKeys.member_paymentSuccessful.tr);

          onSuccess();
        }

        if (purchaseDetails.pendingCompletePurchase) {
          await inAppPurchase.completePurchase(purchaseDetails);
        }
      }
    }
  }

  static StreamSubscription<List<PurchaseDetails>>? subscription;
  static List<String> getProductIds(List<VipGoodListElement>? list) {
    List<String> products = [];
    if (list == null || list.isEmpty) return products;
    for (var i = 0; i < list.length; i++) {
      VipGoodListElement goods = list[i];
      products.add(goods.productId);
    }
    return products;
  }

  ///根据商品id获取苹果商品详情
  static initApplePay(
    List<VipGoodListElement>? goods, {
    required ParamVoidCallback onPaySuccess,
    required ParamSingleCallback<List<ProductDetails>> onInitSuccess,
    required InAppPurchase inAppPurchase,
  }) async {
    if (goods == null || goods.isEmpty) return;
    List<String> productIds = getProductIds(goods);
    final Stream<List<PurchaseDetails>> purchaseUpdated =
        inAppPurchase.purchaseStream;
    subscription =
        purchaseUpdated.listen((List<PurchaseDetails> purchaseDetailsList) {
      _listenToPurchaseUpdated(purchaseDetailsList, onPaySuccess,
          inAppPurchase: inAppPurchase);
    }, onDone: () {
      subscription?.cancel();
      subscription = null;
    }, onError: (Object error) {
      // handle error here.
    });
    final bool isAvailable = await inAppPurchase.isAvailable();
    if (!isAvailable) {
      SmartDialog.showToast(LocaleKeys.member_paymentUnavailable.tr);
    }
    final InAppPurchaseStoreKitPlatformAddition iosPlatformAddition =
        inAppPurchase
            .getPlatformAddition<InAppPurchaseStoreKitPlatformAddition>();
    await iosPlatformAddition.setDelegate(ExamplePaymentQueueDelegate());

    final ProductDetailsResponse productDetailResponse =
        await inAppPurchase.queryProductDetails(productIds.toSet());
    if (productDetailResponse.error != null) {
      SmartDialog.showToast(
          LocaleKeys.member_failedToRetrievePaymentProduct.tr);
      return;
    }

    if (productDetailResponse.productDetails.isEmpty) {
      return;
    }

    onInitSuccess(productDetailResponse.productDetails);
  }
}

class ExamplePaymentQueueDelegate implements SKPaymentQueueDelegateWrapper {
  @override
  bool shouldContinueTransaction(
      SKPaymentTransactionWrapper transaction, SKStorefrontWrapper storefront) {
    return true;
  }

  @override
  bool shouldShowPriceConsent() {
    return false;
  }
}

This is the encapsulated tool class. I commented out the following code and was able to invoke payment normally. But I'm not sure if commenting out this code will have any other impacts. Is there any alternative code for this code? I hope to get help.

    var transactions = await SKPaymentQueueWrapper().transactions();
    for (var skPaymentTransactionWrapper in transactions) {
      SKPaymentQueueWrapper().finishTransaction(skPaymentTransactionWrapper);
    }

Screenshots or Videos

Screenshots / Video demonstration

[Upload media here]

Logs

Logs
[Paste your logs here]

Flutter Doctor output

Doctor output
[✓] Flutter (Channel stable, 3.24.1, on macOS 14.6.1 23G93 darwin-arm64, locale zh-Hans-CN)
    • Flutter version 3.24.1 on channel stable at /Users/evdo/Desktop/development/flutter_sdk
    • Upstream repository https://github.com/flutter/flutter.git
    • Framework revision 5874a72aa4 (2 weeks ago), 2024-08-20 16:46:00 -0500
    • Engine revision c9b9d5780d
    • Dart version 3.5.1
    • DevTools version 2.37.2
    • Pub download mirror https://pub.flutter-io.cn
    • Flutter download mirror https://storage.flutter-io.cn

[✓] Android toolchain - develop for Android devices (Android SDK version 34.0.0)
    • Android SDK at /Users/evdo/Library/Android/sdk
    • Platform android-34, build-tools 34.0.0
    • Java binary at: /Applications/Android Studio.app/Contents/jbr/Contents/Home/bin/java
    • Java version OpenJDK Runtime Environment (build 17.0.6+0-17.0.6b829.9-10027231)
    • All Android licenses accepted.

[✓] Xcode - develop for iOS and macOS (Xcode 15.1)
    • Xcode at /Applications/Xcode.app/Contents/Developer
    • Build 15C65
    • CocoaPods version 1.15.2

[✓] Chrome - develop for the web
    • Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome

[✓] Android Studio (version 2022.3)
    • Android Studio at /Applications/Android Studio.app/Contents
    • Flutter plugin can be installed from:
      🔨 https://plugins.jetbrains.com/plugin/9212-flutter
    • Dart plugin can be installed from:
      🔨 https://plugins.jetbrains.com/plugin/6351-dart
    • Java version OpenJDK Runtime Environment (build 17.0.6+0-17.0.6b829.9-10027231)

[✓] VS Code (version 1.92.2)
    • VS Code at /Users/evdo/Desktop/Visual Studio Code.app/Contents
    • Flutter extension version 3.96.0

[✓] Connected device (4 available)
    • iPhone14 (mobile)               • 00008110-001A64CE2E40A01E • ios            • iOS 17.6.1 21G93
    • macOS (desktop)                 • macos                     • darwin-arm64   • macOS 14.6.1 23G93 darwin-arm64
    • Mac Designed for iPad (desktop) • mac-designed-for-ipad     • darwin         • macOS 14.6.1 23G93 darwin-arm64
    • Chrome (web)                    • chrome                    • web-javascript • Google Chrome 128.0.6613.120

[✓] Network resources
    • All expected network resources are available.

Activity

  1. added
    in triagePresently being triaged by the triage team
    on Sep 9, 2024
  2. huycozy commented on Sep 9, 2024

    @huycozy
    Member

    Hi @davidchzh
    For the issue to be workable, can you provide a completed and minimal reproducible code sample that doesn’t include 3rd party plugins or complex production code? (you may look into plugin sample code and use it to see if the issue happens on your side). And please provide the entire crash stack trace as well. Thank you!

  3. added
    waiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds
    on Sep 9, 2024
  4. removed
    waiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds
    on Sep 9, 2024
  5. himotou commented on Sep 9, 2024

    @himotou
    Author

    Hi @davidchzh For the issue to be workable, can you provide a completed and minimal reproducible code sample that doesn’t include 3rd party plugins or complex production code? (you may look into plugin sample code and use it to see if the issue happens on your side). And please provide the entire crash stack trace as well. Thank you!

    Thank you for your reply!
    For this question, I already know where the problem lies, which is caused by calling the following code.

        var transactions = await SKPaymentQueueWrapper().transactions();
        for (var skPaymentTransactionWrapper in transactions) {
          SKPaymentQueueWrapper().finishTransaction(skPaymentTransactionWrapper);
        }
    

    The embarrassing thing is that I have forgotten why I added this code. I would like to ask if deleting this logic will affect the process, or if there is a new method in the latest version to replace it.

  6. huycozy commented on Sep 10, 2024

    @huycozy
    Member

    The embarrassing thing is that I have forgotten why I added this code. I would like to ask if deleting this logic will affect the process, or if there is a new method in the latest version to replace it.

    From my point of view, since you invoked completePurchase, it will call completePurchase. Perhaps manually calling finishTransaction is being used in case error handling. Please see #91164 if it assembles your case or not.

  7. added
    waiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds
    on Sep 10, 2024
  8. himotou commented on Sep 10, 2024

    @himotou
    Author

    The embarrassing thing is that I have forgotten why I added this code. I would like to ask if deleting this logic will affect the process, or if there is a new method in the latest version to replace it.

    From my point of view, since you invoked completePurchase, it will call completePurchase. Perhaps manually calling finishTransaction is being used in case error handling. Please see #91164 if it assembles your case or not.

    It seems that the two are the same problem. According to my current understanding, the purpose of this code is to "end the last transaction if the last call fails."
    In my code, I will call this logic regardless of whether the last call failed.
    In version 3.1.13, this did not cause an error. It caused an error when I upgraded to version 3.2.

  9. removed
    waiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds
    on Sep 10, 2024
  10. huycozy commented on Sep 11, 2024

    @huycozy
    Member

    #91164 is a documentation issue and the original comment showing it's a type cast error.

    In version 3.1.13, this did not cause an error. It caused an error when I upgraded to version 3.2.

    It could be a regression but need to know the in_app_purchase_storekit version as well. Please also try to narrow the sample code down into a minimal one that can produce the error as reported.

  11. added
    waiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds
    on Sep 11, 2024
  12. himotou commented on Sep 11, 2024

    @himotou
    Author

    #91164 is a documentation issue and the original comment showing it's a type cast error.

    In version 3.1.13, this did not cause an error. It caused an error when I upgraded to version 3.2.

    It could be a regression but need to know the in_app_purchase_storekit version as well. Please also try to narrow the sample code down into a minimal one that can produce the error as reported.

    Sample code
    // Copyright 2013 The Flutter Authors. All rights reserved.
    // Use of this source code is governed by a BSD-style license that can be
    // found in the LICENSE file.
    
    import 'dart:async';
    import 'dart:io';
    
    import 'package:flutter/material.dart';
    import 'package:in_app_purchase/in_app_purchase.dart';
    import 'package:in_app_purchase_android/billing_client_wrappers.dart';
    import 'package:in_app_purchase_android/in_app_purchase_android.dart';
    import 'package:in_app_purchase_storekit/in_app_purchase_storekit.dart';
    import 'package:in_app_purchase_storekit/store_kit_wrappers.dart';
    import 'package:shared_preferences/shared_preferences.dart';
    
    import 'consumable_store.dart';
    
    void main() {
      WidgetsFlutterBinding.ensureInitialized();
    
      runApp(_MyApp());
    }
    
    // Auto-consume must be true on iOS.
    // To try without auto-consume on another platform, change `true` to `false` here.
    final bool _kAutoConsume = Platform.isIOS || true;
    
    const String _kConsumableId = 'com.zzzb.HNZB0001';
    const String _kUpgradeId = 'com.zzzb.HNZB0002';
    
    const List<String> _kProductIds = <String>[
      _kConsumableId,
      _kUpgradeId,
    ];
    
    class _MyApp extends StatefulWidget {
      @override
      State<_MyApp> createState() => _MyAppState();
    }
    
    class _MyAppState extends State<_MyApp> {
      final InAppPurchase _inAppPurchase = InAppPurchase.instance;
      late StreamSubscription<List<PurchaseDetails>> _subscription;
      List<String> _notFoundIds = <String>[];
      List<ProductDetails> _products = <ProductDetails>[];
      List<PurchaseDetails> _purchases = <PurchaseDetails>[];
      List<String> _consumables = <String>[];
      bool _isAvailable = false;
      bool _purchasePending = false;
      bool _loading = true;
      String? _queryProductError;
    
      @override
      void initState() {
        final Stream<List<PurchaseDetails>> purchaseUpdated =
            _inAppPurchase.purchaseStream;
        _subscription =
            purchaseUpdated.listen((List<PurchaseDetails> purchaseDetailsList) {
          _listenToPurchaseUpdated(purchaseDetailsList);
        }, onDone: () {
          _subscription.cancel();
        }, onError: (Object error) {
          // handle error here.
        });
        initStoreInfo();
        super.initState();
      }
    
      Future<void> initStoreInfo() async {
        final bool isAvailable = await _inAppPurchase.isAvailable();
        if (!isAvailable) {
          setState(() {
            _isAvailable = isAvailable;
            _products = <ProductDetails>[];
            _purchases = <PurchaseDetails>[];
            _notFoundIds = <String>[];
            _consumables = <String>[];
            _purchasePending = false;
            _loading = false;
          });
          return;
        }
    
        if (Platform.isIOS) {
          final InAppPurchaseStoreKitPlatformAddition iosPlatformAddition =
              _inAppPurchase
                  .getPlatformAddition<InAppPurchaseStoreKitPlatformAddition>();
          await iosPlatformAddition.setDelegate(ExamplePaymentQueueDelegate());
        }
    
        final ProductDetailsResponse productDetailResponse =
            await _inAppPurchase.queryProductDetails(_kProductIds.toSet());
        if (productDetailResponse.error != null) {
          setState(() {
            _queryProductError = productDetailResponse.error!.message;
            _isAvailable = isAvailable;
            _products = productDetailResponse.productDetails;
            _purchases = <PurchaseDetails>[];
            _notFoundIds = productDetailResponse.notFoundIDs;
            _consumables = <String>[];
            _purchasePending = false;
            _loading = false;
          });
          return;
        }
    
        if (productDetailResponse.productDetails.isEmpty) {
          setState(() {
            _queryProductError = null;
            _isAvailable = isAvailable;
            _products = productDetailResponse.productDetails;
            _purchases = <PurchaseDetails>[];
            _notFoundIds = productDetailResponse.notFoundIDs;
            _consumables = <String>[];
            _purchasePending = false;
            _loading = false;
          });
          return;
        }
    
        final List<String> consumables = await ConsumableStore.load();
        setState(() {
          _isAvailable = isAvailable;
          _products = productDetailResponse.productDetails;
          _notFoundIds = productDetailResponse.notFoundIDs;
          _consumables = consumables;
          _purchasePending = false;
          _loading = false;
        });
      }
    
      @override
      void dispose() {
        if (Platform.isIOS) {
          final InAppPurchaseStoreKitPlatformAddition iosPlatformAddition =
              _inAppPurchase
                  .getPlatformAddition<InAppPurchaseStoreKitPlatformAddition>();
          iosPlatformAddition.setDelegate(null);
        }
        _subscription.cancel();
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        final List<Widget> stack = <Widget>[];
        if (_queryProductError == null) {
          stack.add(
            ListView(
              children: <Widget>[
                _buildConnectionCheckTile(),
                _buildProductList(),
                _buildConsumableBox(),
                _buildRestoreButton(),
              ],
            ),
          );
        } else {
          stack.add(Center(
            child: Text(_queryProductError!),
          ));
        }
        if (_purchasePending) {
          stack.add(
            const Stack(
              children: <Widget>[
                Opacity(
                  opacity: 0.3,
                  child: ModalBarrier(dismissible: false, color: Colors.grey),
                ),
                Center(
                  child: CircularProgressIndicator(),
                ),
              ],
            ),
          );
        }
    
        return MaterialApp(
          home: Scaffold(
            appBar: AppBar(
              title: const Text('IAP Example'),
            ),
            body: Stack(
              children: stack,
            ),
          ),
        );
      }
    
      Card _buildConnectionCheckTile() {
        if (_loading) {
          return const Card(child: ListTile(title: Text('Trying to connect...')));
        }
        final Widget storeHeader = ListTile(
          leading: Icon(_isAvailable ? Icons.check : Icons.block,
              color: _isAvailable
                  ? Colors.green
                  : ThemeData.light().colorScheme.error),
          title:
              Text('The store is ${_isAvailable ? 'available' : 'unavailable'}.'),
        );
        final List<Widget> children = <Widget>[storeHeader];
    
        if (!_isAvailable) {
          children.addAll(<Widget>[
            const Divider(),
            ListTile(
              title: Text('Not connected',
                  style: TextStyle(color: ThemeData.light().colorScheme.error)),
              subtitle: const Text(
                  'Unable to connect to the payments processor. Has this app been configured correctly? See the example README for instructions.'),
            ),
          ]);
        }
        return Card(child: Column(children: children));
      }
    
      Card _buildProductList() {
        if (_loading) {
          return const Card(
              child: ListTile(
                  leading: CircularProgressIndicator(),
                  title: Text('Fetching products...')));
        }
        if (!_isAvailable) {
          return const Card();
        }
        const ListTile productHeader = ListTile(title: Text('Products for Sale'));
        final List<ListTile> productList = <ListTile>[];
        if (_notFoundIds.isNotEmpty) {
          productList.add(ListTile(
              title: Text('[${_notFoundIds.join(", ")}] not found',
                  style: TextStyle(color: ThemeData.light().colorScheme.error)),
              subtitle: const Text(
                  'This app needs special configuration to run. Please see example/README.md for instructions.')));
        }
    
        // This loading previous purchases code is just a demo. Please do not use this as it is.
        // In your app you should always verify the purchase data using the `verificationData` inside the [PurchaseDetails] object before trusting it.
        // We recommend that you use your own server to verify the purchase data.
        final Map<String, PurchaseDetails> purchases =
            Map<String, PurchaseDetails>.fromEntries(
                _purchases.map((PurchaseDetails purchase) {
          if (purchase.pendingCompletePurchase) {
            _inAppPurchase.completePurchase(purchase);
          }
          return MapEntry<String, PurchaseDetails>(purchase.productID, purchase);
        }));
        productList.addAll(_products.map(
          (ProductDetails productDetails) {
            final PurchaseDetails? previousPurchase = purchases[productDetails.id];
            return ListTile(
              title: Text(
                productDetails.title,
              ),
              subtitle: Text(
                productDetails.description,
              ),
              trailing: previousPurchase != null && Platform.isIOS
                  ? IconButton(
                      onPressed: () => confirmPriceChange(context),
                      icon: const Icon(Icons.upgrade))
                  : TextButton(
                      style: TextButton.styleFrom(
                        backgroundColor: Colors.green[800],
                        foregroundColor: Colors.white,
                      ),
                      onPressed: () async {
                        late PurchaseParam purchaseParam;
    
                        if (Platform.isAndroid) {
                          // NOTE: If you are making a subscription purchase/upgrade/downgrade, we recommend you to
                          // verify the latest status of you your subscription by using server side receipt validation
                          // and update the UI accordingly. The subscription purchase status shown
                          // inside the app may not be accurate.
                          // final GooglePlayPurchaseDetails? oldSubscription =
                          //     _getOldSubscription(productDetails, purchases);
    
                          // purchaseParam = GooglePlayPurchaseParam(
                          //     productDetails: productDetails,
                          //     changeSubscriptionParam: (oldSubscription != null)
                          //         ? ChangeSubscriptionParam(
                          //             oldPurchaseDetails: oldSubscription,
                          //             prorationMode:
                          //                 ProrationMode.immediateWithTimeProration,
                          //           )
                          //         : null);
                        } else {
                          purchaseParam = PurchaseParam(
                            productDetails: productDetails,
                          );
                        }
    
                        if (productDetails.id == _kConsumableId) {
                          _inAppPurchase.buyConsumable(
                              purchaseParam: purchaseParam,
                              autoConsume: _kAutoConsume);
                        } else {
                          _inAppPurchase.buyNonConsumable(
                              purchaseParam: purchaseParam);
                        }
                        var transactions =
                            await SKPaymentQueueWrapper().transactions();
                        for (var skPaymentTransactionWrapper in transactions) {
                          SKPaymentQueueWrapper()
                              .finishTransaction(skPaymentTransactionWrapper);
                        }
                      },
                      child: Text(productDetails.price),
                    ),
            );
          },
        ));
    
        return Card(
            child: Column(
                children: <Widget>[productHeader, const Divider()] + productList));
      }
    
      Card _buildConsumableBox() {
        if (_loading) {
          return const Card(
              child: ListTile(
                  leading: CircularProgressIndicator(),
                  title: Text('Fetching consumables...')));
        }
        if (!_isAvailable || _notFoundIds.contains(_kConsumableId)) {
          return const Card();
        }
        const ListTile consumableHeader =
            ListTile(title: Text('Purchased consumables'));
        final List<Widget> tokens = _consumables.map((String id) {
          return GridTile(
            child: IconButton(
              icon: const Icon(
                Icons.stars,
                size: 42.0,
                color: Colors.orange,
              ),
              splashColor: Colors.yellowAccent,
              onPressed: () => consume(id),
            ),
          );
        }).toList();
        return Card(
            child: Column(children: <Widget>[
          consumableHeader,
          const Divider(),
          GridView.count(
            crossAxisCount: 5,
            shrinkWrap: true,
            padding: const EdgeInsets.all(16.0),
            children: tokens,
          )
        ]));
      }
    
      Widget _buildRestoreButton() {
        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).primaryColor,
                  foregroundColor: Colors.white,
                ),
                onPressed: () => _inAppPurchase.restorePurchases(),
                child: const Text('Restore purchases'),
              ),
            ],
          ),
        );
      }
    
      Future<void> consume(String id) async {
        await ConsumableStore.consume(id);
        final List<String> consumables = await ConsumableStore.load();
        setState(() {
          _consumables = consumables;
        });
      }
    
      void showPendingUI() {
        setState(() {
          _purchasePending = true;
        });
      }
    
      Future<void> deliverProduct(PurchaseDetails purchaseDetails) async {
        // IMPORTANT!! Always verify purchase details before delivering the product.
        if (purchaseDetails.productID == _kConsumableId) {
          await ConsumableStore.save(purchaseDetails.purchaseID!);
          final List<String> consumables = await ConsumableStore.load();
          setState(() {
            _purchasePending = false;
            _consumables = consumables;
          });
        } else {
          setState(() {
            _purchases.add(purchaseDetails);
            _purchasePending = false;
          });
        }
      }
    
      void handleError(IAPError error) {
        setState(() {
          _purchasePending = false;
        });
      }
    
      Future<bool> _verifyPurchase(PurchaseDetails purchaseDetails) {
        // IMPORTANT!! Always verify a purchase before delivering the product.
        // For the purpose of an example, we directly return true.
        return Future<bool>.value(true);
      }
    
      void _handleInvalidPurchase(PurchaseDetails purchaseDetails) {
        // handle invalid purchase here if  _verifyPurchase` failed.
      }
    
      Future<void> _listenToPurchaseUpdated(
          List<PurchaseDetails> purchaseDetailsList) async {
        for (final PurchaseDetails purchaseDetails in purchaseDetailsList) {
          if (purchaseDetails.status == PurchaseStatus.pending) {
            showPendingUI();
          } else {
            if (purchaseDetails.status == PurchaseStatus.error) {
              handleError(purchaseDetails.error!);
            } else if (purchaseDetails.status == PurchaseStatus.purchased ||
                purchaseDetails.status == PurchaseStatus.restored) {
              final bool valid = await _verifyPurchase(purchaseDetails);
              if (valid) {
                unawaited(deliverProduct(purchaseDetails));
              } else {
                _handleInvalidPurchase(purchaseDetails);
                return;
              }
            }
            if (Platform.isAndroid) {
              if (!_kAutoConsume && purchaseDetails.productID == _kConsumableId) {
                final InAppPurchaseAndroidPlatformAddition androidAddition =
                    _inAppPurchase.getPlatformAddition<
                        InAppPurchaseAndroidPlatformAddition>();
                await androidAddition.consumePurchase(purchaseDetails);
              }
            }
            if (purchaseDetails.pendingCompletePurchase) {
              await _inAppPurchase.completePurchase(purchaseDetails);
            }
          }
        }
      }
    
      Future<void> confirmPriceChange(BuildContext context) async {
        // Price changes for Android are not handled by the application, but are
        // instead handled by the Play Store. See
        // https://developer.android.com/google/play/billing/price-changes for more
        // information on price changes on Android.
        if (Platform.isIOS) {
          final InAppPurchaseStoreKitPlatformAddition iapStoreKitPlatformAddition =
              _inAppPurchase
                  .getPlatformAddition<InAppPurchaseStoreKitPlatformAddition>();
          await iapStoreKitPlatformAddition.showPriceConsentIfNeeded();
        }
      }
    
      // GooglePlayPurchaseDetails? _getOldSubscription(
      //     ProductDetails productDetails, Map<String, PurchaseDetails> purchases) {
      //   // This is just to demonstrate a subscription upgrade or downgrade.
      //   // This method assumes that you have only 2 subscriptions under a group, 'subscription_silver' & 'subscription_gold'.
      //   // The 'subscription_silver' subscription can be upgraded to 'subscription_gold' and
      //   // the 'subscription_gold' subscription can be downgraded to 'subscription_silver'.
      //   // Please remember to replace the logic of finding the old subscription Id as per your app.
      //   // The old subscription is only required on Android since Apple handles this internally
      //   // by using the subscription group feature in iTunesConnect.
      //   GooglePlayPurchaseDetails? oldSubscription;
      //   if (productDetails.id == _kSilverSubscriptionId &&
      //       purchases[_kGoldSubscriptionId] != null) {
      //     oldSubscription =
      //         purchases[_kGoldSubscriptionId]! as GooglePlayPurchaseDetails;
      //   } else if (productDetails.id == _kGoldSubscriptionId &&
      //       purchases[_kSilverSubscriptionId] != null) {
      //     oldSubscription =
      //         purchases[_kSilverSubscriptionId]! as GooglePlayPurchaseDetails;
      //   }
      //   return oldSubscription;
      // }
    }
    
    /// Example implementation of the
    /// [`SKPaymentQueueDelegate`](https://developer.apple.com/documentation/storekit/skpaymentqueuedelegate?language=objc).
    ///
    /// The payment queue delegate can be implementated to provide information
    /// needed to complete transactions.
    class ExamplePaymentQueueDelegate implements SKPaymentQueueDelegateWrapper {
      @override
      bool shouldContinueTransaction(
          SKPaymentTransactionWrapper transaction, SKStorefrontWrapper storefront) {
        return true;
      }
    
      @override
      bool shouldShowPriceConsent() {
        return false;
      }
    }
    // Copyright 2013 The Flutter Authors. All rights reserved.
    // Use of this source code is governed by a BSD-style license that can be
    // found in the LICENSE file.
    
    // ignore: avoid_classes_with_only_static_members
    /// A store of consumable items.
    ///
    /// This is a development prototype tha stores consumables in the shared
    /// preferences. Do not use this in real world apps.
    class ConsumableStore {
      static const String _kPrefKey = 'consumables';
      static Future<void> _writes = Future<void>.value();
    
      /// Adds a consumable with ID `id` to the store.
      ///
      /// The consumable is only added after the returned Future is complete.
      static Future<void> save(String id) {
        _writes = _writes.then((void _) => _doSave(id));
        return _writes;
      }
    
      /// Consumes a consumable with ID `id` from the store.
      ///
      /// The consumable was only consumed after the returned Future is complete.
      static Future<void> consume(String id) {
        _writes = _writes.then((void _) => _doConsume(id));
        return _writes;
      }
    
      /// Returns the list of consumables from the store.
      static Future<List<String>> load() async {
        return (await SharedPreferences.getInstance()).getStringList(_kPrefKey) ??
            <String>[];
      }
    
      static Future<void> _doSave(String id) async {
        final List<String> cached = await load();
        final SharedPreferences prefs = await SharedPreferences.getInstance();
        cached.add(id);
        await prefs.setStringList(_kPrefKey, cached);
      }
    
      static Future<void> _doConsume(String id) async {
        final List<String> cached = await load();
        final SharedPreferences prefs = await SharedPreferences.getInstance();
        cached.remove(id);
        await prefs.setStringList(_kPrefKey, cached);
      }
    }
    in_app_purchase:3.2.0
    in_app_purchase_storekit:0.3.18+1
    
  13. removed
    waiting for responseThe Flutter team cannot make further progress on this issue until the original reporter responds
    on Sep 11, 2024
  14. himotou commented on Sep 11, 2024

    @himotou
    Author

    When running the demo, I found another problem.
    After selecting the product to call up the payment page, click the x in the upper right corner to cancel the payment, and the page will remain in the loading state.
    The code does not make any changes to the demo logic. It just changes the product id.

    Sample code
    // Copyright 2013 The Flutter Authors. All rights reserved.
    // Use of this source code is governed by a BSD-style license that can be
    // found in the LICENSE file.
    
    import 'dart:async';
    import 'dart:io';
    
    import 'package:flutter/material.dart';
    import 'package:in_app_purchase/in_app_purchase.dart';
    import 'package:in_app_purchase_android/billing_client_wrappers.dart';
    import 'package:in_app_purchase_android/in_app_purchase_android.dart';
    import 'package:in_app_purchase_storekit/in_app_purchase_storekit.dart';
    import 'package:in_app_purchase_storekit/store_kit_wrappers.dart';
    import 'package:shared_preferences/shared_preferences.dart';
    
    import 'consumable_store.dart';
    
    void main() {
      WidgetsFlutterBinding.ensureInitialized();
    
      runApp(_MyApp());
    }
    
    // Auto-consume must be true on iOS.
    // To try without auto-consume on another platform, change `true` to `false` here.
    final bool _kAutoConsume = Platform.isIOS || true;
    
    const String _kConsumableId = 'com.zzzb.HNZB0001';
    const String _kUpgradeId = 'com.zzzb.HNZB0002';
    
    const List<String> _kProductIds = <String>[
      _kConsumableId,
      _kUpgradeId,
    ];
    
    class _MyApp extends StatefulWidget {
      @override
      State<_MyApp> createState() => _MyAppState();
    }
    
    class _MyAppState extends State<_MyApp> {
      final InAppPurchase _inAppPurchase = InAppPurchase.instance;
      late StreamSubscription<List<PurchaseDetails>> _subscription;
      List<String> _notFoundIds = <String>[];
      List<ProductDetails> _products = <ProductDetails>[];
      List<PurchaseDetails> _purchases = <PurchaseDetails>[];
      List<String> _consumables = <String>[];
      bool _isAvailable = false;
      bool _purchasePending = false;
      bool _loading = true;
      String? _queryProductError;
    
      @override
      void initState() {
        final Stream<List<PurchaseDetails>> purchaseUpdated =
            _inAppPurchase.purchaseStream;
        _subscription =
            purchaseUpdated.listen((List<PurchaseDetails> purchaseDetailsList) {
          _listenToPurchaseUpdated(purchaseDetailsList);
        }, onDone: () {
          _subscription.cancel();
        }, onError: (Object error) {
          // handle error here.
        });
        initStoreInfo();
        super.initState();
      }
    
      Future<void> initStoreInfo() async {
        final bool isAvailable = await _inAppPurchase.isAvailable();
        if (!isAvailable) {
          setState(() {
            _isAvailable = isAvailable;
            _products = <ProductDetails>[];
            _purchases = <PurchaseDetails>[];
            _notFoundIds = <String>[];
            _consumables = <String>[];
            _purchasePending = false;
            _loading = false;
          });
          return;
        }
    
        if (Platform.isIOS) {
          final InAppPurchaseStoreKitPlatformAddition iosPlatformAddition =
              _inAppPurchase
                  .getPlatformAddition<InAppPurchaseStoreKitPlatformAddition>();
          await iosPlatformAddition.setDelegate(ExamplePaymentQueueDelegate());
        }
    
        final ProductDetailsResponse productDetailResponse =
            await _inAppPurchase.queryProductDetails(_kProductIds.toSet());
        if (productDetailResponse.error != null) {
          setState(() {
            _queryProductError = productDetailResponse.error!.message;
            _isAvailable = isAvailable;
            _products = productDetailResponse.productDetails;
            _purchases = <PurchaseDetails>[];
            _notFoundIds = productDetailResponse.notFoundIDs;
            _consumables = <String>[];
            _purchasePending = false;
            _loading = false;
          });
          return;
        }
    
        if (productDetailResponse.productDetails.isEmpty) {
          setState(() {
            _queryProductError = null;
            _isAvailable = isAvailable;
            _products = productDetailResponse.productDetails;
            _purchases = <PurchaseDetails>[];
            _notFoundIds = productDetailResponse.notFoundIDs;
            _consumables = <String>[];
            _purchasePending = false;
            _loading = false;
          });
          return;
        }
    
        final List<String> consumables = await ConsumableStore.load();
        setState(() {
          _isAvailable = isAvailable;
          _products = productDetailResponse.productDetails;
          _notFoundIds = productDetailResponse.notFoundIDs;
          _consumables = consumables;
          _purchasePending = false;
          _loading = false;
        });
      }
    
      @override
      void dispose() {
        if (Platform.isIOS) {
          final InAppPurchaseStoreKitPlatformAddition iosPlatformAddition =
              _inAppPurchase
                  .getPlatformAddition<InAppPurchaseStoreKitPlatformAddition>();
          iosPlatformAddition.setDelegate(null);
        }
        _subscription.cancel();
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        final List<Widget> stack = <Widget>[];
        if (_queryProductError == null) {
          stack.add(
            ListView(
              children: <Widget>[
                _buildConnectionCheckTile(),
                _buildProductList(),
                _buildConsumableBox(),
                _buildRestoreButton(),
              ],
            ),
          );
        } else {
          stack.add(Center(
            child: Text(_queryProductError!),
          ));
        }
        if (_purchasePending) {
          stack.add(
            const Stack(
              children: <Widget>[
                Opacity(
                  opacity: 0.3,
                  child: ModalBarrier(dismissible: false, color: Colors.grey),
                ),
                Center(
                  child: CircularProgressIndicator(),
                ),
              ],
            ),
          );
        }
    
        return MaterialApp(
          home: Scaffold(
            appBar: AppBar(
              title: const Text('IAP Example'),
            ),
            body: Stack(
              children: stack,
            ),
          ),
        );
      }
    
      Card _buildConnectionCheckTile() {
        if (_loading) {
          return const Card(child: ListTile(title: Text('Trying to connect...')));
        }
        final Widget storeHeader = ListTile(
          leading: Icon(_isAvailable ? Icons.check : Icons.block,
              color: _isAvailable
                  ? Colors.green
                  : ThemeData.light().colorScheme.error),
          title:
              Text('The store is ${_isAvailable ? 'available' : 'unavailable'}.'),
        );
        final List<Widget> children = <Widget>[storeHeader];
    
        if (!_isAvailable) {
          children.addAll(<Widget>[
            const Divider(),
            ListTile(
              title: Text('Not connected',
                  style: TextStyle(color: ThemeData.light().colorScheme.error)),
              subtitle: const Text(
                  'Unable to connect to the payments processor. Has this app been configured correctly? See the example README for instructions.'),
            ),
          ]);
        }
        return Card(child: Column(children: children));
      }
    
      Card _buildProductList() {
        if (_loading) {
          return const Card(
              child: ListTile(
                  leading: CircularProgressIndicator(),
                  title: Text('Fetching products...')));
        }
        if (!_isAvailable) {
          return const Card();
        }
        const ListTile productHeader = ListTile(title: Text('Products for Sale'));
        final List<ListTile> productList = <ListTile>[];
        if (_notFoundIds.isNotEmpty) {
          productList.add(ListTile(
              title: Text('[${_notFoundIds.join(", ")}] not found',
                  style: TextStyle(color: ThemeData.light().colorScheme.error)),
              subtitle: const Text(
                  'This app needs special configuration to run. Please see example/README.md for instructions.')));
        }
    
        // This loading previous purchases code is just a demo. Please do not use this as it is.
        // In your app you should always verify the purchase data using the `verificationData` inside the [PurchaseDetails] object before trusting it.
        // We recommend that you use your own server to verify the purchase data.
        final Map<String, PurchaseDetails> purchases =
            Map<String, PurchaseDetails>.fromEntries(
                _purchases.map((PurchaseDetails purchase) {
          if (purchase.pendingCompletePurchase) {
            _inAppPurchase.completePurchase(purchase);
          }
          return MapEntry<String, PurchaseDetails>(purchase.productID, purchase);
        }));
        productList.addAll(_products.map(
          (ProductDetails productDetails) {
            final PurchaseDetails? previousPurchase = purchases[productDetails.id];
            return ListTile(
              title: Text(
                productDetails.title,
              ),
              subtitle: Text(
                productDetails.description,
              ),
              trailing: previousPurchase != null && Platform.isIOS
                  ? IconButton(
                      onPressed: () => confirmPriceChange(context),
                      icon: const Icon(Icons.upgrade))
                  : TextButton(
                      style: TextButton.styleFrom(
                        backgroundColor: Colors.green[800],
                        foregroundColor: Colors.white,
                      ),
                      onPressed: () async {
                        late PurchaseParam purchaseParam;
    
                        if (Platform.isAndroid) {
                          // NOTE: If you are making a subscription purchase/upgrade/downgrade, we recommend you to
                          // verify the latest status of you your subscription by using server side receipt validation
                          // and update the UI accordingly. The subscription purchase status shown
                          // inside the app may not be accurate.
                          // final GooglePlayPurchaseDetails? oldSubscription =
                          //     _getOldSubscription(productDetails, purchases);
    
                          // purchaseParam = GooglePlayPurchaseParam(
                          //     productDetails: productDetails,
                          //     changeSubscriptionParam: (oldSubscription != null)
                          //         ? ChangeSubscriptionParam(
                          //             oldPurchaseDetails: oldSubscription,
                          //             prorationMode:
                          //                 ProrationMode.immediateWithTimeProration,
                          //           )
                          //         : null);
                        } else {
                          purchaseParam = PurchaseParam(
                            productDetails: productDetails,
                          );
                        }
    
                        if (productDetails.id == _kConsumableId) {
                          _inAppPurchase.buyConsumable(
                              purchaseParam: purchaseParam,
                              autoConsume: _kAutoConsume);
                        } else {
                          _inAppPurchase.buyNonConsumable(
                              purchaseParam: purchaseParam);
                        }
                      },
                      child: Text(productDetails.price),
                    ),
            );
          },
        ));
    
        return Card(
            child: Column(
                children: <Widget>[productHeader, const Divider()] + productList));
      }
    
      Card _buildConsumableBox() {
        if (_loading) {
          return const Card(
              child: ListTile(
                  leading: CircularProgressIndicator(),
                  title: Text('Fetching consumables...')));
        }
        if (!_isAvailable || _notFoundIds.contains(_kConsumableId)) {
          return const Card();
        }
        const ListTile consumableHeader =
            ListTile(title: Text('Purchased consumables'));
        final List<Widget> tokens = _consumables.map((String id) {
          return GridTile(
            child: IconButton(
              icon: const Icon(
                Icons.stars,
                size: 42.0,
                color: Colors.orange,
              ),
              splashColor: Colors.yellowAccent,
              onPressed: () => consume(id),
            ),
          );
        }).toList();
        return Card(
            child: Column(children: <Widget>[
          consumableHeader,
          const Divider(),
          GridView.count(
            crossAxisCount: 5,
            shrinkWrap: true,
            padding: const EdgeInsets.all(16.0),
            children: tokens,
          )
        ]));
      }
    
      Widget _buildRestoreButton() {
        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).primaryColor,
                  foregroundColor: Colors.white,
                ),
                onPressed: () => _inAppPurchase.restorePurchases(),
                child: const Text('Restore purchases'),
              ),
            ],
          ),
        );
      }
    
      Future<void> consume(String id) async {
        await ConsumableStore.consume(id);
        final List<String> consumables = await ConsumableStore.load();
        setState(() {
          _consumables = consumables;
        });
      }
    
      void showPendingUI() {
        setState(() {
          _purchasePending = true;
        });
      }
    
      Future<void> deliverProduct(PurchaseDetails purchaseDetails) async {
        // IMPORTANT!! Always verify purchase details before delivering the product.
        if (purchaseDetails.productID == _kConsumableId) {
          await ConsumableStore.save(purchaseDetails.purchaseID!);
          final List<String> consumables = await ConsumableStore.load();
          setState(() {
            _purchasePending = false;
            _consumables = consumables;
          });
        } else {
          setState(() {
            _purchases.add(purchaseDetails);
            _purchasePending = false;
          });
        }
      }
    
      void handleError(IAPError error) {
        setState(() {
          _purchasePending = false;
        });
      }
    
      Future<bool> _verifyPurchase(PurchaseDetails purchaseDetails) {
        // IMPORTANT!! Always verify a purchase before delivering the product.
        // For the purpose of an example, we directly return true.
        return Future<bool>.value(true);
      }
    
      void _handleInvalidPurchase(PurchaseDetails purchaseDetails) {
        // handle invalid purchase here if  _verifyPurchase` failed.
      }
    
      Future<void> _listenToPurchaseUpdated(
          List<PurchaseDetails> purchaseDetailsList) async {
        for (final PurchaseDetails purchaseDetails in purchaseDetailsList) {
          if (purchaseDetails.status == PurchaseStatus.pending) {
            showPendingUI();
          } else {
            if (purchaseDetails.status == PurchaseStatus.error) {
              handleError(purchaseDetails.error!);
            } else if (purchaseDetails.status == PurchaseStatus.purchased ||
                purchaseDetails.status == PurchaseStatus.restored) {
              final bool valid = await _verifyPurchase(purchaseDetails);
              if (valid) {
                unawaited(deliverProduct(purchaseDetails));
              } else {
                _handleInvalidPurchase(purchaseDetails);
                return;
              }
            }
            if (Platform.isAndroid) {
              if (!_kAutoConsume && purchaseDetails.productID == _kConsumableId) {
                final InAppPurchaseAndroidPlatformAddition androidAddition =
                    _inAppPurchase.getPlatformAddition<
                        InAppPurchaseAndroidPlatformAddition>();
                await androidAddition.consumePurchase(purchaseDetails);
              }
            }
            if (purchaseDetails.pendingCompletePurchase) {
              await _inAppPurchase.completePurchase(purchaseDetails);
            }
          }
        }
      }
    
      Future<void> confirmPriceChange(BuildContext context) async {
        // Price changes for Android are not handled by the application, but are
        // instead handled by the Play Store. See
        // https://developer.android.com/google/play/billing/price-changes for more
        // information on price changes on Android.
        if (Platform.isIOS) {
          final InAppPurchaseStoreKitPlatformAddition iapStoreKitPlatformAddition =
              _inAppPurchase
                  .getPlatformAddition<InAppPurchaseStoreKitPlatformAddition>();
          await iapStoreKitPlatformAddition.showPriceConsentIfNeeded();
        }
      }
    
      // GooglePlayPurchaseDetails? _getOldSubscription(
      //     ProductDetails productDetails, Map<String, PurchaseDetails> purchases) {
      //   // This is just to demonstrate a subscription upgrade or downgrade.
      //   // This method assumes that you have only 2 subscriptions under a group, 'subscription_silver' & 'subscription_gold'.
      //   // The 'subscription_silver' subscription can be upgraded to 'subscription_gold' and
      //   // the 'subscription_gold' subscription can be downgraded to 'subscription_silver'.
      //   // Please remember to replace the logic of finding the old subscription Id as per your app.
      //   // The old subscription is only required on Android since Apple handles this internally
      //   // by using the subscription group feature in iTunesConnect.
      //   GooglePlayPurchaseDetails? oldSubscription;
      //   if (productDetails.id == _kSilverSubscriptionId &&
      //       purchases[_kGoldSubscriptionId] != null) {
      //     oldSubscription =
      //         purchases[_kGoldSubscriptionId]! as GooglePlayPurchaseDetails;
      //   } else if (productDetails.id == _kGoldSubscriptionId &&
      //       purchases[_kSilverSubscriptionId] != null) {
      //     oldSubscription =
      //         purchases[_kSilverSubscriptionId]! as GooglePlayPurchaseDetails;
      //   }
      //   return oldSubscription;
      // }
    }
    
    /// Example implementation of the
    /// [`SKPaymentQueueDelegate`](https://developer.apple.com/documentation/storekit/skpaymentqueuedelegate?language=objc).
    ///
    /// The payment queue delegate can be implementated to provide information
    /// needed to complete transactions.
    class ExamplePaymentQueueDelegate implements SKPaymentQueueDelegateWrapper {
      @override
      bool shouldContinueTransaction(
          SKPaymentTransactionWrapper transaction, SKStorefrontWrapper storefront) {
        return true;
      }
    
      @override
      bool shouldShowPriceConsent() {
        return false;
      }
    }
    // Copyright 2013 The Flutter Authors. All rights reserved.
    // Use of this source code is governed by a BSD-style license that can be
    // found in the LICENSE file.
    
    // ignore: avoid_classes_with_only_static_members
    /// A store of consumable items.
    ///
    /// This is a development prototype tha stores consumables in the shared
    /// preferences. Do not use this in real world apps.
    class ConsumableStore {
      static const String _kPrefKey = 'consumables';
      static Future<void> _writes = Future<void>.value();
    
      /// Adds a consumable with ID `id` to the store.
      ///
      /// The consumable is only added after the returned Future is complete.
      static Future<void> save(String id) {
        _writes = _writes.then((void _) => _doSave(id));
        return _writes;
      }
    
      /// Consumes a consumable with ID `id` from the store.
      ///
      /// The consumable was only consumed after the returned Future is complete.
      static Future<void> consume(String id) {
        _writes = _writes.then((void _) => _doConsume(id));
        return _writes;
      }
    
      /// Returns the list of consumables from the store.
      static Future<List<String>> load() async {
        return (await SharedPreferences.getInstance()).getStringList(_kPrefKey) ??
            <String>[];
      }
    
      static Future<void> _doSave(String id) async {
        final List<String> cached = await load();
        final SharedPreferences prefs = await SharedPreferences.getInstance();
        cached.add(id);
        await prefs.setStringList(_kPrefKey, cached);
      }
    
      static Future<void> _doConsume(String id) async {
        final List<String> cached = await load();
        final SharedPreferences prefs = await SharedPreferences.getInstance();
        cached.remove(id);
        await prefs.setStringList(_kPrefKey, cached);
      }
    }

    The following is the error log

    <SKPaymentQueue: 0x302224bc0>: Payment completed with error: Error Domain=ASDErrorDomain Code=907 "(null)" UserInfo={client-environment-type=Sandbox, storefront-country-code=CHN, NSUnderlyingError=0x302e9e610 {Error Domain=AMSErrorDomain Code=6 "Payment Sheet Failed" UserInfo={NSLocalizedDescription=Payment Sheet Failed, AMSURL=https://sandbox.itunes.apple.com/WebObjects/MZBuy.woa/wa/inAppBuy?guid=00008110-001A64CE2E40A01E, AMSStatusCode=200, AMSServerPayload={
        "cancel-purchase-batch" = 1;
        customerMessage = "\U4f7f\U7528 Apple\U00a0\U8d26\U6237\U767b\U5f55";
        dialog =     {
            cancelButtonString = "\U53d6\U6d88";
            defaultButton = Buy;
            explanation = "\U8f93\U5165 %%appleId%% \U7684\U5bc6\U7801\U4ee5\U6388\U6743\U6b64\U4ea4\U6613\U3002\n\n[Environment: Sandbox]";
            initialCheckboxValue = 1;
            kind = authorization;
            "m-allowed" = 0;
            message = "\U4f7f\U7528 Apple\U00a0\U8d26\U6237\U767b\U5f55";
            okButtonAction =         {
                buyParams = "offrd-free-trial=false&salableAdamId=1585123475&showIAPExtraDialog=false&offerName=com.zzzb.HNZB0002&productType=A&hasBeenAuthedForBuy=true&quantity=1&price=100000&ageCheck=true&pricingParameters=STDQ&vid=E85F268C-42E3-46CA-A041-6E07E874E7FB&bvrs=1&pg=default&hasConfirmedPaymentSheet=true&offrd-intro-price=false&bid=com.zzzb.HNZB&hasWebOptIn=false";
                itemName = "com.zzzb.HNZB0002";
                kind = Buy;
            };
            okButtonString = "\U8d2d\U4e70";
            paymentSheetInfo =         {
                caseControl = true;
                confirmationTitle = Subscribe;
                countryCode = CN;
                currency = CNY;
                designVersion = 2;
                flexList =             (
                                    {
                        value =                     (
                                                    {
                                style = priceMain;
                                value = "\U00a5100.00";
                            },
                                                    {
                                style = priceSub;
                                value = "\U4e00\U6b21\U6027\U8d39\U7528";
                            }
                        );
                    },
                                    {
                        header = "\U8be6\U7ec6\U4fe1\U606f";
                        value = "\U4ec5\U7528\U4e8e\U6d4b\U8bd5\U3002\U786e\U8ba4\U8d2d\U4e70\U540e\Uff0c\U7cfb\U7edf\U4e0d\U4f1a\U6536\U53d6\U8d39\U7528\U3002";
                    },
                                    {
                        style = account;
                        value = "\U8d26\U6237\Uff1a%%appleId%%";
                    }
                );
                images =             (
                                    {
                        type = itunesgames;
                        value = "17+";
                    }
                );
                price = 100;
                requestor = AppStore;
                salableIcon = "https://is1-ssl.mzstatic.com/image/thumb/Purple211/v4/0e/a8/00/0ea800d2-649a-df24-b6ce-318ba57a57b1/AppIcon-0-0-1x_U007emarketing-0-7-0-0-85-220.png/144x144bb.jpg";
                salableIconType = app;
                salableInfo =             (
                    "\U5c0a\U4eabVIP\U4f1a\U5458/\U5b63\U5361",
                    "\U533b\U7ef4\U5ea6\U89e3\U5256 %%image_0%%",
                    "\U8ba2\U9605"
                );
                styles =             (
                                    {
                        bold = true;
                        name = priceMain;
                        size = large;
                    },
                                    {
                        color = gray;
                        name = priceSub;
                    },
                                    {
                        color = gray;
                        name = account;
                    }
                );
    
                suppressPrice = true;
                title =             {
                    type = text;
                    value = "\U6c99\U7bb1";
                };
            };
        };
        failureType = "";
        "m-allowed" = 0;
        metrics =     {
            actionUrl = "sandbox.itunes.apple.com/WebObjects/MZBuy.woa/wa/inAppBuy";
            dialogId = "MZCommerceInAppBuy.ConfirmationNeededForBuyOneItem.CPS.Auth";
            eventType = dialog;
            message = "\U4f7f\U7528 Apple\U00a0\U8d26\U6237\U767b\U5f55";
            mtEventTime = "2024-09-11 07:43:32 Etc/GMT";
            mtTopic = "xp_its_main";
            osloTitleType = text;
            osloTitleValue = "AbstractSalableInfoModule.SandboxTitle";
            topic = "xp_its_main";
        };
        pings =     (
            "https://xp.apple.com/report/2/xp_its_main?code=MZCommerceInAppBuy.ConfirmationNeededForBuyOneItem.CPS.Auth&buttons=%E8%B4%AD%E4%B9%B0%3A%E5%8F%96%E6%B6%88&baseVersion=1&dsId=17446881249&eventVersion=1&storeFrontHeader=143465-19%2C29&eventTime=1726040616312&eventType=dialog&message=%E4%BD%BF%E7%94%A8%20Apple%C2%A0%E8%B4%A6%E6%88%B7%E7%99%BB%E5%BD%95"
        );
    }, NSLocalizedFailureReason=Payment sheet dismissed with neither an error nor a result}}}
    
  15. huycozy commented on Sep 12, 2024

    @huycozy
    Member

    (I collapsed the sample code on your comments above since they are too long for others to grasp the issue.)

    When using your sample code at #154763 (comment), I can see the fatal crash on iOS.

    To find regression, I was forced to use in_app_purchase: 3.1.13 (with dependency_overrides) but the crash persists. I think it's because in_app_purchase_storekit is still on the latest version 0.3.18+1. I traced it by trying some older in_app_purchase_storekit versions on release timeline of in_app_purchase and in_app_purchase_storekit, I found the crash also occurs with in_app_purchase_storekit: 0.3.8+1 but the app is not terminated as the latest version, I can see the trace, though.

    [ERROR:flutter/runtime/dart_vm_initializer.cc(41)] Unhandled Exception: PlatformException(storekit_finish_transaction_exception, NSInvalidArgumentException, Cannot finish a purchasing transaction, null)
    #0      StandardMethodCodec.decodeEnvelope (package:flutter/src/services/message_codecs.dart:648:7)
    #1      MethodChannel._invokeMethod (package:flutter/src/services/platform_channel.dart:334:18)
    <asynchronous suspension>
    #2      SKPaymentQueueWrapper.finishTransaction (package:in_app_purchase_storekit/src/store_kit_wrappers/sk_payment_queue_wrapper.dart:162:5)
    <asynchronous suspension>

    I haven't found a version of in_app_purchase_storekit that didn't cause this error, if you can find one, please share it.

    In the meantime, I will mark this as a regression.

  16. 17 remaining items

  17. andywhite37 commented on Sep 28, 2024

    @andywhite37

    We are also seeing this issue in iOS as of the past couple weeks. We had a similar workaround mentioned above to complete pending transactions prior to attempting a purchase. I think I added this about a year ago because we were having problems with the purchase flow and someone else recommended doing it. It seems that it's now causing a "crash to desktop" native error in iOS.

    I've tried removing this workaround, but am experiencing other strange behavior which I'm currently trying to debug.

    var transactions = await SKPaymentQueueWrapper().transactions();
        for (var skPaymentTransactionWrapper in transactions) {
          SKPaymentQueueWrapper().finishTransaction(skPaymentTransactionWrapper);
        }
    

    Error:

    *** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: 'Cannot finish a purchasing transaction'
    *** First throw call stack:
    (0x19fb2cf20 0x1979b72b8 0x19fc2b6dc 0x1c8f9e7bc 0x1035afb38 0x1035ad97c 0x1035bec68 0x1035bef50 0x1035b9488 0x106908b9c 0x10638858c 0x1036e8b98 0x1036ea7bc 0x1036fad58 0x1036fa90c 0x19faff710 0x19fafc914 0x19fafbcd8 0x1e45491a8 0x1a2135ae8 0x1a21e9d98 0x1a2363504 0x10332c7a4 0x10332c71c 0x10332c820 0x1c32d3154)
    libc++abi: terminating due to uncaught exception of type NSException
    

    Versions in pubspec.lock:

      in_app_purchase:
        dependency: "direct main"
        description:
          name: in_app_purchase
          sha256: "960f26a08d9351fb8f89f08901f8a829d41b04d45a694b8f776121d9e41dcad6"
          url: "https://pub.dev"
        source: hosted
        version: "3.2.0"
      in_app_purchase_android:
        dependency: transitive
        description:
          name: in_app_purchase_android
          sha256: "25eb8694819caca282a527c26d5a1775164965c554ee99b9c10f3a0e44675c75"
          url: "https://pub.dev"
        source: hosted
        version: "0.3.6+8"
      in_app_purchase_platform_interface:
        dependency: transitive
        description:
          name: in_app_purchase_platform_interface
          sha256: "1d353d38251da5b9fea6635c0ebfc6bb17a2d28d0e86ea5e083bf64244f1fb4c"
          url: "https://pub.dev"
        source: hosted
        version: "1.4.0"
      in_app_purchase_storekit:
        dependency: "direct main"
        description:
          name: in_app_purchase_storekit
          sha256: e9a408da11d055f9af9849859e1251b2b0a2f84f256fa3bf1285c5614a397079
          url: "https://pub.dev"
        source: hosted
        version: "0.3.18+1"
    
  18. andywhite37 commented on Sep 29, 2024

    @andywhite37

    Downgrading from in_app_purchase 3.2.0 to either 3.1.10 or 3.1.13 fixed the issue for me.

  19. LouiseHsu commented on Sep 30, 2024

    @LouiseHsu
    Contributor

    Sorry for the late reply guys - let me investigate and Ill try to get a patch out soon.

  20. self-assigned this
    on Oct 23, 2024
  21. added
    triaged-iosTriaged by iOS platform team
    P2Important issues not at the top of the work list
    on Oct 23, 2024
  22. added
    r: fixedIssue is closed as already fixed in a newer version
    on Nov 14, 2024
  23. github-actions commented on Nov 28, 2024

    @github-actions

    This thread has been automatically locked since there has not been any recent activity after it was closed. If you are still experiencing a similar issue, please open a new bug, including the output of flutter doctor -v and a minimal reproduction of the issue.

  24. locked as resolved and limited conversation to collaborators on Nov 28, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

P2Important issues not at the top of the work listc: fatal crashCrashes that terminate the processc: regressionIt was better in the past than it is nowfound in release: 3.24Found to occur in 3.24has reproducible stepsThe issue has been confirmed reproducible and is ready to work onp: in_app_purchasePlugin for in-app purchasepackageflutter/packages repository. See also p: labels.platform-iosiOS applications specificallyr: fixedIssue is closed as already fixed in a newer versionteam-iosOwned by iOS platform teamtriaged-iosTriaged by iOS platform team

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions