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
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.",

View file

@ -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;

View file

@ -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.",

View file

@ -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.",

View file

@ -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.

View file

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

View file

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

View file

@ -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;

View file

@ -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 =

View file

@ -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==');

View file

@ -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));
}
}
}

View file

@ -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) {

View file

@ -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();

View file

@ -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)));
}

View file

@ -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();
}

View file

@ -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 &&

View file

@ -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(

View file

@ -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(

View file

@ -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