add message action table

This commit is contained in:
otsmr 2025-10-23 22:42:15 +02:00
parent 645dfe16da
commit 1c154e6c67
18 changed files with 997 additions and 479 deletions

View file

@ -97,6 +97,10 @@ class ContactsDao extends DatabaseAccessor<TwonlyDB> with _$ContactsDaoMixin {
return select(contacts)..where((t) => t.userId.equals(userId)); return select(contacts)..where((t) => t.userId.equals(userId));
} }
Future<List<Contact>> getContactsByUsername(String username) async {
return (select(contacts)..where((t) => t.username.equals(username))).get();
}
Future<void> deleteContactByUserId(int userId) { Future<void> deleteContactByUserId(int userId) {
return (delete(contacts)..where((t) => t.userId.equals(userId))).go(); return (delete(contacts)..where((t) => t.userId.equals(userId))).go();
} }

View file

@ -31,4 +31,17 @@ class GroupsDao extends DatabaseAccessor<TwonlyDB> with _$GroupsDaoMixin {
return (select(groupMembers)..where((t) => t.groupId.equals(groupId))) return (select(groupMembers)..where((t) => t.groupId.equals(groupId)))
.get(); .get();
} }
Future<List<Group>> getDirectChat(int userId) async {
final query = (select(groups).join([
leftOuterJoin(
groupMembers,
groupMembers.groupId.equalsExp(groups.groupId) &
groupMembers.contactId.equals(userId),
),
])
..where(groups.isGroupOfTwo.equals(true)));
return query.map((row) => row.readTable(groups)).get();
}
} }

View file

@ -16,6 +16,7 @@ part 'messages.dao.g.dart';
Contacts, Contacts,
MediaFiles, MediaFiles,
MessageHistories, MessageHistories,
MessageActions,
Groups, Groups,
], ],
) )
@ -192,11 +193,10 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
(t) => t.messageId.equals(messageId) & t.senderId.equals(contactId), (t) => t.messageId.equals(messageId) & t.senderId.equals(contactId),
)) ))
.write( .write(
MessagesCompanion( const MessagesCompanion(
isDeletedFromSender: const Value(true), isDeletedFromSender: Value(true),
content: const Value(null), content: Value(null),
modifiedAt: Value(timestamp), mediaId: Value(null),
mediaId: const Value(null),
), ),
); );
} }
@ -215,6 +215,7 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
MessageHistoriesCompanion( MessageHistoriesCompanion(
messageId: Value(messageId), messageId: Value(messageId),
content: Value(msg.content), content: Value(msg.content),
createdAt: Value(timestamp),
), ),
); );
await (update(messages) await (update(messages)
@ -224,29 +225,36 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
.write( .write(
MessagesCompanion( MessagesCompanion(
content: Value(text), content: Value(text),
modifiedAt: Value(timestamp),
), ),
); );
} }
Future<void> handleMessageOpened( Future<void> handleMessageOpened(
String groupId, int contactId,
String messageId, String messageId,
DateTime timestamp, DateTime timestamp,
) async { ) async {
final msg = await getMessageById(messageId).getSingleOrNull(); await into(messageActions).insert(
if (msg == null) return; MessageActionsCompanion(
await (update(messages) messageId: Value(messageId),
..where( contactId: Value(contactId),
(t) => type: const Value(MessageActionType.ackByUserAt),
t.groupId.equals(groupId) & actionAt: Value(timestamp),
t.messageId.equals(messageId) & ),
t.senderId.isNull(), );
)) }
.write(
MessagesCompanion( Future<void> handleMessageAckByServer(
openedAt: Value(timestamp), int contactId,
openedByCounter: Value(msg.openedByCounter + 1), String messageId,
DateTime timestamp,
) async {
await into(messageActions).insert(
MessageActionsCompanion(
messageId: Value(messageId),
contactId: Value(contactId),
type: const Value(MessageActionType.ackByServerAt),
actionAt: Value(timestamp),
), ),
); );
} }

View file

@ -10,4 +10,5 @@ mixin _$MessagesDaoMixin on DatabaseAccessor<TwonlyDB> {
$MessagesTable get messages => attachedDatabase.messages; $MessagesTable get messages => attachedDatabase.messages;
$MessageHistoriesTable get messageHistories => $MessageHistoriesTable get messageHistories =>
attachedDatabase.messageHistories; attachedDatabase.messageHistories;
$MessageActionsTable get messageActions => attachedDatabase.messageActions;
} }

View file

