From fa953b8928015b3777402d946ef50155a38aac47 Mon Sep 17 00:00:00 2001 From: otsmr Date: Sun, 21 Dec 2025 03:33:20 +0100 Subject: [PATCH] adds google and apple payment #162 --- .../NotificationService.swift | 4 +- lib/app.dart | 4 +- lib/src/localization/app_de.arb | 2 +- lib/src/localization/app_en.arb | 2 +- .../generated/app_localizations.dart | 2 +- .../generated/app_localizations_de.dart | 2 +- .../generated/app_localizations_en.dart | 2 +- lib/src/model/json/userdata.dart | 4 +- .../protobuf/api/websocket/error.pbenum.dart | 3 + .../protobuf/api/websocket/error.pbjson.dart | 4 +- lib/src/providers/purchases.provider.dart | 104 ++++++++++++------ lib/src/services/api.service.dart | 22 ++-- .../notifications/pushkeys.notifications.dart | 24 ++-- lib/src/utils/misc.dart | 5 + .../main_camera_controller.dart | 3 +- lib/src/views/chats/chat_list.view.dart | 2 +- .../subscription/subscription.view.dart | 6 +- .../subscription.view.dart | 2 +- pubspec.yaml | 2 +- 19 files changed, 130 insertions(+), 69 deletions(-) diff --git a/ios/NotificationService/NotificationService.swift b/ios/NotificationService/NotificationService.swift index d3db8e5..ab236bd 100644 --- a/ios/NotificationService/NotificationService.swift +++ b/ios/NotificationService/NotificationService.swift @@ -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.", diff --git a/lib/app.dart b/lib/app.dart index 4fd9ef9..67b06e5 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -60,7 +60,7 @@ class _AppState extends State with WidgetsBindingObserver { Future initAsync() async { await setUserPlan(); - await apiService.connect(force: true); + await apiService.connect(); await apiService.listenToNetworkChanges(); } @@ -71,7 +71,7 @@ class _AppState extends State with WidgetsBindingObserver { if (wasPaused) { globalIsAppInBackground = false; twonlyDB.markUpdated(); - unawaited(apiService.connect(force: true)); + unawaited(apiService.connect()); } } else if (state == AppLifecycleState.paused) { wasPaused = true; diff --git a/lib/src/localization/app_de.arb b/lib/src/localization/app_de.arb index d06e5f2..c5cb6f9 100644 --- a/lib/src/localization/app_de.arb +++ b/lib/src/localization/app_de.arb @@ -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.", diff --git a/lib/src/localization/app_en.arb b/lib/src/localization/app_en.arb index fcd8e6c..a1440f8 100644 --- a/lib/src/localization/app_en.arb +++ b/lib/src/localization/app_en.arb @@ -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.", diff --git a/lib/src/localization/generated/app_localizations.dart b/lib/src/localization/generated/app_localizations.dart index eb37a94..0c16111 100644 --- a/lib/src/localization/generated/app_localizations.dart +++ b/lib/src/localization/generated/app_localizations.dart @@ -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. diff --git a/lib/src/localization/generated/app_localizations_de.dart b/lib/src/localization/generated/app_localizations_de.dart index 20236e0..0e5a1db 100644 --- a/lib/src/localization/generated/app_localizations_de.dart +++ b/lib/src/localization/generated/app_localizations_de.dart @@ -1415,7 +1415,7 @@ class AppLocalizationsDe extends AppLocalizations { } @override - String get notificationTitleUnknownUser => 'Jemand'; + String get notificationTitleUnknownUser => '[Unbekannt]'; @override String get notificationCategoryMessageTitle => 'Nachrichten'; diff --git a/lib/src/localization/generated/app_localizations_en.dart b/lib/src/localization/generated/app_localizations_en.dart index 2137aba..e42de3b 100644 --- a/lib/src/localization/generated/app_localizations_en.dart +++ b/lib/src/localization/generated/app_localizations_en.dart @@ -1407,7 +1407,7 @@ class AppLocalizationsEn extends AppLocalizations { } @override - String get notificationTitleUnknownUser => 'Someone'; + String get notificationTitleUnknownUser => '[Unknown]'; @override String get notificationCategoryMessageTitle => 'Messages'; diff --git a/lib/src/model/json/userdata.dart b/lib/src/model/json/userdata.dart index 94308f3..cede1c9 100644 --- a/lib/src/model/json/userdata.dart +++ b/lib/src/model/json/userdata.dart @@ -88,8 +88,8 @@ class UserData { List? lastChangeLogHash; - @JsonKey(defaultValue: false) - bool hideChangeLog = false; + @JsonKey(defaultValue: true) + bool hideChangeLog = true; @JsonKey(defaultValue: true) bool updateFCMToken = true; diff --git a/lib/src/model/protobuf/api/websocket/error.pbenum.dart b/lib/src/model/protobuf/api/websocket/error.pbenum.dart index 83a8518..69c76f0 100644 --- a/lib/src/model/protobuf/api/websocket/error.pbenum.dart +++ b/lib/src/model/protobuf/api/websocket/error.pbenum.dart @@ -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 values = [ Unknown, @@ -125,6 +127,7 @@ class ErrorCode extends $pb.ProtobufEnum { NewDeviceRegistered, InvalidProofOfWork, RegistrationDisabled, + IPAPaymentExpired, ]; static final $core.Map<$core.int, ErrorCode> _byValue = diff --git a/lib/src/model/protobuf/api/websocket/error.pbjson.dart b/lib/src/model/protobuf/api/websocket/error.pbjson.dart index 13a2e18..9f69260 100644 --- a/lib/src/model/protobuf/api/websocket/error.pbjson.dart +++ b/lib/src/model/protobuf/api/websocket/error.pbjson.dart @@ -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=='); diff --git a/lib/src/providers/purchases.provider.dart b/lib/src/providers/purchases.provider.dart index 453a2f9..ab6520d 100644 --- a/lib/src/providers/purchases.provider.dart +++ b/lib/src/providers/purchases.provider.dart @@ -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 products = []; @@ -48,6 +47,8 @@ class PurchasesProvider with ChangeNotifier, DiagnosticableTreeMixin { late StreamSubscription> _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 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 _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 _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)); + } } } diff --git a/lib/src/services/api.service.dart b/lib/src/services/api.service.dart index f9449c4..36ad7bc 100644 --- a/lib/src/services/api.service.dart +++ b/lib/src/services/api.service.dart @@ -117,13 +117,17 @@ class ApiService { } Future 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 close(Function callback) async { @@ -145,18 +149,13 @@ class ApiService { .onConnectivityChanged .listen((List result) async { if (!result.contains(ConnectivityResult.none)) { - await connect(force: true); + await connect(); } // Received changes in available connectivity types! }); } - Future connect({bool force = false}) async { - if (reconnectionTimer != null && !force) { - return false; - } - reconnectionTimer?.cancel(); - reconnectionTimer = null; + Future connect() async { return lockConnecting.protect(() 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) { diff --git a/lib/src/services/notifications/pushkeys.notifications.dart b/lib/src/services/notifications/pushkeys.notifications.dart index cc5ce19..c3555aa 100644 --- a/lib/src/services/notifications/pushkeys.notifications.dart +++ b/lib/src/services/notifications/pushkeys.notifications.dart @@ -303,6 +303,15 @@ Future getPushNotificationFromEncryptedContent( return pushNotification; } +Future 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 encryptPushNotification( @@ -326,15 +335,16 @@ Future 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(); diff --git a/lib/src/utils/misc.dart b/lib/src/utils/misc.dart index 121a9c0..e561563 100644 --- a/lib/src/utils/misc.dart +++ b/lib/src/utils/misc.dart @@ -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))); +} diff --git a/lib/src/views/camera/camera_preview_components/main_camera_controller.dart b/lib/src/views/camera/camera_preview_components/main_camera_controller.dart index bc09f70..8250a53 100644 --- a/lib/src/views/camera/camera_preview_components/main_camera_controller.dart +++ b/lib/src/views/camera/camera_preview_components/main_camera_controller.dart @@ -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(); } diff --git a/lib/src/views/chats/chat_list.view.dart b/lib/src/views/chats/chat_list.view.dart index 6cc05b0..663a088 100644 --- a/lib/src/views/chats/chat_list.view.dart +++ b/lib/src/views/chats/chat_list.view.dart @@ -204,7 +204,7 @@ class _ChatListViewState extends State { 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 && diff --git a/lib/src/views/settings/subscription/subscription.view.dart b/lib/src/views/settings/subscription/subscription.view.dart index e98d999..b54c05d 100644 --- a/lib/src/views/settings/subscription/subscription.view.dart +++ b/lib/src/views/settings/subscription/subscription.view.dart @@ -50,6 +50,7 @@ class _SubscriptionViewState extends State { } } setState(() {}); + await apiService.forceIpaCheck(); } @override @@ -93,7 +94,8 @@ class _SubscriptionViewState extends State { 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 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( diff --git a/lib/src/views/settings/subscription_custom/subscription.view.dart b/lib/src/views/settings/subscription_custom/subscription.view.dart index 96b5012..372bfb1 100644 --- a/lib/src/views/settings/subscription_custom/subscription.view.dart +++ b/lib/src/views/settings/subscription_custom/subscription.view.dart @@ -548,7 +548,7 @@ Future 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( diff --git a/pubspec.yaml b/pubspec.yaml index c41e77b..17e2f00 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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