Merge remote-tracking branch 'origin/dev' into rust_integration

This commit is contained in:
otsmr 2026-04-12 02:36:01 +02:00
commit 8aacdc5235
14 changed files with 292 additions and 60 deletions

View file

@ -1,5 +1,12 @@
# Changelog
## 0.1.5
- Fix: Reupload of media files was not working properly
- Fix: Chats were sometimes ordered wrongly
- Fix: Typing indicator was not always shown
- Fix: Multiple smaller issues
## 0.1.4
- New: Typing and chat open indicator

View file

@ -14,7 +14,7 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
// ignore: matching_super_parameters
MediaFilesDao(super.db);
Future<MediaFile?> insertMedia(MediaFilesCompanion mediaFile) async {
Future<MediaFile?> insertOrUpdateMedia(MediaFilesCompanion mediaFile) async {
try {
var insertMediaFile = mediaFile;
@ -24,7 +24,9 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
);
}
final rowId = await into(mediaFiles).insert(insertMediaFile);
final rowId = await into(
mediaFiles,
).insertOnConflictUpdate(insertMediaFile);
return await (select(
mediaFiles,

View file

@ -318,7 +318,7 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
);
}
final rowId = await into(messages).insert(insertMessage);
final rowId = await into(messages).insertOnConflictUpdate(insertMessage);
await twonlyDB.groupsDao.updateGroup(
message.groupId.value,

View file

@ -31,7 +31,7 @@ class ReceiptsDao extends DatabaseAccessor<TwonlyDB> with _$ReceiptsDaoMixin {
if (receipt == null) return;
if (receipt.messageId != null) {
await into(messageActions).insert(
await into(messageActions).insertOnConflictUpdate(
MessageActionsCompanion(
messageId: Value(receipt.messageId!),
contactId: Value(fromUserId),
@ -113,6 +113,16 @@ class ReceiptsDao extends DatabaseAccessor<TwonlyDB> with _$ReceiptsDaoMixin {
}
}
Future<List<Receipt>> getReceiptsByContactAndMessageId(
int contactId,
String messageId,
) async {
return (select(receipts)..where(
(t) => t.contactId.equals(contactId) & t.messageId.equals(messageId),
))
.get();
}
Future<List<Receipt>> getReceiptsForRetransmission() async {
final markedRetriesTime = clock.now().subtract(
const Duration(
@ -132,6 +142,24 @@ class ReceiptsDao extends DatabaseAccessor<TwonlyDB> with _$ReceiptsDaoMixin {
.get();
}
Future<List<Receipt>> getReceiptsForMediaRetransmissions() async {
final markedRetriesTime = clock.now().subtract(
const Duration(
// give the server time to transmit all messages to the client
seconds: 20,
),
);
return (select(receipts)..where(
(t) =>
(t.markForRetry.isSmallerThanValue(markedRetriesTime) |
t.markForRetryAfterAccepted.isSmallerThanValue(
markedRetriesTime,
)) &
t.willBeRetriedByMediaUpload.equals(true),
))
.get();
}
Stream<List<Receipt>> watchAll() {
return select(receipts).watch();
}
@ -155,6 +183,19 @@ class ReceiptsDao extends DatabaseAccessor<TwonlyDB> with _$ReceiptsDaoMixin {
)..where((c) => c.receiptId.equals(receiptId))).write(updates);
}
Future<void> updateReceiptByContactAndMessageId(
int contactId,
String messageId,
ReceiptsCompanion updates,
) async {
await (update(
receipts,
)..where(
(c) => c.contactId.equals(contactId) & c.messageId.equals(messageId),
))
.write(updates);
}
Future<void> updateReceiptWidthUserId(
int fromUserId,
String receiptId,
@ -168,9 +209,7 @@ class ReceiptsDao extends DatabaseAccessor<TwonlyDB> with _$ReceiptsDaoMixin {
Future<void> markMessagesForRetry(int contactId) async {
await (update(receipts)..where(
(c) =>
c.contactId.equals(contactId) &
c.willBeRetriedByMediaUpload.equals(false),
(c) => c.contactId.equals(contactId) & c.markForRetry.isNull(),
))
.write(
ReceiptsCompanion(

View file

@ -92,12 +92,14 @@ class ApiService {
if (globalIsInBackgroundTask) {
await retransmitRawBytes();
await tryTransmitMessages();
await retransmitAllMessages();
await reuploadMediaFiles();
await tryDownloadAllMediaFiles();
} else if (!globalIsAppInBackground) {
unawaited(retransmitRawBytes());
unawaited(tryTransmitMessages());
unawaited(retransmitAllMessages());
unawaited(tryDownloadAllMediaFiles());
unawaited(reuploadMediaFiles());
twonlyDB.markUpdated();
unawaited(syncFlameCounters());
unawaited(setupNotificationWithUsers());

View file

@ -73,12 +73,38 @@ Future<void> handleMedia(
mediaType = MediaType.audio;
}
var mediaIdValue = const Value<String>.absent();
final messageTmp = await twonlyDB.messagesDao
.getMessageById(media.senderMessageId)
.getSingleOrNull();
if (messageTmp != null) {
Log.warn('This message already exit. Message is dropped.');
return;
if (messageTmp.senderId != fromUserId) {
Log.warn(
'$fromUserId tried to modify the message from ${messageTmp.senderId}.',
);
return;
}
if (messageTmp.mediaId == null) {
Log.warn(
'This message already exit without a mediaId. Message is dropped.',
);
return;
}
final mediaFile = await twonlyDB.mediaFilesDao.getMediaFileById(
messageTmp.mediaId!,
);
if (mediaFile?.downloadState != DownloadState.reuploadRequested) {
Log.warn(
'This message and media file already exit and was not requested again. Dropping it.',
);
return;
}
if (mediaFile != null) {
// media file is reuploaded use the same mediaId
mediaIdValue = Value(mediaFile.mediaId);
}
}
int? displayLimitInMilliseconds;
@ -95,8 +121,9 @@ Future<void> handleMedia(
late Message? message;
await twonlyDB.transaction(() async {
mediaFile = await twonlyDB.mediaFilesDao.insertMedia(
mediaFile = await twonlyDB.mediaFilesDao.insertOrUpdateMedia(
MediaFilesCompanion(
mediaId: mediaIdValue,
downloadState: const Value(DownloadState.pending),
type: Value(mediaType),
requiresAuthentication: Value(media.requiresAuthentication),
@ -205,23 +232,6 @@ Future<void> handleMediaUpdate(
case EncryptedContent_MediaUpdate_Type.DECRYPTION_ERROR:
Log.info('Got media file decryption error ${mediaFile.mediaId}');
final reuploadRequestedBy = mediaFile.reuploadRequestedBy ?? [];
reuploadRequestedBy.add(fromUserId);
await twonlyDB.mediaFilesDao.updateMedia(
mediaFile.mediaId,
MediaFilesCompanion(
uploadState: const Value(UploadState.preprocessing),
reuploadRequestedBy: Value(reuploadRequestedBy),
),
);
final mediaFileUpdated = await MediaFileService.fromMediaId(
mediaFile.mediaId,
);
if (mediaFileUpdated != null) {
if (mediaFileUpdated.uploadRequestPath.existsSync()) {
mediaFileUpdated.uploadRequestPath.deleteSync();
}
unawaited(startBackgroundMediaUpload(mediaFileUpdated));
}
await reuploadMediaFile(fromUserId, mediaFile, message.messageId);
}
}

View file

@ -26,6 +26,148 @@ import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:workmanager/workmanager.dart' hide TaskStatus;
final lockRetransmission = Mutex();
Future<void> reuploadMediaFiles() async {
return lockRetransmission.protect(() async {
final receipts = await twonlyDB.receiptsDao
.getReceiptsForMediaRetransmissions();
if (receipts.isEmpty) return;
Log.info('Reuploading ${receipts.length} media files to the server.');
final contacts = <int, Contact>{};
for (final receipt in receipts) {
if (receipt.retryCount > 1 && receipt.lastRetry != null) {
final twentyFourHoursAgo = DateTime.now().subtract(
const Duration(hours: 24),
);
if (receipt.lastRetry!.isAfter(twentyFourHoursAgo)) {
Log.info(
'Ignoring ${receipt.receiptId} as it was retried in the last 24h',
);
continue;
}
}
var messageId = receipt.messageId;
if (receipt.messageId == null) {
Log.info('Message not in receipt. Loading it from the content.');
try {
final content = EncryptedContent.fromBuffer(receipt.message);
if (content.hasMedia()) {
messageId = content.media.senderMessageId;
await twonlyDB.receiptsDao.updateReceipt(
receipt.receiptId,
ReceiptsCompanion(
messageId: Value(messageId),
),
);
}
} catch (e) {
Log.error(e);
}
}
if (messageId == null) {
Log.error('MessageId is empty for media file receipts');
continue;
}
if (receipt.markForRetryAfterAccepted != null) {
if (!contacts.containsKey(receipt.contactId)) {
final contact = await twonlyDB.contactsDao
.getContactByUserId(receipt.contactId)
.getSingleOrNull();
if (contact == null) {
Log.error(
'Contact does not exists, but has a record in receipts, this should not be possible, because of the DELETE CASCADE relation.',
);
continue;
}
contacts[receipt.contactId] = contact;
}
if (!(contacts[receipt.contactId]?.accepted ?? true)) {
Log.warn(
'Could not send message as contact has still not yet accepted.',
);
continue;
}
}
if (receipt.ackByServerAt == null) {
// media file must be reuploaded again in case the media files
// was deleted by the server, the receiver will request a new media reupload
final message = await twonlyDB.messagesDao
.getMessageById(messageId)
.getSingleOrNull();
if (message == null || message.mediaId == null) {
Log.error(
'Message not found for reupload of the receipt (${message == null} - ${message?.mediaId}).',
);
continue;
}
final mediaFile = await twonlyDB.mediaFilesDao.getMediaFileById(
message.mediaId!,
);
if (mediaFile == null) {
Log.error(
'Mediafile not found for reupload of the receipt (${message.messageId} - ${message.mediaId}).',
);
continue;
}
await reuploadMediaFile(
receipt.contactId,
mediaFile,
message.messageId,
);
} else {
Log.info('Reuploading media file $messageId');
// the media file should be still on the server, so it should be enough
// to just resend the message containing the download token.
await tryToSendCompleteMessage(receipt: receipt);
}
}
});
}
Future<void> reuploadMediaFile(
int contactId,
MediaFile mediaFile,
String messageId,
) async {
Log.info('Reuploading media file: ${mediaFile.mediaId}');
await twonlyDB.receiptsDao.updateReceiptByContactAndMessageId(
contactId,
messageId,
const ReceiptsCompanion(
markForRetry: Value(null),
markForRetryAfterAccepted: Value(null),
),
);
final reuploadRequestedBy = (mediaFile.reuploadRequestedBy ?? [])
..add(contactId);
await twonlyDB.mediaFilesDao.updateMedia(
mediaFile.mediaId,
MediaFilesCompanion(
uploadState: const Value(UploadState.preprocessing),
reuploadRequestedBy: Value(reuploadRequestedBy),
),
);
final mediaFileUpdated = await MediaFileService.fromMediaId(
mediaFile.mediaId,
);
if (mediaFileUpdated != null) {
if (mediaFileUpdated.uploadRequestPath.existsSync()) {
mediaFileUpdated.uploadRequestPath.deleteSync();
}
unawaited(startBackgroundMediaUpload(mediaFileUpdated));
}
}
Future<void> finishStartedPreprocessing() async {
final mediaFiles = await twonlyDB.mediaFilesDao
.getAllMediaFilesPendingUpload();
@ -62,7 +204,7 @@ Future<void> finishStartedPreprocessing() async {
/// It can happen, that a media files is uploaded but not yet marked for been uploaded.
/// For example because the background_downloader plugin has not yet reported the finished upload.
/// In case the the message receipts or a reaction was received, mark the media file as been uploaded.
/// In case the message receipts or a reaction was received, mark the media file as been uploaded.
Future<void> handleMediaRelatedResponseFromReceiver(String messageId) async {
final message = await twonlyDB.messagesDao
.getMessageById(messageId)
@ -100,6 +242,16 @@ Future<void> markUploadAsSuccessful(MediaFile media) async {
message.messageId,
clock.now(),
);
await twonlyDB.receiptsDao.updateReceiptByContactAndMessageId(
contact.contactId,
message.messageId,
ReceiptsCompanion(
ackByServerAt: Value(clock.now()),
retryCount: const Value(1),
lastRetry: Value(clock.now()),
markForRetry: const Value(null),
),
);
}
}
}
@ -122,7 +274,7 @@ Future<MediaFileService?> initializeMediaUpload(
const MediaFilesCompanion(isDraftMedia: Value(false)),
);
final mediaFile = await twonlyDB.mediaFilesDao.insertMedia(
final mediaFile = await twonlyDB.mediaFilesDao.insertOrUpdateMedia(
MediaFilesCompanion(
uploadState: const Value(UploadState.initialized),
displayLimitInMilliseconds: Value(displayLimitInMilliseconds),
@ -313,7 +465,8 @@ Future<void> _createUploadRequest(MediaFileService media) async {
}
if (media.mediaFile.reuploadRequestedBy != null) {
type = EncryptedContent_Media_Type.REUPLOAD;
// not used any more... Receiver detects automatically if it is an reupload...
// type = EncryptedContent_Media_Type.REUPLOAD;
}
final notEncryptedContent = EncryptedContent(
@ -340,6 +493,7 @@ Future<void> _createUploadRequest(MediaFileService media) async {
final cipherText = await sendCipherText(
groupMember.contactId,
notEncryptedContent,
messageId: message.messageId,
onlyReturnEncryptedData: true,
);

View file

@ -23,7 +23,7 @@ import 'package:twonly/src/utils/misc.dart';
final lockRetransmission = Mutex();
Future<void> tryTransmitMessages() async {
Future<void> retransmitAllMessages() async {
return lockRetransmission.protect(() async {
final receipts = await twonlyDB.receiptsDao.getReceiptsForRetransmission();
@ -304,7 +304,11 @@ Future<void> sendCipherTextToGroup(
}) async {
final groupMembers = await twonlyDB.groupsDao.getGroupNonLeftMembers(groupId);
if (!onlySendIfNoReceiptsAreOpen) {
if (messageId != null ||
encryptedContent.hasReaction() ||
encryptedContent.hasMedia() ||
encryptedContent.hasTextMessage()) {
// only update the counter in case this is a actual message
await twonlyDB.groupsDao.increaseLastMessageExchange(groupId, clock.now());
}
@ -330,11 +334,11 @@ Future<(Uint8List, Uint8List?)?> sendCipherText(
bool onlySendIfNoReceiptsAreOpen = false,
}) async {
if (onlySendIfNoReceiptsAreOpen) {
if (await twonlyDB.receiptsDao.getReceiptCountForContact(
contactId,
) >
0) {
// this prevents that this message is send in case the receiver is not online
final openReceipts = await twonlyDB.receiptsDao.getReceiptCountForContact(
contactId,
);
if (openReceipts > 2) {
// this prevents that these types of messages are send in case the receiver is offline
return null;
}
}
@ -344,12 +348,31 @@ Future<(Uint8List, Uint8List?)?> sendCipherText(
..type = pb.Message_Type.CIPHERTEXT
..encryptedContent = encryptedContent.writeToBuffer();
var retryCounter = 0;
DateTime? lastRetry;
if (messageId != null) {
final receipts = await twonlyDB.receiptsDao
.getReceiptsByContactAndMessageId(contactId, messageId);
for (final receipt in receipts) {
if (receipt.lastRetry != null) {
lastRetry = receipt.lastRetry;
}
retryCounter += 1;
Log.info('Removing duplicated receipt for message $messageId');
await twonlyDB.receiptsDao.deleteReceipt(receipt.receiptId);
}
}
final receipt = await twonlyDB.receiptsDao.insertReceipt(
ReceiptsCompanion(
contactId: Value(contactId),
message: Value(response.writeToBuffer()),
messageId: Value(messageId),
willBeRetriedByMediaUpload: Value(onlyReturnEncryptedData),
retryCount: Value(retryCounter),
lastRetry: Value(lastRetry),
),
);

View file

@ -52,13 +52,14 @@ class SaveToGalleryButtonState extends State<SaveToGalleryButton> {
await widget.storeImageAsOriginal!();
}
final newMediaFile = await twonlyDB.mediaFilesDao.insertMedia(
MediaFilesCompanion(
type: Value(widget.mediaService.mediaFile.type),
createdAt: Value(clock.now()),
stored: const Value(true),
),
);
final newMediaFile = await twonlyDB.mediaFilesDao
.insertOrUpdateMedia(
MediaFilesCompanion(
type: Value(widget.mediaService.mediaFile.type),
createdAt: Value(clock.now()),
stored: const Value(true),
),
);
if (newMediaFile != null) {
final newService = MediaFileService(newMediaFile);

View file

@ -124,7 +124,7 @@ class _ChatMessagesViewState extends State<ChatMessagesView> {
if (gUser.typingIndicators) {
unawaited(sendTypingIndication(widget.groupId, false));
_nextTypingIndicator = Timer.periodic(const Duration(seconds: 5), (
_nextTypingIndicator = Timer.periodic(const Duration(seconds: 4), (
_,
) async {
await sendTypingIndication(widget.groupId, false);

View file

@ -112,7 +112,7 @@ class _TypingIndicatorState extends State<TypingIndicator>
member.lastChatOpened!,
)
.inSeconds <=
8;
6;
}
@override

View file

@ -101,6 +101,7 @@ class HomeViewState extends State<HomeView> {
if (mounted) setState(() {});
};
activePageIdx = widget.initialPage;
globalUpdateOfHomeViewPageIndex = (index) {
homeViewPageController.jumpToPage(index);
setState(() {
@ -111,9 +112,8 @@ class HomeViewState extends State<HomeView> {
if (response.payload != null &&
response.payload!.startsWith(Routes.chats)) {
await routerProvider.push(response.payload!);
} else {
globalUpdateOfHomeViewPageIndex(0);
}
globalUpdateOfHomeViewPageIndex(0);
});
unawaited(_mainCameraController.selectCamera(0, true));
unawaited(initAsync());
@ -153,20 +153,14 @@ class HomeViewState extends State<HomeView> {
if (widget.initialPage == 0 ||
(notificationAppLaunchDetails != null &&
notificationAppLaunchDetails.didNotificationLaunchApp)) {
var pushed = false;
if (notificationAppLaunchDetails?.didNotificationLaunchApp ?? false) {
final payload =
notificationAppLaunchDetails?.notificationResponse?.payload;
if (payload != null && payload.startsWith(Routes.chats)) {
await routerProvider.push(payload);
pushed = true;
globalUpdateOfHomeViewPageIndex(0);
}
}
if (!pushed) {
globalUpdateOfHomeViewPageIndex(0);
}
}
final draftMedia = await twonlyDB.mediaFilesDao.getDraftMediaFile();

View file

@ -111,7 +111,7 @@ class _ImportMediaViewState extends State<ImportMediaView> {
continue;
}
final mediaFile = await twonlyDB.mediaFilesDao.insertMedia(
final mediaFile = await twonlyDB.mediaFilesDao.insertOrUpdateMedia(
MediaFilesCompanion(
type: Value(type),
createdAt: Value(file.lastModDateTime),

View file

@ -3,7 +3,7 @@ description: "twonly, a privacy-friendly way to connect with friends through sec
publish_to: 'none'
version: 0.1.4+104
version: 0.1.5+105
environment:
sdk: ^3.11.0