From 28d53242d8fa00be4fcb9429ce85d8e87f45899b Mon Sep 17 00:00:00 2001 From: Vishesh Handa Date: Sun, 26 Jul 2020 11:59:38 +0200 Subject: [PATCH] Move away from RevenueCat The iOS updates keep getting rejected, and I think it's because RevenueCat is taking too long to respond. Additionally, revenueCat doesn't really give us anything useful as its receipt validation isn't perfect, and I've had to roll my own. Plus from a privacy point of view, this is better as we are no longer talking to any third party service. This has so far only been tested on iOS --- ios/Podfile.lock | 10 -- lib/iap.dart | 18 +- lib/widgets/purchase_slider.dart | 5 +- lib/widgets/purchase_widget.dart | 295 ++++++++++++++++++------------- pubspec.lock | 28 +-- pubspec.yaml | 1 - scripts/setup_env.dart | 1 - 7 files changed, 208 insertions(+), 150 deletions(-) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 2189e640..17352e67 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -105,10 +105,6 @@ PODS: - path_provider_macos (0.0.1): - Flutter - PromisesObjC (1.2.9) - - Purchases (3.2.2) - - purchases_flutter (1.1.0): - - Flutter - - Purchases (~> 3.2.2) - quick_actions (0.0.1): - Flutter - Reachability (3.2) @@ -152,7 +148,6 @@ DEPENDENCIES: - package_info (from `.symlinks/plugins/package_info/ios`) - path_provider (from `.symlinks/plugins/path_provider/ios`) - path_provider_macos (from `.symlinks/plugins/path_provider_macos/ios`) - - purchases_flutter (from `.symlinks/plugins/purchases_flutter/ios`) - quick_actions (from `.symlinks/plugins/quick_actions/ios`) - receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`) - share (from `.symlinks/plugins/share/ios`) @@ -179,7 +174,6 @@ SPEC REPOS: - GoogleUtilities - nanopb - PromisesObjC - - Purchases - Reachability - Sentry @@ -216,8 +210,6 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/path_provider/ios" path_provider_macos: :path: ".symlinks/plugins/path_provider_macos/ios" - purchases_flutter: - :path: ".symlinks/plugins/purchases_flutter/ios" quick_actions: :path: ".symlinks/plugins/quick_actions/ios" receive_sharing_intent: @@ -269,8 +261,6 @@ SPEC CHECKSUMS: path_provider: fb74bd0465e96b594bb3b5088ee4a4e7bb1f2a9d path_provider_macos: f760a3c5b04357c380e2fddb6f9db6f3015897e0 PromisesObjC: b48e0338dbbac2207e611750777895f7a5811b75 - Purchases: 392c729893c011c7d4d37d7f7c9f863e597d4a9b - purchases_flutter: 329011208c046353be37f984026815e9dd046456 quick_actions: 6cb2390c4dab0e737c94573c27e18d9666710720 Reachability: 33e18b67625424e47b6cde6d202dce689ad7af96 receive_sharing_intent: c0d87310754e74c0f9542947e7cbdf3a0335a3b1 diff --git a/lib/iap.dart b/lib/iap.dart index f8c0e09f..da789021 100644 --- a/lib/iap.dart +++ b/lib/iap.dart @@ -57,6 +57,7 @@ class InAppPurchases { static Future _subscriptionStatus() async { InAppPurchaseConnection.enablePendingPurchases(); var iapConn = InAppPurchaseConnection.instance; + var dtNow = DateTime.now().toUtc(); if (Platform.isIOS) { var verificationData = await iapConn.refreshPurchaseVerificationData(); @@ -70,12 +71,12 @@ class InAppPurchases { var dt = await getExpiryDate( purchase.verificationData.serverVerificationData, purchase.productID); - if (dt == null || !dt.isAfter(DateTime.now())) { + if (dt == null || !dt.isAfter(dtNow)) { continue; } return SubscriptionStatus(true, dt); } - return SubscriptionStatus(false, DateTime.now().toUtc()); + return SubscriptionStatus(false, dtNow); } return null; @@ -117,7 +118,7 @@ Future getExpiryDate(String receipt, String sku) async { } var expiryDateMs = b['expiry_date'] as int; - return DateTime.fromMillisecondsSinceEpoch(expiryDateMs); + return DateTime.fromMillisecondsSinceEpoch(expiryDateMs, isUtc: true); } class SubscriptionStatus { @@ -130,3 +131,14 @@ class SubscriptionStatus { String toString() => "SubscriptionStatus{isPro: $isPro, expiryDate: $expiryDate}"; } + +Future verifyPurchase(PurchaseDetails purchase) async { + var dt = await getExpiryDate( + purchase.verificationData.serverVerificationData, + purchase.productID, + ); + if (dt == null || !dt.isAfter(DateTime.now())) { + return SubscriptionStatus(false, dt); + } + return SubscriptionStatus(true, dt); +} diff --git a/lib/widgets/purchase_slider.dart b/lib/widgets/purchase_slider.dart index ec1d68d3..429dd6b3 100644 --- a/lib/widgets/purchase_slider.dart +++ b/lib/widgets/purchase_slider.dart @@ -5,11 +5,12 @@ import 'package:equatable/equatable.dart'; class PaymentInfo extends Equatable { final double value; final String text; + final String id; - PaymentInfo(this.value, this.text); + PaymentInfo({@required this.id, @required this.value, @required this.text}); @override - List get props => [value, text]; + List get props => [value, text, id]; } typedef PaymentSliderChanged = Function(PaymentInfo); diff --git a/lib/widgets/purchase_widget.dart b/lib/widgets/purchase_widget.dart index 7e6ab154..d640345b 100644 --- a/lib/widgets/purchase_widget.dart +++ b/lib/widgets/purchase_widget.dart @@ -1,96 +1,71 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:purchases_flutter/purchases_flutter.dart'; +import 'package:in_app_purchase/in_app_purchase.dart'; -import 'package:gitjournal/.env.dart'; import 'package:gitjournal/analytics.dart'; -import 'package:gitjournal/error_reporting.dart'; import 'package:gitjournal/iap.dart'; import 'package:gitjournal/settings.dart'; import 'package:gitjournal/utils/logger.dart'; import 'package:gitjournal/widgets/purchase_slider.dart'; class PurchaseButton extends StatelessWidget { - final Package package; + final ProductDetails product; - PurchaseButton(this.package); + PurchaseButton(this.product); @override Widget build(BuildContext context) { - var price = package != null ? package.product.priceString : "Dev Mode"; + var price = product != null ? product.price : "Dev Mode"; return RaisedButton( child: Text('Subscribe for $price / month'), color: Theme.of(context).primaryColor, padding: const EdgeInsets.fromLTRB(32.0, 16.0, 32.0, 16.0), - onPressed: package != null ? () => _handlePurchase(context) : null, + onPressed: product != null ? () => _initPurchase(context) : null, ); } - void _handlePurchase(BuildContext context) async { - try { - var purchaserInfo = await Purchases.purchasePackage(package); - var isPro = purchaserInfo.entitlements.all["pro"].isActive; - if (isPro) { - Settings.instance.proMode = true; - Settings.instance.proExpirationDate = - purchaserInfo.latestExpirationDate; - Settings.instance.save(); + void _initPurchase(BuildContext context) async { + var purchaseParam = PurchaseParam(productDetails: product); + var sentSuccess = await InAppPurchaseConnection.instance + .buyNonConsumable(purchaseParam: purchaseParam); - getAnalytics().logEvent( - name: "purchase_screen_thank_you", - ); - - Navigator.of(context).popAndPushNamed('/purchase_thank_you'); - return; - } - } on PlatformException catch (e) { - var errorCode = PurchasesErrorHelper.getErrorCode(e); - var errorContent = ""; - switch (errorCode) { - case PurchasesErrorCode.purchaseCancelledError: - errorContent = "User cancelled"; - break; - - case PurchasesErrorCode.purchaseNotAllowedError: - errorContent = "User not allowed to purchase"; - break; - - default: - errorContent = errorCode.toString(); - break; - } - - var dialog = AlertDialog( - title: const Text("Purchase Failed"), - content: Text(errorContent), - actions: [ - FlatButton( - child: const Text("OK"), - onPressed: () => Navigator.of(context).pop(), - ), - ], - ); + if (!sentSuccess) { + var err = "Failed to send purchase request"; + var dialog = PurchaseFailedDialog(err); await showDialog(context: context, builder: (context) => dialog); + return; } + return null; } } +Set _generateSkus() { + var list = {'sku_monthly_min'}; + for (var i = 0; i < 50; i++) { + list.add("sku_monthly_min$i"); + } + print(list); + return list; +} + class PurchaseWidget extends StatefulWidget { @override _PurchaseWidgetState createState() => _PurchaseWidgetState(); } class _PurchaseWidgetState extends State { - List _offerings; - Offering _selectedOffering; + List _products; + ProductDetails _selectedProduct; + StreamSubscription> _subscription; final defaultSku = "sku_monthly_min2"; String error = ""; + bool pendingPurchase = false; @override void initState() { @@ -99,32 +74,30 @@ class _PurchaseWidgetState extends State { } Future initPlatformState() async { - await InAppPurchases.confirmProPurchase(); - if (Settings.instance.proMode) { - Navigator.of(context).pop(); - } + // In parallel check if the purchase has been made + InAppPurchases.confirmProPurchase().then((void _) { + if (Settings.instance.proMode) { + Navigator.of(context).pop(); + } + }); - await Purchases.setup( - environment['revenueCat'], - appUserId: Settings.instance.pseudoId, - ); + final iapCon = InAppPurchaseConnection.instance; - Offerings offerings; - try { - offerings = await Purchases.getOfferings(); - } catch (e, stackTrace) { - logExceptionWarning(e, stackTrace); + final bool available = await iapCon.isAvailable(); + if (!available) { setState(() { - error = e.toString(); + error = "Store cannot be reached"; }); return; } - var offeringList = offerings.all.values.toList(); - offeringList.retainWhere((Offering o) => o.identifier.contains("monthly")); - offeringList.sort((Offering a, Offering b) => - a.monthly.product.price.compareTo(b.monthly.product.price)); - Log.i("Offerings: $offeringList"); + final response = await iapCon.queryProductDetails(_generateSkus()); + if (response.error != null) { + Log.e("IAP queryProductDetails: ${response.error}"); + } + var products = response.productDetails; + products.sort((a, b) => a.price.compareTo(b.price)); + Log.i("Products: ${products.map((e) => '${e.id} ${e.price}')}"); // If the widget was removed from the tree while the asynchronous platform // message was in flight, we want to discard the reply rather than calling @@ -132,38 +105,87 @@ class _PurchaseWidgetState extends State { if (!mounted) return; setState(() { - _offerings = offeringList; - _selectedOffering = _offerings.isNotEmpty ? _offerings.first : null; + _products = products; + _selectedProduct = _products.isNotEmpty ? _products.first : null; - if (_offerings.length > 1) { - for (var o in _offerings) { - var prod = o.monthly.product; - if (prod.identifier == defaultSku) { - _selectedOffering = o; + if (_products.length > 1) { + for (var p in _products) { + if (p.id == defaultSku) { + _selectedProduct = p; break; } } } else { - var fakePackageJson = { - 'identifier': 'monthly_fake', - 'product': { - 'identifier': 'fake_product', - 'title': 'Fake Product', - 'priceString': '0 Fake', - 'price': 0.0, - }, - }; - - var fakeOffer = Offering.fromJson({ - 'identifier': 'monthly_fake_offering', - 'monthly': fakePackageJson, - 'availablePackages': [fakePackageJson], - }); - - _offerings = [fakeOffer]; - _selectedOffering = _offerings[0]; + // FIXME: Add a fake product for development } }); + + // Start listening for changes + final purchaseUpdates = iapCon.purchaseUpdatedStream; + _subscription = purchaseUpdates.listen(_listenToPurchaseUpdated); + } + + void _listenToPurchaseUpdated(List purchaseDetailsList) { + purchaseDetailsList.forEach((PurchaseDetails purchaseDetails) async { + if (purchaseDetails.status == PurchaseStatus.pending) { + //showPendingUI(); + Log.i("Pending - ${purchaseDetails.productID}"); + setState(() { + pendingPurchase = true; + }); + return; + } + + setState(() { + pendingPurchase = false; + }); + if (purchaseDetails.status == PurchaseStatus.error) { + _handleIAPError(purchaseDetails.error); + } else if (purchaseDetails.status == PurchaseStatus.purchased) { + var subStatus = await verifyPurchase(purchaseDetails); + if (subStatus.isPro) { + _deliverProduct(subStatus); + } else { + _handleError("Failed to purchase product"); + return; + } + } + if (Platform.isAndroid) { + await InAppPurchaseConnection.instance.consumePurchase(purchaseDetails); + } + if (purchaseDetails.pendingCompletePurchase) { + await InAppPurchaseConnection.instance + .completePurchase(purchaseDetails); + } + }); + } + + void _handleIAPError(IAPError err) { + var msg = "${err.code} - ${err.message} - ${err.details}"; + _handleError(msg); + } + + void _handleError(String err) { + var dialog = PurchaseFailedDialog(err); + showDialog(context: context, builder: (context) => dialog); + } + + void _deliverProduct(SubscriptionStatus status) { + Settings.instance.proMode = status.isPro; + Settings.instance.proExpirationDate = status.expiryDate.toIso8601String(); + Settings.instance.save(); + + getAnalytics().logEvent( + name: "purchase_screen_thank_you", + ); + + Navigator.of(context).popAndPushNamed('/purchase_thank_you'); + } + + @override + void dispose() { + _subscription?.cancel(); + super.dispose(); } @override @@ -171,20 +193,35 @@ class _PurchaseWidgetState extends State { if (error.isNotEmpty) { return Text("Failed to load: $error"); } - return _offerings == null + if (pendingPurchase) { + return const CircularProgressIndicator(); + } + return _products == null ? const CircularProgressIndicator() : buildBody(context); } - PaymentInfo _fromOffering(Offering o) { - var prod = o.monthly.product; - return PaymentInfo(prod.price, prod.priceString); + PaymentInfo _fromProductDetail(ProductDetails pd) { + if (pd == null) return null; + + double value = -1; + if (pd.skProduct != null) { + value = double.parse(pd.skProduct.price); + } else if (pd.skuDetail != null) { + value = pd.skuDetail.originalPriceAmountMicros.toDouble() / 100000; + } + + return PaymentInfo( + id: pd.id, + text: pd.price, + value: value, + ); } - Offering _fromPaymentInfo(PaymentInfo info) { - for (var o in _offerings) { - if (o.monthly.product.priceString == info.text) { - return o; + ProductDetails _fromPaymentInfo(PaymentInfo info) { + for (var p in _products) { + if (p.id == info.id) { + return p; } } assert(false); @@ -193,11 +230,11 @@ class _PurchaseWidgetState extends State { Widget buildBody(BuildContext context) { var slider = PurchaseSlider( - values: _offerings.map(_fromOffering).toList(), - selectedValue: _fromOffering(_selectedOffering), + values: _products.map(_fromProductDetail).toList(), + selectedValue: _fromProductDetail(_selectedProduct), onChanged: (PaymentInfo info) { setState(() { - _selectedOffering = _fromPaymentInfo(info); + _selectedProduct = _fromPaymentInfo(info); }); }, ); @@ -210,7 +247,7 @@ class _PurchaseWidgetState extends State { icon: const Icon(Icons.arrow_left), onPressed: () { setState(() { - _selectedOffering = _prevOffering(); + _selectedProduct = _prevProduct(); }); }, ), @@ -219,7 +256,7 @@ class _PurchaseWidgetState extends State { icon: const Icon(Icons.arrow_right), onPressed: () { setState(() { - _selectedOffering = _nextOffering(); + _selectedProduct = _nextProduct(); }); }, ), @@ -227,26 +264,26 @@ class _PurchaseWidgetState extends State { mainAxisSize: MainAxisSize.max, ), const SizedBox(height: 16.0), - PurchaseButton(_selectedOffering?.monthly), + PurchaseButton(_selectedProduct), ], mainAxisAlignment: MainAxisAlignment.spaceAround, ); } - Offering _prevOffering() { - for (var i = 0; i < _offerings.length; i++) { - if (_offerings[i] == _selectedOffering) { - return i > 0 ? _offerings[i - 1] : _offerings[i]; + ProductDetails _prevProduct() { + for (var i = 0; i < _products.length; i++) { + if (_products[i] == _selectedProduct) { + return i > 0 ? _products[i - 1] : _products[i]; } } return null; } - Offering _nextOffering() { - for (var i = 0; i < _offerings.length; i++) { - if (_offerings[i] == _selectedOffering) { - return i < _offerings.length - 1 ? _offerings[i + 1] : _offerings[i]; + ProductDetails _nextProduct() { + for (var i = 0; i < _products.length; i++) { + if (_products[i] == _selectedProduct) { + return i < _products.length - 1 ? _products[i + 1] : _products[i]; } } @@ -270,3 +307,23 @@ class _PurchaseSliderButton extends StatelessWidget { ); } } + +class PurchaseFailedDialog extends StatelessWidget { + final String text; + + PurchaseFailedDialog(this.text); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text("Purchase Failed"), + content: Text(text), + actions: [ + FlatButton( + child: const Text("OK"), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index bad051a9..d8fe8280 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -140,7 +140,7 @@ packages: name: crypto url: "https://pub.dartlang.org" source: hosted - version: "2.1.4" + version: "2.1.5" csslib: dependency: transitive description: @@ -206,6 +206,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.1.1" + fake_async: + dependency: transitive + description: + name: fake_async + url: "https://pub.dartlang.org" + source: hosted + version: "1.1.0" file: dependency: transitive description: @@ -475,7 +482,7 @@ packages: name: json_rpc_2 url: "https://pub.dartlang.org" source: hosted - version: "2.1.0" + version: "2.2.0" launch_review: dependency: "direct main" description: @@ -582,7 +589,7 @@ packages: name: path url: "https://pub.dartlang.org" source: hosted - version: "1.6.4" + version: "1.7.0" path_provider: dependency: transitive description: @@ -652,7 +659,7 @@ packages: name: process url: "https://pub.dartlang.org" source: hosted - version: "3.0.12" + version: "3.0.13" provider: dependency: "direct main" description: @@ -674,13 +681,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.4.4" - purchases_flutter: - dependency: "direct main" - description: - name: purchases_flutter - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0" quick_actions: dependency: "direct main" description: @@ -895,21 +895,21 @@ packages: name: test url: "https://pub.dartlang.org" source: hosted - version: "1.14.4" + version: "1.14.7" test_api: dependency: transitive description: name: test_api url: "https://pub.dartlang.org" source: hosted - version: "0.2.15" + version: "0.2.16" test_core: dependency: transitive description: name: test_core url: "https://pub.dartlang.org" source: hosted - version: "0.3.4" + version: "0.3.7" tool_base: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 5221f87a..8b42e9b4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -46,7 +46,6 @@ dependencies: sentry: ">=3.0.0 <4.0.0" flutter_sentry: ^0.4.2 equatable: ^1.1.0 - purchases_flutter: ^1.1.0 cached_network_image: ^2.2.0+1 ssh_key: ^0.5.1 isolate: ^2.0.3 diff --git a/scripts/setup_env.dart b/scripts/setup_env.dart index 7456a799..ba431e56 100644 --- a/scripts/setup_env.dart +++ b/scripts/setup_env.dart @@ -4,7 +4,6 @@ import 'dart:io'; Future main() async { final config = { 'sentry': Platform.environment['SENTRY_DSN'], - 'revenueCat': Platform.environment['REVENUE_CAT_API_KEY'], }; final filename = 'lib/.env.dart';