This commit is contained in:
otsmr 2025-06-03 15:19:35 +02:00
parent 036c4cab77
commit 588a790554
24 changed files with 171 additions and 97 deletions

View file

@ -3,6 +3,7 @@ import 'package:twonly/globals.dart';
import 'package:twonly/src/localization/generated/app_localizations.dart';
import 'package:twonly/src/providers/connection.provider.dart';
import 'package:twonly/src/providers/settings.provider.dart';
import 'package:twonly/src/services/api/media_send.dart';
import 'package:twonly/src/services/notification.service.dart';
import 'package:twonly/src/utils/storage.dart';
import 'package:twonly/src/views/onboarding.view.dart';
@ -71,7 +72,9 @@ class _AppState extends State<App> with WidgetsBindingObserver {
Future initAsync() async {
setUserPlan();
apiService.connect();
await apiService.connect();
// call this function so invalid media files are get purged
retryMediaUpload(true);
}
@override

View file

@ -42,6 +42,7 @@ void main() async {
apiService = ApiService();
twonlyDB = TwonlyDatabase();
await twonlyDB.messagesDao.resetPendingDownloadState();
await twonlyDB.messagesDao.handleMediaFilesOlderThan7Days();
// purge media files in the background
purgeReceivedMediaFiles();

View file

@ -69,6 +69,22 @@ class MessagesDao extends DatabaseAccessor<TwonlyDatabase>
.write(MessagesCompanion(contentJson: Value(null)));
}
Future handleMediaFilesOlderThan7Days() {
/// media files will be deleted by the server after 7 days, so delete them here also
return (update(messages)
..where(
(t) => (t.kind.equals(MessageKind.media.name) &
t.openedAt.isNull() &
t.messageOtherId.isNull() &
(t.sendAt.isSmallerThanValue(
DateTime.now().subtract(
Duration(days: 8),
),
))),
))
.write(MessagesCompanion(errorWhileSending: Value(true)));
}
Future<List<Message>> getAllMessagesPendingDownloading() {
return (select(messages)
..where(

View file

@ -13,7 +13,8 @@ enum MessageKind {
flameSync,
opened,
ack,
pushKey
pushKey,
receiveMediaError,
}
enum DownloadState {

View file

@ -228,7 +228,7 @@
"checkoutOptions": "Optionen",
"checkoutPayYearly": "Jährlich bezahlen",
"checkoutTotal": "Gesamt",
"selectPaymentMethode": "Zahlungsmethode auswählen",
"selectPaymentMethod": "Zahlungsmethode auswählen",
"twonlyCredit": "twonly-Guthaben",
"notEnoughCredit": "Du hast nicht genügend Guthaben!",
"chargeCredit": "Guthaben aufladen",

View file

@ -386,7 +386,7 @@
"refund": "Refund",
"checkoutPayYearly": "Pay yearly",
"checkoutTotal": "Total",
"selectPaymentMethode": "Select Payment Method",
"selectPaymentMethod": "Select Payment Method",
"twonlyCredit": "twonly-Credit",
"notEnoughCredit": "You do not have enough credit!",
"chargeCredit": "Charge credit",

View file

@ -1382,11 +1382,11 @@ abstract class AppLocalizations {
/// **'Total'**
String get checkoutTotal;
/// No description provided for @selectPaymentMethode.
/// No description provided for @selectPaymentMethod.
///
/// In en, this message translates to:
/// **'Select Payment Method'**
String get selectPaymentMethode;
String get selectPaymentMethod;
/// No description provided for @twonlyCredit.
///

View file

@ -725,7 +725,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get checkoutTotal => 'Gesamt';
@override
String get selectPaymentMethode => 'Zahlungsmethode auswählen';
String get selectPaymentMethod => 'Zahlungsmethode auswählen';
@override
String get twonlyCredit => 'twonly-Guthaben';

View file

@ -720,7 +720,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get checkoutTotal => 'Total';
@override
String get selectPaymentMethode => 'Select Payment Method';
String get selectPaymentMethod => 'Select Payment Method';
@override
String get twonlyCredit => 'twonly-Credit';

View file

@ -39,11 +39,12 @@ class MessageJson {
final int? messageId;
DateTime timestamp;
MessageJson(
{required this.kind,
this.messageId,
required this.content,
required this.timestamp});
MessageJson({
required this.kind,
this.messageId,
required this.content,
required this.timestamp,
});
@override
String toString() {

View file

@ -51,7 +51,7 @@ class ApiService {
// reconnection params
Timer? reconnectionTimer;
// int _reconnectionDelay = 5;
int _reconnectionDelay = 5;
final HashMap<Int64, server.ServerToClient?> messagesV0 = HashMap();
IOWebSocketChannel? _channel;
@ -81,7 +81,7 @@ class ApiService {
if (!globalIsAppInBackground) {
retransmitRawBytes();
tryTransmitMessages();
retryMediaUpload();
retryMediaUpload(false);
tryDownloadAllMediaFiles();
notifyContactsAboutProfileChange();
twonlyDB.markUpdated();
@ -92,6 +92,7 @@ class ApiService {
Future onConnected() async {
await authenticate();
_reconnectionDelay = 5;
globalCallbackConnectionState(true);
}
@ -100,6 +101,12 @@ class ApiService {
isAuthenticated = false;
globalCallbackConnectionState(false);
await twonlyDB.messagesDao.resetPendingDownloadState();
reconnectionTimer ??= Timer(Duration(seconds: _reconnectionDelay), () {
Log.info("starting with reconnection.");
reconnectionTimer = null;
connect();
});
_reconnectionDelay += 5;
}
Future close(Function callback) async {
@ -113,11 +120,13 @@ class ApiService {
callback();
}
Future<bool> connect() async {
Future<bool> connect({bool force = false}) async {
if (reconnectionTimer != null && !force) {
return false;
}
reconnectionTimer?.cancel();
final user = await getUser();
if (user != null && user.isDemoUser) {
print("DEMO user");
// the demo user should not be able to connect to the API server...
globalCallbackConnectionState(true);
return false;
}
@ -126,9 +135,7 @@ class ApiService {
return true;
}
// ensure that the connect function is not called again by the timer.
if (reconnectionTimer != null) {
reconnectionTimer!.cancel();
}
reconnectionTimer?.cancel();
isAuthenticated = false;

View file

@ -12,8 +12,7 @@ import 'package:http/http.dart' as http;
// import 'package:twonly/src/providers/api/api_utils.dart';
import 'package:twonly/src/services/api/media_send.dart';
import 'package:cryptography_plus/cryptography_plus.dart';
import 'package:twonly/src/model/protobuf/api/client_to_server.pb.dart'
as client;
import 'package:twonly/src/services/api/utils.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/storage.dart';
@ -75,7 +74,8 @@ Future<bool> isAllowedToDownload(bool isVideo) async {
return false;
}
Future startDownloadMedia(Message message, bool force) async {
Future startDownloadMedia(Message message, bool force,
{int retryCounter = 0}) async {
if (message.contentJson == null) return;
if (downloadStartedForMediaReceived[message.messageId] != null) {
DateTime started = downloadStartedForMediaReceived[message.messageId]!;
@ -151,12 +151,10 @@ Future startDownloadMedia(Message message, bool force) async {
}, onDone: () async {
if (r.statusCode != 200) {
Log.error("Download error: $r");
await twonlyDB.messagesDao.updateMessageByMessageId(
message.messageId,
MessagesCompanion(
errorWhileSending: Value(true),
),
);
if (r.statusCode == 418) {
Log.error("Got custom error code: ${chunks.toList()}");
handleMediaError(message);
}
return;
}
@ -172,13 +170,18 @@ Future startDownloadMedia(Message message, bool force) async {
offset += chunk.length;
}
await writeMediaFile(message.messageId, "encrypted", bytes);
handleEncryptedFile(message, encryptedBytesTmp: bytes);
handleEncryptedFile(message,
encryptedBytesTmp: bytes, retryCounter: retryCounter);
return;
});
});
}
Future handleEncryptedFile(Message msg, {Uint8List? encryptedBytesTmp}) async {
Future handleEncryptedFile(
Message msg, {
Uint8List? encryptedBytesTmp,
int retryCounter = 0,
}) async {
Uint8List? encryptedBytes =
encryptedBytesTmp ?? await readMediaFile(msg.messageId, "encrypted");
@ -211,16 +214,16 @@ Future handleEncryptedFile(Message msg, {Uint8List? encryptedBytesTmp}) async {
await writeMediaFile(msg.messageId, "png", imageBytes);
} catch (e) {
Log.error("Decryption error: $e");
await twonlyDB.messagesDao.updateMessageByMessageId(
msg.messageId,
MessagesCompanion(
errorWhileSending: Value(true),
),
);
// answers with ok, so the server will delete the message
var ok = client.Response_Ok()..none = true;
return client.Response()..ok = ok;
if (retryCounter >= 1) {
Log.error(
"could not decrypt the media file in the second try. reporting error to user: $e");
handleMediaError(msg);
return;
}
Log.error("could not decrypt the media file trying again: $e");
startDownloadMedia(msg, true, retryCounter: retryCounter + 1);
// try downloading again....
return;
}
await twonlyDB.messagesDao.updateMessageByMessageId(

View file

@ -103,7 +103,7 @@ Future<bool> checkForFailedUploads() async {
}
final lockingHandleMediaFile = Mutex();
Future retryMediaUpload({int maxRetries = 3}) async {
Future retryMediaUpload(bool appRestarted, {int maxRetries = 3}) async {
if (maxRetries == 0) {
Log.error("retried media upload 3 times. abort retrying");
return;
@ -116,16 +116,23 @@ Future retryMediaUpload({int maxRetries = 3}) async {
Log.info("re uploading ${mediaFiles.length} media files.");
for (final mediaFile in mediaFiles) {
if (mediaFile.messageIds == null || mediaFile.metadata == null) {
// the media upload was canceled,
if (mediaFile.uploadTokens != null) {
/// the file was already uploaded.
/// notify the server to remove the upload
apiService.getDownloadTokens(mediaFile.uploadTokens!.uploadToken, 0);
if (appRestarted) {
/// When the app got restarted and the messageIds or the metadata is not
/// set then the app was closed before the images was send.
// the media upload was canceled,
if (mediaFile.uploadTokens != null) {
/// the file was already uploaded.
/// notify the server to remove the upload
apiService.getDownloadTokens(
mediaFile.uploadTokens!.uploadToken, 0);
}
await twonlyDB.mediaUploadsDao
.deleteMediaUpload(mediaFile.mediaUploadId);
Log.info(
"upload can be removed, the finalized function was never called...",
);
}
await twonlyDB.mediaUploadsDao
.deleteMediaUpload(mediaFile.mediaUploadId);
Log.info(
"upload can be removed, the finalized function was never called...");
continue;
}
@ -138,7 +145,7 @@ Future retryMediaUpload({int maxRetries = 3}) async {
return false;
});
if (retry) {
await retryMediaUpload(maxRetries: maxRetries - 1);
await retryMediaUpload(false, maxRetries: maxRetries - 1);
}
}

View file

@ -112,6 +112,7 @@ Future<Map<String, dynamic>> getAllMessagesForRetransmitting() async {
Future<Result> sendRetransmitMessage(
String stateId, RetransmitMessage msg) async {
Log.info("Sending ${msg.messageId}");
Result resp =
await apiService.sendTextMessage(msg.userId, msg.bytes, msg.pushData);
@ -130,8 +131,8 @@ Future<Result> sendRetransmitMessage(
}
if (resp.isSuccess) {
retry = false;
if (msg.messageId != null) {
retry = false;
await twonlyDB.messagesDao.updateMessageByMessageId(
msg.messageId!,
MessagesCompanion(acknowledgeByServer: Value(true)),

View file

@ -86,23 +86,35 @@ Future<client.Response> handleNewMessage(int fromUserId, Uint8List body) async {
}
}
case MessageKind.opened:
final update = MessagesCompanion(openedAt: Value(message.timestamp));
await twonlyDB.messagesDao.updateMessageByOtherUser(
fromUserId,
message.messageId!,
update,
);
final openedMessage = await twonlyDB.messagesDao
.getMessageByMessageId(message.messageId!)
.getSingleOrNull();
if (openedMessage != null &&
openedMessage.kind == MessageKind.textMessage) {
await twonlyDB.messagesDao.openedAllNonMediaMessagesFromOtherUser(
case MessageKind.receiveMediaError:
if (message.messageId != null) {
await twonlyDB.messagesDao.updateMessageByOtherUser(
fromUserId,
message.messageId!,
MessagesCompanion(
errorWhileSending: Value(true),
),
);
}
case MessageKind.opened:
if (message.messageId != null) {
final update = MessagesCompanion(openedAt: Value(message.timestamp));
await twonlyDB.messagesDao.updateMessageByOtherUser(
fromUserId,
message.messageId!,
update,
);
final openedMessage = await twonlyDB.messagesDao
.getMessageByMessageId(message.messageId!)
.getSingleOrNull();
if (openedMessage != null &&
openedMessage.kind == MessageKind.textMessage) {
await twonlyDB.messagesDao.openedAllNonMediaMessagesFromOtherUser(
fromUserId,
);
}
}
break;
case MessageKind.rejectRequest:

View file

@ -1,6 +1,8 @@
import 'package:drift/drift.dart';
import 'package:fixnum/fixnum.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/database/tables/messages_table.dart';
import 'package:twonly/src/database/twonly_database.dart';
import 'package:twonly/src/model/json/message.dart';
import 'package:twonly/src/model/protobuf/api/client_to_server.pb.dart'
as client;
@ -66,3 +68,23 @@ Future rejectUser(int contactId) async {
),
);
}
Future handleMediaError(Message message) async {
await twonlyDB.messagesDao.updateMessageByMessageId(
message.messageId,
MessagesCompanion(
errorWhileSending: Value(true),
),
);
if (message.messageOtherId != null) {
encryptAndSendMessageAsync(
null,
message.contactId,
MessageJson(
kind: MessageKind.receiveMediaError,
timestamp: DateTime.now(),
content: MessageContent(),
messageId: message.messageOtherId),
);
}
}

View file

@ -112,7 +112,7 @@ String formatDuration(int seconds) {
}
}
InputDecoration getInputDecoration(context, hintText) {
InputDecoration getInputDecoration(BuildContext context, String hintText) {
final primaryColor =
Theme.of(context).colorScheme.primary; // Get the primary color
return InputDecoration(
@ -279,7 +279,7 @@ Future insertDemoContacts() async {
if (config['accepted'] ?? false) {
for (var i = 0; i < 20; i++) {
int chatId = Random().nextInt(chatMessages.length);
int? messageId = await twonlyDB.messagesDao.insertMessage(
await twonlyDB.messagesDao.insertMessage(
MessagesCompanion(
contactId: Value(userId),
kind: Value(MessageKind.textMessage),

View file

@ -1,7 +1,10 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:path_provider/path_provider.dart';
import 'package:provider/provider.dart';
import 'package:twonly/src/model/json/userdata.dart';
import 'package:twonly/src/providers/connection.provider.dart';
import 'package:twonly/src/utils/log.dart';
Future<bool> isUserCreated() async {
@ -28,6 +31,17 @@ Future<UserData?> getUser() async {
}
}
Future updateUsersPlan(BuildContext context, String planId) async {
context.read<CustomChangeProvider>().plan = planId;
var user = await getUser();
if (user != null) {
user.subscriptionPlan = planId;
await updateUser(user);
}
if (!context.mounted) return;
context.read<CustomChangeProvider>().updatePlan(planId);
}
Future updateUser(UserData userData) async {
final storage = FlutterSecureStorage();
storage.write(key: "userData", value: jsonEncode(userData));

View file

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

View file

@ -8,6 +8,7 @@ import 'package:lottie/lottie.dart';
import 'package:no_screenshot/no_screenshot.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/database/daos/contacts_dao.dart';
import 'package:twonly/src/services/api/utils.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/views/camera/share_image_editor_view.dart';
import 'package:twonly/src/views/components/animate_icon.dart';
@ -252,13 +253,9 @@ class _MediaViewerViewState extends State<MediaViewerView> {
if ((imageBytes == null && !content.isVideo) ||
(content.isVideo && videoController == null)) {
Log.error("media files are not found...");
// When the message should be downloaded but imageBytes are null then a error happened
await twonlyDB.messagesDao.updateMessageByMessageId(
current.messageId,
MessagesCompanion(
errorWhileSending: Value(true),
),
);
await handleMediaError(current);
return nextMediaOrExit();
}

View file

@ -68,7 +68,7 @@ class HomeViewState extends State<HomeView> {
offsetRatio = offsetFromOne.abs();
});
}
if (cameraController == null && !initCameraStarted) {
if (cameraController == null && !initCameraStarted && offsetRatio < 1) {
initCameraStarted = true;
selectCamera(selectedCameraDetails.cameraId, false, false);
}

View file

@ -135,7 +135,7 @@ class _CheckoutViewState extends State<CheckoutView> {
Navigator.pop(context);
}
},
child: Text(context.lang.selectPaymentMethode)),
child: Text(context.lang.selectPaymentMethod)),
),
SizedBox(height: 20)
],

View file

@ -1,8 +1,6 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/providers/connection.provider.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/utils/storage.dart';
import 'package:twonly/src/views/settings/subscription/subscription.view.dart';
@ -82,7 +80,7 @@ class _SelectPaymentViewState extends State<SelectPaymentView> {
(balanceInCents == null || balanceInCents! >= checkoutInCents));
return Scaffold(
appBar: AppBar(
title: Text(context.lang.selectPaymentMethode),
title: Text(context.lang.selectPaymentMethod),
),
body: SafeArea(
child: Column(
@ -222,21 +220,12 @@ class _SelectPaymentViewState extends State<SelectPaymentView> {
widget.planId!, widget.payMonthly!, tryAutoRenewal);
if (!context.mounted) return;
if (res.isSuccess) {
context.read<CustomChangeProvider>().plan =
widget.planId!;
var user = await getUser();
if (user != null) {
user.subscriptionPlan = widget.planId!;
await updateUser(user);
}
await updateUsersPlan(context, widget.planId!);
if (!context.mounted) return;
context
.read<CustomChangeProvider>()
.updatePlan(widget.planId!);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content:
Text(context.lang.planSuccessUpgraded)),
content: Text(context.lang.planSuccessUpgraded),
),
);
Navigator.of(context).pop(true);
} else {

View file

@ -556,12 +556,12 @@ Future redeemUserInviteCode(BuildContext context, String newPlan) async {
if (res.isSuccess) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.lang.redeemUserInviteCodeSuccess)),
content: Text(context.lang.redeemUserInviteCodeSuccess),
),
);
// reconnect to load new plan.
apiService.close(() {
apiService.connect();
});
await apiService.close(() {});
await apiService.connect();
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(errorCodeToText(context, res.error))),