@ -6,7 +6,7 @@ import 'package:twonly/src/utils/log.dart';
part 'receipts.dao.g.dart'; part 'receipts.dao.g.dart';
@DriftAccessor(tables: [Receipts, Messages]) @DriftAccessor(tables: [Receipts, Messages, MessageActions])
class ReceiptsDao extends DatabaseAccessor<TwonlyDB> with _$ReceiptsDaoMixin { class ReceiptsDao extends DatabaseAccessor<TwonlyDB> with _$ReceiptsDaoMixin {
// this constructor is required so that the main database can create an instance // this constructor is required so that the main database can create an instance
// of this object. // of this object.
@ -24,11 +24,11 @@ class ReceiptsDao extends DatabaseAccessor<TwonlyDB> with _$ReceiptsDaoMixin {
if (receipt == null) return; if (receipt == null) return;
if (receipt.messageId != null) { if (receipt.messageId != null) {
await (update(messages) await into(messageActions).insert(
..where((t) => t.messageId.equals(receipt.messageId!))) MessageActionsCompanion(
.write( messageId: Value(receipt.messageId!),
const MessagesCompanion( contactId: Value(fromUserId),
ackByUser: Value(true), type: const Value(MessageActionType.ackByUserAt),
), ),
); );
} }
@ -81,6 +81,10 @@ class ReceiptsDao extends DatabaseAccessor<TwonlyDB> with _$ReceiptsDaoMixin {
.get(); .get();
} }
Stream<List<Receipt>> watchAll() {
return select(receipts).watch();
}
Future<void> updateReceipt( Future<void> updateReceipt(
String receiptId, String receiptId,
ReceiptsCompanion updates, ReceiptsCompanion updates,

View file

@ -9,4 +9,5 @@ mixin _$ReceiptsDaoMixin on DatabaseAccessor<TwonlyDB> {
$MediaFilesTable get mediaFiles => attachedDatabase.mediaFiles; $MediaFilesTable get mediaFiles => attachedDatabase.mediaFiles;
$MessagesTable get messages => attachedDatabase.messages; $MessagesTable get messages => attachedDatabase.messages;
$ReceiptsTable get receipts => attachedDatabase.receipts; $ReceiptsTable get receipts => attachedDatabase.receipts;
$MessageActionsTable get messageActions => attachedDatabase.messageActions;
} }

View file

@ -30,28 +30,50 @@ class Messages extends Table {
BoolColumn get isEdited => boolean().withDefault(const Constant(false))(); BoolColumn get isEdited => boolean().withDefault(const Constant(false))();
BoolColumn get ackByUser => boolean().withDefault(const Constant(false))();
BoolColumn get ackByServer => boolean().withDefault(const Constant(false))();
IntColumn get openedByCounter => integer().withDefault(const Constant(0))();
DateTimeColumn get openedAt => dateTime().nullable()();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
DateTimeColumn get modifiedAt =>
dateTime().nullable().withDefault(currentDateAndTime)();
@override @override
Set<Column> get primaryKey => {messageId}; Set<Column> get primaryKey => {messageId};
} }
@DataClassName('MessageHistory') enum MessageActionType {
class MessageHistories extends Table { openedAt,
modifiedAt,
ackByUserAt,
ackByServerAt,
}
@DataClassName('MessageAction')
class MessageActions extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get messageId => TextColumn get messageId =>
text().references(Messages, #messageId, onDelete: KeyAction.cascade)(); text().references(Messages, #messageId, onDelete: KeyAction.cascade)();
IntColumn get contactId =>
integer().references(Contacts, #contactId, onDelete: KeyAction.cascade)();
TextColumn get type => textEnum<MessageActionType>()();
DateTimeColumn get actionAt => dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {id};
}
@DataClassName('MessageHistory')
class MessageHistories extends Table {
IntColumn get id => integer().autoIncrement()();
TextColumn get messageId =>
text().references(Messages, #messageId, onDelete: KeyAction.cascade)();
IntColumn get contactId =>
integer().references(Contacts, #contactId, onDelete: KeyAction.cascade)();
TextColumn get content => text().nullable()(); TextColumn get content => text().nullable()();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
@override @override
Set<Column> get primaryKey => {messageId, createdAt}; Set<Column> get primaryKey => {id};
} }

View file

@ -42,6 +42,7 @@ part 'twonly.db.g.dart';
SignalSessionStores, SignalSessionStores,
SignalContactPreKeys, SignalContactPreKeys,
SignalContactSignedPreKeys, SignalContactSignedPreKeys,
MessageActions
], ],
daos: [ daos: [
MessagesDao, MessagesDao,

File diff suppressed because it is too large Load diff

View file

@ -76,12 +76,22 @@ Future<void> handleUploadStatusUpdate(TaskStatusUpdate update) async {
), ),
); );
await twonlyDB.messagesDao.updateMessagesByMediaId( /// As the messages where send in a bulk acknowledge all messages.
media.mediaId,
const MessagesCompanion( final messages =
ackByServer: Value(true), await twonlyDB.messagesDao.getMessagesByMediaId(media.mediaId);
), for (final message in messages) {
final contacts =
await twonlyDB.groupsDao.getGroupMembers(message.groupId);
for (final contact in contacts) {
await twonlyDB.messagesDao.handleMessageAckByServer(
contact.contactId,
message.messageId,
DateTime.now(),
); );
}
}
return; return;
} }
Log.error( Log.error(

View file

@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:fixnum/fixnum.dart';
import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart'; import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart';
import 'package:mutex/mutex.dart'; import 'package:mutex/mutex.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
@ -126,11 +127,10 @@ Future<(Uint8List, Uint8List?)?> tryToSendCompleteMessage({
if (resp.isSuccess) { if (resp.isSuccess) {
if (receipt.messageId != null) { if (receipt.messageId != null) {
await twonlyDB.messagesDao.updateMessageId( await twonlyDB.messagesDao.handleMessageAckByServer(
receipt.contactId,
receipt.messageId!, receipt.messageId!,
const MessagesCompanion( DateTime.now(),
ackByServer: Value(true),
),
); );
} }
if (!receipt.contactWillSendsReceipt) { if (!receipt.contactWillSendsReceipt) {
@ -158,6 +158,37 @@ Future<(Uint8List, Uint8List?)?> tryToSendCompleteMessage({
return null; return null;
} }
Future<void> insertAndSendTextMessage(
String groupId,
String textMessage,
) async {
final message = await twonlyDB.messagesDao.insertMessage(
MessagesCompanion(
groupId: Value(groupId),
content: Value(textMessage),
),
);
if (message == null) {
Log.error('Could not insert message into database');
return;
}
final groupMembers = await twonlyDB.groupsDao.getGroupMembers(groupId);
for (final groupMember in groupMembers) {
unawaited(sendCipherText(
groupMember.contactId,
pb.EncryptedContent(
textMessage: pb.EncryptedContent_TextMessage(
senderMessageId: message.messageId,
text: textMessage,
timestamp: Int64(message.createdAt.millisecondsSinceEpoch),
),
),
));
}
}
Future<(Uint8List, Uint8List?)?> sendCipherText( Future<(Uint8List, Uint8List?)?> sendCipherText(
int contactId, int contactId,
pb.EncryptedContent encryptedContent, { pb.EncryptedContent encryptedContent, {

View file

@ -160,7 +160,6 @@ Future<PlaintextContent?> handleEncryptedMessage(
if (content.hasMessageUpdate()) { if (content.hasMessageUpdate()) {
await handleMessageUpdate( await handleMessageUpdate(
fromUserId, fromUserId,
content.groupId,
content.messageUpdate, content.messageUpdate,
); );
return null; return null;

View file

@ -92,8 +92,6 @@ Future<void> handleMedia(
senderId: Value(fromUserId), senderId: Value(fromUserId),
groupId: Value(groupId), groupId: Value(groupId),
mediaId: Value(mediaFile.mediaId), mediaId: Value(mediaFile.mediaId),
ackByServer: const Value(true),
ackByUser: const Value(true),
quotesMessageId: Value( quotesMessageId: Value(
media.hasQuoteMessageId() ? media.quoteMessageId : null, media.hasQuoteMessageId() ? media.quoteMessageId : null,
), ),

View file

@ -5,7 +5,6 @@ import 'package:twonly/src/utils/log.dart';
Future<void> handleMessageUpdate( Future<void> handleMessageUpdate(
int contactId, int contactId,
String groupId,
EncryptedContent_MessageUpdate messageUpdate, EncryptedContent_MessageUpdate messageUpdate,
) async { ) async {
switch (messageUpdate.type) { switch (messageUpdate.type) {
@ -14,7 +13,7 @@ Future<void> handleMessageUpdate(
'Opened message ${messageUpdate.multipleSenderMessageIds.length}'); 'Opened message ${messageUpdate.multipleSenderMessageIds.length}');
for (final senderMessageId in messageUpdate.multipleSenderMessageIds) { for (final senderMessageId in messageUpdate.multipleSenderMessageIds) {
await twonlyDB.messagesDao.handleMessageOpened( await twonlyDB.messagesDao.handleMessageOpened(
groupId, contactId,
senderMessageId, senderMessageId,
fromTimestamp(messageUpdate.timestamp), fromTimestamp(messageUpdate.timestamp),
); );

View file

@ -20,8 +20,6 @@ Future<void> handleTextMessage(
senderId: Value(fromUserId), senderId: Value(fromUserId),
groupId: Value(groupId), groupId: Value(groupId),
content: Value(textMessage.text), content: Value(textMessage.text),
ackByServer: const Value(true),
ackByUser: const Value(true),
quotesMessageId: Value( quotesMessageId: Value(
textMessage.hasQuoteMessageId() ? textMessage.quoteMessageId : null, textMessage.hasQuoteMessageId() ? textMessage.quoteMessageId : null,
), ),

View file

@ -3,8 +3,8 @@ import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
import 'package:twonly/src/model/json/message_old.dart';
import 'package:twonly/src/services/api/messages.dart'; import 'package:twonly/src/services/api/messages.dart';
import 'package:twonly/src/utils/misc.dart';
class AutomatedTestingView extends StatefulWidget { class AutomatedTestingView extends StatefulWidget {
const AutomatedTestingView({super.key}); const AutomatedTestingView({super.key});
@ -36,27 +36,29 @@ class _AutomatedTestingViewState extends State<AutomatedTestingView> {
title: const Text('Sending a lot of messages.'), title: const Text('Sending a lot of messages.'),
subtitle: Text(lotsOfMessagesStatus), subtitle: Text(lotsOfMessagesStatus),
onTap: () async { onTap: () async {
await twonlyDB.messageRetransmissionDao final username = await showUserNameDialog(context);
.clearRetransmissionTable(); if (username == null) return;
final contacts = final contacts =
await twonlyDB.contactsDao.getAllNotBlockedContacts(); await twonlyDB.contactsDao.getContactsByUsername(username);
for (final contact in contacts) { for (final contact in contacts) {
final groups =
await twonlyDB.groupsDao.getDirectChat(contact.userId);
for (final group in groups) {
for (var i = 0; i < 200; i++) { for (var i = 0; i < 200; i++) {
setState(() { setState(() {
lotsOfMessagesStatus = lotsOfMessagesStatus =
'At message $i to ${contact.username}.'; 'At message $i to ${contact.username}.';
}); });
await sendTextMessage( await insertAndSendTextMessage(
contact.userId, group.groupId,
TextMessageContent( 'Message $i.',
text: 'TestMessage $i',
),
null,
); );
} }
} }
}
}, },
), ),
], ],
@ -64,3 +66,37 @@ class _AutomatedTestingViewState extends State<AutomatedTestingView> {
); );
} }
} }
Future<String?> showUserNameDialog(
BuildContext context,
) {
final controller = TextEditingController();
return showDialog<String>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Username'),
content: TextField(
controller: controller,
autofocus: true,
),
actions: <Widget>[
TextButton(
child: Text(context.lang.cancel),
onPressed: () {
Navigator.of(context).pop(); // Close the dialog
},
),
TextButton(
child: Text(context.lang.ok),
onPressed: () {
Navigator.of(context)
.pop(controller.text); // Return the input text
},
),
],
);
},
);
}

View file

@ -1,14 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:drift/drift.dart' hide Column;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/json/message_old.dart';
import 'package:twonly/src/services/api/messages.dart';
class RetransmissionDataView extends StatefulWidget { class RetransmissionDataView extends StatefulWidget {
const RetransmissionDataView({super.key}); const RetransmissionDataView({super.key});
@ -19,33 +12,22 @@ class RetransmissionDataView extends StatefulWidget {
class RetransMsg { class RetransMsg {
RetransMsg({ RetransMsg({
required this.json, required this.receipt,
required this.retrans,
required this.contact, required this.contact,
}); });
final MessageJson json; final Receipt receipt;
final MessageRetransmission retrans;
final Contact? contact; final Contact? contact;
static List<RetransMsg> fromRaw( static List<RetransMsg> fromRaw(
List<MessageRetransmission> retrans, List<Receipt> receipts,
Map<int, Contact> contacts, Map<int, Contact> contacts,
) { ) {
final res = <RetransMsg>[]; final res = <RetransMsg>[];
for (final receipt in receipts) {
for (final retrans in retrans) {
final json = MessageJson.fromJson(
jsonDecode(
utf8.decode(
gzip.decode(retrans.plaintextContent),
),
) as Map<String, dynamic>,
);
res.add( res.add(
RetransMsg( RetransMsg(
json: json, receipt: receipt,
retrans: retrans, contact: contacts[receipt.contactId],
contact: contacts[retrans.contactId],
), ),
); );
} }
@ -54,9 +36,9 @@ class RetransMsg {
} }
class _RetransmissionDataViewState extends State<RetransmissionDataView> { class _RetransmissionDataViewState extends State<RetransmissionDataView> {
List<MessageRetransmission> retransmissions = []; List<Receipt> retransmissions = [];
Map<int, Contact> contacts = {}; Map<int, Contact> contacts = {};
StreamSubscription<List<MessageRetransmission>>? subscriptionRetransmission; StreamSubscription<List<Receipt>>? subscriptionRetransmission;
StreamSubscription<List<Contact>>? subscriptionContacts; StreamSubscription<List<Contact>>? subscriptionContacts;
List<RetransMsg> messages = []; List<RetransMsg> messages = [];
@ -85,7 +67,7 @@ class _RetransmissionDataViewState extends State<RetransmissionDataView> {
setState(() {}); setState(() {});
}); });
subscriptionRetransmission = subscriptionRetransmission =
twonlyDB.messageRetransmissionDao.watchAllMessages().listen((updated) { twonlyDB.receiptsDao.watchAll().listen((updated) {
retransmissions = updated; retransmissions = updated;
if (contacts.isNotEmpty) { if (contacts.isNotEmpty) {
messages = RetransMsg.fromRaw(retransmissions, contacts); messages = RetransMsg.fromRaw(retransmissions, contacts);
@ -108,7 +90,7 @@ class _RetransmissionDataViewState extends State<RetransmissionDataView> {
.map( .map(
(retrans) => ListTile( (retrans) => ListTile(
title: Text( title: Text(
'${retrans.retrans.retransmissionId}: ${retrans.json.kind}', retrans.receipt.receiptId,
), ),
subtitle: Column( subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -117,64 +99,13 @@ class _RetransmissionDataViewState extends State<RetransmissionDataView> {
'To ${retrans.contact?.username}', 'To ${retrans.contact?.username}',
), ),
Text( Text(
'Server-Ack: ${retrans.retrans.acknowledgeByServerAt}', 'Server-Ack: ${retrans.receipt.ackByServerAt}',
), ),
Text( Text(
'Retry: ${retrans.retrans.retryCount} : ${retrans.retrans.lastRetry}', 'Retry: ${retrans.receipt.retryCount} : ${retrans.receipt.lastRetry}',
), ),
], ],
), ),
trailing: SizedBox(
width: 80,
child: Row(
children: [
SizedBox(
height: 20,
width: 40,
child: Center(
child: GestureDetector(
onDoubleTap: () async {
await twonlyDB.messageRetransmissionDao
.deleteRetransmissionById(
retrans.retrans.retransmissionId,
);
},
child: const FaIcon(
FontAwesomeIcons.trash,
size: 15,
),
),
),
),
SizedBox(
width: 40,
child: OutlinedButton(
style: ButtonStyle(
padding: WidgetStateProperty.all<EdgeInsets>(
EdgeInsets.zero,
),
),
onPressed: () async {
await twonlyDB.messageRetransmissionDao
.updateRetransmission(
retrans.retrans.retransmissionId,
const MessageRetransmissionsCompanion(
acknowledgeByServerAt: Value(null),
),
);
await sendRetransmitMessage(
retrans.retrans.retransmissionId,
);
},
child: const FaIcon(
FontAwesomeIcons.arrowRotateLeft,
size: 15,
),
),
),
],
),
),
), ),
) )
.toList(), .toList(),

View file

@ -8,8 +8,6 @@ import 'package:package_info_plus/package_info_plus.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
import 'package:twonly/src/constants/secure_storage_keys.dart'; import 'package:twonly/src/constants/secure_storage_keys.dart';
import 'package:twonly/src/model/protobuf/api/http/http_requests.pb.dart'; import 'package:twonly/src/model/protobuf/api/http/http_requests.pb.dart';
import 'package:twonly/src/services/api/mediafiles/upload.service.dart'
show createDownloadToken, uint8ListToHex;
import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/views/settings/help/contact_us/submit_message.view.dart'; import 'package:twonly/src/views/settings/help/contact_us/submit_message.view.dart';
@ -32,7 +30,7 @@ class _ContactUsState extends State<ContactUsView> {
Future<String?> uploadDebugLog() async { Future<String?> uploadDebugLog() async {
if (debugLogDownloadToken != null) return debugLogDownloadToken; if (debugLogDownloadToken != null) return debugLogDownloadToken;
final downloadToken = createDownloadToken(); final downloadToken = getRandomUint8List(32);
final debugLog = await loadLogFile(); final debugLog = await loadLogFile();