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
This commit is contained in:
Vishesh Handa
2020-07-26 11:59:38 +02:00
parent 998da3e94c
commit 28d53242d8
7 changed files with 208 additions and 150 deletions

@ -105,10 +105,6 @@ PODS:
- path_provider_macos (0.0.1): - path_provider_macos (0.0.1):
- Flutter - Flutter
- PromisesObjC (1.2.9) - PromisesObjC (1.2.9)
- Purchases (3.2.2)
- purchases_flutter (1.1.0):
- Flutter
- Purchases (~> 3.2.2)
- quick_actions (0.0.1): - quick_actions (0.0.1):
- Flutter - Flutter
- Reachability (3.2) - Reachability (3.2)
@ -152,7 +148,6 @@ DEPENDENCIES:
- package_info (from `.symlinks/plugins/package_info/ios`) - package_info (from `.symlinks/plugins/package_info/ios`)
- path_provider (from `.symlinks/plugins/path_provider/ios`) - path_provider (from `.symlinks/plugins/path_provider/ios`)
- path_provider_macos (from `.symlinks/plugins/path_provider_macos/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`) - quick_actions (from `.symlinks/plugins/quick_actions/ios`)
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`) - receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
- share (from `.symlinks/plugins/share/ios`) - share (from `.symlinks/plugins/share/ios`)
@ -179,7 +174,6 @@ SPEC REPOS:
- GoogleUtilities - GoogleUtilities
- nanopb - nanopb
- PromisesObjC - PromisesObjC
- Purchases
- Reachability - Reachability
- Sentry - Sentry
@ -216,8 +210,6 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/path_provider/ios" :path: ".symlinks/plugins/path_provider/ios"
path_provider_macos: path_provider_macos:
:path: ".symlinks/plugins/path_provider_macos/ios" :path: ".symlinks/plugins/path_provider_macos/ios"
purchases_flutter:
:path: ".symlinks/plugins/purchases_flutter/ios"
quick_actions: quick_actions:
:path: ".symlinks/plugins/quick_actions/ios" :path: ".symlinks/plugins/quick_actions/ios"
receive_sharing_intent: receive_sharing_intent:
@ -269,8 +261,6 @@ SPEC CHECKSUMS:
path_provider: fb74bd0465e96b594bb3b5088ee4a4e7bb1f2a9d path_provider: fb74bd0465e96b594bb3b5088ee4a4e7bb1f2a9d
path_provider_macos: f760a3c5b04357c380e2fddb6f9db6f3015897e0 path_provider_macos: f760a3c5b04357c380e2fddb6f9db6f3015897e0
PromisesObjC: b48e0338dbbac2207e611750777895f7a5811b75 PromisesObjC: b48e0338dbbac2207e611750777895f7a5811b75
Purchases: 392c729893c011c7d4d37d7f7c9f863e597d4a9b
purchases_flutter: 329011208c046353be37f984026815e9dd046456
quick_actions: 6cb2390c4dab0e737c94573c27e18d9666710720 quick_actions: 6cb2390c4dab0e737c94573c27e18d9666710720
Reachability: 33e18b67625424e47b6cde6d202dce689ad7af96 Reachability: 33e18b67625424e47b6cde6d202dce689ad7af96
receive_sharing_intent: c0d87310754e74c0f9542947e7cbdf3a0335a3b1 receive_sharing_intent: c0d87310754e74c0f9542947e7cbdf3a0335a3b1

@ -57,6 +57,7 @@ class InAppPurchases {
static Future<SubscriptionStatus> _subscriptionStatus() async { static Future<SubscriptionStatus> _subscriptionStatus() async {
InAppPurchaseConnection.enablePendingPurchases(); InAppPurchaseConnection.enablePendingPurchases();
var iapConn = InAppPurchaseConnection.instance; var iapConn = InAppPurchaseConnection.instance;
var dtNow = DateTime.now().toUtc();
if (Platform.isIOS) { if (Platform.isIOS) {
var verificationData = await iapConn.refreshPurchaseVerificationData(); var verificationData = await iapConn.refreshPurchaseVerificationData();
@ -70,12 +71,12 @@ class InAppPurchases {
var dt = await getExpiryDate( var dt = await getExpiryDate(
purchase.verificationData.serverVerificationData, purchase.verificationData.serverVerificationData,
purchase.productID); purchase.productID);
if (dt == null || !dt.isAfter(DateTime.now())) { if (dt == null || !dt.isAfter(dtNow)) {
continue; continue;
} }
return SubscriptionStatus(true, dt); return SubscriptionStatus(true, dt);
} }
return SubscriptionStatus(false, DateTime.now().toUtc()); return SubscriptionStatus(false, dtNow);
} }
return null; return null;
@ -117,7 +118,7 @@ Future<DateTime> getExpiryDate(String receipt, String sku) async {
} }
var expiryDateMs = b['expiry_date'] as int; var expiryDateMs = b['expiry_date'] as int;
return DateTime.fromMillisecondsSinceEpoch(expiryDateMs); return DateTime.fromMillisecondsSinceEpoch(expiryDateMs, isUtc: true);
} }
class SubscriptionStatus { class SubscriptionStatus {
@ -130,3 +131,14 @@ class SubscriptionStatus {
String toString() => String toString() =>
"SubscriptionStatus{isPro: $isPro, expiryDate: $expiryDate}"; "SubscriptionStatus{isPro: $isPro, expiryDate: $expiryDate}";
} }
Future<SubscriptionStatus> 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);
}

@ -5,11 +5,12 @@ import 'package:equatable/equatable.dart';
class PaymentInfo extends Equatable { class PaymentInfo extends Equatable {
final double value; final double value;
final String text; final String text;
final String id;
PaymentInfo(this.value, this.text); PaymentInfo({@required this.id, @required this.value, @required this.text});
@override @override
List<Object> get props => [value, text]; List<Object> get props => [value, text, id];
} }
typedef PaymentSliderChanged = Function(PaymentInfo); typedef PaymentSliderChanged = Function(PaymentInfo);

@ -1,96 +1,71 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart'; 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/analytics.dart';
import 'package:gitjournal/error_reporting.dart';
import 'package:gitjournal/iap.dart'; import 'package:gitjournal/iap.dart';
import 'package:gitjournal/settings.dart'; import 'package:gitjournal/settings.dart';
import 'package:gitjournal/utils/logger.dart'; import 'package:gitjournal/utils/logger.dart';
import 'package:gitjournal/widgets/purchase_slider.dart'; import 'package:gitjournal/widgets/purchase_slider.dart';
class PurchaseButton extends StatelessWidget { class PurchaseButton extends StatelessWidget {
final Package package; final ProductDetails product;
PurchaseButton(this.package); PurchaseButton(this.product);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var price = package != null ? package.product.priceString : "Dev Mode"; var price = product != null ? product.price : "Dev Mode";
return RaisedButton( return RaisedButton(
child: Text('Subscribe for $price / month'), child: Text('Subscribe for $price / month'),
color: Theme.of(context).primaryColor, color: Theme.of(context).primaryColor,
padding: const EdgeInsets.fromLTRB(32.0, 16.0, 32.0, 16.0), 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 { void _initPurchase(BuildContext context) async {
try { var purchaseParam = PurchaseParam(productDetails: product);
var purchaserInfo = await Purchases.purchasePackage(package); var sentSuccess = await InAppPurchaseConnection.instance
var isPro = purchaserInfo.entitlements.all["pro"].isActive; .buyNonConsumable(purchaseParam: purchaseParam);
if (isPro) {
Settings.instance.proMode = true;
Settings.instance.proExpirationDate =
purchaserInfo.latestExpirationDate;
Settings.instance.save();
getAnalytics().logEvent( if (!sentSuccess) {
name: "purchase_screen_thank_you", var err = "Failed to send purchase request";
); var dialog = PurchaseFailedDialog(err);
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: <Widget>[
FlatButton(
child: const Text("OK"),
onPressed: () => Navigator.of(context).pop(),
),
],
);
await showDialog(context: context, builder: (context) => dialog); await showDialog(context: context, builder: (context) => dialog);
return;
} }
return null; return null;
} }
} }
Set<String> _generateSkus() {
var list = <String>{'sku_monthly_min'};
for (var i = 0; i < 50; i++) {
list.add("sku_monthly_min$i");
}
print(list);
return list;
}
class PurchaseWidget extends StatefulWidget { class PurchaseWidget extends StatefulWidget {
@override @override
_PurchaseWidgetState createState() => _PurchaseWidgetState(); _PurchaseWidgetState createState() => _PurchaseWidgetState();
} }
class _PurchaseWidgetState extends State<PurchaseWidget> { class _PurchaseWidgetState extends State<PurchaseWidget> {
List<Offering> _offerings; List<ProductDetails> _products;
Offering _selectedOffering; ProductDetails _selectedProduct;
StreamSubscription<List<PurchaseDetails>> _subscription;
final defaultSku = "sku_monthly_min2"; final defaultSku = "sku_monthly_min2";
String error = ""; String error = "";
bool pendingPurchase = false;
@override @override
void initState() { void initState() {
@ -99,32 +74,30 @@ class _PurchaseWidgetState extends State<PurchaseWidget> {
} }
Future<void> initPlatformState() async { Future<void> initPlatformState() async {
await InAppPurchases.confirmProPurchase(); // In parallel check if the purchase has been made
if (Settings.instance.proMode) { InAppPurchases.confirmProPurchase().then((void _) {
Navigator.of(context).pop(); if (Settings.instance.proMode) {
} Navigator.of(context).pop();
}
});
await Purchases.setup( final iapCon = InAppPurchaseConnection.instance;
environment['revenueCat'],
appUserId: Settings.instance.pseudoId,
);
Offerings offerings; final bool available = await iapCon.isAvailable();
try { if (!available) {
offerings = await Purchases.getOfferings();
} catch (e, stackTrace) {
logExceptionWarning(e, stackTrace);
setState(() { setState(() {
error = e.toString(); error = "Store cannot be reached";
}); });
return; return;
} }
var offeringList = offerings.all.values.toList(); final response = await iapCon.queryProductDetails(_generateSkus());
offeringList.retainWhere((Offering o) => o.identifier.contains("monthly")); if (response.error != null) {
offeringList.sort((Offering a, Offering b) => Log.e("IAP queryProductDetails: ${response.error}");
a.monthly.product.price.compareTo(b.monthly.product.price)); }
Log.i("Offerings: $offeringList"); 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 // 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 // message was in flight, we want to discard the reply rather than calling
@ -132,38 +105,87 @@ class _PurchaseWidgetState extends State<PurchaseWidget> {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_offerings = offeringList; _products = products;
_selectedOffering = _offerings.isNotEmpty ? _offerings.first : null; _selectedProduct = _products.isNotEmpty ? _products.first : null;
if (_offerings.length > 1) { if (_products.length > 1) {
for (var o in _offerings) { for (var p in _products) {
var prod = o.monthly.product; if (p.id == defaultSku) {
if (prod.identifier == defaultSku) { _selectedProduct = p;
_selectedOffering = o;
break; break;
} }
} }
} else { } else {
var fakePackageJson = { // FIXME: Add a fake product for development
'identifier': 'monthly_fake',
'product': {
'identifier': 'fake_product',
'title': 'Fake Product',
'priceString': '0 Fake',
'price': 0.0,
},
};
var fakeOffer = Offering.fromJson(<String, dynamic>{
'identifier': 'monthly_fake_offering',
'monthly': fakePackageJson,
'availablePackages': [fakePackageJson],
});
_offerings = [fakeOffer];
_selectedOffering = _offerings[0];
} }
}); });
// Start listening for changes
final purchaseUpdates = iapCon.purchaseUpdatedStream;
_subscription = purchaseUpdates.listen(_listenToPurchaseUpdated);
}
void _listenToPurchaseUpdated(List<PurchaseDetails> 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 @override
@ -171,20 +193,35 @@ class _PurchaseWidgetState extends State<PurchaseWidget> {
if (error.isNotEmpty) { if (error.isNotEmpty) {
return Text("Failed to load: $error"); return Text("Failed to load: $error");
} }
return _offerings == null if (pendingPurchase) {
return const CircularProgressIndicator();
}
return _products == null
? const CircularProgressIndicator() ? const CircularProgressIndicator()
: buildBody(context); : buildBody(context);
} }
PaymentInfo _fromOffering(Offering o) { PaymentInfo _fromProductDetail(ProductDetails pd) {
var prod = o.monthly.product; if (pd == null) return null;
return PaymentInfo(prod.price, prod.priceString);
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) { ProductDetails _fromPaymentInfo(PaymentInfo info) {
for (var o in _offerings) { for (var p in _products) {
if (o.monthly.product.priceString == info.text) { if (p.id == info.id) {
return o; return p;
} }
} }
assert(false); assert(false);
@ -193,11 +230,11 @@ class _PurchaseWidgetState extends State<PurchaseWidget> {
Widget buildBody(BuildContext context) { Widget buildBody(BuildContext context) {
var slider = PurchaseSlider( var slider = PurchaseSlider(
values: _offerings.map(_fromOffering).toList(), values: _products.map(_fromProductDetail).toList(),
selectedValue: _fromOffering(_selectedOffering), selectedValue: _fromProductDetail(_selectedProduct),
onChanged: (PaymentInfo info) { onChanged: (PaymentInfo info) {
setState(() { setState(() {
_selectedOffering = _fromPaymentInfo(info); _selectedProduct = _fromPaymentInfo(info);
}); });
}, },
); );
@ -210,7 +247,7 @@ class _PurchaseWidgetState extends State<PurchaseWidget> {
icon: const Icon(Icons.arrow_left), icon: const Icon(Icons.arrow_left),
onPressed: () { onPressed: () {
setState(() { setState(() {
_selectedOffering = _prevOffering(); _selectedProduct = _prevProduct();
}); });
}, },
), ),
@ -219,7 +256,7 @@ class _PurchaseWidgetState extends State<PurchaseWidget> {
icon: const Icon(Icons.arrow_right), icon: const Icon(Icons.arrow_right),
onPressed: () { onPressed: () {
setState(() { setState(() {
_selectedOffering = _nextOffering(); _selectedProduct = _nextProduct();
}); });
}, },
), ),
@ -227,26 +264,26 @@ class _PurchaseWidgetState extends State<PurchaseWidget> {
mainAxisSize: MainAxisSize.max, mainAxisSize: MainAxisSize.max,
), ),
const SizedBox(height: 16.0), const SizedBox(height: 16.0),
PurchaseButton(_selectedOffering?.monthly), PurchaseButton(_selectedProduct),
], ],
mainAxisAlignment: MainAxisAlignment.spaceAround, mainAxisAlignment: MainAxisAlignment.spaceAround,
); );
} }
Offering _prevOffering() { ProductDetails _prevProduct() {
for (var i = 0; i < _offerings.length; i++) { for (var i = 0; i < _products.length; i++) {
if (_offerings[i] == _selectedOffering) { if (_products[i] == _selectedProduct) {
return i > 0 ? _offerings[i - 1] : _offerings[i]; return i > 0 ? _products[i - 1] : _products[i];
} }
} }
return null; return null;
} }
Offering _nextOffering() { ProductDetails _nextProduct() {
for (var i = 0; i < _offerings.length; i++) { for (var i = 0; i < _products.length; i++) {
if (_offerings[i] == _selectedOffering) { if (_products[i] == _selectedProduct) {
return i < _offerings.length - 1 ? _offerings[i + 1] : _offerings[i]; 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: <Widget>[
FlatButton(
child: const Text("OK"),
onPressed: () => Navigator.of(context).pop(),
),
],
);
}
}

@ -140,7 +140,7 @@ packages:
name: crypto name: crypto
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.1.4" version: "2.1.5"
csslib: csslib:
dependency: transitive dependency: transitive
description: description:
@ -206,6 +206,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
fake_async:
dependency: transitive
description:
name: fake_async
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.0"
file: file:
dependency: transitive dependency: transitive
description: description:
@ -475,7 +482,7 @@ packages:
name: json_rpc_2 name: json_rpc_2
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.1.0" version: "2.2.0"
launch_review: launch_review:
dependency: "direct main" dependency: "direct main"
description: description:
@ -582,7 +589,7 @@ packages:
name: path name: path
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.6.4" version: "1.7.0"
path_provider: path_provider:
dependency: transitive dependency: transitive
description: description:
@ -652,7 +659,7 @@ packages:
name: process name: process
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "3.0.12" version: "3.0.13"
provider: provider:
dependency: "direct main" dependency: "direct main"
description: description:
@ -674,13 +681,6 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.4.4" 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: quick_actions:
dependency: "direct main" dependency: "direct main"
description: description:
@ -895,21 +895,21 @@ packages:
name: test name: test
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "1.14.4" version: "1.14.7"
test_api: test_api:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.2.15" version: "0.2.16"
test_core: test_core:
dependency: transitive dependency: transitive
description: description:
name: test_core name: test_core
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.3.4" version: "0.3.7"
tool_base: tool_base:
dependency: transitive dependency: transitive
description: description:

@ -46,7 +46,6 @@ dependencies:
sentry: ">=3.0.0 <4.0.0" sentry: ">=3.0.0 <4.0.0"
flutter_sentry: ^0.4.2 flutter_sentry: ^0.4.2
equatable: ^1.1.0 equatable: ^1.1.0
purchases_flutter: ^1.1.0
cached_network_image: ^2.2.0+1 cached_network_image: ^2.2.0+1
ssh_key: ^0.5.1 ssh_key: ^0.5.1
isolate: ^2.0.3 isolate: ^2.0.3

@ -4,7 +4,6 @@ import 'dart:io';
Future<void> main() async { Future<void> main() async {
final config = { final config = {
'sentry': Platform.environment['SENTRY_DSN'], 'sentry': Platform.environment['SENTRY_DSN'],
'revenueCat': Platform.environment['REVENUE_CAT_API_KEY'],
}; };
final filename = 'lib/.env.dart'; final filename = 'lib/.env.dart';