From 5ae943bcf301a7df28731a3b596657e72d4ef71a Mon Sep 17 00:00:00 2001 From: otsmr Date: Sun, 26 Oct 2025 01:14:46 +0200 Subject: [PATCH] message and media sending does work --- lib/app.dart | 17 +- lib/main.dart | 12 + lib/src/database/daos/contacts.dao.dart | 17 + lib/src/database/daos/groups.dao.dart | 47 +- lib/src/database/daos/mediafiles.dao.dart | 25 +- lib/src/database/daos/messages.dao.dart | 87 ++-- lib/src/database/daos/receipts.dao.dart | 34 +- lib/src/database/daos/receipts.dao.g.dart | 2 + lib/src/database/daos/signal.dao.dart | 3 +- .../signal/connect_pre_key_store.dart | 7 +- lib/src/database/tables/messages.table.dart | 2 + lib/src/database/tables/receipts.table.dart | 10 + lib/src/database/twonly.db.dart | 1 + lib/src/database/twonly.db.g.dart | 455 +++++++++++++++++- lib/src/model/json/userdata.dart | 3 + lib/src/model/json/userdata.g.dart | 2 + lib/src/model/memory_item.model.dart | 4 + .../client/generated/messages.pb.dart | 26 +- .../client/generated/messages.pbjson.dart | 48 +- lib/src/model/protobuf/client/messages.proto | 4 +- lib/src/services/api.service.dart | 5 - .../api/mediafiles/download.service.dart | 6 +- .../mediafiles/media_background.service.dart | 8 +- .../api/mediafiles/upload.service.dart | 70 ++- lib/src/services/api/messages.dart | 42 +- lib/src/services/api/server_messages.dart | 57 ++- .../media.server_messages.dart | 26 +- .../messages.server_messages.dart | 27 +- .../text_message.server_messages.dart | 2 + lib/src/services/api/utils.dart | 4 +- .../mediafiles/mediafile.service.dart | 24 +- .../notifications/pushkeys.notifications.dart | 8 +- lib/src/services/signal/prekeys.signal.dart | 7 +- lib/src/utils/misc.dart | 45 +- .../save_to_gallery.dart | 10 +- .../views/camera/share_image_editor_view.dart | 10 +- lib/src/views/chats/chat_list.view.dart | 51 +- .../chat_list_components/group_list_item.dart | 67 ++- lib/src/views/chats/chat_messages.view.dart | 114 ++--- .../chat_list_entry.dart | 22 +- .../chat_media_entry.dart | 17 +- .../in_chat_media_viewer.dart | 6 +- .../message_context_menu.dart | 1 + .../message_send_state_icon.dart | 128 ++--- lib/src/views/chats/media_viewer.view.dart | 20 +- .../emoji_reactions_row.component.dart | 1 + lib/src/views/chats/start_new_chat.view.dart | 4 +- lib/src/views/memories/memories.view.dart | 10 +- .../memories/memories_item_thumbnail.dart | 10 +- lib/src/views/onboarding/register.view.dart | 2 +- .../developer/automated_testing.view.dart | 12 +- .../developer/retransmission_data.view.dart | 44 ++ .../updates/62_database_migration.view.dart | 436 ++++++++++++++++- test/unit_test.dart | 39 ++ 54 files changed, 1712 insertions(+), 429 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index acd726c..14e339b 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,9 +1,6 @@ import 'dart:async'; -import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; -import 'package:path/path.dart' show join; -import 'package:path_provider/path_provider.dart'; import 'package:provider/provider.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/src/localization/generated/app_localizations.dart'; @@ -160,14 +157,14 @@ class _AppMainWidgetState extends State { } Future initAsync() async { - _showDatabaseMigration = File( - join( - (await getApplicationSupportDirectory()).path, - 'twonly_database.sqlite', - ), - ).existsSync(); - _isUserCreated = await isUserCreated(); + + if (_isUserCreated) { + if (gUser.appVersion < 62) { + _showDatabaseMigration = true; + } + } + setState(() { _isLoaded = true; }); diff --git a/lib/main.dart b/lib/main.dart index c182e2c..ce9f360 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,9 @@ +import 'dart:io'; import 'package:camera/camera.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:provider/provider.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/src/database/twonly.db.dart'; @@ -35,6 +38,15 @@ void main() async { gCameras = await availableCameras(); + // try { + // File(join((await getApplicationSupportDirectory()).path, 'twonly.sqlite')) + // .deleteSync(); + // } catch (e) {} + // await updateUserdata((u) { + // u.appVersion = 0; + // return u; + // }); + apiService = ApiService(); twonlyDB = TwonlyDB(); diff --git a/lib/src/database/daos/contacts.dao.dart b/lib/src/database/daos/contacts.dao.dart index de674dc..898297c 100644 --- a/lib/src/database/daos/contacts.dao.dart +++ b/lib/src/database/daos/contacts.dao.dart @@ -1,6 +1,7 @@ import 'package:drift/drift.dart'; import 'package:twonly/src/database/tables/contacts.table.dart'; import 'package:twonly/src/database/twonly.db.dart'; +import 'package:twonly/src/database/twonly_database_old.dart' as old; import 'package:twonly/src/services/notifications/pushkeys.notifications.dart'; part 'contacts.dao.g.dart'; @@ -111,6 +112,22 @@ String getContactDisplayName(Contact user) { return name; } +String getContactDisplayNameOld(old.Contact user) { + var name = user.username; + if (user.nickName != null && user.nickName != '') { + name = user.nickName!; + } else if (user.displayName != null) { + name = user.displayName!; + } + if (user.deleted) { + name = applyStrikethrough(name); + } + if (name.length > 12) { + return '${name.substring(0, 12)}...'; + } + return name; +} + String applyStrikethrough(String text) { return text.split('').map((char) => '$char\u0336').join(); } diff --git a/lib/src/database/daos/groups.dao.dart b/lib/src/database/daos/groups.dao.dart index 3de814b..92c24cd 100644 --- a/lib/src/database/daos/groups.dao.dart +++ b/lib/src/database/daos/groups.dao.dart @@ -1,7 +1,10 @@ import 'package:drift/drift.dart'; import 'package:hashlib/random.dart'; +import 'package:twonly/globals.dart'; import 'package:twonly/src/database/tables/groups.table.dart'; import 'package:twonly/src/database/twonly.db.dart'; +import 'package:twonly/src/utils/log.dart'; +import 'package:twonly/src/utils/misc.dart'; part 'groups.dao.g.dart'; @@ -33,12 +36,46 @@ class GroupsDao extends DatabaseAccessor with _$GroupsDaoMixin { .get(); } - Future insertGroup(GroupsCompanion group) async { - await into(groups).insert( - group.copyWith( - groupId: Value(uuid.v4()), - ), + Future createNewGroup(GroupsCompanion group) async { + final insertGroup = group.copyWith( + groupId: Value(uuid.v4()), + isGroupAdmin: const Value(true), ); + return _insertGroup(insertGroup); + } + + Future createNewDirectChat( + int contactId, + GroupsCompanion group, + ) async { + final groupIdDirectChat = getUUIDforDirectChat(contactId, gUser.userId); + final insertGroup = group.copyWith( + groupId: Value(groupIdDirectChat), + isDirectChat: const Value(true), + isGroupAdmin: const Value(true), + ); + + final result = await _insertGroup(insertGroup); + if (result != null) { + await into(groupMembers).insert(GroupMembersCompanion( + groupId: Value(result.groupId), + contactId: Value( + contactId, + ), + )); + } + return result; + } + + Future _insertGroup(GroupsCompanion group) async { + try { + final rowId = await into(groups).insert(group); + return await (select(groups)..where((t) => t.rowId.equals(rowId))) + .getSingle(); + } catch (e) { + Log.error('Could not insert group: $e'); + return null; + } } Future> getGroupContact(String groupId) async { diff --git a/lib/src/database/daos/mediafiles.dao.dart b/lib/src/database/daos/mediafiles.dao.dart index 30e82a7..cc01ca9 100644 --- a/lib/src/database/daos/mediafiles.dao.dart +++ b/lib/src/database/daos/mediafiles.dao.dart @@ -16,11 +16,15 @@ class MediaFilesDao extends DatabaseAccessor Future insertMedia(MediaFilesCompanion mediaFile) async { try { - final rowId = await into(mediaFiles).insert( - mediaFile.copyWith( + var insertMediaFile = mediaFile; + + if (insertMediaFile.mediaId == const Value.absent()) { + insertMediaFile = mediaFile.copyWith( mediaId: Value(uuid.v7()), - ), - ); + ); + } + + final rowId = await into(mediaFiles).insert(insertMediaFile); return await (select(mediaFiles)..where((t) => t.rowId.equals(rowId))) .getSingle(); @@ -72,11 +76,22 @@ class MediaFilesDao extends DatabaseAccessor Future> getAllMediaFilesPendingDownload() async { return (select(mediaFiles) - ..where((t) => t.downloadState.equals(DownloadState.pending.name))) + ..where( + (t) => + t.downloadState.equals(DownloadState.pending.name) | + t.downloadState.equals(DownloadState.downloading.name), + )) .get(); } Stream> watchAllStoredMediaFiles() { return (select(mediaFiles)..where((t) => t.stored.equals(true))).watch(); } + + Stream> watchNewestMediaFiles() { + return (select(mediaFiles) + ..orderBy([(t) => OrderingTerm.desc(t.createdAt)]) + ..limit(100)) + .watch(); + } } diff --git a/lib/src/database/daos/messages.dao.dart b/lib/src/database/daos/messages.dao.dart index f892173..e3e2d43 100644 --- a/lib/src/database/daos/messages.dao.dart +++ b/lib/src/database/daos/messages.dao.dart @@ -38,24 +38,28 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { } Stream> watchMediaNotOpened(String groupId) { - return (select(messages) - ..where( - (t) => - t.openedAt.isNull() & - t.groupId.equals(groupId) & - t.senderId.isNotNull() & - t.type.equals(MessageType.media.name), - ) - ..orderBy([(t) => OrderingTerm.asc(t.createdAt)])) - .watch(); + final query = select(messages).join([ + leftOuterJoin(mediaFiles, mediaFiles.mediaId.equalsExp(messages.mediaId)), + ]) + ..where( + mediaFiles.downloadState + .equals(DownloadState.reuploadRequested.name) + .not() & + messages.openedAt.isNull() & + messages.groupId.equals(groupId) & + messages.mediaId.isNotNull() & + messages.senderId.isNotNull() & + messages.type.equals(MessageType.media.name), + ); + return query.map((row) => row.readTable(messages)).watch(); } - Stream> watchLastMessage(String groupId) { + Stream watchLastMessage(String groupId) { return (select(messages) ..where((t) => t.groupId.equals(groupId)) ..orderBy([(t) => OrderingTerm.desc(t.createdAt)]) ..limit(1)) - .watch(); + .watchSingleOrNull(); } Stream> watchByGroupId(String groupId) { @@ -64,6 +68,16 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { .watch(); } + Stream> watchMessageActionChanges(String messageId) { + return (select(messageActions)..where((t) => t.messageId.equals(messageId))) + .watch(); + } + + Stream watchMessageById(String messageId) { + return (select(messages)..where((t) => t.messageId.equals(messageId))) + .watchSingleOrNull(); + } + // Future removeOldMessages() { // return (update(messages) // ..where( @@ -206,14 +220,20 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { String messageId, DateTime timestamp, ) async { - await into(messageActions).insert( + await into(messageActions).insertOnConflictUpdate( MessageActionsCompanion( messageId: Value(messageId), contactId: Value(contactId), - type: const Value(MessageActionType.ackByUserAt), + type: const Value(MessageActionType.openedAt), actionAt: Value(timestamp), ), ); + if (await haveAllMembers(messageId, MessageActionType.openedAt)) { + await twonlyDB.messagesDao.updateMessageId( + messageId, + MessagesCompanion(openedAt: Value(DateTime.now())), + ); + } } Future handleMessageAckByServer( @@ -221,7 +241,7 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { String messageId, DateTime timestamp, ) async { - await into(messageActions).insert( + await into(messageActions).insertOnConflictUpdate( MessageActionsCompanion( messageId: Value(messageId), contactId: Value(contactId), @@ -229,14 +249,22 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { actionAt: Value(timestamp), ), ); + if (await haveAllMembers(messageId, MessageActionType.ackByServerAt)) { + await twonlyDB.messagesDao.updateMessageId( + messageId, + MessagesCompanion(ackByServer: Value(DateTime.now())), + ); + } } Future haveAllMembers( - String groupId, String messageId, MessageActionType action, ) async { - final members = await twonlyDB.groupsDao.getGroupMembers(groupId); + final message = + await twonlyDB.messagesDao.getMessageById(messageId).getSingleOrNull(); + if (message == null) return true; + final members = await twonlyDB.groupsDao.getGroupMembers(message.groupId); final actions = await (select(messageActions) ..where( @@ -291,11 +319,15 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { Future insertMessage(MessagesCompanion message) async { try { - final rowId = await into(messages).insert( - message.copyWith( + var insertMessage = message; + + if (message.messageId == const Value.absent()) { + insertMessage = message.copyWith( messageId: Value(uuid.v7()), - ), - ); + ); + } + + final rowId = await into(messages).insert(insertMessage); await twonlyDB.groupsDao.updateGroup( message.groupId.value, @@ -323,19 +355,6 @@ class MessagesDao extends DatabaseAccessor with _$MessagesDaoMixin { .getSingleOrNull(); } - Future reopenedMedia(String messageId) async { - await (delete(messageActions) - ..where( - (t) => - t.messageId.equals(messageId) & - t.contactId.isNull() & - t.type.equals( - MessageActionType.openedAt.name, - ), - )) - .go(); - } - // Future deleteMessagesByContactId(int contactId) { // return (delete(messages) // ..where( diff --git a/lib/src/database/daos/receipts.dao.dart b/lib/src/database/daos/receipts.dao.dart index 03453e2..ecbfeee 100644 --- a/lib/src/database/daos/receipts.dao.dart +++ b/lib/src/database/daos/receipts.dao.dart @@ -7,7 +7,7 @@ import 'package:twonly/src/utils/log.dart'; part 'receipts.dao.g.dart'; -@DriftAccessor(tables: [Receipts, Messages, MessageActions]) +@DriftAccessor(tables: [Receipts, Messages, MessageActions, ReceivedReceipts]) class ReceiptsDao extends DatabaseAccessor with _$ReceiptsDaoMixin { // this constructor is required so that the main database can create an instance // of this object. @@ -52,11 +52,13 @@ class ReceiptsDao extends DatabaseAccessor with _$ReceiptsDaoMixin { Future insertReceipt(ReceiptsCompanion entry) async { try { - final id = await into(receipts).insert( - entry.copyWith( + var insertEntry = entry; + if (entry.receiptId == const Value.absent()) { + insertEntry = entry.copyWith( receiptId: Value(uuid.v4()), - ), - ); + ); + } + final id = await into(receipts).insert(insertEntry); return await (select(receipts)..where((t) => t.rowId.equals(id))) .getSingle(); } catch (e) { @@ -97,4 +99,26 @@ class ReceiptsDao extends DatabaseAccessor with _$ReceiptsDaoMixin { await (update(receipts)..where((c) => c.receiptId.equals(receiptId))) .write(updates); } + + Future isDuplicated(String receiptId) async { + return await (select(receivedReceipts) + ..where((t) => t.receiptId.equals(receiptId))) + .getSingleOrNull() != + null; + // try { + // return await (select() + // ..where( + // (t) => t.receiptId.equals(receiptId), + // )) + // .getSingleOrNull(); + // } catch (e) { + // Log.error(e); + // return null; + // } + } + + Future gotReceipt(String receiptId) async { + await into(receivedReceipts) + .insert(ReceivedReceiptsCompanion(receiptId: Value(receiptId))); + } } diff --git a/lib/src/database/daos/receipts.dao.g.dart b/lib/src/database/daos/receipts.dao.g.dart index d495737..4230aa8 100644 --- a/lib/src/database/daos/receipts.dao.g.dart +++ b/lib/src/database/daos/receipts.dao.g.dart @@ -10,4 +10,6 @@ mixin _$ReceiptsDaoMixin on DatabaseAccessor { $MessagesTable get messages => attachedDatabase.messages; $ReceiptsTable get receipts => attachedDatabase.receipts; $MessageActionsTable get messageActions => attachedDatabase.messageActions; + $ReceivedReceiptsTable get receivedReceipts => + attachedDatabase.receivedReceipts; } diff --git a/lib/src/database/daos/signal.dao.dart b/lib/src/database/daos/signal.dao.dart index 4458f50..2b64aac 100644 --- a/lib/src/database/daos/signal.dao.dart +++ b/lib/src/database/daos/signal.dao.dart @@ -57,7 +57,7 @@ class SignalDao extends DatabaseAccessor with _$SignalDaoMixin { tbl.preKeyId.equals(preKey.preKeyId), )) .go(); - Log.info('Using prekey ${preKey.preKeyId} for $contactId'); + Log.info('[PREKEY] Using prekey ${preKey.preKeyId} for $contactId'); return preKey; } return null; @@ -68,6 +68,7 @@ class SignalDao extends DatabaseAccessor with _$SignalDaoMixin { List preKeys, ) async { for (final preKey in preKeys) { + Log.info('[PREKEY] Inserting others ${preKey.preKeyId}'); try { await into(signalContactPreKeys).insert(preKey); } catch (e) { diff --git a/lib/src/database/signal/connect_pre_key_store.dart b/lib/src/database/signal/connect_pre_key_store.dart index 1c3da47..18fd5e6 100644 --- a/lib/src/database/signal/connect_pre_key_store.dart +++ b/lib/src/database/signal/connect_pre_key_store.dart @@ -19,15 +19,17 @@ class ConnectPreKeyStore extends PreKeyStore { ..where((tbl) => tbl.preKeyId.equals(preKeyId))) .get(); if (preKeyRecord.isEmpty) { - throw InvalidKeyIdException('No such preKey record! - $preKeyId'); + throw InvalidKeyIdException( + '[PREKEY] No such preKey record! - $preKeyId'); } - Log.info('Contact used preKey $preKeyId'); + Log.info('[PREKEY] Contact used my preKey $preKeyId'); final preKey = preKeyRecord.first.preKey; return PreKeyRecord.fromBuffer(preKey); } @override Future removePreKey(int preKeyId) async { + Log.info('[PREKEY] Removing $preKeyId from my own storage.'); await (twonlyDB.delete(twonlyDB.signalPreKeyStores) ..where((tbl) => tbl.preKeyId.equals(preKeyId))) .go(); @@ -40,6 +42,7 @@ class ConnectPreKeyStore extends PreKeyStore { preKey: Value(record.serialize()), ); + Log.info('[PREKEY] Storing $preKeyId from my own storage.'); try { await twonlyDB.into(twonlyDB.signalPreKeyStores).insert(preKeyCompanion); } catch (e) { diff --git a/lib/src/database/tables/messages.table.dart b/lib/src/database/tables/messages.table.dart index cff2f6d..cf18f12 100644 --- a/lib/src/database/tables/messages.table.dart +++ b/lib/src/database/tables/messages.table.dart @@ -35,6 +35,8 @@ class Messages extends Table { DateTimeColumn get openedAt => dateTime().nullable()(); DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); DateTimeColumn get modifiedAt => dateTime().nullable()(); + DateTimeColumn get ackByUser => dateTime().nullable()(); + DateTimeColumn get ackByServer => dateTime().nullable()(); @override Set get primaryKey => {messageId}; diff --git a/lib/src/database/tables/receipts.table.dart b/lib/src/database/tables/receipts.table.dart index 997b208..77a76e4 100644 --- a/lib/src/database/tables/receipts.table.dart +++ b/lib/src/database/tables/receipts.table.dart @@ -30,3 +30,13 @@ class Receipts extends Table { @override Set get primaryKey => {receiptId}; } + +@DataClassName('ReceivedReceipt') +class ReceivedReceipts extends Table { + TextColumn get receiptId => text()(); + + DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); + + @override + Set get primaryKey => {receiptId}; +} diff --git a/lib/src/database/twonly.db.dart b/lib/src/database/twonly.db.dart index 22e3907..b5681f2 100644 --- a/lib/src/database/twonly.db.dart +++ b/lib/src/database/twonly.db.dart @@ -36,6 +36,7 @@ part 'twonly.db.g.dart'; Groups, GroupMembers, Receipts, + ReceivedReceipts, SignalIdentityKeyStores, SignalPreKeyStores, SignalSenderKeyStores, diff --git a/lib/src/database/twonly.db.g.dart b/lib/src/database/twonly.db.g.dart index 0a25803..c88339a 100644 --- a/lib/src/database/twonly.db.g.dart +++ b/lib/src/database/twonly.db.g.dart @@ -2299,6 +2299,18 @@ class $MessagesTable extends Messages with TableInfo<$MessagesTable, Message> { late final GeneratedColumn modifiedAt = GeneratedColumn( 'modified_at', aliasedName, true, type: DriftSqlType.dateTime, requiredDuringInsert: false); + static const VerificationMeta _ackByUserMeta = + const VerificationMeta('ackByUser'); + @override + late final GeneratedColumn ackByUser = GeneratedColumn( + 'ack_by_user', aliasedName, true, + type: DriftSqlType.dateTime, requiredDuringInsert: false); + static const VerificationMeta _ackByServerMeta = + const VerificationMeta('ackByServer'); + @override + late final GeneratedColumn ackByServer = GeneratedColumn( + 'ack_by_server', aliasedName, true, + type: DriftSqlType.dateTime, requiredDuringInsert: false); @override List get $columns => [ groupId, @@ -2313,7 +2325,9 @@ class $MessagesTable extends Messages with TableInfo<$MessagesTable, Message> { isDeletedFromSender, openedAt, createdAt, - modifiedAt + modifiedAt, + ackByUser, + ackByServer ]; @override String get aliasedName => _alias ?? actualTableName; @@ -2387,6 +2401,18 @@ class $MessagesTable extends Messages with TableInfo<$MessagesTable, Message> { modifiedAt.isAcceptableOrUnknown( data['modified_at']!, _modifiedAtMeta)); } + if (data.containsKey('ack_by_user')) { + context.handle( + _ackByUserMeta, + ackByUser.isAcceptableOrUnknown( + data['ack_by_user']!, _ackByUserMeta)); + } + if (data.containsKey('ack_by_server')) { + context.handle( + _ackByServerMeta, + ackByServer.isAcceptableOrUnknown( + data['ack_by_server']!, _ackByServerMeta)); + } return context; } @@ -2422,6 +2448,10 @@ class $MessagesTable extends Messages with TableInfo<$MessagesTable, Message> { .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, modifiedAt: attachedDatabase.typeMapping .read(DriftSqlType.dateTime, data['${effectivePrefix}modified_at']), + ackByUser: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}ack_by_user']), + ackByServer: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}ack_by_server']), ); } @@ -2448,6 +2478,8 @@ class Message extends DataClass implements Insertable { final DateTime? openedAt; final DateTime createdAt; final DateTime? modifiedAt; + final DateTime? ackByUser; + final DateTime? ackByServer; const Message( {required this.groupId, required this.messageId, @@ -2461,7 +2493,9 @@ class Message extends DataClass implements Insertable { required this.isDeletedFromSender, this.openedAt, required this.createdAt, - this.modifiedAt}); + this.modifiedAt, + this.ackByUser, + this.ackByServer}); @override Map toColumns(bool nullToAbsent) { final map = {}; @@ -2494,6 +2528,12 @@ class Message extends DataClass implements Insertable { if (!nullToAbsent || modifiedAt != null) { map['modified_at'] = Variable(modifiedAt); } + if (!nullToAbsent || ackByUser != null) { + map['ack_by_user'] = Variable(ackByUser); + } + if (!nullToAbsent || ackByServer != null) { + map['ack_by_server'] = Variable(ackByServer); + } return map; } @@ -2526,6 +2566,12 @@ class Message extends DataClass implements Insertable { modifiedAt: modifiedAt == null && nullToAbsent ? const Value.absent() : Value(modifiedAt), + ackByUser: ackByUser == null && nullToAbsent + ? const Value.absent() + : Value(ackByUser), + ackByServer: ackByServer == null && nullToAbsent + ? const Value.absent() + : Value(ackByServer), ); } @@ -2548,6 +2594,8 @@ class Message extends DataClass implements Insertable { openedAt: serializer.fromJson(json['openedAt']), createdAt: serializer.fromJson(json['createdAt']), modifiedAt: serializer.fromJson(json['modifiedAt']), + ackByUser: serializer.fromJson(json['ackByUser']), + ackByServer: serializer.fromJson(json['ackByServer']), ); } @override @@ -2568,6 +2616,8 @@ class Message extends DataClass implements Insertable { 'openedAt': serializer.toJson(openedAt), 'createdAt': serializer.toJson(createdAt), 'modifiedAt': serializer.toJson(modifiedAt), + 'ackByUser': serializer.toJson(ackByUser), + 'ackByServer': serializer.toJson(ackByServer), }; } @@ -2584,7 +2634,9 @@ class Message extends DataClass implements Insertable { bool? isDeletedFromSender, Value openedAt = const Value.absent(), DateTime? createdAt, - Value modifiedAt = const Value.absent()}) => + Value modifiedAt = const Value.absent(), + Value ackByUser = const Value.absent(), + Value ackByServer = const Value.absent()}) => Message( groupId: groupId ?? this.groupId, messageId: messageId ?? this.messageId, @@ -2602,6 +2654,8 @@ class Message extends DataClass implements Insertable { openedAt: openedAt.present ? openedAt.value : this.openedAt, createdAt: createdAt ?? this.createdAt, modifiedAt: modifiedAt.present ? modifiedAt.value : this.modifiedAt, + ackByUser: ackByUser.present ? ackByUser.value : this.ackByUser, + ackByServer: ackByServer.present ? ackByServer.value : this.ackByServer, ); Message copyWithCompanion(MessagesCompanion data) { return Message( @@ -2626,6 +2680,9 @@ class Message extends DataClass implements Insertable { createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, modifiedAt: data.modifiedAt.present ? data.modifiedAt.value : this.modifiedAt, + ackByUser: data.ackByUser.present ? data.ackByUser.value : this.ackByUser, + ackByServer: + data.ackByServer.present ? data.ackByServer.value : this.ackByServer, ); } @@ -2644,7 +2701,9 @@ class Message extends DataClass implements Insertable { ..write('isDeletedFromSender: $isDeletedFromSender, ') ..write('openedAt: $openedAt, ') ..write('createdAt: $createdAt, ') - ..write('modifiedAt: $modifiedAt') + ..write('modifiedAt: $modifiedAt, ') + ..write('ackByUser: $ackByUser, ') + ..write('ackByServer: $ackByServer') ..write(')')) .toString(); } @@ -2663,7 +2722,9 @@ class Message extends DataClass implements Insertable { isDeletedFromSender, openedAt, createdAt, - modifiedAt); + modifiedAt, + ackByUser, + ackByServer); @override bool operator ==(Object other) => identical(this, other) || @@ -2680,7 +2741,9 @@ class Message extends DataClass implements Insertable { other.isDeletedFromSender == this.isDeletedFromSender && other.openedAt == this.openedAt && other.createdAt == this.createdAt && - other.modifiedAt == this.modifiedAt); + other.modifiedAt == this.modifiedAt && + other.ackByUser == this.ackByUser && + other.ackByServer == this.ackByServer); } class MessagesCompanion extends UpdateCompanion { @@ -2697,6 +2760,8 @@ class MessagesCompanion extends UpdateCompanion { final Value openedAt; final Value createdAt; final Value modifiedAt; + final Value ackByUser; + final Value ackByServer; final Value rowid; const MessagesCompanion({ this.groupId = const Value.absent(), @@ -2712,6 +2777,8 @@ class MessagesCompanion extends UpdateCompanion { this.openedAt = const Value.absent(), this.createdAt = const Value.absent(), this.modifiedAt = const Value.absent(), + this.ackByUser = const Value.absent(), + this.ackByServer = const Value.absent(), this.rowid = const Value.absent(), }); MessagesCompanion.insert({ @@ -2728,6 +2795,8 @@ class MessagesCompanion extends UpdateCompanion { this.openedAt = const Value.absent(), this.createdAt = const Value.absent(), this.modifiedAt = const Value.absent(), + this.ackByUser = const Value.absent(), + this.ackByServer = const Value.absent(), this.rowid = const Value.absent(), }) : groupId = Value(groupId), messageId = Value(messageId), @@ -2746,6 +2815,8 @@ class MessagesCompanion extends UpdateCompanion { Expression? openedAt, Expression? createdAt, Expression? modifiedAt, + Expression? ackByUser, + Expression? ackByServer, Expression? rowid, }) { return RawValuesInsertable({ @@ -2763,6 +2834,8 @@ class MessagesCompanion extends UpdateCompanion { if (openedAt != null) 'opened_at': openedAt, if (createdAt != null) 'created_at': createdAt, if (modifiedAt != null) 'modified_at': modifiedAt, + if (ackByUser != null) 'ack_by_user': ackByUser, + if (ackByServer != null) 'ack_by_server': ackByServer, if (rowid != null) 'rowid': rowid, }); } @@ -2781,6 +2854,8 @@ class MessagesCompanion extends UpdateCompanion { Value? openedAt, Value? createdAt, Value? modifiedAt, + Value? ackByUser, + Value? ackByServer, Value? rowid}) { return MessagesCompanion( groupId: groupId ?? this.groupId, @@ -2796,6 +2871,8 @@ class MessagesCompanion extends UpdateCompanion { openedAt: openedAt ?? this.openedAt, createdAt: createdAt ?? this.createdAt, modifiedAt: modifiedAt ?? this.modifiedAt, + ackByUser: ackByUser ?? this.ackByUser, + ackByServer: ackByServer ?? this.ackByServer, rowid: rowid ?? this.rowid, ); } @@ -2843,6 +2920,12 @@ class MessagesCompanion extends UpdateCompanion { if (modifiedAt.present) { map['modified_at'] = Variable(modifiedAt.value); } + if (ackByUser.present) { + map['ack_by_user'] = Variable(ackByUser.value); + } + if (ackByServer.present) { + map['ack_by_server'] = Variable(ackByServer.value); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -2865,6 +2948,8 @@ class MessagesCompanion extends UpdateCompanion { ..write('openedAt: $openedAt, ') ..write('createdAt: $createdAt, ') ..write('modifiedAt: $modifiedAt, ') + ..write('ackByUser: $ackByUser, ') + ..write('ackByServer: $ackByServer, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -4243,6 +4328,200 @@ class ReceiptsCompanion extends UpdateCompanion { } } +class $ReceivedReceiptsTable extends ReceivedReceipts + with TableInfo<$ReceivedReceiptsTable, ReceivedReceipt> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $ReceivedReceiptsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _receiptIdMeta = + const VerificationMeta('receiptId'); + @override + late final GeneratedColumn receiptId = GeneratedColumn( + 'receipt_id', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + static const VerificationMeta _createdAtMeta = + const VerificationMeta('createdAt'); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime); + @override + List get $columns => [receiptId, createdAt]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'received_receipts'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('receipt_id')) { + context.handle(_receiptIdMeta, + receiptId.isAcceptableOrUnknown(data['receipt_id']!, _receiptIdMeta)); + } else if (isInserting) { + context.missing(_receiptIdMeta); + } + if (data.containsKey('created_at')) { + context.handle(_createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); + } + return context; + } + + @override + Set get $primaryKey => {receiptId}; + @override + ReceivedReceipt map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return ReceivedReceipt( + receiptId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}receipt_id'])!, + createdAt: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, + ); + } + + @override + $ReceivedReceiptsTable createAlias(String alias) { + return $ReceivedReceiptsTable(attachedDatabase, alias); + } +} + +class ReceivedReceipt extends DataClass implements Insertable { + final String receiptId; + final DateTime createdAt; + const ReceivedReceipt({required this.receiptId, required this.createdAt}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['receipt_id'] = Variable(receiptId); + map['created_at'] = Variable(createdAt); + return map; + } + + ReceivedReceiptsCompanion toCompanion(bool nullToAbsent) { + return ReceivedReceiptsCompanion( + receiptId: Value(receiptId), + createdAt: Value(createdAt), + ); + } + + factory ReceivedReceipt.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return ReceivedReceipt( + receiptId: serializer.fromJson(json['receiptId']), + createdAt: serializer.fromJson(json['createdAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'receiptId': serializer.toJson(receiptId), + 'createdAt': serializer.toJson(createdAt), + }; + } + + ReceivedReceipt copyWith({String? receiptId, DateTime? createdAt}) => + ReceivedReceipt( + receiptId: receiptId ?? this.receiptId, + createdAt: createdAt ?? this.createdAt, + ); + ReceivedReceipt copyWithCompanion(ReceivedReceiptsCompanion data) { + return ReceivedReceipt( + receiptId: data.receiptId.present ? data.receiptId.value : this.receiptId, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + ); + } + + @override + String toString() { + return (StringBuffer('ReceivedReceipt(') + ..write('receiptId: $receiptId, ') + ..write('createdAt: $createdAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(receiptId, createdAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ReceivedReceipt && + other.receiptId == this.receiptId && + other.createdAt == this.createdAt); +} + +class ReceivedReceiptsCompanion extends UpdateCompanion { + final Value receiptId; + final Value createdAt; + final Value rowid; + const ReceivedReceiptsCompanion({ + this.receiptId = const Value.absent(), + this.createdAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + ReceivedReceiptsCompanion.insert({ + required String receiptId, + this.createdAt = const Value.absent(), + this.rowid = const Value.absent(), + }) : receiptId = Value(receiptId); + static Insertable custom({ + Expression? receiptId, + Expression? createdAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (receiptId != null) 'receipt_id': receiptId, + if (createdAt != null) 'created_at': createdAt, + if (rowid != null) 'rowid': rowid, + }); + } + + ReceivedReceiptsCompanion copyWith( + {Value? receiptId, + Value? createdAt, + Value? rowid}) { + return ReceivedReceiptsCompanion( + receiptId: receiptId ?? this.receiptId, + createdAt: createdAt ?? this.createdAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (receiptId.present) { + map['receipt_id'] = Variable(receiptId.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('ReceivedReceiptsCompanion(') + ..write('receiptId: $receiptId, ') + ..write('createdAt: $createdAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + class $SignalIdentityKeyStoresTable extends SignalIdentityKeyStores with TableInfo<$SignalIdentityKeyStoresTable, SignalIdentityKeyStore> { @override @@ -6130,6 +6409,8 @@ abstract class _$TwonlyDB extends GeneratedDatabase { late final $ReactionsTable reactions = $ReactionsTable(this); late final $GroupMembersTable groupMembers = $GroupMembersTable(this); late final $ReceiptsTable receipts = $ReceiptsTable(this); + late final $ReceivedReceiptsTable receivedReceipts = + $ReceivedReceiptsTable(this); late final $SignalIdentityKeyStoresTable signalIdentityKeyStores = $SignalIdentityKeyStoresTable(this); late final $SignalPreKeyStoresTable signalPreKeyStores = @@ -6163,6 +6444,7 @@ abstract class _$TwonlyDB extends GeneratedDatabase { reactions, groupMembers, receipts, + receivedReceipts, signalIdentityKeyStores, signalPreKeyStores, signalSenderKeyStores, @@ -7854,6 +8136,8 @@ typedef $$MessagesTableCreateCompanionBuilder = MessagesCompanion Function({ Value openedAt, Value createdAt, Value modifiedAt, + Value ackByUser, + Value ackByServer, Value rowid, }); typedef $$MessagesTableUpdateCompanionBuilder = MessagesCompanion Function({ @@ -7870,6 +8154,8 @@ typedef $$MessagesTableUpdateCompanionBuilder = MessagesCompanion Function({ Value openedAt, Value createdAt, Value modifiedAt, + Value ackByUser, + Value ackByServer, Value rowid, }); @@ -8042,6 +8328,12 @@ class $$MessagesTableFilterComposer ColumnFilters get modifiedAt => $composableBuilder( column: $table.modifiedAt, builder: (column) => ColumnFilters(column)); + ColumnFilters get ackByUser => $composableBuilder( + column: $table.ackByUser, builder: (column) => ColumnFilters(column)); + + ColumnFilters get ackByServer => $composableBuilder( + column: $table.ackByServer, builder: (column) => ColumnFilters(column)); + $$GroupsTableFilterComposer get groupId { final $$GroupsTableFilterComposer composer = $composerBuilder( composer: this, @@ -8245,6 +8537,12 @@ class $$MessagesTableOrderingComposer ColumnOrderings get modifiedAt => $composableBuilder( column: $table.modifiedAt, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get ackByUser => $composableBuilder( + column: $table.ackByUser, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get ackByServer => $composableBuilder( + column: $table.ackByServer, builder: (column) => ColumnOrderings(column)); + $$GroupsTableOrderingComposer get groupId { final $$GroupsTableOrderingComposer composer = $composerBuilder( composer: this, @@ -8362,6 +8660,12 @@ class $$MessagesTableAnnotationComposer GeneratedColumn get modifiedAt => $composableBuilder( column: $table.modifiedAt, builder: (column) => column); + GeneratedColumn get ackByUser => + $composableBuilder(column: $table.ackByUser, builder: (column) => column); + + GeneratedColumn get ackByServer => $composableBuilder( + column: $table.ackByServer, builder: (column) => column); + $$GroupsTableAnnotationComposer get groupId { final $$GroupsTableAnnotationComposer composer = $composerBuilder( composer: this, @@ -8571,6 +8875,8 @@ class $$MessagesTableTableManager extends RootTableManager< Value openedAt = const Value.absent(), Value createdAt = const Value.absent(), Value modifiedAt = const Value.absent(), + Value ackByUser = const Value.absent(), + Value ackByServer = const Value.absent(), Value rowid = const Value.absent(), }) => MessagesCompanion( @@ -8587,6 +8893,8 @@ class $$MessagesTableTableManager extends RootTableManager< openedAt: openedAt, createdAt: createdAt, modifiedAt: modifiedAt, + ackByUser: ackByUser, + ackByServer: ackByServer, rowid: rowid, ), createCompanionCallback: ({ @@ -8603,6 +8911,8 @@ class $$MessagesTableTableManager extends RootTableManager< Value openedAt = const Value.absent(), Value createdAt = const Value.absent(), Value modifiedAt = const Value.absent(), + Value ackByUser = const Value.absent(), + Value ackByServer = const Value.absent(), Value rowid = const Value.absent(), }) => MessagesCompanion.insert( @@ -8619,6 +8929,8 @@ class $$MessagesTableTableManager extends RootTableManager< openedAt: openedAt, createdAt: createdAt, modifiedAt: modifiedAt, + ackByUser: ackByUser, + ackByServer: ackByServer, rowid: rowid, ), withReferenceMapper: (p0) => p0 @@ -10060,6 +10372,135 @@ typedef $$ReceiptsTableProcessedTableManager = ProcessedTableManager< (Receipt, $$ReceiptsTableReferences), Receipt, PrefetchHooks Function({bool contactId, bool messageId})>; +typedef $$ReceivedReceiptsTableCreateCompanionBuilder + = ReceivedReceiptsCompanion Function({ + required String receiptId, + Value createdAt, + Value rowid, +}); +typedef $$ReceivedReceiptsTableUpdateCompanionBuilder + = ReceivedReceiptsCompanion Function({ + Value receiptId, + Value createdAt, + Value rowid, +}); + +class $$ReceivedReceiptsTableFilterComposer + extends Composer<_$TwonlyDB, $ReceivedReceiptsTable> { + $$ReceivedReceiptsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get receiptId => $composableBuilder( + column: $table.receiptId, builder: (column) => ColumnFilters(column)); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, builder: (column) => ColumnFilters(column)); +} + +class $$ReceivedReceiptsTableOrderingComposer + extends Composer<_$TwonlyDB, $ReceivedReceiptsTable> { + $$ReceivedReceiptsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get receiptId => $composableBuilder( + column: $table.receiptId, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, builder: (column) => ColumnOrderings(column)); +} + +class $$ReceivedReceiptsTableAnnotationComposer + extends Composer<_$TwonlyDB, $ReceivedReceiptsTable> { + $$ReceivedReceiptsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get receiptId => + $composableBuilder(column: $table.receiptId, builder: (column) => column); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); +} + +class $$ReceivedReceiptsTableTableManager extends RootTableManager< + _$TwonlyDB, + $ReceivedReceiptsTable, + ReceivedReceipt, + $$ReceivedReceiptsTableFilterComposer, + $$ReceivedReceiptsTableOrderingComposer, + $$ReceivedReceiptsTableAnnotationComposer, + $$ReceivedReceiptsTableCreateCompanionBuilder, + $$ReceivedReceiptsTableUpdateCompanionBuilder, + ( + ReceivedReceipt, + BaseReferences<_$TwonlyDB, $ReceivedReceiptsTable, ReceivedReceipt> + ), + ReceivedReceipt, + PrefetchHooks Function()> { + $$ReceivedReceiptsTableTableManager( + _$TwonlyDB db, $ReceivedReceiptsTable table) + : super(TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$ReceivedReceiptsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$ReceivedReceiptsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$ReceivedReceiptsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: ({ + Value receiptId = const Value.absent(), + Value createdAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => + ReceivedReceiptsCompanion( + receiptId: receiptId, + createdAt: createdAt, + rowid: rowid, + ), + createCompanionCallback: ({ + required String receiptId, + Value createdAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => + ReceivedReceiptsCompanion.insert( + receiptId: receiptId, + createdAt: createdAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + )); +} + +typedef $$ReceivedReceiptsTableProcessedTableManager = ProcessedTableManager< + _$TwonlyDB, + $ReceivedReceiptsTable, + ReceivedReceipt, + $$ReceivedReceiptsTableFilterComposer, + $$ReceivedReceiptsTableOrderingComposer, + $$ReceivedReceiptsTableAnnotationComposer, + $$ReceivedReceiptsTableCreateCompanionBuilder, + $$ReceivedReceiptsTableUpdateCompanionBuilder, + ( + ReceivedReceipt, + BaseReferences<_$TwonlyDB, $ReceivedReceiptsTable, ReceivedReceipt> + ), + ReceivedReceipt, + PrefetchHooks Function()>; typedef $$SignalIdentityKeyStoresTableCreateCompanionBuilder = SignalIdentityKeyStoresCompanion Function({ required int deviceId, @@ -11497,6 +11938,8 @@ class $TwonlyDBManager { $$GroupMembersTableTableManager(_db, _db.groupMembers); $$ReceiptsTableTableManager get receipts => $$ReceiptsTableTableManager(_db, _db.receipts); + $$ReceivedReceiptsTableTableManager get receivedReceipts => + $$ReceivedReceiptsTableTableManager(_db, _db.receivedReceipts); $$SignalIdentityKeyStoresTableTableManager get signalIdentityKeyStores => $$SignalIdentityKeyStoresTableTableManager( _db, _db.signalIdentityKeyStores); diff --git a/lib/src/model/json/userdata.dart b/lib/src/model/json/userdata.dart index 98a9cd9..2a921f3 100644 --- a/lib/src/model/json/userdata.dart +++ b/lib/src/model/json/userdata.dart @@ -22,6 +22,9 @@ class UserData { String? avatarSvg; String? avatarJson; + @JsonKey(defaultValue: 0) + int appVersion = 0; + @JsonKey(defaultValue: 0) int avatarCounter = 0; diff --git a/lib/src/model/json/userdata.g.dart b/lib/src/model/json/userdata.g.dart index 9336cdf..17cf5cf 100644 --- a/lib/src/model/json/userdata.g.dart +++ b/lib/src/model/json/userdata.g.dart @@ -14,6 +14,7 @@ UserData _$UserDataFromJson(Map json) => UserData( ) ..avatarSvg = json['avatarSvg'] as String? ..avatarJson = json['avatarJson'] as String? + ..appVersion = (json['appVersion'] as num?)?.toInt() ?? 0 ..avatarCounter = (json['avatarCounter'] as num?)?.toInt() ?? 0 ..isDeveloper = json['isDeveloper'] as bool? ?? false ..deviceId = (json['deviceId'] as num?)?.toInt() ?? 0 @@ -77,6 +78,7 @@ Map _$UserDataToJson(UserData instance) => { 'displayName': instance.displayName, 'avatarSvg': instance.avatarSvg, 'avatarJson': instance.avatarJson, + 'appVersion': instance.appVersion, 'avatarCounter': instance.avatarCounter, 'isDeveloper': instance.isDeveloper, 'deviceId': instance.deviceId, diff --git a/lib/src/model/memory_item.model.dart b/lib/src/model/memory_item.model.dart index 2f17535..f0a9532 100644 --- a/lib/src/model/memory_item.model.dart +++ b/lib/src/model/memory_item.model.dart @@ -19,6 +19,10 @@ class MemoryItem { final mediaService = await MediaFileService.fromMediaId(message.mediaId!); if (mediaService == null) continue; + if (!mediaService.imagePreviewAvailable) { + continue; + } + items .putIfAbsent( message.mediaId!, diff --git a/lib/src/model/protobuf/client/generated/messages.pb.dart b/lib/src/model/protobuf/client/generated/messages.pb.dart index a209170..e777cc5 100644 --- a/lib/src/model/protobuf/client/generated/messages.pb.dart +++ b/lib/src/model/protobuf/client/generated/messages.pb.dart @@ -388,7 +388,7 @@ class EncryptedContent_MessageUpdate extends $pb.GeneratedMessage { factory EncryptedContent_MessageUpdate({ EncryptedContent_MessageUpdate_Type? type, $core.String? senderMessageId, - $core.Iterable<$core.String>? multipleSenderMessageIds, + $core.Iterable<$core.String>? multipleTargetMessageIds, $core.String? text, $fixnum.Int64? timestamp, }) { @@ -399,8 +399,8 @@ class EncryptedContent_MessageUpdate extends $pb.GeneratedMessage { if (senderMessageId != null) { $result.senderMessageId = senderMessageId; } - if (multipleSenderMessageIds != null) { - $result.multipleSenderMessageIds.addAll(multipleSenderMessageIds); + if (multipleTargetMessageIds != null) { + $result.multipleTargetMessageIds.addAll(multipleTargetMessageIds); } if (text != null) { $result.text = text; @@ -417,7 +417,7 @@ class EncryptedContent_MessageUpdate extends $pb.GeneratedMessage { static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'EncryptedContent.MessageUpdate', createEmptyInstance: create) ..e(1, _omitFieldNames ? '' : 'type', $pb.PbFieldType.OE, defaultOrMaker: EncryptedContent_MessageUpdate_Type.DELETE, valueOf: EncryptedContent_MessageUpdate_Type.valueOf, enumValues: EncryptedContent_MessageUpdate_Type.values) ..aOS(2, _omitFieldNames ? '' : 'senderMessageId', protoName: 'senderMessageId') - ..pPS(3, _omitFieldNames ? '' : 'multipleSenderMessageIds', protoName: 'multipleSenderMessageIds') + ..pPS(3, _omitFieldNames ? '' : 'multipleTargetMessageIds', protoName: 'multipleTargetMessageIds') ..aOS(4, _omitFieldNames ? '' : 'text') ..aInt64(5, _omitFieldNames ? '' : 'timestamp') ..hasRequiredFields = false @@ -463,7 +463,7 @@ class EncryptedContent_MessageUpdate extends $pb.GeneratedMessage { void clearSenderMessageId() => clearField(2); @$pb.TagNumber(3) - $core.List<$core.String> get multipleSenderMessageIds => $_getList(2); + $core.List<$core.String> get multipleTargetMessageIds => $_getList(2); @$pb.TagNumber(4) $core.String get text => $_getSZ(3); @@ -663,14 +663,14 @@ class EncryptedContent_Media extends $pb.GeneratedMessage { class EncryptedContent_MediaUpdate extends $pb.GeneratedMessage { factory EncryptedContent_MediaUpdate({ EncryptedContent_MediaUpdate_Type? type, - $core.String? targetMediaId, + $core.String? targetMessageId, }) { final $result = create(); if (type != null) { $result.type = type; } - if (targetMediaId != null) { - $result.targetMediaId = targetMediaId; + if (targetMessageId != null) { + $result.targetMessageId = targetMessageId; } return $result; } @@ -680,7 +680,7 @@ class EncryptedContent_MediaUpdate extends $pb.GeneratedMessage { static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'EncryptedContent.MediaUpdate', createEmptyInstance: create) ..e(1, _omitFieldNames ? '' : 'type', $pb.PbFieldType.OE, defaultOrMaker: EncryptedContent_MediaUpdate_Type.REOPENED, valueOf: EncryptedContent_MediaUpdate_Type.valueOf, enumValues: EncryptedContent_MediaUpdate_Type.values) - ..aOS(2, _omitFieldNames ? '' : 'targetMediaId', protoName: 'targetMediaId') + ..aOS(2, _omitFieldNames ? '' : 'targetMessageId', protoName: 'targetMessageId') ..hasRequiredFields = false ; @@ -715,13 +715,13 @@ class EncryptedContent_MediaUpdate extends $pb.GeneratedMessage { void clearType() => clearField(1); @$pb.TagNumber(2) - $core.String get targetMediaId => $_getSZ(1); + $core.String get targetMessageId => $_getSZ(1); @$pb.TagNumber(2) - set targetMediaId($core.String v) { $_setString(1, v); } + set targetMessageId($core.String v) { $_setString(1, v); } @$pb.TagNumber(2) - $core.bool hasTargetMediaId() => $_has(1); + $core.bool hasTargetMessageId() => $_has(1); @$pb.TagNumber(2) - void clearTargetMediaId() => clearField(2); + void clearTargetMessageId() => clearField(2); } class EncryptedContent_ContactRequest extends $pb.GeneratedMessage { diff --git a/lib/src/model/protobuf/client/generated/messages.pbjson.dart b/lib/src/model/protobuf/client/generated/messages.pbjson.dart index f4c557c..483604a 100644 --- a/lib/src/model/protobuf/client/generated/messages.pbjson.dart +++ b/lib/src/model/protobuf/client/generated/messages.pbjson.dart @@ -158,7 +158,7 @@ const EncryptedContent_MessageUpdate$json = { '2': [ {'1': 'type', '3': 1, '4': 1, '5': 14, '6': '.EncryptedContent.MessageUpdate.Type', '10': 'type'}, {'1': 'senderMessageId', '3': 2, '4': 1, '5': 9, '9': 0, '10': 'senderMessageId', '17': true}, - {'1': 'multipleSenderMessageIds', '3': 3, '4': 3, '5': 9, '10': 'multipleSenderMessageIds'}, + {'1': 'multipleTargetMessageIds', '3': 3, '4': 3, '5': 9, '10': 'multipleTargetMessageIds'}, {'1': 'text', '3': 4, '4': 1, '5': 9, '9': 1, '10': 'text', '17': true}, {'1': 'timestamp', '3': 5, '4': 1, '5': 3, '10': 'timestamp'}, ], @@ -221,7 +221,7 @@ const EncryptedContent_MediaUpdate$json = { '1': 'MediaUpdate', '2': [ {'1': 'type', '3': 1, '4': 1, '5': 14, '6': '.EncryptedContent.MediaUpdate.Type', '10': 'type'}, - {'1': 'targetMediaId', '3': 2, '4': 1, '5': 9, '10': 'targetMediaId'}, + {'1': 'targetMessageId', '3': 2, '4': 1, '5': 9, '10': 'targetMessageId'}, ], '4': [EncryptedContent_MediaUpdate_Type$json], }; @@ -338,8 +338,8 @@ final $typed_data.Uint8List encryptedContentDescriptor = $convert.base64Decode( 'AFIFZW1vammIAQESGwoGcmVtb3ZlGAMgASgISAFSBnJlbW92ZYgBAUIICgZfZW1vamlCCQoHX3' 'JlbW92ZRq3AgoNTWVzc2FnZVVwZGF0ZRI4CgR0eXBlGAEgASgOMiQuRW5jcnlwdGVkQ29udGVu' 'dC5NZXNzYWdlVXBkYXRlLlR5cGVSBHR5cGUSLQoPc2VuZGVyTWVzc2FnZUlkGAIgASgJSABSD3' - 'NlbmRlck1lc3NhZ2VJZIgBARI6ChhtdWx0aXBsZVNlbmRlck1lc3NhZ2VJZHMYAyADKAlSGG11' - 'bHRpcGxlU2VuZGVyTWVzc2FnZUlkcxIXCgR0ZXh0GAQgASgJSAFSBHRleHSIAQESHAoJdGltZX' + 'NlbmRlck1lc3NhZ2VJZIgBARI6ChhtdWx0aXBsZVRhcmdldE1lc3NhZ2VJZHMYAyADKAlSGG11' + 'bHRpcGxlVGFyZ2V0TWVzc2FnZUlkcxIXCgR0ZXh0GAQgASgJSAFSBHRleHSIAQESHAoJdGltZX' 'N0YW1wGAUgASgDUgl0aW1lc3RhbXAiLQoEVHlwZRIKCgZERUxFVEUQABINCglFRElUX1RFWFQQ' 'ARIKCgZPUEVORUQQAkISChBfc2VuZGVyTWVzc2FnZUlkQgcKBV90ZXh0GowFCgVNZWRpYRIoCg' '9zZW5kZXJNZXNzYWdlSWQYASABKAlSD3NlbmRlck1lc3NhZ2VJZBIwCgR0eXBlGAIgASgOMhwu' @@ -353,24 +353,24 @@ final $typed_data.Uint8List encryptedContentDescriptor = $convert.base64Decode( 'VSD2VuY3J5cHRpb25Ob25jZYgBASIzCgRUeXBlEgwKCFJFVVBMT0FEEAASCQoFSU1BR0UQARIJ' 'CgVWSURFTxACEgcKA0dJRhADQh0KG19kaXNwbGF5TGltaXRJbk1pbGxpc2Vjb25kc0IRCg9fcX' 'VvdGVNZXNzYWdlSWRCEAoOX2Rvd25sb2FkVG9rZW5CEAoOX2VuY3J5cHRpb25LZXlCEAoOX2Vu' - 'Y3J5cHRpb25NYWNCEgoQX2VuY3J5cHRpb25Ob25jZRqjAQoLTWVkaWFVcGRhdGUSNgoEdHlwZR' - 'gBIAEoDjIiLkVuY3J5cHRlZENvbnRlbnQuTWVkaWFVcGRhdGUuVHlwZVIEdHlwZRIkCg10YXJn' - 'ZXRNZWRpYUlkGAIgASgJUg10YXJnZXRNZWRpYUlkIjYKBFR5cGUSDAoIUkVPUEVORUQQABIKCg' - 'ZTVE9SRUQQARIUChBERUNSWVBUSU9OX0VSUk9SEAIaeAoOQ29udGFjdFJlcXVlc3QSOQoEdHlw' - 'ZRgBIAEoDjIlLkVuY3J5cHRlZENvbnRlbnQuQ29udGFjdFJlcXVlc3QuVHlwZVIEdHlwZSIrCg' - 'RUeXBlEgsKB1JFUVVFU1QQABIKCgZSRUpFQ1QQARIKCgZBQ0NFUFQQAhrSAQoNQ29udGFjdFVw' - 'ZGF0ZRI4CgR0eXBlGAEgASgOMiQuRW5jcnlwdGVkQ29udGVudC5Db250YWN0VXBkYXRlLlR5cG' - 'VSBHR5cGUSIQoJYXZhdGFyU3ZnGAIgASgJSABSCWF2YXRhclN2Z4gBARIlCgtkaXNwbGF5TmFt' - 'ZRgDIAEoCUgBUgtkaXNwbGF5TmFtZYgBASIfCgRUeXBlEgsKB1JFUVVFU1QQABIKCgZVUERBVE' - 'UQAUIMCgpfYXZhdGFyU3ZnQg4KDF9kaXNwbGF5TmFtZRrVAQoIUHVzaEtleXMSMwoEdHlwZRgB' - 'IAEoDjIfLkVuY3J5cHRlZENvbnRlbnQuUHVzaEtleXMuVHlwZVIEdHlwZRIZCgVrZXlJZBgCIA' - 'EoA0gAUgVrZXlJZIgBARIVCgNrZXkYAyABKAxIAVIDa2V5iAEBEiEKCWNyZWF0ZWRBdBgEIAEo' - 'A0gCUgljcmVhdGVkQXSIAQEiHwoEVHlwZRILCgdSRVFVRVNUEAASCgoGVVBEQVRFEAFCCAoGX2' - 'tleUlkQgYKBF9rZXlCDAoKX2NyZWF0ZWRBdBqHAQoJRmxhbWVTeW5jEiIKDGZsYW1lQ291bnRl' - 'chgBIAEoA1IMZmxhbWVDb3VudGVyEjYKFmxhc3RGbGFtZUNvdW50ZXJDaGFuZ2UYAiABKANSFm' - 'xhc3RGbGFtZUNvdW50ZXJDaGFuZ2USHgoKYmVzdEZyaWVuZBgDIAEoCFIKYmVzdEZyaWVuZEIK' - 'CghfZ3JvdXBJZEIPCg1faXNEaXJlY3RDaGF0QhcKFV9zZW5kZXJQcm9maWxlQ291bnRlckIQCg' - '5fbWVzc2FnZVVwZGF0ZUIICgZfbWVkaWFCDgoMX21lZGlhVXBkYXRlQhAKDl9jb250YWN0VXBk' - 'YXRlQhEKD19jb250YWN0UmVxdWVzdEIMCgpfZmxhbWVTeW5jQgsKCV9wdXNoS2V5c0ILCglfcm' - 'VhY3Rpb25CDgoMX3RleHRNZXNzYWdl'); + 'Y3J5cHRpb25NYWNCEgoQX2VuY3J5cHRpb25Ob25jZRqnAQoLTWVkaWFVcGRhdGUSNgoEdHlwZR' + 'gBIAEoDjIiLkVuY3J5cHRlZENvbnRlbnQuTWVkaWFVcGRhdGUuVHlwZVIEdHlwZRIoCg90YXJn' + 'ZXRNZXNzYWdlSWQYAiABKAlSD3RhcmdldE1lc3NhZ2VJZCI2CgRUeXBlEgwKCFJFT1BFTkVEEA' + 'ASCgoGU1RPUkVEEAESFAoQREVDUllQVElPTl9FUlJPUhACGngKDkNvbnRhY3RSZXF1ZXN0EjkK' + 'BHR5cGUYASABKA4yJS5FbmNyeXB0ZWRDb250ZW50LkNvbnRhY3RSZXF1ZXN0LlR5cGVSBHR5cG' + 'UiKwoEVHlwZRILCgdSRVFVRVNUEAASCgoGUkVKRUNUEAESCgoGQUNDRVBUEAIa0gEKDUNvbnRh' + 'Y3RVcGRhdGUSOAoEdHlwZRgBIAEoDjIkLkVuY3J5cHRlZENvbnRlbnQuQ29udGFjdFVwZGF0ZS' + '5UeXBlUgR0eXBlEiEKCWF2YXRhclN2ZxgCIAEoCUgAUglhdmF0YXJTdmeIAQESJQoLZGlzcGxh' + 'eU5hbWUYAyABKAlIAVILZGlzcGxheU5hbWWIAQEiHwoEVHlwZRILCgdSRVFVRVNUEAASCgoGVV' + 'BEQVRFEAFCDAoKX2F2YXRhclN2Z0IOCgxfZGlzcGxheU5hbWUa1QEKCFB1c2hLZXlzEjMKBHR5' + 'cGUYASABKA4yHy5FbmNyeXB0ZWRDb250ZW50LlB1c2hLZXlzLlR5cGVSBHR5cGUSGQoFa2V5SW' + 'QYAiABKANIAFIFa2V5SWSIAQESFQoDa2V5GAMgASgMSAFSA2tleYgBARIhCgljcmVhdGVkQXQY' + 'BCABKANIAlIJY3JlYXRlZEF0iAEBIh8KBFR5cGUSCwoHUkVRVUVTVBAAEgoKBlVQREFURRABQg' + 'gKBl9rZXlJZEIGCgRfa2V5QgwKCl9jcmVhdGVkQXQahwEKCUZsYW1lU3luYxIiCgxmbGFtZUNv' + 'dW50ZXIYASABKANSDGZsYW1lQ291bnRlchI2ChZsYXN0RmxhbWVDb3VudGVyQ2hhbmdlGAIgAS' + 'gDUhZsYXN0RmxhbWVDb3VudGVyQ2hhbmdlEh4KCmJlc3RGcmllbmQYAyABKAhSCmJlc3RGcmll' + 'bmRCCgoIX2dyb3VwSWRCDwoNX2lzRGlyZWN0Q2hhdEIXChVfc2VuZGVyUHJvZmlsZUNvdW50ZX' + 'JCEAoOX21lc3NhZ2VVcGRhdGVCCAoGX21lZGlhQg4KDF9tZWRpYVVwZGF0ZUIQCg5fY29udGFj' + 'dFVwZGF0ZUIRCg9fY29udGFjdFJlcXVlc3RCDAoKX2ZsYW1lU3luY0ILCglfcHVzaEtleXNCCw' + 'oJX3JlYWN0aW9uQg4KDF90ZXh0TWVzc2FnZQ=='); diff --git a/lib/src/model/protobuf/client/messages.proto b/lib/src/model/protobuf/client/messages.proto index cd37d58..890ef28 100644 --- a/lib/src/model/protobuf/client/messages.proto +++ b/lib/src/model/protobuf/client/messages.proto @@ -66,7 +66,7 @@ message EncryptedContent { } Type type = 1; optional string senderMessageId = 2; - repeated string multipleSenderMessageIds = 3; + repeated string multipleTargetMessageIds = 3; optional string text = 4; int64 timestamp = 5; } @@ -99,7 +99,7 @@ message EncryptedContent { DECRYPTION_ERROR = 2; } Type type = 1; - string targetMediaId = 2; + string targetMessageId = 2; } message ContactRequest { diff --git a/lib/src/services/api.service.dart b/lib/src/services/api.service.dart index 79a9dc3..4d6ff9e 100644 --- a/lib/src/services/api.service.dart +++ b/lib/src/services/api.service.dart @@ -156,11 +156,6 @@ class ApiService { } reconnectionTimer?.cancel(); reconnectionTimer = null; - final user = await getUser(); - if (user != null) { - globalCallbackConnectionState(isConnected: true); - return false; - } return lockConnecting.protect(() async { if (_channel != null) { return true; diff --git a/lib/src/services/api/mediafiles/download.service.dart b/lib/src/services/api/mediafiles/download.service.dart index 8da0d48..9f4e910 100644 --- a/lib/src/services/api/mediafiles/download.service.dart +++ b/lib/src/services/api/mediafiles/download.service.dart @@ -95,6 +95,7 @@ Future handleDownloadStatusUpdate(TaskStatusUpdate update) async { } if (failed) { + Log.error('Background media upload failed: ${update.status}'); await requestMediaReupload(mediaId); } else { await handleEncryptedFile(mediaId); @@ -194,6 +195,9 @@ Future downloadFileFast( if (response.statusCode == 404 || response.statusCode == 403 || response.statusCode == 400) { + Log.error( + 'Got ${response.statusCode} from server. Requesting upload again', + ); // Message was deleted from the server. Requesting it again from the sender to upload it again... await requestMediaReupload(media.mediaId); return; @@ -217,7 +221,7 @@ Future requestMediaReupload(String mediaId) async { EncryptedContent( mediaUpdate: EncryptedContent_MediaUpdate( type: EncryptedContent_MediaUpdate_Type.DECRYPTION_ERROR, - targetMediaId: mediaId, + targetMessageId: messages.first.messageId, ), ), ); diff --git a/lib/src/services/api/mediafiles/media_background.service.dart b/lib/src/services/api/mediafiles/media_background.service.dart index 7b9102b..39f5eb7 100644 --- a/lib/src/services/api/mediafiles/media_background.service.dart +++ b/lib/src/services/api/mediafiles/media_background.service.dart @@ -58,6 +58,12 @@ Future handleUploadStatusUpdate(TaskStatusUpdate update) async { final mediaId = update.task.taskId.replaceAll('upload_', ''); final media = await twonlyDB.mediaFilesDao.getMediaFileById(mediaId); + if (update.status == TaskStatus.enqueued || + update.status == TaskStatus.running) { + // Ignore these updates + return; + } + if (media == null) { Log.error( 'Got an upload task but no upload media in the media upload database', @@ -115,7 +121,7 @@ Future handleUploadStatusUpdate(TaskStatusUpdate update) async { final mediaService = await MediaFileService.fromMedia(media); - await mediaService.setUploadState(UploadState.uploading); + await mediaService.setUploadState(UploadState.uploaded); // In all other cases just try the upload again... await startBackgroundMediaUpload(mediaService); } diff --git a/lib/src/services/api/mediafiles/upload.service.dart b/lib/src/services/api/mediafiles/upload.service.dart index 6c8c783..22326f0 100644 --- a/lib/src/services/api/mediafiles/upload.service.dart +++ b/lib/src/services/api/mediafiles/upload.service.dart @@ -6,9 +6,11 @@ import 'package:cryptography_plus/cryptography_plus.dart'; import 'package:drift/drift.dart'; import 'package:fixnum/fixnum.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:mutex/mutex.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/src/constants/secure_storage_keys.dart'; import 'package:twonly/src/database/tables/mediafiles.table.dart'; +import 'package:twonly/src/database/tables/messages.table.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/model/protobuf/api/http/http_requests.pb.dart'; import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart'; @@ -47,6 +49,7 @@ Future insertMediaFileInMessagesTable( MessagesCompanion( groupId: Value(groupId), mediaId: Value(mediaService.mediaFile.mediaId), + type: const Value(MessageType.media), ), ); if (message != null) { @@ -147,12 +150,13 @@ Future _createUploadRequest(MediaFileService media) async { } final notEncryptedContent = EncryptedContent( + groupId: message.groupId, media: EncryptedContent_Media( senderMessageId: message.messageId, type: type, requiresAuthentication: media.mediaFile.requiresAuthentication, timestamp: Int64(message.createdAt.millisecondsSinceEpoch), - downloadToken: media.mediaFile.downloadToken, + downloadToken: downloadToken.toList(), encryptionKey: media.mediaFile.encryptionKey, encryptionNonce: media.mediaFile.encryptionNonce, encryptionMac: media.mediaFile.encryptionMac, @@ -202,36 +206,50 @@ Future _createUploadRequest(MediaFileService media) async { await media.uploadRequestPath.writeAsBytes(uploadRequestBytes); } +Mutex protectUpload = Mutex(); + Future _uploadUploadRequest(MediaFileService media) async { - final apiAuthTokenRaw = await const FlutterSecureStorage() - .read(key: SecureStorageKeys.apiAuthToken); - if (apiAuthTokenRaw == null) { - Log.error('api auth token not defined.'); - return; - } - final apiAuthToken = uint8ListToHex(base64Decode(apiAuthTokenRaw)); + await protectUpload.protect(() async { + final currentMedia = + await twonlyDB.mediaFilesDao.getMediaFileById(media.mediaFile.mediaId); - final apiUrl = - 'http${apiService.apiSecure}://${apiService.apiHost}/api/upload'; + if (currentMedia == null || + currentMedia.uploadState == UploadState.backgroundUploadTaskStarted) { + Log.info('Download for ${media.mediaFile.mediaId} already started.'); + return null; + } - // try { - Log.info('Starting upload from ${media.mediaFile.mediaId}'); + final apiAuthTokenRaw = await const FlutterSecureStorage() + .read(key: SecureStorageKeys.apiAuthToken); - final task = UploadTask.fromFile( - taskId: 'upload_${media.mediaFile.mediaId}', - displayName: media.mediaFile.type.name, - file: media.uploadRequestPath, - url: apiUrl, - priority: 0, - retries: 10, - headers: { - 'x-twonly-auth-token': apiAuthToken, - }, - ); + if (apiAuthTokenRaw == null) { + Log.error('api auth token not defined.'); + return null; + } + final apiAuthToken = uint8ListToHex(base64Decode(apiAuthTokenRaw)); - Log.info('Enqueue upload task: ${task.taskId}'); + final apiUrl = + 'http${apiService.apiSecure}://${apiService.apiHost}/api/upload'; - await FileDownloader().enqueue(task); + // try { + Log.info('Starting upload from ${media.mediaFile.mediaId}'); - await media.setUploadState(UploadState.backgroundUploadTaskStarted); + final task = UploadTask.fromFile( + taskId: 'upload_${media.mediaFile.mediaId}', + displayName: media.mediaFile.type.name, + file: media.uploadRequestPath, + url: apiUrl, + priority: 0, + retries: 10, + headers: { + 'x-twonly-auth-token': apiAuthToken, + }, + ); + + Log.info('Enqueue upload task: ${task.taskId}'); + + await FileDownloader().enqueue(task); + + await media.setUploadState(UploadState.backgroundUploadTaskStarted); + }); } diff --git a/lib/src/services/api/messages.dart b/lib/src/services/api/messages.dart index 1298115..8c3408c 100644 --- a/lib/src/services/api/messages.dart +++ b/lib/src/services/api/messages.dart @@ -4,6 +4,7 @@ import 'package:fixnum/fixnum.dart'; import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart'; import 'package:mutex/mutex.dart'; import 'package:twonly/globals.dart'; +import 'package:twonly/src/database/tables/messages.table.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/model/protobuf/api/websocket/error.pb.dart'; import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart' @@ -35,7 +36,6 @@ Future tryTransmitMessages() async { Future<(Uint8List, Uint8List?)?> tryToSendCompleteMessage({ String? receiptId, Receipt? receipt, - bool reupload = false, bool onlyReturnEncryptedData = false, }) async { try { @@ -49,15 +49,6 @@ Future<(Uint8List, Uint8List?)?> tryToSendCompleteMessage({ } receiptId = receipt.receiptId; - if (reupload) { - await twonlyDB.receiptsDao.updateReceipt( - receiptId, - const ReceiptsCompanion( - ackByServerAt: Value(null), - ), - ); - } - if (!onlyReturnEncryptedData && receipt.ackByServerAt != null) { Log.error('$receiptId message already uploaded!'); return null; @@ -71,12 +62,18 @@ Future<(Uint8List, Uint8List?)?> tryToSendCompleteMessage({ final encryptedContent = pb.EncryptedContent.fromBuffer(message.encryptedContent); - var pushData = await getPushDataFromEncryptedContent( + final pushNotification = await getPushNotificationFromEncryptedContent( receipt.contactId, receipt.messageId, encryptedContent, ); + Uint8List? pushData; + if (pushNotification != null) { + pushData = + await encryptPushNotification(receipt.contactId, pushNotification); + } + if (message.type == pb.Message_Type.TEST_NOTIFICATION) { pushData = (PushNotification()..kind = PushKind.testNotification) .writeToBuffer(); @@ -167,6 +164,7 @@ Future insertAndSendTextMessage( MessagesCompanion( groupId: Value(groupId), content: Value(textMessage), + type: const Value(MessageType.text), quotesMessageId: Value(quotesMessageId), ), ); @@ -187,26 +185,24 @@ Future insertAndSendTextMessage( encryptedContent.textMessage.quoteMessageId = quotesMessageId; } - await sendCipherTextToGroup(groupId, encryptedContent); + await sendCipherTextToGroup(groupId, encryptedContent, message.messageId); } Future sendCipherTextToGroup( String groupId, pb.EncryptedContent encryptedContent, + String? messageId, ) async { final groupMembers = await twonlyDB.groupsDao.getGroupMembers(groupId); - final group = await twonlyDB.groupsDao.getGroup(groupId); - if (group == null) return; - encryptedContent - ..groupId = groupId - ..isDirectChat = group.isDirectChat; + encryptedContent.groupId = groupId; for (final groupMember in groupMembers) { unawaited( sendCipherText( groupMember.contactId, encryptedContent, + messageId: messageId, ), ); } @@ -216,6 +212,7 @@ Future<(Uint8List, Uint8List?)?> sendCipherText( int contactId, pb.EncryptedContent encryptedContent, { bool onlyReturnEncryptedData = false, + String? messageId, }) async { final response = pb.Message() ..type = pb.Message_Type.CIPHERTEXT @@ -225,6 +222,7 @@ Future<(Uint8List, Uint8List?)?> sendCipherText( ReceiptsCompanion( contactId: Value(contactId), message: Value(response.writeToBuffer()), + messageId: Value(messageId), ackByServerAt: Value(onlyReturnEncryptedData ? DateTime.now() : null), ), ); @@ -249,15 +247,23 @@ Future notifyContactAboutOpeningMessage( biggestMessageId = messageOtherId; } } + Log.info('Opened messages: $messageOtherIds'); + await sendCipherText( contactId, pb.EncryptedContent( messageUpdate: pb.EncryptedContent_MessageUpdate( type: pb.EncryptedContent_MessageUpdate_Type.OPENED, - multipleSenderMessageIds: messageOtherIds, + multipleTargetMessageIds: messageOtherIds, ), ), ); + for (final messageId in messageOtherIds) { + await twonlyDB.messagesDao.updateMessageId( + messageId, + MessagesCompanion(openedAt: Value(DateTime.now())), + ); + } await updateLastMessageId(contactId, biggestMessageId); } diff --git a/lib/src/services/api/server_messages.dart b/lib/src/services/api/server_messages.dart index 74aa8e5..9484785 100644 --- a/lib/src/services/api/server_messages.dart +++ b/lib/src/services/api/server_messages.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'package:drift/drift.dart'; +import 'package:hashlib/random.dart'; import 'package:mutex/mutex.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/src/database/twonly.db.dart' hide Message; @@ -50,10 +51,20 @@ Future handleServerMessage(server.ServerToClient msg) async { DateTime lastPushKeyRequest = DateTime.now().subtract(const Duration(hours: 1)); +Mutex protectReceiptCheck = Mutex(); + Future handleNewMessage(int fromUserId, Uint8List body) async { final message = Message.fromBuffer(body); final receiptId = message.receiptId; + await protectReceiptCheck.protect(() async { + if (await twonlyDB.receiptsDao.isDuplicated(receiptId)) { + Log.error('Got duplicated message from the server. Ignoring it.'); + return; + } + await twonlyDB.receiptsDao.gotReceipt(receiptId); + }); + switch (message.type) { case Message_Type.SENDER_DELIVERY_RECEIPT: Log.info('Got delivery receipt for $receiptId!'); @@ -65,7 +76,15 @@ Future handleNewMessage(int fromUserId, Uint8List body) async { Log.info( 'Got decryption error: ${message.plaintextContent.decryptionErrorMessage.type} for $receiptId', ); - await tryToSendCompleteMessage(receiptId: receiptId, reupload: true); + final newReceiptId = uuid.v4(); + await twonlyDB.receiptsDao.updateReceipt( + receiptId, + ReceiptsCompanion( + receiptId: Value(newReceiptId), + ackByServerAt: const Value(null), + ), + ); + await tryToSendCompleteMessage(receiptId: newReceiptId); } case Message_Type.CIPHERTEXT: @@ -112,7 +131,7 @@ Future handleEncryptedMessage( final (content, decryptionErrorType) = await signalDecryptMessage( fromUserId, encryptedContentRaw, - messageType as int, + messageType.value, ); if (content == null) { @@ -147,7 +166,24 @@ Future handleEncryptedMessage( return null; } + if (content.hasMessageUpdate()) { + await handleMessageUpdate( + fromUserId, + content.messageUpdate, + ); + return null; + } + + if (content.hasMediaUpdate()) { + await handleMediaUpdate( + fromUserId, + content.mediaUpdate, + ); + return null; + } + if (!content.hasGroupId()) { + Log.error('Messages should have a groupId $fromUserId.'); return null; } @@ -157,14 +193,6 @@ Future handleEncryptedMessage( return null; } - if (content.hasMessageUpdate()) { - await handleMessageUpdate( - fromUserId, - content.messageUpdate, - ); - return null; - } - if (content.hasTextMessage()) { await handleTextMessage( fromUserId, @@ -192,14 +220,5 @@ Future handleEncryptedMessage( return null; } - if (content.hasMediaUpdate()) { - await handleMediaUpdate( - fromUserId, - content.groupId, - content.mediaUpdate, - ); - return null; - } - return null; } diff --git a/lib/src/services/api/server_messages/media.server_messages.dart b/lib/src/services/api/server_messages/media.server_messages.dart index b822fb4..5d2ee34 100644 --- a/lib/src/services/api/server_messages/media.server_messages.dart +++ b/lib/src/services/api/server_messages/media.server_messages.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:drift/drift.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/src/database/tables/mediafiles.table.dart'; +import 'package:twonly/src/database/tables/messages.table.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart'; import 'package:twonly/src/services/api/mediafiles/download.service.dart'; @@ -92,6 +93,7 @@ Future handleMedia( senderId: Value(fromUserId), groupId: Value(groupId), mediaId: Value(mediaFile.mediaId), + type: const Value(MessageType.media), quotesMessageId: Value( media.hasQuoteMessageId() ? media.quoteMessageId : null, ), @@ -112,16 +114,17 @@ Future handleMedia( Future handleMediaUpdate( int fromUserId, - String groupId, EncryptedContent_MediaUpdate mediaUpdate, ) async { - final messages = await twonlyDB.messagesDao - .getMessagesByMediaId(mediaUpdate.targetMediaId); - if (messages.length != 1) return; - final message = messages.first; - if (message.senderId != fromUserId) return; + final message = await twonlyDB.messagesDao + .getMessageById(mediaUpdate.targetMessageId) + .getSingleOrNull(); + if (message == null) { + Log.error( + 'Got media update to message ${mediaUpdate.targetMessageId} but message not found.'); + } final mediaFile = - await twonlyDB.mediaFilesDao.getMediaFileById(message.mediaId!); + await twonlyDB.mediaFilesDao.getMediaFileById(message!.mediaId!); if (mediaFile == null) { Log.info( 'Got media file update, but media file was not found ${message.mediaId}', @@ -140,15 +143,8 @@ Future handleMediaUpdate( ); case EncryptedContent_MediaUpdate_Type.STORED: Log.info('Got media file stored ${mediaFile.mediaId}'); - await twonlyDB.mediaFilesDao.updateMedia( - mediaFile.mediaId, - const MediaFilesCompanion( - stored: Value(true), - ), - ); - final mediaService = await MediaFileService.fromMedia(mediaFile); - unawaited(mediaService.createThumbnail()); + await mediaService.storeMediaFile(); case EncryptedContent_MediaUpdate_Type.DECRYPTION_ERROR: Log.info('Got media file decryption error ${mediaFile.mediaId}'); diff --git a/lib/src/services/api/server_messages/messages.server_messages.dart b/lib/src/services/api/server_messages/messages.server_messages.dart index b0b2457..ff87010 100644 --- a/lib/src/services/api/server_messages/messages.server_messages.dart +++ b/lib/src/services/api/server_messages/messages.server_messages.dart @@ -9,17 +9,20 @@ Future handleMessageUpdate( ) async { switch (messageUpdate.type) { case EncryptedContent_MessageUpdate_Type.OPENED: - Log.info( - 'Opened message ${messageUpdate.multipleSenderMessageIds.length}', - ); - for (final senderMessageId in messageUpdate.multipleSenderMessageIds) { + for (final targetMessageId in messageUpdate.multipleTargetMessageIds) { + Log.info( + 'Opened message $targetMessageId', + ); await twonlyDB.messagesDao.handleMessageOpened( contactId, - senderMessageId, + targetMessageId, fromTimestamp(messageUpdate.timestamp), ); } case EncryptedContent_MessageUpdate_Type.DELETE: + if (!await isSender(contactId, messageUpdate.senderMessageId)) { + return; + } Log.info('Delete message ${messageUpdate.senderMessageId}'); await twonlyDB.messagesDao.handleMessageDeletion( contactId, @@ -27,6 +30,9 @@ Future handleMessageUpdate( fromTimestamp(messageUpdate.timestamp), ); case EncryptedContent_MessageUpdate_Type.EDIT_TEXT: + if (!await isSender(contactId, messageUpdate.senderMessageId)) { + return; + } Log.info('Edit message ${messageUpdate.senderMessageId}'); await twonlyDB.messagesDao.handleTextEdit( contactId, @@ -36,3 +42,14 @@ Future handleMessageUpdate( ); } } + +Future isSender(int fromUserId, String messageId) async { + final message = + await twonlyDB.messagesDao.getMessageById(messageId).getSingleOrNull(); + if (message == null) return false; + if (message.senderId == fromUserId) { + return true; + } + Log.error('Contact $fromUserId tried to modify the message $messageId'); + return false; +} diff --git a/lib/src/services/api/server_messages/text_message.server_messages.dart b/lib/src/services/api/server_messages/text_message.server_messages.dart index 7efbdfd..c20d803 100644 --- a/lib/src/services/api/server_messages/text_message.server_messages.dart +++ b/lib/src/services/api/server_messages/text_message.server_messages.dart @@ -1,5 +1,6 @@ import 'package:drift/drift.dart'; import 'package:twonly/globals.dart'; +import 'package:twonly/src/database/tables/messages.table.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart'; import 'package:twonly/src/services/api/utils.dart'; @@ -20,6 +21,7 @@ Future handleTextMessage( senderId: Value(fromUserId), groupId: Value(groupId), content: Value(textMessage.text), + type: const Value(MessageType.text), quotesMessageId: Value( textMessage.hasQuoteMessageId() ? textMessage.quoteMessageId : null, ), diff --git a/lib/src/services/api/utils.dart b/lib/src/services/api/utils.dart index e0a101e..c7af9bc 100644 --- a/lib/src/services/api/utils.dart +++ b/lib/src/services/api/utils.dart @@ -26,7 +26,7 @@ class Result { } DateTime fromTimestamp(Int64 timeStamp) { - return DateTime.fromMillisecondsSinceEpoch(timeStamp.toInt() * 1000); + return DateTime.fromMillisecondsSinceEpoch(timeStamp.toInt()); } // ignore: strict_raw_type @@ -88,7 +88,7 @@ Future handleMediaError(MediaFile media) async { EncryptedContent( mediaUpdate: EncryptedContent_MediaUpdate( type: EncryptedContent_MediaUpdate_Type.DECRYPTION_ERROR, - targetMediaId: message.mediaId, + targetMessageId: message.messageId, ), ), ); diff --git a/lib/src/services/mediafiles/mediafile.service.dart b/lib/src/services/mediafiles/mediafile.service.dart index 73d5a78..744c54d 100644 --- a/lib/src/services/mediafiles/mediafile.service.dart +++ b/lib/src/services/mediafiles/mediafile.service.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:drift/drift.dart'; import 'package:path/path.dart'; @@ -129,6 +130,9 @@ class MediaFileService { } } + bool get imagePreviewAvailable => + thumbnailPath.existsSync() || storedPath.existsSync(); + Future storeMediaFile() async { Log.info('Storing media file ${mediaFile.mediaId}'); await twonlyDB.mediaFilesDao.updateMedia( @@ -137,7 +141,25 @@ class MediaFileService { stored: Value(true), ), ); - await tempPath.copy(storedPath.path); + await twonlyDB.messagesDao.updateMessagesByMediaId( + mediaFile.mediaId, + const MessagesCompanion( + mediaStored: Value(true), + ), + ); + + if (originalPath.existsSync()) { + await originalPath.copy(tempPath.path); + await compressMedia(); + } + if (tempPath.existsSync()) { + await tempPath.copy(storedPath.path); + } else { + Log.error( + 'Could not store image neither tempPath nor originalPath exists.', + ); + } + unawaited(createThumbnail()); await updateFromDB(); } diff --git a/lib/src/services/notifications/pushkeys.notifications.dart b/lib/src/services/notifications/pushkeys.notifications.dart index 1abf6fc..4a50c61 100644 --- a/lib/src/services/notifications/pushkeys.notifications.dart +++ b/lib/src/services/notifications/pushkeys.notifications.dart @@ -195,12 +195,12 @@ Future updateLastMessageId(int fromUserId, String messageId) async { } } -Future getPushDataFromEncryptedContent( +Future getPushNotificationFromEncryptedContent( int toUserId, String? messageId, EncryptedContent content, ) async { - late PushKind kind; + PushKind? kind; String? reactionContent; if (content.hasReaction()) { @@ -270,6 +270,8 @@ Future getPushDataFromEncryptedContent( } } + if (kind == null) return null; + final pushNotification = PushNotification()..kind = kind; if (reactionContent != null) { pushNotification.reactionContent = reactionContent; @@ -277,7 +279,7 @@ Future getPushDataFromEncryptedContent( if (messageId != null) { pushNotification.messageId = messageId; } - return encryptPushNotification(toUserId, pushNotification); + return pushNotification; } /// this will trigger a push notification diff --git a/lib/src/services/signal/prekeys.signal.dart b/lib/src/services/signal/prekeys.signal.dart index ccee8ec..e70d239 100644 --- a/lib/src/services/signal/prekeys.signal.dart +++ b/lib/src/services/signal/prekeys.signal.dart @@ -31,13 +31,13 @@ Future requestNewPrekeysForContact(int contactId) async { .isAfter(DateTime.now().subtract(const Duration(seconds: 60)))) { return; } - Log.info('Requesting new PREKEYS for $contactId'); + Log.info('[PREKEY] Requesting new PREKEYS for $contactId'); lastPreKeyRequest = DateTime.now(); await requestNewKeys.protect(() async { final otherKeys = await apiService.getPreKeysByUserId(contactId); if (otherKeys != null) { Log.info( - 'got fresh ${otherKeys.preKeys.length} pre keys from other $contactId!', + '[PREKEY] Got fresh ${otherKeys.preKeys.length} pre keys from other $contactId!', ); final preKeys = otherKeys.preKeys .map( @@ -50,7 +50,8 @@ Future requestNewPrekeysForContact(int contactId) async { .toList(); await twonlyDB.signalDao.insertPreKeys(preKeys); } else { - Log.error('could not load new pre keys for user $contactId'); + // 104400 + Log.error('[PREKEY] Could not load new pre keys for user $contactId'); } }); } diff --git a/lib/src/utils/misc.dart b/lib/src/utils/misc.dart index 79848fd..bd34ec1 100644 --- a/lib/src/utils/misc.dart +++ b/lib/src/utils/misc.dart @@ -4,6 +4,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_image_compress/flutter_image_compress.dart'; import 'package:gal/gal.dart'; import 'package:intl/intl.dart'; +import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart'; import 'package:local_auth/local_auth.dart'; import 'package:pie_menu/pie_menu.dart'; import 'package:provider/provider.dart'; @@ -246,11 +247,14 @@ String formatBytes(int bytes, {int decimalPlaces = 2}) { } bool isUUIDNewer(String uuid1, String uuid2) { - final timestamp1 = int.parse(uuid1.substring(0, 8), radix: 16); - final timestamp2 = int.parse(uuid2.substring(0, 8), radix: 16); - print(timestamp1); - print(timestamp2); - return timestamp1 > timestamp2; + try { + final timestamp1 = int.parse(uuid1.substring(0, 8), radix: 16); + final timestamp2 = int.parse(uuid2.substring(0, 8), radix: 16); + return timestamp1 > timestamp2; + } catch (e) { + Log.error(e); + return true; + } } String uint8ListToHex(List bytes) { @@ -313,3 +317,34 @@ Color getMessageColorFromType( } return color; } + +String getUUIDforDirectChat(int a, int b) { + if (a < 0 || b < 0) { + throw ArgumentError('Inputs must be non-negative integers.'); + } + if (a > integerMax || b > integerMax) { + throw ArgumentError('Inputs must be <= 0x7fffffff.'); + } + + // Mask to 64 bits in case inputs exceed 64 bits + final mask64 = (BigInt.one << 64) - BigInt.one; + final ai = BigInt.from(a) & mask64; + final bi = BigInt.from(b) & mask64; + + // Ensure the bigger integer is in front (high 64 bits) + final hi = ai >= bi ? ai : bi; + final lo = ai >= bi ? bi : ai; + + final combined = (hi << 64) | lo; + + final hex = combined.toRadixString(16).padLeft(32, '0'); + + final parts = [ + hex.substring(0, 8), + hex.substring(8, 12), + hex.substring(12, 16), + hex.substring(16, 20), + hex.substring(20, 32), + ]; + return parts.join('-'); +} diff --git a/lib/src/views/camera/camera_preview_components/save_to_gallery.dart b/lib/src/views/camera/camera_preview_components/save_to_gallery.dart index 6824617..5c81e0f 100644 --- a/lib/src/views/camera/camera_preview_components/save_to_gallery.dart +++ b/lib/src/views/camera/camera_preview_components/save_to_gallery.dart @@ -1,20 +1,20 @@ import 'dart:async'; -import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:twonly/globals.dart'; +import 'package:twonly/src/database/tables/mediafiles.table.dart'; import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; import 'package:twonly/src/utils/misc.dart'; class SaveToGalleryButton extends StatefulWidget { const SaveToGalleryButton({ - required this.getMergedImage, + required this.storeImageAsOriginal, required this.isLoading, required this.displayButtonLabel, required this.mediaService, super.key, }); - final Future Function() getMergedImage; + final Future Function() storeImageAsOriginal; final bool displayButtonLabel; final MediaFileService mediaService; final bool isLoading; @@ -45,6 +45,10 @@ class SaveToGalleryButtonState extends State { _imageSaving = true; }); + if (widget.mediaService.mediaFile.type == MediaType.image) { + await widget.storeImageAsOriginal(); + } + String? res; final storedMediaPath = widget.mediaService.storedPath; diff --git a/lib/src/views/camera/share_image_editor_view.dart b/lib/src/views/camera/share_image_editor_view.dart index e5d787a..6cf4910 100644 --- a/lib/src/views/camera/share_image_editor_view.dart +++ b/lib/src/views/camera/share_image_editor_view.dart @@ -171,20 +171,20 @@ class _ShareImageEditorView extends State { ), const SizedBox(height: 8), NotificationBadge( - count: (media.type != MediaType.video) + count: (media.type == MediaType.video) ? '0' : media.displayLimitInMilliseconds == null ? '∞' : media.displayLimitInMilliseconds.toString(), child: ActionButton( - (media.type != MediaType.video) + (media.type == MediaType.video) ? media.displayLimitInMilliseconds == null ? Icons.repeat_rounded : Icons.repeat_one_rounded : Icons.timer_outlined, tooltipText: context.lang.protectAsARealTwonly, onPressed: () async { - if (media.type != MediaType.video) { + if (media.type == MediaType.video) { await mediaService.setDisplayLimit( (media.displayLimitInMilliseconds == null) ? 0 : null, ); @@ -311,7 +311,7 @@ class _ShareImageEditorView extends State { } } - if (layers.length > 1 || media.type != MediaType.video) { + if (layers.length > 1 || media.type == MediaType.video) { for (final x in layers) { x.showCustomButtons = false; } @@ -434,7 +434,7 @@ class _ShareImageEditorView extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ SaveToGalleryButton( - getMergedImage: getEditedImageBytes, + storeImageAsOriginal: storeImageAsOriginal, mediaService: mediaService, displayButtonLabel: widget.sendToGroup == null, isLoading: loadingImage, diff --git a/lib/src/views/chats/chat_list.view.dart b/lib/src/views/chats/chat_list.view.dart index adde0ea..f13fac1 100644 --- a/lib/src/views/chats/chat_list.view.dart +++ b/lib/src/views/chats/chat_list.view.dart @@ -209,31 +209,32 @@ class _ChatListViewState extends State { child: isConnected ? Container() : const ConnectionInfo(), ), Positioned.fill( - child: (_groupsNotPinned.isEmpty && _groupsPinned.isEmpty) - ? Center( - child: Padding( - padding: const EdgeInsets.all(10), - child: OutlinedButton.icon( - icon: const Icon(Icons.person_add), - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => const AddNewUserView(), - ), - ); - }, - label: Text(context.lang.chatListViewSearchUserNameBtn), + child: RefreshIndicator( + onRefresh: () async { + await apiService.close(() {}); + await apiService.connect(force: true); + await Future.delayed(const Duration(seconds: 1)); + }, + child: (_groupsNotPinned.isEmpty && _groupsPinned.isEmpty) + ? Center( + child: Padding( + padding: const EdgeInsets.all(10), + child: OutlinedButton.icon( + icon: const Icon(Icons.person_add), + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const AddNewUserView(), + ), + ); + }, + label: + Text(context.lang.chatListViewSearchUserNameBtn), + ), ), - ), - ) - : RefreshIndicator( - onRefresh: () async { - await apiService.close(() {}); - await apiService.connect(force: true); - await Future.delayed(const Duration(seconds: 1)); - }, - child: ListView.builder( + ) + : ListView.builder( itemCount: _groupsPinned.length + (_groupsPinned.isNotEmpty ? 1 : 0) + _groupsNotPinned.length + @@ -276,7 +277,7 @@ class _ChatListViewState extends State { ); }, ), - ), + ), ), ], ), diff --git a/lib/src/views/chats/chat_list_components/group_list_item.dart b/lib/src/views/chats/chat_list_components/group_list_item.dart index dbb2fa4..dd4bab5 100644 --- a/lib/src/views/chats/chat_list_components/group_list_item.dart +++ b/lib/src/views/chats/chat_list_components/group_list_item.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:mutex/mutex.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/src/database/tables/mediafiles.table.dart'; import 'package:twonly/src/database/tables/messages.table.dart'; @@ -36,10 +37,12 @@ class _UserListItem extends State { List messagesNotOpened = []; late StreamSubscription> messagesNotOpenedStream; - List lastMessages = []; - late StreamSubscription> lastMessageStream; + Message? lastMessage; + late StreamSubscription lastMessageStream; + late StreamSubscription> lastMediaFilesStream; List previewMessages = []; + List previewMediaFiles = []; bool hasNonOpenedMediaFile = false; @override @@ -52,6 +55,7 @@ class _UserListItem extends State { void dispose() { messagesNotOpenedStream.cancel(); lastMessageStream.cancel(); + lastMediaFilesStream.cancel(); super.dispose(); } @@ -59,30 +63,44 @@ class _UserListItem extends State { lastMessageStream = twonlyDB.messagesDao .watchLastMessage(widget.group.groupId) .listen((update) { - updateState(update, messagesNotOpened); + protectUpdateState.protect(() async { + await updateState(update, messagesNotOpened); + }); }); messagesNotOpenedStream = twonlyDB.messagesDao .watchMessageNotOpened(widget.group.groupId) .listen((update) { - updateState(lastMessages, update); + protectUpdateState.protect(() async { + await updateState(lastMessage, update); + }); + }); + + lastMediaFilesStream = + twonlyDB.mediaFilesDao.watchNewestMediaFiles().listen((mediaFiles) { + for (final mediaFile in mediaFiles) { + final index = + previewMediaFiles.indexWhere((t) => t.mediaId == mediaFile.mediaId); + if (index >= 0) { + previewMediaFiles[index] = mediaFile; + } + } + setState(() {}); }); } - void updateState( - List newLastMessages, + Mutex protectUpdateState = Mutex(); + + Future updateState( + Message? newLastMessage, List newMessagesNotOpened, - ) { - if (newLastMessages.isEmpty) { + ) async { + if (newLastMessage == null) { // there are no messages at all currentMessage = null; previewMessages = []; - } else if (newMessagesNotOpened.isEmpty) { - // there are no not opened messages show just the last message in the table - currentMessage = newLastMessages.last; - previewMessages = newLastMessages; - } else { - // filter first for received messages + } else if (newMessagesNotOpened.isNotEmpty) { + // Filter for the preview non opened messages. First messages which where send but not yet opened by the other side. final receivedMessages = newMessagesNotOpened.where((x) => x.senderId != null).toList(); @@ -93,6 +111,10 @@ class _UserListItem extends State { previewMessages = newMessagesNotOpened; currentMessage = newMessagesNotOpened.first; } + } else { + // there are no not opened messages show just the last message in the table + currentMessage = newLastMessage; + previewMessages = [newLastMessage]; } final msgs = @@ -106,7 +128,18 @@ class _UserListItem extends State { hasNonOpenedMediaFile = false; } - lastMessages = newLastMessages; + for (final message in previewMessages) { + if (message.mediaId != null && + !previewMediaFiles.any((t) => t.mediaId == message.mediaId)) { + final mediaFile = + await twonlyDB.mediaFilesDao.getMediaFileById(message.mediaId!); + if (mediaFile != null) { + previewMediaFiles.add(mediaFile); + } + } + } + + lastMessage = newLastMessage; messagesNotOpened = newMessagesNotOpened; setState(() { // sets lastMessages, messagesNotOpened and currentMessage @@ -136,7 +169,7 @@ class _UserListItem extends State { await startDownloadMedia(mediaFile, true); return; } - if (mediaFile.downloadState! == DownloadState.downloaded) { + if (mediaFile.downloadState! == DownloadState.ready) { if (!mounted) return; await Navigator.push( context, @@ -184,7 +217,7 @@ class _UserListItem extends State { ? Text(context.lang.chatsTapToSend) : Row( children: [ - MessageSendStateIcon(previewMessages), + MessageSendStateIcon(previewMessages, previewMediaFiles), const Text('•'), const SizedBox(width: 5), if (currentMessage != null) diff --git a/lib/src/views/chats/chat_messages.view.dart b/lib/src/views/chats/chat_messages.view.dart index a318f43..538d9b7 100644 --- a/lib/src/views/chats/chat_messages.view.dart +++ b/lib/src/views/chats/chat_messages.view.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:collection'; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:mutex/mutex.dart'; import 'package:pie_menu/pie_menu.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:twonly/globals.dart'; @@ -94,6 +95,8 @@ class _ChatMessagesViewState extends State { super.dispose(); } + Mutex protectMessageUpdating = Mutex(); + Future initStreams() async { final groupStream = twonlyDB.groupsDao.watchGroup(group.groupId); userSub = groupStream.listen((newGroup) { @@ -105,55 +108,64 @@ class _ChatMessagesViewState extends State { final msgStream = twonlyDB.messagesDao.watchByGroupId(group.groupId); messageSub = msgStream.listen((newMessages) async { - await flutterLocalNotificationsPlugin.cancelAll(); - - final chatItems = []; - final storedMediaFiles = []; - - DateTime? lastDate; - - final openedMessages = >{}; - - for (final msg in newMessages) { - if (msg.type == MessageType.text && - msg.senderId != null && - msg.openedAt == null) { - openedMessages[msg.senderId!]!.add(msg.messageId); - } - - if (msg.type == MessageType.media && msg.mediaStored) { - storedMediaFiles.add(msg); - } - - if (lastDate == null || - msg.createdAt.day != lastDate.day || - msg.createdAt.month != lastDate.month || - msg.createdAt.year != lastDate.year) { - chatItems.add(ChatItem.date(msg.createdAt)); - lastDate = msg.createdAt; - } else if (msg.createdAt.difference(lastDate).inMinutes >= 20) { - chatItems.add(ChatItem.time(msg.createdAt)); - lastDate = msg.createdAt; - } - chatItems.add(ChatItem.message(msg)); + /// In case a message is not open yet the message is updated, which will trigger this watch to be called again. + /// So as long as the Mutex is locked just return... + if (protectMessageUpdating.isLocked) { + return; } + await protectMessageUpdating.protect(() async { + await flutterLocalNotificationsPlugin.cancelAll(); - for (final contactId in openedMessages.keys) { - await notifyContactAboutOpeningMessage( - contactId, - openedMessages[contactId]!, - ); - } + final chatItems = []; + final storedMediaFiles = []; - await twonlyDB.messagesDao.openedAllTextMessages(widget.group.groupId); + DateTime? lastDate; - setState(() { - messages = chatItems.reversed.toList(); + final openedMessages = >{}; + + for (final msg in newMessages) { + if (msg.type == MessageType.text && + msg.senderId != null && + msg.openedAt == null) { + if (openedMessages[msg.senderId!] == null) { + openedMessages[msg.senderId!] = []; + } + openedMessages[msg.senderId!]!.add(msg.messageId); + } + + if (msg.type == MessageType.media && msg.mediaStored) { + storedMediaFiles.add(msg); + } + + if (lastDate == null || + msg.createdAt.day != lastDate.day || + msg.createdAt.month != lastDate.month || + msg.createdAt.year != lastDate.year) { + chatItems.add(ChatItem.date(msg.createdAt)); + lastDate = msg.createdAt; + } else if (msg.createdAt.difference(lastDate).inMinutes >= 20) { + chatItems.add(ChatItem.time(msg.createdAt)); + lastDate = msg.createdAt; + } + chatItems.add(ChatItem.message(msg)); + } + + for (final contactId in openedMessages.keys) { + await notifyContactAboutOpeningMessage( + contactId, + openedMessages[contactId]!, + ); + } + + if (!mounted) return; + setState(() { + messages = chatItems.reversed.toList(); + }); + + final items = await MemoryItem.convertFromMessages(storedMediaFiles); + galleryItems = items.values.toList(); + setState(() {}); }); - - final items = await MemoryItem.convertFromMessages(storedMediaFiles); - galleryItems = items.values.toList(); - setState(() {}); }); } @@ -396,18 +408,8 @@ bool isLastMessageFromSameUser(List messages, int index) { if (index <= 0) { return true; // If there is no previous message, return true } - - final lastMessage = messages[index - 1]; - final currentMessage = messages[index]; - - if (lastMessage.isMessage && currentMessage.isMessage) { - // Check if both messages have the same quotesMessageId (or both are null) - return (lastMessage.message!.quotesMessageId == null && - currentMessage.message!.quotesMessageId == null) || - (lastMessage.message!.quotesMessageId != null && - currentMessage.message!.quotesMessageId != null); - } - return false; + return (messages[index - 1].message?.senderId == + messages[index].message?.senderId); } double calculateNumberOfLines(String text, double width, double fontSize) { diff --git a/lib/src/views/chats/chat_messages_components/chat_list_entry.dart b/lib/src/views/chats/chat_messages_components/chat_list_entry.dart index 990f127..9c01271 100644 --- a/lib/src/views/chats/chat_messages_components/chat_list_entry.dart +++ b/lib/src/views/chats/chat_messages_components/chat_list_entry.dart @@ -1,4 +1,7 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; +import 'package:twonly/globals.dart'; import 'package:twonly/src/database/tables/messages.table.dart' hide MessageActions; import 'package:twonly/src/database/twonly.db.dart'; @@ -35,14 +38,31 @@ class ChatListEntry extends StatefulWidget { class _ChatListEntryState extends State { MediaFileService? mediaService; + StreamSubscription? mediaFileSub; + @override void initState() { initAsync(); super.initState(); } + @override + void dispose() { + mediaFileSub?.cancel(); + super.dispose(); + } + Future initAsync() async { - mediaService = await MediaFileService.fromMediaId(widget.message.messageId); + if (widget.message.mediaId != null) { + final mediaFileStream = + twonlyDB.mediaFilesDao.watchMedia(widget.message.mediaId!); + mediaFileSub = mediaFileStream.listen((mediaFiles) async { + if (mediaFiles != null) { + mediaService = await MediaFileService.fromMedia(mediaFiles); + if (mounted) setState(() {}); + } + }); + } setState(() {}); } diff --git a/lib/src/views/chats/chat_messages_components/chat_media_entry.dart b/lib/src/views/chats/chat_messages_components/chat_media_entry.dart index 32fa297..8afe01a 100644 --- a/lib/src/views/chats/chat_messages_components/chat_media_entry.dart +++ b/lib/src/views/chats/chat_messages_components/chat_media_entry.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'package:drift/drift.dart' show Value; import 'package:flutter/material.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/src/database/tables/mediafiles.table.dart'; @@ -47,17 +48,20 @@ class _ChatMediaEntryState extends State { EncryptedContent( mediaUpdate: EncryptedContent_MediaUpdate( type: EncryptedContent_MediaUpdate_Type.REOPENED, - targetMediaId: widget.message.mediaId, + targetMessageId: widget.message.messageId, ), ), + null, + ); + await twonlyDB.messagesDao.updateMessageId( + widget.message.messageId, + const MessagesCompanion(openedAt: Value(null)), ); - await twonlyDB.messagesDao.reopenedMedia(widget.message.messageId); } } Future onTap() async { - if (widget.mediaService.mediaFile.downloadState == - DownloadState.downloaded && + if (widget.mediaService.mediaFile.downloadState == DownloadState.ready && widget.message.openedAt == null) { if (!mounted) return; await Navigator.push( @@ -91,7 +95,10 @@ class _ChatMediaEntryState extends State { onTap: (widget.message.type == MessageType.media) ? onTap : null, child: SizedBox( width: 150, - height: widget.message.mediaStored ? 271 : null, + height: (widget.message.mediaStored && + widget.mediaService.imagePreviewAvailable) + ? 271 + : null, child: Align( alignment: Alignment.centerRight, child: ClipRRect( diff --git a/lib/src/views/chats/chat_messages_components/in_chat_media_viewer.dart b/lib/src/views/chats/chat_messages_components/in_chat_media_viewer.dart index 255cac9..d3a01d7 100644 --- a/lib/src/views/chats/chat_messages_components/in_chat_media_viewer.dart +++ b/lib/src/views/chats/chat_messages_components/in_chat_media_viewer.dart @@ -58,7 +58,7 @@ class _InChatMediaViewerState extends State { bool loadIndex() { if (widget.message.mediaStored) { final index = widget.galleryItems.indexWhere( - (x) => x.mediaService.mediaFile.mediaId == (widget.message.messageId), + (x) => x.mediaService.mediaFile.mediaId == (widget.message.mediaId), ); if (index != -1) { galleryItemIndex = index; @@ -112,7 +112,8 @@ class _InChatMediaViewerState extends State { @override Widget build(BuildContext context) { - if (!widget.message.mediaStored) { + if (!widget.message.mediaStored || + !widget.mediaService.imagePreviewAvailable) { return Container( constraints: const BoxConstraints( minHeight: 39, @@ -130,6 +131,7 @@ class _InChatMediaViewerState extends State { ), child: MessageSendStateIcon( [widget.message], + [widget.mediaService.mediaFile], mainAxisAlignment: MainAxisAlignment.center, canBeReopened: widget.canBeReopened, ), diff --git a/lib/src/views/chats/chat_messages_components/message_context_menu.dart b/lib/src/views/chats/chat_messages_components/message_context_menu.dart index 542fcdd..349f349 100644 --- a/lib/src/views/chats/chat_messages_components/message_context_menu.dart +++ b/lib/src/views/chats/chat_messages_components/message_context_menu.dart @@ -64,6 +64,7 @@ class MessageContextMenu extends StatelessWidget { remove: false, ), ), + null, ); }, child: const FaIcon(FontAwesomeIcons.faceLaugh), diff --git a/lib/src/views/chats/chat_messages_components/message_send_state_icon.dart b/lib/src/views/chats/chat_messages_components/message_send_state_icon.dart index d423c10..2354fd0 100644 --- a/lib/src/views/chats/chat_messages_components/message_send_state_icon.dart +++ b/lib/src/views/chats/chat_messages_components/message_send_state_icon.dart @@ -1,11 +1,10 @@ import 'dart:collection'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:twonly/globals.dart'; import 'package:twonly/src/database/tables/mediafiles.table.dart'; import 'package:twonly/src/database/tables/messages.table.dart'; import 'package:twonly/src/database/twonly.db.dart'; -import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/views/components/animate_icon.dart'; @@ -18,49 +17,37 @@ enum MessageSendState { sending, } -Future messageSendStateFromMessage(Message msg) async { - MessageSendState state; - - final ackByServer = await twonlyDB.messagesDao.haveAllMembers( - msg.groupId, - msg.messageId, - MessageActionType.ackByServerAt, - ); - - if (!ackByServer) { - if (msg.senderId == null) { - state = MessageSendState.sending; - } else { - state = MessageSendState.receiving; +MessageSendState messageSendStateFromMessage(Message msg) { + if (msg.senderId == null) { + /// messages was send by me, look up if every messages was received by the server... + if (msg.ackByServer == null) { + return MessageSendState.sending; } - } else { - if (msg.senderId == null) { - // message send - if (msg.openedAt == null) { - state = MessageSendState.send; - } else { - state = MessageSendState.sendOpened; - } + if (msg.openedAt != null) { + return MessageSendState.sendOpened; } else { - // message received - if (msg.openedAt == null) { - state = MessageSendState.received; - } else { - state = MessageSendState.receivedOpened; - } + return MessageSendState.send; } } - return state; + + // message received + if (msg.openedAt == null) { + return MessageSendState.received; + } else { + return MessageSendState.receivedOpened; + } } class MessageSendStateIcon extends StatefulWidget { const MessageSendStateIcon( - this.messages, { + this.messages, + this.mediaFiles, { super.key, this.canBeReopened = false, this.mainAxisAlignment = MainAxisAlignment.end, }); final List messages; + final List mediaFiles; final MainAxisAlignment mainAxisAlignment; final bool canBeReopened; @@ -69,17 +56,30 @@ class MessageSendStateIcon extends StatefulWidget { } class _MessageSendStateIconState extends State { - List icons = []; - String text = ''; - Widget? textWidget; - @override void initState() { super.initState(); - initAsync(); } - Future initAsync() async { + Widget getLoaderIcon(Color color) { + return Row( + children: [ + SizedBox( + width: 10, + height: 10, + child: CircularProgressIndicator(strokeWidth: 1, color: color), + ), + const SizedBox(width: 2), + ], + ); + } + + @override + Widget build(BuildContext context) { + final icons = []; + var text = ''; + Widget? textWidget; + textWidget = null; final kindsAlreadyShown = HashSet(); for (final message in widget.messages) { @@ -87,16 +87,14 @@ class _MessageSendStateIconState extends State { if (kindsAlreadyShown.contains(message.type)) continue; kindsAlreadyShown.add(message.type); - final state = await messageSendStateFromMessage(message); + final state = messageSendStateFromMessage(message); final mediaFile = message.mediaId == null ? null - : await MediaFileService.fromMediaId(message.mediaId!); + : widget.mediaFiles + .firstWhereOrNull((t) => t.mediaId == message.mediaId); - if (!mounted) return; - - final color = - getMessageColorFromType(message, mediaFile?.mediaFile, context); + final color = getMessageColorFromType(message, mediaFile, context); Widget icon = const Placeholder(); textWidget = null; @@ -126,11 +124,10 @@ class _MessageSendStateIconState extends State { icon = Icon(Icons.square_rounded, size: 14, color: color); text = context.lang.messageSendState_Received; if (message.type == MessageType.media) { - if (mediaFile!.mediaFile.downloadState == DownloadState.pending) { + if (mediaFile!.downloadState == DownloadState.pending) { text = context.lang.messageSendState_TapToLoad; } - if (mediaFile.mediaFile.downloadState == - DownloadState.downloading) { + if (mediaFile.downloadState == DownloadState.downloading) { text = context.lang.messageSendState_Loading; icon = getLoaderIcon(color); } @@ -153,12 +150,12 @@ class _MessageSendStateIconState extends State { } if (mediaFile != null) { - if (mediaFile.mediaFile.stored) { + if (mediaFile.reopenByContact) { icon = FaIcon(FontAwesomeIcons.repeat, size: 12, color: color); text = context.lang.messageReopened; } - if (mediaFile.mediaFile.reuploadRequestedBy != null) { + if (mediaFile.downloadState == DownloadState.reuploadRequested) { icon = FaIcon(FontAwesomeIcons.clockRotateLeft, size: 12, color: color); textWidget = Text( @@ -175,24 +172,6 @@ class _MessageSendStateIconState extends State { } } - setState(() {}); - } - - Widget getLoaderIcon(Color color) { - return Row( - children: [ - SizedBox( - width: 10, - height: 10, - child: CircularProgressIndicator(strokeWidth: 1, color: color), - ), - const SizedBox(width: 2), - ], - ); - } - - @override - Widget build(BuildContext context) { if (icons.isEmpty) return Container(); var icon = icons[0]; @@ -201,18 +180,11 @@ class _MessageSendStateIconState extends State { icon = Stack( alignment: Alignment.center, children: [ - // First icon (bottom icon) - icons[0], - - Transform( - transform: Matrix4.identity() - ..scaleByDouble(0.7, 0.7, 0.7, 0.7) // Scale to half - ..translateByDouble(3, 5, 0, 1), - // Move down by 10 pixels (adjust as needed) - alignment: Alignment.center, + Transform.scale( + scale: 1.3, child: icons[1], ), - // Second icon (top icon, slightly offset) + icons[0], ], ); } @@ -223,7 +195,7 @@ class _MessageSendStateIconState extends State { icon, const SizedBox(width: 3), if (textWidget != null) - textWidget! + textWidget else Text( text, diff --git a/lib/src/views/chats/media_viewer.view.dart b/lib/src/views/chats/media_viewer.view.dart index 5a17af7..626ea9b 100644 --- a/lib/src/views/chats/media_viewer.view.dart +++ b/lib/src/views/chats/media_viewer.view.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'package:drift/drift.dart' hide Column; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:lottie/lottie.dart'; @@ -163,17 +162,19 @@ class _MediaViewerViewState extends State { // } final stream = - twonlyDB.mediaFilesDao.watchMedia(currentMedia!.mediaFile.mediaId); + twonlyDB.mediaFilesDao.watchMedia(allMediaFiles.first.mediaId!); var downloadTriggered = false; await downloadStateListener?.cancel(); downloadStateListener = stream.listen((updated) async { if (updated == null) return; - if (updated.downloadState != DownloadState.downloaded) { + if (updated.downloadState != DownloadState.ready) { if (!downloadTriggered) { downloadTriggered = true; - await startDownloadMedia(currentMedia!.mediaFile, true); + final mediaFile = await twonlyDB.mediaFilesDao + .getMediaFileById(allMediaFiles.first.mediaId!); + await startDownloadMedia(mediaFile!, true); unawaited(tryDownloadAllMediaFiles(force: true)); } return; @@ -211,11 +212,6 @@ class _MediaViewerViewState extends State { [currentMessage!.messageId], ); - await twonlyDB.messagesDao.updateMessageId( - currentMessage!.messageId, - MessagesCompanion(openedAt: Value(DateTime.now())), - ); - if (!currentMediaLocal.tempPath.existsSync()) { Log.error('Temp media file not found...'); await handleMediaError(currentMediaLocal.mediaFile); @@ -289,9 +285,10 @@ class _MediaViewerViewState extends State { pb.EncryptedContent( mediaUpdate: pb.EncryptedContent_MediaUpdate( type: pb.EncryptedContent_MediaUpdate_Type.STORED, - targetMediaId: currentMedia!.mediaFile.mediaId, + targetMessageId: currentMessage!.messageId, ), ), + null, ); setState(() { imageSaved = true; @@ -537,8 +534,7 @@ class _MediaViewerViewState extends State { ], ), ), - if (currentMedia?.mediaFile.downloadState != - DownloadState.downloaded) + if (currentMedia?.mediaFile.downloadState != DownloadState.ready) const Positioned.fill( child: Center( child: SizedBox( diff --git a/lib/src/views/chats/media_viewer_components/emoji_reactions_row.component.dart b/lib/src/views/chats/media_viewer_components/emoji_reactions_row.component.dart index bea166e..524f8bd 100644 --- a/lib/src/views/chats/media_viewer_components/emoji_reactions_row.component.dart +++ b/lib/src/views/chats/media_viewer_components/emoji_reactions_row.component.dart @@ -52,6 +52,7 @@ class _EmojiReactionWidgetState extends State { emoji: widget.emoji, ), ), + null, ); setState(() { diff --git a/lib/src/views/chats/start_new_chat.view.dart b/lib/src/views/chats/start_new_chat.view.dart index 2a0829c..0aad997 100644 --- a/lib/src/views/chats/start_new_chat.view.dart +++ b/lib/src/views/chats/start_new_chat.view.dart @@ -167,9 +167,9 @@ class UserList extends StatelessWidget { var directChat = await twonlyDB.groupsDao.getDirectChat(user.userId); if (directChat == null) { - await twonlyDB.groupsDao.insertGroup( + await twonlyDB.groupsDao.createNewDirectChat( + user.userId, GroupsCompanion( - isDirectChat: const Value(true), groupName: Value( getContactDisplayName(user), ), diff --git a/lib/src/views/memories/memories.view.dart b/lib/src/views/memories/memories.view.dart index 2438382..9a2d263 100644 --- a/lib/src/views/memories/memories.view.dart +++ b/lib/src/views/memories/memories.view.dart @@ -49,12 +49,14 @@ class MemoriesViewState extends State { final applicationSupportDirectory = await getApplicationSupportDirectory(); for (final mediaFile in mediaFiles) { + final mediaService = MediaFileService( + mediaFile, + applicationSupportDirectory: applicationSupportDirectory, + ); + if (!mediaService.imagePreviewAvailable) continue; galleryItems.add( MemoryItem( - mediaService: MediaFileService( - mediaFile, - applicationSupportDirectory: applicationSupportDirectory, - ), + mediaService: mediaService, messages: [], ), ); diff --git a/lib/src/views/memories/memories_item_thumbnail.dart b/lib/src/views/memories/memories_item_thumbnail.dart index cab777e..d4434d4 100644 --- a/lib/src/views/memories/memories_item_thumbnail.dart +++ b/lib/src/views/memories/memories_item_thumbnail.dart @@ -37,13 +37,19 @@ class _MemoriesItemThumbnailState extends State { @override Widget build(BuildContext context) { + final media = widget.galleryItem.mediaService; return GestureDetector( onTap: widget.onTap, child: Hero( - tag: widget.galleryItem.mediaService.mediaFile.mediaId, + tag: media.mediaFile.mediaId, child: Stack( children: [ - Image.file(widget.galleryItem.mediaService.thumbnailPath), + if (media.thumbnailPath.existsSync()) + Image.file(media.thumbnailPath) + else if (media.storedPath.existsSync()) + Image.file(media.storedPath) + else + const Text('Media file removed.'), if (widget.galleryItem.mediaService.mediaFile.type == MediaType.video) const Positioned.fill( diff --git a/lib/src/views/onboarding/register.view.dart b/lib/src/views/onboarding/register.view.dart index 7f6dd27..4a1b97a 100644 --- a/lib/src/views/onboarding/register.view.dart +++ b/lib/src/views/onboarding/register.view.dart @@ -84,7 +84,7 @@ class _RegisterViewState extends State { username: username, displayName: username, subscriptionPlan: 'Preview', - ); + )..appVersion = 62; await const FlutterSecureStorage() .write(key: SecureStorageKeys.userData, value: jsonEncode(userData)); diff --git a/lib/src/views/settings/developer/automated_testing.view.dart b/lib/src/views/settings/developer/automated_testing.view.dart index 52e17d8..89f3774 100644 --- a/lib/src/views/settings/developer/automated_testing.view.dart +++ b/lib/src/views/settings/developer/automated_testing.view.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/src/services/api/messages.dart'; +import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; class AutomatedTestingView extends StatefulWidget { @@ -38,11 +39,13 @@ class _AutomatedTestingViewState extends State { onTap: () async { final username = await showUserNameDialog(context); if (username == null) return; + Log.info('Requested to send to $username'); - final contacts = - await twonlyDB.contactsDao.getContactsByUsername(username); + final contacts = await twonlyDB.contactsDao + .getContactsByUsername(username.toLowerCase()); for (final contact in contacts) { + Log.info('Sending to ${contact.username}'); final group = await twonlyDB.groupsDao.getDirectChat(contact.userId); for (var i = 0; i < 200; i++) { @@ -67,10 +70,10 @@ class _AutomatedTestingViewState extends State { Future showUserNameDialog( BuildContext context, -) { +) async { final controller = TextEditingController(); - return showDialog( + await showDialog( context: context, builder: (BuildContext context) { return AlertDialog( @@ -97,4 +100,5 @@ Future showUserNameDialog( ); }, ); + return controller.text; } diff --git a/lib/src/views/settings/developer/retransmission_data.view.dart b/lib/src/views/settings/developer/retransmission_data.view.dart index 66fe34d..7beca18 100644 --- a/lib/src/views/settings/developer/retransmission_data.view.dart +++ b/lib/src/views/settings/developer/retransmission_data.view.dart @@ -1,7 +1,14 @@ import 'dart:async'; +import 'package:drift/drift.dart' hide Column; import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:hashlib/random.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/src/database/twonly.db.dart'; +import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart' + as pb; +import 'package:twonly/src/services/api/messages.dart'; +import 'package:twonly/src/services/notifications/pushkeys.notifications.dart'; class RetransmissionDataView extends StatefulWidget { const RetransmissionDataView({super.key}); @@ -101,11 +108,48 @@ class _RetransmissionDataViewState extends State { Text( 'Server-Ack: ${retrans.receipt.ackByServerAt}', ), + if (retrans.receipt.messageId != null) + Text( + 'MessageId: ${retrans.receipt.messageId}', + ), + if (retrans.receipt.messageId != null) + FutureBuilder( + future: getPushNotificationFromEncryptedContent( + retrans.receipt.contactId, + retrans.receipt.messageId, + pb.EncryptedContent.fromBuffer( + pb.Message.fromBuffer(retrans.receipt.message) + .encryptedContent, + ), + ), + builder: (d, a) { + if (!a.hasData) return Container(); + return Text( + 'PushKind: ${a.data?.kind}', + ); + }, + ), Text( 'Retry: ${retrans.receipt.retryCount} : ${retrans.receipt.lastRetry}', ), ], ), + trailing: FilledButton.icon( + onPressed: () async { + final newReceiptId = uuid.v4(); + await twonlyDB.receiptsDao.updateReceipt( + retrans.receipt.receiptId, + ReceiptsCompanion( + receiptId: Value(newReceiptId), + ackByServerAt: const Value(null), + ), + ); + await tryToSendCompleteMessage( + receiptId: newReceiptId, + ); + }, + label: const FaIcon(FontAwesomeIcons.arrowRotateRight), + ), ), ) .toList(), diff --git a/lib/src/views/updates/62_database_migration.view.dart b/lib/src/views/updates/62_database_migration.view.dart index 6e8e663..f67943a 100644 --- a/lib/src/views/updates/62_database_migration.view.dart +++ b/lib/src/views/updates/62_database_migration.view.dart @@ -1,4 +1,20 @@ +import 'dart:io'; +import 'package:drift/drift.dart'; import 'package:flutter/material.dart'; +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:restart_app/restart_app.dart'; +import 'package:twonly/globals.dart'; +import 'package:twonly/src/database/daos/contacts.dao.dart'; +import 'package:twonly/src/database/tables/mediafiles.table.dart'; +import 'package:twonly/src/database/tables/messages.table.dart'; +import 'package:twonly/src/database/twonly.db.dart'; +import 'package:twonly/src/database/twonly_database_old.dart' + show TwonlyDatabaseOld; +import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; +import 'package:twonly/src/utils/log.dart'; +import 'package:twonly/src/utils/misc.dart'; +import 'package:twonly/src/utils/storage.dart'; class DatabaseMigrationView extends StatefulWidget { const DatabaseMigrationView({super.key}); @@ -8,8 +24,426 @@ class DatabaseMigrationView extends StatefulWidget { } class _DatabaseMigrationViewState extends State { + bool _isMigrating = false; + bool _isMigratingFinished = false; + int _contactsMigrated = 0; + int _storedMediaFiles = 0; + + Future startMigration() async { + setState(() { + _isMigrating = true; + }); + + final oldDatabase = TwonlyDatabaseOld(); + final oldContacts = await oldDatabase.contacts.select().get(); + final oldMessages = await oldDatabase.messages.select().get(); + + for (final oldContact in oldContacts) { + await twonlyDB.contactsDao.insertContact( + ContactsCompanion( + userId: Value(oldContact.userId), + username: Value(oldContact.username), + displayName: Value(oldContact.displayName), + nickName: Value(oldContact.nickName), + avatarSvg: Value(oldContact.avatarSvg), + senderProfileCounter: const Value(0), + accepted: Value(oldContact.accepted), + requested: Value(oldContact.requested), + blocked: Value(oldContact.blocked), + verified: Value(oldContact.verified), + deleted: Value(oldContact.deleted), + createdAt: Value(oldContact.createdAt), + ), + ); + setState(() { + _contactsMigrated += 1; + }); + if (!oldContact.deleted) { + final group = await twonlyDB.groupsDao.createNewDirectChat( + oldContact.userId, + GroupsCompanion( + pinned: Value(oldContact.pinned), + archived: Value(oldContact.archived), + groupName: Value(getContactDisplayNameOld(oldContact)), + totalMediaCounter: Value(oldContact.totalMediaCounter), + alsoBestFriend: Value(oldContact.alsoBestFriend), + createdAt: Value(oldContact.createdAt), + lastFlameCounterChange: Value(oldContact.lastFlameCounterChange), + lastFlameSync: Value(oldContact.lastFlameSync), + lastMessageExchange: Value(oldContact.lastMessageExchange), + lastMessageReceived: Value(oldContact.lastMessageReceived), + lastMessageSend: Value(oldContact.lastMessageSend), + flameCounter: Value(oldContact.flameCounter), + ), + ); + if (group == null) continue; + for (final oldMessage in oldMessages) { + if (oldMessage.mediaUploadId == null && + oldMessage.mediaDownloadId == null) { + /// only interested in media files... + continue; + } + if (oldMessage.contactId != oldContact.userId) continue; + if (!oldMessage.mediaStored) continue; + + var storedMediaPath = + join((await getApplicationSupportDirectory()).path, 'media'); + if (oldMessage.mediaDownloadId != null) { + storedMediaPath = + '${join(storedMediaPath, 'received')}/${oldMessage.mediaDownloadId}'; + } else { + storedMediaPath = + '${join(storedMediaPath, 'send')}/${oldMessage.mediaDownloadId}'; + } + + var type = MediaType.image; + if (File('$storedMediaPath.mp4').existsSync()) { + type = MediaType.video; + storedMediaPath = '$storedMediaPath.mp4'; + } else if (File('$storedMediaPath.png').existsSync()) { + type = MediaType.image; + storedMediaPath = '$storedMediaPath.png'; + } else if (File('$storedMediaPath.webp').existsSync()) { + type = MediaType.image; + storedMediaPath = '$storedMediaPath.webp'; + } else { + continue; + } + + final uniqueId = Value( + getUUIDforDirectChat( + oldMessage.messageOtherId ?? oldMessage.messageId, + oldMessage.contactId ^ gUser.userId, + ), + ); + + final mediaFile = await twonlyDB.mediaFilesDao.insertMedia( + MediaFilesCompanion( + mediaId: uniqueId, + stored: const Value(true), + type: Value(type), + createdAt: Value(oldMessage.sendAt), + ), + ); + if (mediaFile == null) continue; + + final message = await twonlyDB.messagesDao.insertMessage( + MessagesCompanion( + messageId: uniqueId, + groupId: Value(group.groupId), + mediaId: uniqueId, + type: const Value(MessageType.media), + ), + ); + if (message == null) continue; + + final mediaService = await MediaFileService.fromMedia(mediaFile); + File(storedMediaPath).copySync(mediaService.storedPath.path); + setState(() { + _storedMediaFiles += 1; + }); + } + } + } + + final memoriesPath = Directory( + join((await getApplicationSupportDirectory()).path, 'media', 'memories'), + ); + final files = memoriesPath.listSync(); + for (final file in files) { + if (file.path.contains('thumbnail')) continue; + final type = + file.path.contains('mp4') ? MediaType.video : MediaType.image; + final stat = FileStat.statSync(file.path); + final mediaFile = await twonlyDB.mediaFilesDao.insertMedia( + MediaFilesCompanion( + type: Value(type), + createdAt: Value(stat.modified), + ), + ); + final mediaService = await MediaFileService.fromMedia(mediaFile!); + File(file.path).copySync(mediaService.storedPath.path); + setState(() { + _storedMediaFiles += 1; + }); + } + + final oldContactPreKeys = + await oldDatabase.signalContactPreKeys.select().get(); + for (final oldContactPreKey in oldContactPreKeys) { + try { + await twonlyDB + .into(twonlyDB.signalContactPreKeys) + .insert(SignalContactPreKey.fromJson(oldContactPreKey.toJson())); + } catch (e) { + Log.error(e); + } + } + + final oldSignalSessionStores = + await oldDatabase.signalSessionStores.select().get(); + for (final oldSignalSessionStore in oldSignalSessionStores) { + try { + await twonlyDB.into(twonlyDB.signalSessionStores).insert( + SignalSessionStore.fromJson(oldSignalSessionStore.toJson())); + } catch (e) { + Log.error(e); + } + } + + final oldSignalSenderKeyStores = + await oldDatabase.signalSenderKeyStores.select().get(); + for (final oldSignalSenderKeyStore in oldSignalSenderKeyStores) { + try { + await twonlyDB.into(twonlyDB.signalSenderKeyStores).insert( + SignalSenderKeyStore.fromJson(oldSignalSenderKeyStore.toJson()), + ); + } catch (e) { + Log.error(e); + } + } + + final oldSignalPreyKeyStores = + await oldDatabase.signalPreKeyStores.select().get(); + for (final oldSignalPreyKeyStore in oldSignalPreyKeyStores) { + try { + await twonlyDB + .into(twonlyDB.signalPreKeyStores) + .insert(SignalPreKeyStore.fromJson(oldSignalPreyKeyStore.toJson())); + } catch (e) { + Log.error(e); + } + } + + final oldSignalIdentityKeyStores = + await oldDatabase.signalIdentityKeyStores.select().get(); + for (final oldSignalIdentityKeyStore in oldSignalIdentityKeyStores) { + try { + await twonlyDB.into(twonlyDB.signalIdentityKeyStores).insert( + SignalIdentityKeyStore.fromJson( + oldSignalIdentityKeyStore.toJson()), + ); + } catch (e) { + Log.error(e); + } + } + + final oldSignalContactSignedPreKeys = + await oldDatabase.signalContactSignedPreKeys.select().get(); + for (final oldSignalContactSignedPreKey in oldSignalContactSignedPreKeys) { + try { + await twonlyDB.into(twonlyDB.signalContactSignedPreKeys).insert( + SignalContactSignedPreKey.fromJson( + oldSignalContactSignedPreKey.toJson(), + ), + ); + } catch (e) { + Log.error(e); + } + } + + await updateUserdata((u) { + u.appVersion = 62; + return u; + }); + + setState(() { + _isMigratingFinished = true; + }); + } + @override Widget build(BuildContext context) { - return const Placeholder(); + return Scaffold( + body: Padding( + padding: const EdgeInsets.all(12), + child: _isMigratingFinished + ? ListView( + children: [ + const SizedBox(height: 40), + const Text( + 'Deine Daten wurden migriert.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 35, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 40), + ...[ + '${_contactsMigrated} Kontakte', + '${_storedMediaFiles} gespeicherte Mediendateien', + ].map( + (e) => Text( + e, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 17), + ), + ), + const SizedBox(height: 40), + const Text( + 'Sollte du feststellen, dass es bei der Migration Fehler gab, zum Beispiel, dass Bilder fehlen, dann melde dies bitte über das Feedback-Formular. Du hast dafür eine Woche Zeit, danach werden deine alte Daten unwiederruflich gelöscht.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 12), + ), + const SizedBox(height: 30), + FilledButton( + onPressed: () { + Restart.restartApp( + notificationTitle: 'Deine Daten wurden migriert.', + notificationBody: 'Click here to open the app again', + ); + }, + child: const Text( + 'App neu starten', + ), + ), + ], + ) + : _isMigrating + ? ListView( + children: [ + const SizedBox(height: 40), + const Text( + 'Deine Daten werden migriert.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 35, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 40), + const Center( + child: SizedBox( + width: 80, + height: 80, + child: CircularProgressIndicator(), + ), + ), + const SizedBox(height: 40), + const Text( + 'twonly während der Migration NICHT schließen!', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 20, color: Colors.red), + ), + const SizedBox(height: 40), + const Text( + 'Aktueller Status', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 20), + ), + ...[ + '${_contactsMigrated} Kontakte', + '${_storedMediaFiles} gespeicherte Mediendateien', + ].map( + (e) => Text( + e, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 17), + ), + ), + ], + ) + : ListView( + children: [ + const SizedBox(height: 40), + const Text( + 'twonly. Jetzt besser als je zuvor.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 35, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 30), + const Text( + 'Das sind die neuen Features.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 20), + ), + const SizedBox(height: 10), + ...[ + 'Gruppen', + 'Nachrichten bearbeiten & löschen', + ].map( + (e) => Text( + e, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 17), + ), + ), + const Text( + 'Technische Neuerungen', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 17), + ), + ...[ + 'Client-to-Client (C2C) Protokoll umgestellt auf ProtoBuf.', + 'Verwendung von UUIDs in der Datenbank', + 'Von Grund auf neues Datenbank-Schema', + 'Verbesserung der Zuverlässigkeit von C2C Nachrichten', + 'Verbesserung von Videos', + ].map( + (e) => Text( + e, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 10), + ), + ), + const SizedBox(height: 50), + const Text( + 'Was bedeutet das für dich?', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 20), + ), + const Text( + 'Aufgrund der technischen Umstellung müssen wir deine alte Datenbank sowie deine gespeicherten Bilder migieren. Durch die Migration gehen einige Informationen verloren.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 14), + ), + const SizedBox(height: 10), + const Text( + 'Was nach der Migration erhalten bleibt.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 15), + ), + ...[ + 'Gespeicherte Bilder', + 'Kontakte', + 'Flammen', + ].map( + (e) => Text( + e, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 13), + ), + ), + const SizedBox(height: 10), + const Text( + 'Was durch die Migration verloren geht.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 15, color: Colors.red), + ), + ...[ + 'Text-Nachrichten und Reaktionen', + 'Alles, was gesendet wurde, aber noch nicht empfangen wurde, wie Nachrichten und Bilder.', + ].map( + (e) => Text( + e, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 13), + ), + ), + const SizedBox(height: 30), + FilledButton( + onPressed: startMigration, + child: const Text( + 'Jetzt starten', + ), + ), + ], + ), + ), + ); } } diff --git a/test/unit_test.dart b/test/unit_test.dart index 3466630..dafea36 100644 --- a/test/unit_test.dart +++ b/test/unit_test.dart @@ -1,5 +1,6 @@ import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; +import 'package:hashlib/random.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/pow.dart'; import 'package:twonly/src/views/components/animate_icon.dart'; @@ -21,5 +22,43 @@ void main() { final list1 = Uint8List.fromList([41, 41, 41, 41, 41, 41, 41]); expect(list1, hexToUint8List(uint8ListToHex(list1))); }); + + test('Zero inputs produce all-zero UUID', () { + expect( + getUUIDforDirectChat(0, 0), + '00000000-0000-0000-0000-000000000000', + ); + expect(getUUIDforDirectChat(0, 0).length, uuid.v1().length); + }); + + test('Max int values (0x7fffffff)', () { + const max32 = 0x7fffffff; // 2147483647 + expect( + getUUIDforDirectChat(max32, max32), + '00000000-7fff-ffff-0000-00007fffffff', + ); + }); + + test('Bigger goes front', () { + expect( + getUUIDforDirectChat(1, 0), + '00000000-0000-0001-0000-000000000000', + ); + expect( + getUUIDforDirectChat(0, 1), + '00000000-0000-0001-0000-000000000000', + ); + }); + + test('Arbitrary within 32-bit range', () { + expect( + getUUIDforDirectChat(0x12345678, 0x0abcdef0), + '00000000-1234-5678-0000-00000abcdef0', + ); + }); + + test('Reject values > 0x7fffffff', () { + expect(() => getUUIDforDirectChat(0x80000000, 0), throwsArgumentError); + }); }); }