New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[iOS] [in_app_purchase] Can't restore iOS last purchases #89950
Comments
@erperejildo |
in my case I don't receive any kind of response. I'm not talking about after a new purchase, just to restore them |
@erperejildo Is this a correct interpretation of the issue you are facing ? |
exactly |
Thanks for confirming. |
any estimation for this fix @darshankawar? |
/cc @mvanbeusekom |
Hi @erperejildo, Regarding the documentation, this is indeed not very consistent and I will submit a fix shortly (thank you for pointing that out). The correct way to handle it is to include the platform check: if (defaultTargetPlatform == TargetPlatform.android) {
InAppPurchaseAndroidPlatformAddition.enablePendingPurchases();
}
runApp(MyApp()); Regarding the issue with restoring the In App Purchases, according to Apple's documentation restoring transactions has no effect when:
Could you make sure your configuration complies to these points? In the meantime I will also look into the issue and see if everything is configured correctly on our end. |
Thanks for replying. To give you more context my app has 2 products: one is payed version and the other is a monthly subscription. My user has bought the first one, for which I used a generated iOS promo code.
|
Hi @mvanbeusekom, any update on this? |
Hi @stuartmorgan, I am working on this but having some issues with my Apple Sandbox account making it a bit difficult to make purchases. So bear with me, I will make sure to provide another update at the end of the week. |
@erperejildo, unfortunately I didn't get much further as of today. I will pick this back up on Monday, sorry for the delay but we are working on it. |
It's ok. Keep me updated and good weekend |
Any update @mvanbeusekom ? Is there any kind of temporary fix/hack I could do in the meantime? At least to not block the rest of the fixes and features I need to deploy to iOS |
@erperejildo, I am currently testing with the example App (with some help from my colleague @BeMacized). We discovered that the example App contains a mistake and doesn't test on the After adding this status (see PR flutter/plugins#4392) the example App works correctly and restores purchases after hitting the button. Here is the test scenario we used ((note that to run the example App yourself you also need to configure App Store Connect correctly, documentation can be found here) :
During our debug sessions all previously purchased products are correctly returned (in our case 52 items). So that seems to work according to expectations. |
Cool, thanks. Let me have a look and I’ll get back to you ASAP. |
@mvanbeusekom I tried the example and works fine. I put the restorePurchases after the listener so I can restore it right away.
so that's exactly what we have for the example, where I bought something that got restored after building again. The different on my app is that the listener is not being triggered. Here I didn't buy anything else because it was bought already. Does this change only work for products that we bought after the upgrade maybe? Also I get an empty list of products: I think the problem to that could be related to the check above if I'm not mistaken: |
First a small clarification that is not directly related to the problem but I think is good to know: the reason the example App shows a button to execute the "Restore" functionality is because this is the recommended practise from Apple (see the "important" section of the article Restoring Purchased Products). The error code Can you confirm you are running your debug tests on the simulator? The indication that you do have an error is most certainly why the |
Interesting article from Apple, but that makes the flow a bit different. For example, in my app, if you are a paid user you won’t see any ads, but that has to be checked as soon as the app is loading at beginning, without you clicking any button. Anyway, this is more an iOS thing that I’ll have to check. Thanks It should be related to the emulator then. I thought it was fine because previously, I used to check everything on the simulator but yes, I’ll double check on real device again |
@mvanbeusekom I tried on real device and I got similar result.
That never goes in. I created a button to restore the purchases just to double check what you mentioned wasn't affecting anything:
and again, this doesn't trigger the listener. I'll try to create another repo following the example but replacing my info to see if the problem is coming from not buying new products or because I'm doing something wrong |
Ok, I created as mentioned a new repo after cloning the example. The only change I made (apart from login with my team credentials, adding a version and use the real plugin package 1.0.9 to make it work) was adding my IDs:
These IDs can be checked also here: Something else I checked was going to my profile subscriptions on my device and check that I had a version bought there. So after clicking Restore purchases button I don't get anything: The IDs were not found. Also, I still not understanding why we try to do The UI says that I need special configuration to run when everything is fine: What I do get (and never saw this in my app) is this message every time when I try to restore the purchases:
Someone mentioned it should be ok on production (https://stackoverflow.com/a/63611198/4858133) but I can't push a new version to test it like that. |
@erperejildo thank you for the additional information. Can I ask you to test with a separate or new sandbox tester account (not sure if you are able to create one, as you need to use an email address that has never been associated with an Apple ID before)? I will do the same on my side and setup a new App (based on the in_app_purchase example), define the same products you did and do some additional testing. This will probably be tomorrow but will let you know the results. |
Hi @erperejildo, Looking more detailed at your screenshot, I have the feeling you are looking in the wrong place. The code block shown (included below as reference) is part of the initialisation code of the example App and indeed doesn't include any products on start up. if (productDetailResponse.productDetails.isEmpty) {
setState(() {
_queryProductError = null;
_isAvailable = isAvailable;
_products = productDetailResponse.productDetails;
_purchases = [];
_notFoundIds = productDetailResponse.notFoundIDs;
_consumables = [];
_purchasePending = false;
_loading = false;
});
return;
} The actual implementation of the If have setup a new application in Apple App Store Connect and configured it with the two products you showed in your screenshot:
Next I created a new Flutter App and copied the example code into the
After this I closed and removed the App and did a reboot of my device (this last one just to make sure all is starting fresh, not sure it has any real impact). When the device restarted I ran the example App again and immediately hit the "Restore purchases" button. Again the breakpoint on line 344 was hit and the product was part of the list with the main.dart// 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/foundation.dart';
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_ios/in_app_purchase_ios.dart';
import 'package:in_app_purchase_ios/store_kit_wrappers.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
if (defaultTargetPlatform == TargetPlatform.android) {
// For play billing library 2.0 on Android, it is mandatory to call
// [enablePendingPurchases](https://developer.android.com/reference/com/android/billingclient/api/BillingClient.Builder.html#enablependingpurchases)
// as part of initializing the app.
InAppPurchaseAndroidPlatformAddition.enablePendingPurchases();
}
runApp(_MyApp());
}
const String _kNonConsumableId = 'basic';
const String _kSubscriptionId = 'PRO';
const List<String> _kProductIds = <String>[
_kNonConsumableId,
_kSubscriptionId,
];
class _MyApp extends StatefulWidget {
@override
_MyAppState createState() => _MyAppState();
}
class _MyAppState extends State<_MyApp> {
final InAppPurchase _inAppPurchase = InAppPurchase.instance;
late StreamSubscription<List<PurchaseDetails>> _subscription;
List<String> _notFoundIds = [];
List<ProductDetails> _products = [];
List<PurchaseDetails> _purchases = [];
List<String> _consumables = [];
bool _isAvailable = false;
bool _purchasePending = false;
bool _loading = true;
String? _queryProductError;
@override
void initState() {
final Stream<List<PurchaseDetails>> purchaseUpdated =
_inAppPurchase.purchaseStream;
_subscription = purchaseUpdated.listen((purchaseDetailsList) {
_listenToPurchaseUpdated(purchaseDetailsList);
}, onDone: () {
_subscription.cancel();
}, onError: (error) {
// handle error here.
});
initStoreInfo();
super.initState();
}
Future<void> initStoreInfo() async {
final bool isAvailable = await _inAppPurchase.isAvailable();
if (!isAvailable) {
setState(() {
_isAvailable = isAvailable;
_products = [];
_purchases = [];
_notFoundIds = [];
_consumables = [];
_purchasePending = false;
_loading = false;
});
return;
}
if (Platform.isIOS) {
var iosPlatformAddition = _inAppPurchase
.getPlatformAddition<InAppPurchaseIosPlatformAddition>();
await iosPlatformAddition.setDelegate(ExamplePaymentQueueDelegate());
}
ProductDetailsResponse productDetailResponse =
await _inAppPurchase.queryProductDetails(_kProductIds.toSet());
if (productDetailResponse.error != null) {
setState(() {
_queryProductError = productDetailResponse.error!.message;
_isAvailable = isAvailable;
_products = productDetailResponse.productDetails;
_purchases = [];
_notFoundIds = productDetailResponse.notFoundIDs;
_consumables = [];
_purchasePending = false;
_loading = false;
});
return;
}
if (productDetailResponse.productDetails.isEmpty) {
setState(() {
_queryProductError = null;
_isAvailable = isAvailable;
_products = productDetailResponse.productDetails;
_purchases = [];
_notFoundIds = productDetailResponse.notFoundIDs;
_consumables = [];
_purchasePending = false;
_loading = false;
});
return;
}
setState(() {
_isAvailable = isAvailable;
_products = productDetailResponse.productDetails;
_notFoundIds = productDetailResponse.notFoundIDs;
_consumables = [];
_purchasePending = false;
_loading = false;
});
}
@override
void dispose() {
if (Platform.isIOS) {
var iosPlatformAddition = _inAppPurchase
.getPlatformAddition<InAppPurchaseIosPlatformAddition>();
iosPlatformAddition.setDelegate(null);
}
_subscription.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
List<Widget> stack = [];
if (_queryProductError == null) {
stack.add(
ListView(
children: [
_buildConnectionCheckTile(),
_buildProductList(),
_buildRestoreButton(),
],
),
);
} else {
stack.add(Center(
child: Text(_queryProductError!),
));
}
if (_purchasePending) {
stack.add(
Stack(
children: [
Opacity(
opacity: 0.3,
child: const 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 Card(child: ListTile(title: const Text('Trying to connect...')));
}
final Widget storeHeader = ListTile(
leading: Icon(_isAvailable ? Icons.check : Icons.block,
color: _isAvailable ? Colors.green : ThemeData.light().errorColor),
title: Text(
'The store is ' + (_isAvailable ? 'available' : 'unavailable') + '.'),
);
final List<Widget> children = <Widget>[storeHeader];
if (!_isAvailable) {
children.addAll([
Divider(),
ListTile(
title: Text('Not connected',
style: TextStyle(color: ThemeData.light().errorColor)),
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 Card(
child: (ListTile(
leading: CircularProgressIndicator(),
title: Text('Fetching products...'))));
}
if (!_isAvailable) {
return Card();
}
final ListTile productHeader = ListTile(title: Text('Products for Sale'));
List<ListTile> productList = <ListTile>[];
if (_notFoundIds.isNotEmpty) {
productList.add(ListTile(
title: Text('[${_notFoundIds.join(", ")}] not found',
style: TextStyle(color: ThemeData.light().errorColor)),
subtitle: 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.
Map<String, PurchaseDetails> purchases =
Map.fromEntries(_purchases.map((PurchaseDetails purchase) {
if (purchase.pendingCompletePurchase) {
_inAppPurchase.completePurchase(purchase);
}
return MapEntry<String, PurchaseDetails>(purchase.productID, purchase);
}));
productList.addAll(_products.map(
(ProductDetails productDetails) {
PurchaseDetails? previousPurchase = purchases[productDetails.id];
return ListTile(
title: Text(
productDetails.title,
),
subtitle: Text(
productDetails.description,
),
trailing: previousPurchase != null
? IconButton(
onPressed: () => confirmPriceChange(context),
icon: Icon(Icons.upgrade))
: TextButton(
child: Text(productDetails.price),
style: TextButton.styleFrom(
backgroundColor: Colors.green[800],
primary: Colors.white,
),
onPressed: () {
late PurchaseParam purchaseParam;
if (Platform.isAndroid) {
purchaseParam = GooglePlayPurchaseParam(
productDetails: productDetails,
applicationUserName: null,
changeSubscriptionParam: null,
);
} else {
purchaseParam = PurchaseParam(
productDetails: productDetails,
applicationUserName: null,
);
}
_inAppPurchase.buyNonConsumable(
purchaseParam: purchaseParam);
},
));
},
));
return Card(
child:
Column(children: <Widget>[productHeader, Divider()] + productList));
}
Widget _buildRestoreButton() {
if (_loading) {
return Container();
}
return Padding(
padding: const EdgeInsets.all(4.0),
child: Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
child: Text('Restore purchases'),
style: TextButton.styleFrom(
backgroundColor: Theme.of(context).primaryColor,
primary: Colors.white,
),
onPressed: () => _inAppPurchase.restorePurchases(),
),
],
),
);
}
void showPendingUI() {
setState(() {
_purchasePending = true;
});
}
void deliverProduct(PurchaseDetails purchaseDetails) async {
// IMPORTANT!! Always verify purchase details before delivering the product.
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.
}
void _listenToPurchaseUpdated(List<PurchaseDetails> purchaseDetailsList) {
purchaseDetailsList.forEach((PurchaseDetails purchaseDetails) async {
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) {
bool valid = await _verifyPurchase(purchaseDetails);
if (valid) {
deliverProduct(purchaseDetails);
} else {
_handleInvalidPurchase(purchaseDetails);
return;
}
}
if (purchaseDetails.pendingCompletePurchase) {
await _inAppPurchase.completePurchase(purchaseDetails);
}
}
});
}
Future<void> confirmPriceChange(BuildContext context) async {
if (Platform.isAndroid) {
final InAppPurchaseAndroidPlatformAddition androidAddition =
_inAppPurchase
.getPlatformAddition<InAppPurchaseAndroidPlatformAddition>();
var priceChangeConfirmationResult =
await androidAddition.launchPriceChangeConfirmationFlow(
sku: 'purchaseId',
);
if (priceChangeConfirmationResult.responseCode == BillingResponse.ok) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('Price change accepted'),
));
} else {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(
priceChangeConfirmationResult.debugMessage ??
"Price change failed with code ${priceChangeConfirmationResult.responseCode}",
),
));
}
}
if (Platform.isIOS) {
var iapIosPlatformAddition = _inAppPurchase
.getPlatformAddition<InAppPurchaseIosPlatformAddition>();
await iapIosPlatformAddition.showPriceConsentIfNeeded();
}
}
}
/// 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;
}
}
pubspec.yamlname: fl_issue_89950
description: A new Flutter project.
publish_to: 'none'
version: 1.0.0+1
environment:
sdk: ">=2.14.0 <3.0.0"
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.2
in_app_purchase: ^1.0.9
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^1.0.0
flutter:
uses-material-design: true
|
forgot to mention but I also tried that. Created a new test user, replaced the one I had on my sandbox account and logged in with that one in the app. Same result. I copied those 2 files but after this and the account changes still getting the same error:
Also what I tried:
I'm connected by cable and building the app from Android Studio/Xcode. This breakpoint is never triggered because previous listener is never called: I can't reproduce those steps you mentioned because on the first one you are buying the product and my products list still empty. My understanding is that is more related to my account, but I'm not sure how/why. I posted a message on iOS forum and opened a issue here as well: |
Now I understand what you mean when you mentioned "products list still empty". Sorry it took some time for me to click. For the product list to get populated the most important thing to do is to make sure you update your "Bundle identifier" in Xcode. This should match with the bundle identifier of the App you registered in the App Store Connect portal. In this comment you show a screenshot of Xcode in which it shows you are using a Bundle ID of "dev.flutter.plugins.inAppPurchaseExampleMyRents". You must have also registered an App with the same Bundle identifier in the App Store Connect portal. If this is not the case make sure you other change the Bundle ID in Xcode so it matches a valid App in registered in App Store Connect or create a new App in App Store Connect. Yesterday I started with a completely new application and took the following steps (I tried to write this down as detailed as possible so you might be able to retrace if you have an inconsistency): Register the App identifier
The App ID should now be created. Remember the value used as "Bundle ID" as you will need to add it in your App. Add a new App to App Store Connect
The new App is now created and we can now define the products to the test App. Add products to the App
Once the products are entered we are ready to configure the Flutter App. Setting up the Flutter App
Now you should be able to run the App and the products should show up in your App and you should also be able to buy them with your sandbox account. Let me know if this helps. |
Sorry for the confusion then, I thought that was like Android. I tried with my app and now it's fine. With this and 1.0.9 everything is working. Thank you! |
No problem at all. Very glad I could help and you were able to resolve the issue. Thank you for letting us know. |
@mvanbeusekom Now that you did all the work and wrote it down, are there clarifications to be made in the README to avoid confusion? https://github.com/flutter/plugins/blob/master/packages/in_app_purchase/in_app_purchase/README.md |
I have been looking into this a bit more detailed and the codelab (which is referred to in the README.md) explains the configuration of Apple's App Store (via App Store Connect) in quite some detail, the one thing that is missing there is the part where you need to register your Bundle ID via Apple's Developer portal (or member center). So this would be good to add and make sure we stress on the relation between the Bundle ID that you register and the one you need to configure in Xcode. Do you think this would suffice or would you feel we need to also include more details into the README.md file? |
Sounds like plenty of detail to me. |
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 |
Steps to Reproduce
in_app_purchase
: https://pub.dev/packages/in_app_purchase#restoring-previous-purchasesExpected results:
I should my past purchases
Actual results:
I don't get any purchases on iOS (Android works fine)
More info:
I had already this plugin working perfectly on both platforms but I updated it to latest version facing all the breaking changes (so all products are setup ok and already have user on both platforms that bought my app). The documentation is not helping much because I read different steps/code:
That's what I read here: https://pub.dev/packages/in_app_purchase but if I go to https://codelabs.developers.google.com/codelabs/flutter-in-app-purchases#5 (also linked from previous page) we don't have the check for Android:
This is my code already working for Android:
I can add
_inAppPurchase.completePurchase(purchase);
or:but that doesn't solve anything because
this.purchases
still empty since we are not going inside the firstlisten
and the list of purchases still empty.The text was updated successfully, but these errors were encountered: