diff --git a/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md b/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md index 4daa6a2d1dcb..b1e40be882c0 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md +++ b/packages/in_app_purchase/in_app_purchase_ios/CHANGELOG.md @@ -1,3 +1,8 @@ +## 0.2.0 + +* BREAKING CHANGE : Refactor to handle new `PurchaseStatus` named `canceled`. This means developers + can distinguish between an error and user cancellation. + ## 0.1.4 * Require Dart SDK >= 2.14. diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/enum_converters.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/enum_converters.dart index 8a0526f07ad7..1c2bee5a069a 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/enum_converters.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/store_kit_wrappers/enum_converters.dart @@ -30,7 +30,8 @@ class SKTransactionStatusConverter } /// Converts an [SKPaymentTransactionStateWrapper] to a [PurchaseStatus]. - PurchaseStatus toPurchaseStatus(SKPaymentTransactionStateWrapper object) { + PurchaseStatus toPurchaseStatus( + SKPaymentTransactionStateWrapper object, SKError? error) { switch (object) { case SKPaymentTransactionStateWrapper.purchasing: case SKPaymentTransactionStateWrapper.deferred: @@ -40,6 +41,14 @@ class SKTransactionStatusConverter case SKPaymentTransactionStateWrapper.restored: return PurchaseStatus.restored; case SKPaymentTransactionStateWrapper.failed: + // According to the Apple documentation the error code "2" indicates + // the user cancelled the payment (SKErrorPaymentCancelled) and error + // code "15" indicates the cancellation of the overlay (SKErrorOverlayCancelled). + // An overview of all error codes can be found at: https://developer.apple.com/documentation/storekit/skerrorcode?language=objc + if (error != null && (error.code == 2 || error.code == 15)) { + return PurchaseStatus.canceled; + } + return PurchaseStatus.error; case SKPaymentTransactionStateWrapper.unspecified: return PurchaseStatus.error; } diff --git a/packages/in_app_purchase/in_app_purchase_ios/lib/src/types/app_store_purchase_details.dart b/packages/in_app_purchase/in_app_purchase_ios/lib/src/types/app_store_purchase_details.dart index 6d6f241d6ca8..58e965a1f19f 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/lib/src/types/app_store_purchase_details.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/lib/src/types/app_store_purchase_details.dart @@ -56,7 +56,7 @@ class AppStorePurchaseDetails extends PurchaseDetails { purchaseID: transaction.transactionIdentifier, skPaymentTransaction: transaction, status: SKTransactionStatusConverter() - .toPurchaseStatus(transaction.transactionState), + .toPurchaseStatus(transaction.transactionState, transaction.error), transactionDate: transaction.transactionTimeStamp != null ? (transaction.transactionTimeStamp! * 1000).toInt().toString() : null, @@ -66,7 +66,8 @@ class AppStorePurchaseDetails extends PurchaseDetails { source: kIAPSource), ); - if (purchaseDetails.status == PurchaseStatus.error) { + if (purchaseDetails.status == PurchaseStatus.error || + purchaseDetails.status == PurchaseStatus.canceled) { purchaseDetails.error = IAPError( source: kIAPSource, code: kPurchaseErrorCode, diff --git a/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml b/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml index 80ef921cada5..5129f5ebd66c 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml +++ b/packages/in_app_purchase/in_app_purchase_ios/pubspec.yaml @@ -2,7 +2,7 @@ name: in_app_purchase_ios description: An implementation for the iOS platform of the Flutter `in_app_purchase` plugin. This uses the iOS StoreKit Framework. repository: https://github.com/flutter/plugins/tree/master/packages/in_app_purchase/in_app_purchase_ios issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+in_app_purchase%22 -version: 0.1.4 +version: 0.2.0 environment: sdk: ">=2.14.0 <3.0.0" @@ -19,7 +19,7 @@ dependencies: collection: ^1.15.0 flutter: sdk: flutter - in_app_purchase_platform_interface: ^1.1.0 + in_app_purchase_platform_interface: ^1.3.0 json_annotation: ^4.3.0 meta: ^1.3.0 diff --git a/packages/in_app_purchase/in_app_purchase_ios/test/fakes/fake_ios_platform.dart b/packages/in_app_purchase/in_app_purchase_ios/test/fakes/fake_ios_platform.dart index e7dbd1a49ae2..30a42b249001 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/test/fakes/fake_ios_platform.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/test/fakes/fake_ios_platform.dart @@ -18,7 +18,7 @@ class FakeIOSPlatform { channel.setMockMethodCallHandler(onMethodCall); } - // pre-configured store informations + // pre-configured store information String? receiptData; late Set validProductIDs; late Map validProducts; @@ -26,6 +26,7 @@ class FakeIOSPlatform { late List finishedTransactions; late bool testRestoredTransactionsNull; late bool testTransactionFail; + late int testTransactionCancel; PlatformException? queryProductException; PlatformException? restoreException; SKError? testRestoredError; @@ -67,6 +68,7 @@ class FakeIOSPlatform { finishedTransactions = []; testRestoredTransactionsNull = false; testTransactionFail = false; + testTransactionCancel = -1; queryProductException = null; restoreException = null; testRestoredError = null; @@ -107,6 +109,20 @@ class FakeIOSPlatform { originalTransaction: null); } + SKPaymentTransactionWrapper createCanceledTransaction( + String productId, int errorCode) { + return SKPaymentTransactionWrapper( + transactionIdentifier: '', + payment: SKPaymentWrapper(productIdentifier: productId), + transactionState: SKPaymentTransactionStateWrapper.failed, + transactionTimeStamp: 123123.121, + error: SKError( + code: errorCode, + domain: 'ios_domain', + userInfo: {'message': 'an error message'}), + originalTransaction: null); + } + Future onMethodCall(MethodCall call) { switch (call.method) { case '-[SKPaymentQueue canMakePayments:]': @@ -167,6 +183,11 @@ class FakeIOSPlatform { createFailedTransaction(id); InAppPurchaseIosPlatform.observer .updatedTransactions(transactions: [transaction_failed]); + } else if (testTransactionCancel > 0) { + SKPaymentTransactionWrapper transaction_canceled = + createCanceledTransaction(id, testTransactionCancel); + InAppPurchaseIosPlatform.observer + .updatedTransactions(transactions: [transaction_canceled]); } else { SKPaymentTransactionWrapper transaction_finished = createPurchasedTransaction( diff --git a/packages/in_app_purchase/in_app_purchase_ios/test/in_app_purchase_ios_platform_test.dart b/packages/in_app_purchase/in_app_purchase_ios/test/in_app_purchase_ios_platform_test.dart index 865468f532bf..76d30af125e4 100644 --- a/packages/in_app_purchase/in_app_purchase_ios/test/in_app_purchase_ios_platform_test.dart +++ b/packages/in_app_purchase/in_app_purchase_ios/test/in_app_purchase_ios_platform_test.dart @@ -130,7 +130,7 @@ void main() { expect( actual.status, SKTransactionStatusConverter() - .toPurchaseStatus(expected.transactionState), + .toPurchaseStatus(expected.transactionState, expected.error), ); expect(actual.verificationData.localVerificationData, fakeIOSPlatform.receiptData); @@ -275,6 +275,62 @@ void main() { expect(completerError.message, 'ios_domain'); expect(completerError.details, {'message': 'an error message'}); }); + + test( + 'should get canceled purchase status when error code is SKErrorPaymentCancelled', + () async { + fakeIOSPlatform.testTransactionCancel = 2; + List details = []; + Completer completer = Completer(); + + Stream> stream = iapIosPlatform.purchaseStream; + late StreamSubscription subscription; + subscription = stream.listen((purchaseDetailsList) { + details.addAll(purchaseDetailsList); + purchaseDetailsList.forEach((purchaseDetails) { + if (purchaseDetails.status == PurchaseStatus.canceled) { + completer.complete(purchaseDetails.status); + subscription.cancel(); + } + }); + }); + final AppStorePurchaseParam purchaseParam = AppStorePurchaseParam( + productDetails: + AppStoreProductDetails.fromSKProduct(dummyProductWrapper), + applicationUserName: 'appName'); + await iapIosPlatform.buyNonConsumable(purchaseParam: purchaseParam); + + PurchaseStatus purchaseStatus = await completer.future; + expect(purchaseStatus, PurchaseStatus.canceled); + }); + + test( + 'should get canceled purchase status when error code is SKErrorOverlayCancelled', + () async { + fakeIOSPlatform.testTransactionCancel = 15; + List details = []; + Completer completer = Completer(); + + Stream> stream = iapIosPlatform.purchaseStream; + late StreamSubscription subscription; + subscription = stream.listen((purchaseDetailsList) { + details.addAll(purchaseDetailsList); + purchaseDetailsList.forEach((purchaseDetails) { + if (purchaseDetails.status == PurchaseStatus.canceled) { + completer.complete(purchaseDetails.status); + subscription.cancel(); + } + }); + }); + final AppStorePurchaseParam purchaseParam = AppStorePurchaseParam( + productDetails: + AppStoreProductDetails.fromSKProduct(dummyProductWrapper), + applicationUserName: 'appName'); + await iapIosPlatform.buyNonConsumable(purchaseParam: purchaseParam); + + PurchaseStatus purchaseStatus = await completer.future; + expect(purchaseStatus, PurchaseStatus.canceled); + }); }); group('complete purchase', () {