mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-01-15 08:18:41 +00:00
adds google and apple payment #162
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
This commit is contained in:
parent
074ead8b4f
commit
fa953b8928
19 changed files with 130 additions and 69 deletions
|
|
@ -209,11 +209,11 @@ func getPushNotificationText(pushNotification: PushNotification) -> (String, Str
|
|||
let systemLanguage = Locale.current.language.languageCode?.identifier ?? "en" // Get the current system language
|
||||
|
||||
var pushNotificationText: [PushKind: String] = [:]
|
||||
var title = "Someone"
|
||||
var title = "[Unknown]"
|
||||
|
||||
// Define the messages based on the system language
|
||||
if systemLanguage.contains("de") { // German
|
||||
title = "Jemand"
|
||||
title = "[Unbekannt]"
|
||||
pushNotificationText = [
|
||||
.text: "hat eine Nachricht{inGroup} gesendet.",
|
||||
.twonly: "hat ein twonly{inGroup} gesendet.",
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||
|
||||
Future<void> initAsync() async {
|
||||
await setUserPlan();
|
||||
await apiService.connect(force: true);
|
||||
await apiService.connect();
|
||||
await apiService.listenToNetworkChanges();
|
||||
}
|
||||
|
||||
|
|
@ -71,7 +71,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||
if (wasPaused) {
|
||||
globalIsAppInBackground = false;
|
||||
twonlyDB.markUpdated();
|
||||
unawaited(apiService.connect(force: true));
|
||||
unawaited(apiService.connect());
|
||||
}
|
||||
} else if (state == AppLifecycleState.paused) {
|
||||
wasPaused = true;
|
||||
|
|
|
|||
|
|
@ -411,7 +411,7 @@
|
|||
"notificationReactionToImage": "hat mit {reaction} auf dein Bild reagiert.",
|
||||
"notificationReactionToAudio": "hat mit {reaction} auf deine Sprachnachricht reagiert.",
|
||||
"notificationResponse": "hat dir{inGroup} geantwortet.",
|
||||
"notificationTitleUnknownUser": "Jemand",
|
||||
"notificationTitleUnknownUser": "[Unbekannt]",
|
||||
"notificationCategoryMessageTitle": "Nachrichten",
|
||||
"notificationCategoryMessageDesc": "Nachrichten von anderen Benutzern.",
|
||||
"groupContextMenuDeleteGroup": "Dadurch werden alle Nachrichten in diesem Chat dauerhaft gelöscht.",
|
||||
|
|
|
|||
|
|
@ -441,7 +441,7 @@
|
|||
"notificationReactionToImage": "has reacted with {reaction} to your image.",
|
||||
"notificationReactionToAudio": "has reacted with {reaction} to your audio message.",
|
||||
"notificationResponse": "has responded{inGroup}.",
|
||||
"notificationTitleUnknownUser": "Someone",
|
||||
"notificationTitleUnknownUser": "[Unknown]",
|
||||
"notificationCategoryMessageTitle": "Messages",
|
||||
"notificationCategoryMessageDesc": "Messages from other users.",
|
||||
"groupContextMenuDeleteGroup": "This will permanently delete all messages in this chat.",
|
||||
|
|
|
|||
|
|
@ -2567,7 +2567,7 @@ abstract class AppLocalizations {
|
|||
/// No description provided for @notificationTitleUnknownUser.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Someone'**
|
||||
/// **'[Unknown]'**
|
||||
String get notificationTitleUnknownUser;
|
||||
|
||||
/// No description provided for @notificationCategoryMessageTitle.
|
||||
|
|
|
|||
|
|
@ -1415,7 +1415,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
}
|
||||
|
||||
@override
|
||||
String get notificationTitleUnknownUser => 'Jemand';
|
||||
String get notificationTitleUnknownUser => '[Unbekannt]';
|
||||
|
||||
@override
|
||||
String get notificationCategoryMessageTitle => 'Nachrichten';
|
||||
|
|
|
|||
|
|
@ -1407,7 +1407,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
}
|
||||
|
||||
@override
|
||||
String get notificationTitleUnknownUser => 'Someone';
|
||||
String get notificationTitleUnknownUser => '[Unknown]';
|
||||
|
||||
@override
|
||||
String get notificationCategoryMessageTitle => 'Messages';
|
||||
|
|
|
|||
|
|
@ -88,8 +88,8 @@ class UserData {
|
|||
|
||||
List<int>? lastChangeLogHash;
|
||||
|
||||
@JsonKey(defaultValue: false)
|
||||
bool hideChangeLog = false;
|
||||
@JsonKey(defaultValue: true)
|
||||
bool hideChangeLog = true;
|
||||
|
||||
@JsonKey(defaultValue: true)
|
||||
bool updateFCMToken = true;
|
||||
|
|
|
|||
|
|
@ -87,6 +87,8 @@ class ErrorCode extends $pb.ProtobufEnum {
|
|||
ErrorCode._(1032, _omitEnumNames ? '' : 'InvalidProofOfWork');
|
||||
static const ErrorCode RegistrationDisabled =
|
||||
ErrorCode._(1033, _omitEnumNames ? '' : 'RegistrationDisabled');
|
||||
static const ErrorCode IPAPaymentExpired =
|
||||
ErrorCode._(1034, _omitEnumNames ? '' : 'IPAPaymentExpired');
|
||||
|
||||
static const $core.List<ErrorCode> values = <ErrorCode>[
|
||||
Unknown,
|
||||
|
|
@ -125,6 +127,7 @@ class ErrorCode extends $pb.ProtobufEnum {
|
|||
NewDeviceRegistered,
|
||||
InvalidProofOfWork,
|
||||
RegistrationDisabled,
|
||||
IPAPaymentExpired,
|
||||
];
|
||||
|
||||
static final $core.Map<$core.int, ErrorCode> _byValue =
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ const ErrorCode$json = {
|
|||
{'1': 'NewDeviceRegistered', '2': 1031},
|
||||
{'1': 'InvalidProofOfWork', '2': 1032},
|
||||
{'1': 'RegistrationDisabled', '2': 1033},
|
||||
{'1': 'IPAPaymentExpired', '2': 1034},
|
||||
],
|
||||
};
|
||||
|
||||
|
|
@ -74,4 +75,5 @@ final $typed_data.Uint8List errorCodeDescriptor = $convert.base64Decode(
|
|||
'bGFuRG93bmdyYWRlEIEIEhkKFFBsYW5VcGdyYWRlTm90WWVhcmx5EIIIEhgKE0ludmFsaWRTaW'
|
||||
'duZWRQcmVLZXkQgwgSEwoOVXNlcklkTm90Rm91bmQQhAgSFwoSVXNlcklkQWxyZWFkeVRha2Vu'
|
||||
'EIUIEhcKEkFwcFZlcnNpb25PdXRkYXRlZBCGCBIYChNOZXdEZXZpY2VSZWdpc3RlcmVkEIcIEh'
|
||||
'cKEkludmFsaWRQcm9vZk9mV29yaxCICBIZChRSZWdpc3RyYXRpb25EaXNhYmxlZBCJCA==');
|
||||
'cKEkludmFsaWRQcm9vZk9mV29yaxCICBIZChRSZWdpc3RyYXRpb25EaXNhYmxlZBCJCBIWChFJ'
|
||||
'UEFQYXltZW50RXhwaXJlZBCKCA==');
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:in_app_purchase/in_app_purchase.dart';
|
||||
import 'package:twonly/globals.dart';
|
||||
import 'package:twonly/src/constants/subscription.keys.dart';
|
||||
import 'package:twonly/src/model/protobuf/api/websocket/error.pb.dart';
|
||||
import 'package:twonly/src/model/purchases/purchasable_product.dart';
|
||||
import 'package:twonly/src/services/subscription.service.dart';
|
||||
import 'package:twonly/src/utils/log.dart';
|
||||
import 'package:twonly/src/utils/storage.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
// Gives the option to override in tests.
|
||||
class IAPConnection {
|
||||
|
|
@ -23,6 +26,8 @@ class IAPConnection {
|
|||
|
||||
enum StoreState { loading, available, notAvailable }
|
||||
|
||||
Timer? globalForceIpaCheck;
|
||||
|
||||
class PurchasesProvider with ChangeNotifier, DiagnosticableTreeMixin {
|
||||
PurchasesProvider() {
|
||||
final purchaseUpdated = iapConnection.purchaseStream;
|
||||
|
|
@ -32,15 +37,9 @@ class PurchasesProvider with ChangeNotifier, DiagnosticableTreeMixin {
|
|||
onError: _updateStreamOnError,
|
||||
);
|
||||
|
||||
forceIpaCheck = Timer(const Duration(seconds: 10), () {
|
||||
Log.warn('Force Ipa check was not stopped. Requesting forced check...');
|
||||
apiService.forceIpaCheck();
|
||||
});
|
||||
loadPurchases();
|
||||
}
|
||||
|
||||
late Timer forceIpaCheck;
|
||||
|
||||
SubscriptionPlan plan = SubscriptionPlan.Free;
|
||||
StoreState storeState = StoreState.loading;
|
||||
List<PurchasableProduct> products = [];
|
||||
|
|
@ -48,6 +47,8 @@ class PurchasesProvider with ChangeNotifier, DiagnosticableTreeMixin {
|
|||
late StreamSubscription<List<PurchaseDetails>> _subscription;
|
||||
final InAppPurchase iapConnection = IAPConnection.instance;
|
||||
|
||||
bool _userTriggeredBuyButton = false;
|
||||
|
||||
void updatePlan(SubscriptionPlan newPlan) {
|
||||
plan = newPlan;
|
||||
notifyListeners();
|
||||
|
|
@ -77,11 +78,19 @@ class PurchasesProvider with ChangeNotifier, DiagnosticableTreeMixin {
|
|||
storeState = StoreState.available;
|
||||
notifyListeners();
|
||||
|
||||
final user = await getUser();
|
||||
if (user != null && isPayingUser(planFromString(user.subscriptionPlan))) {
|
||||
Log.info('Started IPA timer for verification.');
|
||||
globalForceIpaCheck = Timer(const Duration(seconds: 5), () async {
|
||||
Log.warn('Force Ipa check was not stopped. Requesting forced check...');
|
||||
await apiService.forceIpaCheck();
|
||||
});
|
||||
}
|
||||
|
||||
await iapConnection.restorePurchases();
|
||||
}
|
||||
|
||||
Future<void> buy(PurchasableProduct product) async {
|
||||
Log.info('User wants to buy ${product.id}');
|
||||
final purchaseParam = PurchaseParam(productDetails: product.productDetails);
|
||||
switch (product.id) {
|
||||
// case storeKeyConsumable:
|
||||
|
|
@ -89,6 +98,9 @@ class PurchasesProvider with ChangeNotifier, DiagnosticableTreeMixin {
|
|||
case SubscriptionKeys.proMonthly:
|
||||
case SubscriptionKeys.proYearly:
|
||||
case SubscriptionKeys.familyYearly:
|
||||
_userTriggeredBuyButton = true;
|
||||
Log.info('User wants to buy ${product.id}');
|
||||
|
||||
await iapConnection.buyNonConsumable(purchaseParam: purchaseParam);
|
||||
default:
|
||||
throw ArgumentError.value(
|
||||
|
|
@ -108,44 +120,70 @@ class PurchasesProvider with ChangeNotifier, DiagnosticableTreeMixin {
|
|||
}
|
||||
|
||||
Future<bool> _verifyPurchase(PurchaseDetails purchaseDetails) async {
|
||||
Log.info(purchaseDetails.productID);
|
||||
Log.info(purchaseDetails.verificationData.serverVerificationData);
|
||||
Log.info(purchaseDetails.verificationData.source);
|
||||
if (kDebugMode) {
|
||||
Log.info(purchaseDetails.productID);
|
||||
Log.info(purchaseDetails.verificationData.serverVerificationData);
|
||||
// if (Platform.isIOS) {
|
||||
// final data = purchaseDetails.verificationData.serverVerificationData;
|
||||
// printWrapped(data);
|
||||
// final datas = data.split('.')[1];
|
||||
// printWrapped(datas);
|
||||
// }
|
||||
Log.info(purchaseDetails.verificationData.source);
|
||||
}
|
||||
final res = await apiService.ipaPurchase(
|
||||
purchaseDetails.productID,
|
||||
purchaseDetails.verificationData.source,
|
||||
purchaseDetails.verificationData.serverVerificationData,
|
||||
);
|
||||
// plan is updated in the apiProvider, as the server updates its states and responses with
|
||||
// an ok authenticated which is processed in the apiProvider...
|
||||
if (res.isSuccess) {
|
||||
if (Platform.isAndroid) {
|
||||
await updateUserdata((u) {
|
||||
u.subscriptionPlanIdStore = purchaseDetails.productID;
|
||||
return u;
|
||||
});
|
||||
}
|
||||
}
|
||||
if (res.isError) {
|
||||
if (res.error == ErrorCode.IPAPaymentExpired &&
|
||||
_userTriggeredBuyButton &&
|
||||
Platform.isIOS) {
|
||||
await launchUrl(
|
||||
Uri.parse('https://apps.apple.com/account/subscriptions'),
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
}
|
||||
}
|
||||
return res.isSuccess;
|
||||
}
|
||||
|
||||
Future<void> _handlePurchase(PurchaseDetails purchaseDetails) async {
|
||||
var validPurchase = false;
|
||||
Log.info(
|
||||
'_handlePurchase: ${purchaseDetails.productID}, ${purchaseDetails.status}',
|
||||
);
|
||||
if (purchaseDetails.status == PurchaseStatus.purchased) {
|
||||
Log.info('purchased: ${purchaseDetails.productID}');
|
||||
validPurchase = await _verifyPurchase(purchaseDetails);
|
||||
if (validPurchase) {
|
||||
var plan = SubscriptionPlan.Pro;
|
||||
if (purchaseDetails.productID.contains('family')) {
|
||||
plan = SubscriptionPlan.Family;
|
||||
}
|
||||
await updateUserdata((u) {
|
||||
u
|
||||
..subscriptionPlan = plan.name
|
||||
..subscriptionPlanIdStore = purchaseDetails.productID;
|
||||
return u;
|
||||
});
|
||||
updatePlan(plan);
|
||||
}
|
||||
await _verifyPurchase(purchaseDetails);
|
||||
}
|
||||
if (purchaseDetails.status == PurchaseStatus.restored) {
|
||||
// there is a
|
||||
forceIpaCheck.cancel();
|
||||
if (purchaseDetails.status == PurchaseStatus.restored &&
|
||||
purchaseDetails.error == null) {
|
||||
globalForceIpaCheck?.cancel();
|
||||
|
||||
if (gUser.subscriptionPlan != SubscriptionPlan.Family.name ||
|
||||
gUser.subscriptionPlan != SubscriptionPlan.Pro.name) {
|
||||
// app was installed on some one other...
|
||||
// subscription is handled on the server, so on a new device the subscription comes from the server again...
|
||||
final user = await getUser();
|
||||
|
||||
if (user != null &&
|
||||
(user.subscriptionPlan != SubscriptionPlan.Family.name &&
|
||||
user.subscriptionPlan != SubscriptionPlan.Pro.name)) {
|
||||
for (var i = 0; i < 100; i++) {
|
||||
if (apiService.isAuthenticated) {
|
||||
Log.info(
|
||||
'current user does not have a sub: ${purchaseDetails.productID}');
|
||||
await _verifyPurchase(purchaseDetails);
|
||||
break;
|
||||
}
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -117,13 +117,17 @@ class ApiService {
|
|||
}
|
||||
|
||||
Future<void> startReconnectionTimer() async {
|
||||
if (reconnectionTimer?.isActive ?? false) {
|
||||
return;
|
||||
}
|
||||
reconnectionTimer?.cancel();
|
||||
reconnectionTimer ??=
|
||||
Timer(Duration(seconds: _reconnectionDelay), () async {
|
||||
Log.info('Starting reconnection timer with $_reconnectionDelay s delay');
|
||||
reconnectionTimer = Timer(Duration(seconds: _reconnectionDelay), () async {
|
||||
Log.info('Reconnection timer triggered');
|
||||
reconnectionTimer = null;
|
||||
await connect(force: true);
|
||||
await connect();
|
||||
});
|
||||
_reconnectionDelay += 2;
|
||||
_reconnectionDelay = 3;
|
||||
}
|
||||
|
||||
Future<void> close(Function callback) async {
|
||||
|
|
@ -145,18 +149,13 @@ class ApiService {
|
|||
.onConnectivityChanged
|
||||
.listen((List<ConnectivityResult> result) async {
|
||||
if (!result.contains(ConnectivityResult.none)) {
|
||||
await connect(force: true);
|
||||
await connect();
|
||||
}
|
||||
// Received changes in available connectivity types!
|
||||
});
|
||||
}
|
||||
|
||||
Future<bool> connect({bool force = false}) async {
|
||||
if (reconnectionTimer != null && !force) {
|
||||
return false;
|
||||
}
|
||||
reconnectionTimer?.cancel();
|
||||
reconnectionTimer = null;
|
||||
Future<bool> connect() async {
|
||||
return lockConnecting.protect<bool>(() async {
|
||||
if (_channel != null) {
|
||||
return true;
|
||||
|
|
@ -292,6 +291,7 @@ class ApiService {
|
|||
if (_channel == null) {
|
||||
Log.warn('sending request while api is not connected');
|
||||
if (!await connect()) {
|
||||
Log.warn('could not connected again');
|
||||
return Result.error(ErrorCode.InternalError);
|
||||
}
|
||||
if (_channel == null) {
|
||||
|
|
|
|||
|
|
@ -303,6 +303,15 @@ Future<PushNotification?> getPushNotificationFromEncryptedContent(
|
|||
return pushNotification;
|
||||
}
|
||||
|
||||
Future<void> requestNewPushKeysForUser(int toUserId) async {
|
||||
await sendCipherText(
|
||||
toUserId,
|
||||
EncryptedContent()
|
||||
..pushKeys = (EncryptedContent_PushKeys()
|
||||
..type = EncryptedContent_PushKeys_Type.REQUEST),
|
||||
);
|
||||
}
|
||||
|
||||
/// this will trigger a push notification
|
||||
/// push notification only containing the message kind and username
|
||||
Future<Uint8List?> encryptPushNotification(
|
||||
|
|
@ -326,15 +335,16 @@ Future<Uint8List?> encryptPushNotification(
|
|||
// this will be enforced after every app uses this system... :/
|
||||
// return null;
|
||||
Log.warn('Using insecure key as the receiver does not send a push key!');
|
||||
|
||||
await sendCipherText(
|
||||
toUserId,
|
||||
EncryptedContent()
|
||||
..pushKeys = (EncryptedContent_PushKeys()
|
||||
..type = EncryptedContent_PushKeys_Type.REQUEST),
|
||||
);
|
||||
await requestNewPushKeysForUser(toUserId);
|
||||
}
|
||||
} else {
|
||||
final createdAt = DateTime.fromMillisecondsSinceEpoch(
|
||||
pushUser.pushKeys.last.createdAtUnixTimestamp.toInt(),
|
||||
);
|
||||
final timeBefore = DateTime.now().subtract(const Duration(days: 8));
|
||||
if (createdAt.isBefore(timeBefore)) {
|
||||
await requestNewPushKeysForUser(toUserId);
|
||||
}
|
||||
try {
|
||||
key = pushUser.pushKeys.last.key;
|
||||
keyId = pushUser.pushKeys.last.id.toInt();
|
||||
|
|
|
|||
|
|
@ -361,3 +361,8 @@ String getAvatarSvg(Uint8List avatarSvgCompressed) {
|
|||
final raw = gzip.decode(avatarSvgCompressed);
|
||||
return utf8.decode(raw);
|
||||
}
|
||||
|
||||
void printWrapped(String text) {
|
||||
final pattern = RegExp('.{1,800}'); // 800 is the size of each chunk
|
||||
pattern.allMatches(text).forEach((match) => print(match.group(0)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,8 +52,9 @@ class MainCameraController {
|
|||
} catch (e) {
|
||||
Log.warn(e);
|
||||
}
|
||||
await cameraController?.dispose();
|
||||
final cameraControllerTemp = cameraController;
|
||||
cameraController = null;
|
||||
await cameraControllerTemp?.dispose();
|
||||
initCameraStarted = false;
|
||||
selectedCameraDetails = SelectedCameraDetails();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -204,7 +204,7 @@ class _ChatListViewState extends State<ChatListView> {
|
|||
child: RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
await apiService.close(() {});
|
||||
await apiService.connect(force: true);
|
||||
await apiService.connect();
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
},
|
||||
child: (_groupsNotPinned.isEmpty &&
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ class _SubscriptionViewState extends State<SubscriptionView> {
|
|||
}
|
||||
}
|
||||
setState(() {});
|
||||
await apiService.forceIpaCheck();
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -93,7 +94,8 @@ class _SubscriptionViewState extends State<SubscriptionView> {
|
|||
PlanCard(
|
||||
plan: currentPlan,
|
||||
),
|
||||
if (!isPayingUser(currentPlan)) ...[
|
||||
if (!isPayingUser(currentPlan) ||
|
||||
currentPlan == SubscriptionPlan.Tester) ...[
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(18),
|
||||
|
|
@ -404,7 +406,7 @@ Future<void> redeemUserInviteCode(BuildContext context, String newPlan) async {
|
|||
);
|
||||
// reconnect to load new plan.
|
||||
await apiService.close(() {});
|
||||
await apiService.connect(force: true);
|
||||
await apiService.connect();
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
|
|
|
|||
|
|
@ -548,7 +548,7 @@ Future<void> redeemUserInviteCode(BuildContext context, String newPlan) async {
|
|||
);
|
||||
// reconnect to load new plan.
|
||||
await apiService.close(() {});
|
||||
await apiService.connect(force: true);
|
||||
await apiService.connect();
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ description: "twonly, a privacy-friendly way to connect with friends through sec
|
|||
|
||||
publish_to: 'none'
|
||||
|
||||
version: 0.0.76+76
|
||||
version: 0.0.77+77
|
||||
|
||||
environment:
|
||||
sdk: ^3.6.0
|
||||
|
|
|
|||
Loading…
Reference in a new issue