adds google and apple payment #162
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run

This commit is contained in:
otsmr 2025-12-21 03:33:20 +01:00
parent 074ead8b4f
commit fa953b8928
19 changed files with 130 additions and 69 deletions

View file

@ -209,11 +209,11 @@ func getPushNotificationText(pushNotification: PushNotification) -> (String, Str
let systemLanguage = Locale.current.language.languageCode?.identifier ?? "en" // Get the current system language let systemLanguage = Locale.current.language.languageCode?.identifier ?? "en" // Get the current system language
var pushNotificationText: [PushKind: String] = [:] var pushNotificationText: [PushKind: String] = [:]
var title = "Someone" var title = "[Unknown]"
// Define the messages based on the system language // Define the messages based on the system language
if systemLanguage.contains("de") { // German if systemLanguage.contains("de") { // German
title = "Jemand" title = "[Unbekannt]"
pushNotificationText = [ pushNotificationText = [
.text: "hat eine Nachricht{inGroup} gesendet.", .text: "hat eine Nachricht{inGroup} gesendet.",
.twonly: "hat ein twonly{inGroup} gesendet.", .twonly: "hat ein twonly{inGroup} gesendet.",

View file

@ -60,7 +60,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
Future<void> initAsync() async { Future<void> initAsync() async {
await setUserPlan(); await setUserPlan();
await apiService.connect(force: true); await apiService.connect();
await apiService.listenToNetworkChanges(); await apiService.listenToNetworkChanges();
} }
@ -71,7 +71,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
if (wasPaused) { if (wasPaused) {
globalIsAppInBackground = false; globalIsAppInBackground = false;
twonlyDB.markUpdated(); twonlyDB.markUpdated();
unawaited(apiService.connect(force: true)); unawaited(apiService.connect());
} }
} else if (state == AppLifecycleState.paused) { } else if (state == AppLifecycleState.paused) {
wasPaused = true; wasPaused = true;

View file

@ -411,7 +411,7 @@
"notificationReactionToImage": "hat mit {reaction} auf dein Bild reagiert.", "notificationReactionToImage": "hat mit {reaction} auf dein Bild reagiert.",
"notificationReactionToAudio": "hat mit {reaction} auf deine Sprachnachricht reagiert.", "notificationReactionToAudio": "hat mit {reaction} auf deine Sprachnachricht reagiert.",
"notificationResponse": "hat dir{inGroup} geantwortet.", "notificationResponse": "hat dir{inGroup} geantwortet.",
"notificationTitleUnknownUser": "Jemand", "notificationTitleUnknownUser": "[Unbekannt]",
"notificationCategoryMessageTitle": "Nachrichten", "notificationCategoryMessageTitle": "Nachrichten",
"notificationCategoryMessageDesc": "Nachrichten von anderen Benutzern.", "notificationCategoryMessageDesc": "Nachrichten von anderen Benutzern.",
"groupContextMenuDeleteGroup": "Dadurch werden alle Nachrichten in diesem Chat dauerhaft gelöscht.", "groupContextMenuDeleteGroup": "Dadurch werden alle Nachrichten in diesem Chat dauerhaft gelöscht.",

View file

@ -441,7 +441,7 @@
"notificationReactionToImage": "has reacted with {reaction} to your image.", "notificationReactionToImage": "has reacted with {reaction} to your image.",
"notificationReactionToAudio": "has reacted with {reaction} to your audio message.", "notificationReactionToAudio": "has reacted with {reaction} to your audio message.",
"notificationResponse": "has responded{inGroup}.", "notificationResponse": "has responded{inGroup}.",
"notificationTitleUnknownUser": "Someone", "notificationTitleUnknownUser": "[Unknown]",
"notificationCategoryMessageTitle": "Messages", "notificationCategoryMessageTitle": "Messages",
"notificationCategoryMessageDesc": "Messages from other users.", "notificationCategoryMessageDesc": "Messages from other users.",
"groupContextMenuDeleteGroup": "This will permanently delete all messages in this chat.", "groupContextMenuDeleteGroup": "This will permanently delete all messages in this chat.",

View file

@ -2567,7 +2567,7 @@ abstract class AppLocalizations {
/// No description provided for @notificationTitleUnknownUser. /// No description provided for @notificationTitleUnknownUser.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Someone'** /// **'[Unknown]'**
String get notificationTitleUnknownUser; String get notificationTitleUnknownUser;
/// No description provided for @notificationCategoryMessageTitle. /// No description provided for @notificationCategoryMessageTitle.

View file

@ -1415,7 +1415,7 @@ class AppLocalizationsDe extends AppLocalizations {
} }
@override @override
String get notificationTitleUnknownUser => 'Jemand'; String get notificationTitleUnknownUser => '[Unbekannt]';
@override @override
String get notificationCategoryMessageTitle => 'Nachrichten'; String get notificationCategoryMessageTitle => 'Nachrichten';

View file

@ -1407,7 +1407,7 @@ class AppLocalizationsEn extends AppLocalizations {
} }
@override @override
String get notificationTitleUnknownUser => 'Someone'; String get notificationTitleUnknownUser => '[Unknown]';
@override @override
String get notificationCategoryMessageTitle => 'Messages'; String get notificationCategoryMessageTitle => 'Messages';

View file

@ -88,8 +88,8 @@ class UserData {
List<int>? lastChangeLogHash; List<int>? lastChangeLogHash;
@JsonKey(defaultValue: false) @JsonKey(defaultValue: true)
bool hideChangeLog = false; bool hideChangeLog = true;
@JsonKey(defaultValue: true) @JsonKey(defaultValue: true)
bool updateFCMToken = true; bool updateFCMToken = true;

View file

@ -87,6 +87,8 @@ class ErrorCode extends $pb.ProtobufEnum {
ErrorCode._(1032, _omitEnumNames ? '' : 'InvalidProofOfWork'); ErrorCode._(1032, _omitEnumNames ? '' : 'InvalidProofOfWork');
static const ErrorCode RegistrationDisabled = static const ErrorCode RegistrationDisabled =
ErrorCode._(1033, _omitEnumNames ? '' : 'RegistrationDisabled'); ErrorCode._(1033, _omitEnumNames ? '' : 'RegistrationDisabled');
static const ErrorCode IPAPaymentExpired =
ErrorCode._(1034, _omitEnumNames ? '' : 'IPAPaymentExpired');
static const $core.List<ErrorCode> values = <ErrorCode>[ static const $core.List<ErrorCode> values = <ErrorCode>[
Unknown, Unknown,
@ -125,6 +127,7 @@ class ErrorCode extends $pb.ProtobufEnum {
NewDeviceRegistered, NewDeviceRegistered,
InvalidProofOfWork, InvalidProofOfWork,
RegistrationDisabled, RegistrationDisabled,
IPAPaymentExpired,
]; ];
static final $core.Map<$core.int, ErrorCode> _byValue = static final $core.Map<$core.int, ErrorCode> _byValue =

View file

@ -54,6 +54,7 @@ const ErrorCode$json = {
{'1': 'NewDeviceRegistered', '2': 1031}, {'1': 'NewDeviceRegistered', '2': 1031},
{'1': 'InvalidProofOfWork', '2': 1032}, {'1': 'InvalidProofOfWork', '2': 1032},
{'1': 'RegistrationDisabled', '2': 1033}, {'1': 'RegistrationDisabled', '2': 1033},
{'1': 'IPAPaymentExpired', '2': 1034},
], ],
}; };
@ -74,4 +75,5 @@ final $typed_data.Uint8List errorCodeDescriptor = $convert.base64Decode(
'bGFuRG93bmdyYWRlEIEIEhkKFFBsYW5VcGdyYWRlTm90WWVhcmx5EIIIEhgKE0ludmFsaWRTaW' 'bGFuRG93bmdyYWRlEIEIEhkKFFBsYW5VcGdyYWRlTm90WWVhcmx5EIIIEhgKE0ludmFsaWRTaW'
'duZWRQcmVLZXkQgwgSEwoOVXNlcklkTm90Rm91bmQQhAgSFwoSVXNlcklkQWxyZWFkeVRha2Vu' 'duZWRQcmVLZXkQgwgSEwoOVXNlcklkTm90Rm91bmQQhAgSFwoSVXNlcklkQWxyZWFkeVRha2Vu'
'EIUIEhcKEkFwcFZlcnNpb25PdXRkYXRlZBCGCBIYChNOZXdEZXZpY2VSZWdpc3RlcmVkEIcIEh' 'EIUIEhcKEkFwcFZlcnNpb25PdXRkYXRlZBCGCBIYChNOZXdEZXZpY2VSZWdpc3RlcmVkEIcIEh'
'cKEkludmFsaWRQcm9vZk9mV29yaxCICBIZChRSZWdpc3RyYXRpb25EaXNhYmxlZBCJCA=='); 'cKEkludmFsaWRQcm9vZk9mV29yaxCICBIZChRSZWdpc3RyYXRpb25EaXNhYmxlZBCJCBIWChFJ'
'UEFQYXltZW50RXhwaXJlZBCKCA==');

View file

@ -1,12 +1,15 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:in_app_purchase/in_app_purchase.dart'; import 'package:in_app_purchase/in_app_purchase.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
import 'package:twonly/src/constants/subscription.keys.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/model/purchases/purchasable_product.dart';
import 'package:twonly/src/services/subscription.service.dart'; import 'package:twonly/src/services/subscription.service.dart';
import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/storage.dart'; import 'package:twonly/src/utils/storage.dart';
import 'package:url_launcher/url_launcher.dart';
// Gives the option to override in tests. // Gives the option to override in tests.
class IAPConnection { class IAPConnection {
@ -23,6 +26,8 @@ class IAPConnection {
enum StoreState { loading, available, notAvailable } enum StoreState { loading, available, notAvailable }
Timer? globalForceIpaCheck;
class PurchasesProvider with ChangeNotifier, DiagnosticableTreeMixin { class PurchasesProvider with ChangeNotifier, DiagnosticableTreeMixin {
PurchasesProvider() { PurchasesProvider() {
final purchaseUpdated = iapConnection.purchaseStream; final purchaseUpdated = iapConnection.purchaseStream;
@ -32,15 +37,9 @@ class PurchasesProvider with ChangeNotifier, DiagnosticableTreeMixin {
onError: _updateStreamOnError, onError: _updateStreamOnError,
); );
forceIpaCheck = Timer(const Duration(seconds: 10), () {
Log.warn('Force Ipa check was not stopped. Requesting forced check...');
apiService.forceIpaCheck();
});
loadPurchases(); loadPurchases();
} }
late Timer forceIpaCheck;
SubscriptionPlan plan = SubscriptionPlan.Free; SubscriptionPlan plan = SubscriptionPlan.Free;
StoreState storeState = StoreState.loading; StoreState storeState = StoreState.loading;
List<PurchasableProduct> products = []; List<PurchasableProduct> products = [];
@ -48,6 +47,8 @@ class PurchasesProvider with ChangeNotifier, DiagnosticableTreeMixin {
late StreamSubscription<List<PurchaseDetails>> _subscription; late StreamSubscription<List<PurchaseDetails>> _subscription;
final InAppPurchase iapConnection = IAPConnection.instance; final InAppPurchase iapConnection = IAPConnection.instance;
bool _userTriggeredBuyButton = false;
void updatePlan(SubscriptionPlan newPlan) { void updatePlan(SubscriptionPlan newPlan) {
plan = newPlan; plan = newPlan;
notifyListeners(); notifyListeners();
@ -77,11 +78,19 @@ class PurchasesProvider with ChangeNotifier, DiagnosticableTreeMixin {
storeState = StoreState.available; storeState = StoreState.available;
notifyListeners(); 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(); await iapConnection.restorePurchases();
} }
Future<void> buy(PurchasableProduct product) async { Future<void> buy(PurchasableProduct product) async {
Log.info('User wants to buy ${product.id}');
final purchaseParam = PurchaseParam(productDetails: product.productDetails); final purchaseParam = PurchaseParam(productDetails: product.productDetails);
switch (product.id) { switch (product.id) {
// case storeKeyConsumable: // case storeKeyConsumable:
@ -89,6 +98,9 @@ class PurchasesProvider with ChangeNotifier, DiagnosticableTreeMixin {
case SubscriptionKeys.proMonthly: case SubscriptionKeys.proMonthly:
case SubscriptionKeys.proYearly: case SubscriptionKeys.proYearly:
case SubscriptionKeys.familyYearly: case SubscriptionKeys.familyYearly:
_userTriggeredBuyButton = true;
Log.info('User wants to buy ${product.id}');
await iapConnection.buyNonConsumable(purchaseParam: purchaseParam); await iapConnection.buyNonConsumable(purchaseParam: purchaseParam);
default: default:
throw ArgumentError.value( throw ArgumentError.value(
@ -108,44 +120,70 @@ class PurchasesProvider with ChangeNotifier, DiagnosticableTreeMixin {
} }
Future<bool> _verifyPurchase(PurchaseDetails purchaseDetails) async { Future<bool> _verifyPurchase(PurchaseDetails purchaseDetails) async {
Log.info(purchaseDetails.productID); if (kDebugMode) {
Log.info(purchaseDetails.verificationData.serverVerificationData); Log.info(purchaseDetails.productID);
Log.info(purchaseDetails.verificationData.source); 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( final res = await apiService.ipaPurchase(
purchaseDetails.productID, purchaseDetails.productID,
purchaseDetails.verificationData.source, purchaseDetails.verificationData.source,
purchaseDetails.verificationData.serverVerificationData, 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; return res.isSuccess;
} }
Future<void> _handlePurchase(PurchaseDetails purchaseDetails) async { Future<void> _handlePurchase(PurchaseDetails purchaseDetails) async {
var validPurchase = false; Log.info(
'_handlePurchase: ${purchaseDetails.productID}, ${purchaseDetails.status}',
);
if (purchaseDetails.status == PurchaseStatus.purchased) { if (purchaseDetails.status == PurchaseStatus.purchased) {
Log.info('purchased: ${purchaseDetails.productID}'); await _verifyPurchase(purchaseDetails);
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);
}
} }
if (purchaseDetails.status == PurchaseStatus.restored) { if (purchaseDetails.status == PurchaseStatus.restored &&
// there is a purchaseDetails.error == null) {
forceIpaCheck.cancel(); globalForceIpaCheck?.cancel();
if (gUser.subscriptionPlan != SubscriptionPlan.Family.name || final user = await getUser();
gUser.subscriptionPlan != SubscriptionPlan.Pro.name) {
// app was installed on some one other... if (user != null &&
// subscription is handled on the server, so on a new device the subscription comes from the server again... (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));
}
} }
} }

View file

@ -117,13 +117,17 @@ class ApiService {
} }
Future<void> startReconnectionTimer() async { Future<void> startReconnectionTimer() async {
if (reconnectionTimer?.isActive ?? false) {
return;
}
reconnectionTimer?.cancel(); reconnectionTimer?.cancel();
reconnectionTimer ??= Log.info('Starting reconnection timer with $_reconnectionDelay s delay');
Timer(Duration(seconds: _reconnectionDelay), () async { reconnectionTimer = Timer(Duration(seconds: _reconnectionDelay), () async {
Log.info('Reconnection timer triggered');
reconnectionTimer = null; reconnectionTimer = null;
await connect(force: true); await connect();
}); });
_reconnectionDelay += 2; _reconnectionDelay = 3;
} }
Future<void> close(Function callback) async { Future<void> close(Function callback) async {
@ -145,18 +149,13 @@ class ApiService {
.onConnectivityChanged .onConnectivityChanged
.listen((List<ConnectivityResult> result) async { .listen((List<ConnectivityResult> result) async {
if (!result.contains(ConnectivityResult.none)) { if (!result.contains(ConnectivityResult.none)) {
await connect(force: true); await connect();
} }
// Received changes in available connectivity types! // Received changes in available connectivity types!
}); });
} }
Future<bool> connect({bool force = false}) async { Future<bool> connect() async {
if (reconnectionTimer != null && !force) {
return false;
}
reconnectionTimer?.cancel();
reconnectionTimer = null;
return lockConnecting.protect<bool>(() async { return lockConnecting.protect<bool>(() async {
if (_channel != null) { if (_channel != null) {
return true; return true;
@ -292,6 +291,7 @@ class ApiService {
if (_channel == null) { if (_channel == null) {
Log.warn('sending request while api is not connected'); Log.warn('sending request while api is not connected');
if (!await connect()) { if (!await connect()) {
Log.warn('could not connected again');
return Result.error(ErrorCode.InternalError); return Result.error(ErrorCode.InternalError);
} }
if (_channel == null) { if (_channel == null) {

View file

@ -303,6 +303,15 @@ Future<PushNotification?> getPushNotificationFromEncryptedContent(
return pushNotification; 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 /// this will trigger a push notification
/// push notification only containing the message kind and username /// push notification only containing the message kind and username
Future<Uint8List?> encryptPushNotification( Future<Uint8List?> encryptPushNotification(
@ -326,15 +335,16 @@ Future<Uint8List?> encryptPushNotification(
// this will be enforced after every app uses this system... :/ // this will be enforced after every app uses this system... :/
// return null; // return null;
Log.warn('Using insecure key as the receiver does not send a push key!'); Log.warn('Using insecure key as the receiver does not send a push key!');
await requestNewPushKeysForUser(toUserId);
await sendCipherText(
toUserId,
EncryptedContent()
..pushKeys = (EncryptedContent_PushKeys()
..type = EncryptedContent_PushKeys_Type.REQUEST),
);
} }
} else { } 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 { try {
key = pushUser.pushKeys.last.key; key = pushUser.pushKeys.last.key;
keyId = pushUser.pushKeys.last.id.toInt(); keyId = pushUser.pushKeys.last.id.toInt();

View file

@ -361,3 +361,8 @@ String getAvatarSvg(Uint8List avatarSvgCompressed) {
final raw = gzip.decode(avatarSvgCompressed); final raw = gzip.decode(avatarSvgCompressed);
return utf8.decode(raw); 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)));
}

View file

@ -52,8 +52,9 @@ class MainCameraController {
} catch (e) { } catch (e) {
Log.warn(e); Log.warn(e);
} }
await cameraController?.dispose(); final cameraControllerTemp = cameraController;
cameraController = null; cameraController = null;
await cameraControllerTemp?.dispose();
initCameraStarted = false; initCameraStarted = false;
selectedCameraDetails = SelectedCameraDetails(); selectedCameraDetails = SelectedCameraDetails();
} }

View file

@ -204,7 +204,7 @@ class _ChatListViewState extends State<ChatListView> {
child: RefreshIndicator( child: RefreshIndicator(
onRefresh: () async { onRefresh: () async {
await apiService.close(() {}); await apiService.close(() {});
await apiService.connect(force: true); await apiService.connect();
await Future.delayed(const Duration(seconds: 1)); await Future.delayed(const Duration(seconds: 1));
}, },
child: (_groupsNotPinned.isEmpty && child: (_groupsNotPinned.isEmpty &&

View file

@ -50,6 +50,7 @@ class _SubscriptionViewState extends State<SubscriptionView> {
} }
} }
setState(() {}); setState(() {});
await apiService.forceIpaCheck();
} }
@override @override
@ -93,7 +94,8 @@ class _SubscriptionViewState extends State<SubscriptionView> {
PlanCard( PlanCard(
plan: currentPlan, plan: currentPlan,
), ),
if (!isPayingUser(currentPlan)) ...[ if (!isPayingUser(currentPlan) ||
currentPlan == SubscriptionPlan.Tester) ...[
Center( Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(18), padding: const EdgeInsets.all(18),
@ -404,7 +406,7 @@ Future<void> redeemUserInviteCode(BuildContext context, String newPlan) async {
); );
// reconnect to load new plan. // reconnect to load new plan.
await apiService.close(() {}); await apiService.close(() {});
await apiService.connect(force: true); await apiService.connect();
} else { } else {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(

View file

@ -548,7 +548,7 @@ Future<void> redeemUserInviteCode(BuildContext context, String newPlan) async {
); );
// reconnect to load new plan. // reconnect to load new plan.
await apiService.close(() {}); await apiService.close(() {});
await apiService.connect(force: true); await apiService.connect();
} else { } else {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(

View file

@ -3,7 +3,7 @@ description: "twonly, a privacy-friendly way to connect with friends through sec
publish_to: 'none' publish_to: 'none'
version: 0.0.76+76 version: 0.0.77+77
environment: environment:
sdk: ^3.6.0 sdk: ^3.6.0