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));
}
Future<List<Contact>> getContactsByUsername(String username) async {
return (select(contacts)..where((t) => t.username.equals(username))).get();
}
Future<void> deleteContactByUserId(int userId) {
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)))
.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,
MediaFiles,
MessageHistories,
MessageActions,
Groups,
],
)
@ -192,11 +193,10 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
(t) => t.messageId.equals(messageId) & t.senderId.equals(contactId),
))
.write(
MessagesCompanion(
isDeletedFromSender: const Value(true),
content: const Value(null),
modifiedAt: Value(timestamp),
mediaId: const Value(null),
const MessagesCompanion(
isDeletedFromSender: Value(true),
content: Value(null),
mediaId: Value(null),
),
);
}
@ -215,6 +215,7 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
MessageHistoriesCompanion(
messageId: Value(messageId),
content: Value(msg.content),
createdAt: Value(timestamp),
),
);
await (update(messages)
@ -224,29 +225,36 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
.write(
MessagesCompanion(
content: Value(text),
modifiedAt: Value(timestamp),
),
);
}
Future<void> handleMessageOpened(
String groupId,
int contactId,
String messageId,
DateTime timestamp,
) async {
final msg = await getMessageById(messageId).getSingleOrNull();
if (msg == null) return;
await (update(messages)
..where(
(t) =>
t.groupId.equals(groupId) &
t.messageId.equals(messageId) &
t.senderId.isNull(),
))
.write(
MessagesCompanion(
openedAt: Value(timestamp),
openedByCounter: Value(msg.openedByCounter + 1),
await into(messageActions).insert(
MessageActionsCompanion(
messageId: Value(messageId),
contactId: Value(contactId),
type: const Value(MessageActionType.ackByUserAt),
actionAt: Value(timestamp),
),
);
}
Future<void> handleMessageAckByServer(
int contactId,
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;
$MessageHistoriesTable get 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';
@DriftAccessor(tables: [Receipts, Messages])
@DriftAccessor(tables: [Receipts, Messages, MessageActions])
class ReceiptsDao extends DatabaseAccessor<TwonlyDB> with _$ReceiptsDaoMixin {
// this constructor is required so that the main database can create an instance
// of this object.
@ -24,11 +24,11 @@ class ReceiptsDao extends DatabaseAccessor<TwonlyDB> with _$ReceiptsDaoMixin {
if (receipt == null) return;
if (receipt.messageId != null) {
await (update(messages)
..where((t) => t.messageId.equals(receipt.messageId!)))
.write(
const MessagesCompanion(
ackByUser: Value(true),
await into(messageActions).insert(
MessageActionsCompanion(
messageId: Value(receipt.messageId!),
contactId: Value(fromUserId),
type: const Value(MessageActionType.ackByUserAt),
),
);
}
@ -81,6 +81,10 @@ class ReceiptsDao extends DatabaseAccessor<TwonlyDB> with _$ReceiptsDaoMixin {
.get();
}
Stream<List<Receipt>> watchAll() {
return select(receipts).watch();
}
Future<void> updateReceipt(
String receiptId,
ReceiptsCompanion updates,

View file

@ -9,4 +9,5 @@ mixin _$ReceiptsDaoMixin on DatabaseAccessor<TwonlyDB> {
$MediaFilesTable get mediaFiles => attachedDatabase.mediaFiles;
$MessagesTable get messages => attachedDatabase.messages;
$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 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 modifiedAt =>
dateTime().nullable().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {messageId};
}
@DataClassName('MessageHistory')
class MessageHistories extends Table {
enum MessageActionType {
openedAt,
modifiedAt,
ackByUserAt,
ackByServerAt,
}
@DataClassName('MessageAction')
class MessageActions 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 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()();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {messageId, createdAt};
Set<Column> get primaryKey => {id};
}

View file

@ -42,6 +42,7 @@ part 'twonly.db.g.dart';
SignalSessionStores,
SignalContactPreKeys,
SignalContactSignedPreKeys,
MessageActions
],
daos: [
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(
media.mediaId,
const MessagesCompanion(
ackByServer: Value(true),
),
);
/// As the messages where send in a bulk acknowledge all messages.
final messages =
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;
}
Log.error(

View file

@ -1,5 +1,6 @@
import 'dart:async';
import 'package:drift/drift.dart';
import 'package:fixnum/fixnum.dart';
import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart';
import 'package:mutex/mutex.dart';
import 'package:twonly/globals.dart';
@ -126,11 +127,10 @@ Future<(Uint8List, Uint8List?)?> tryToSendCompleteMessage({
if (resp.isSuccess) {
if (receipt.messageId != null) {
await twonlyDB.messagesDao.updateMessageId(
await twonlyDB.messagesDao.handleMessageAckByServer(
receipt.contactId,
receipt.messageId!,
const MessagesCompanion(
ackByServer: Value(true),
),
DateTime.now(),
);
}
if (!receipt.contactWillSendsReceipt) {
@ -158,6 +158,37 @@ Future<(Uint8List, Uint8List?)?> tryToSendCompleteMessage({
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(
int contactId,
pb.EncryptedContent encryptedContent, {

View file

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

View file

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

View file

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

View file

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

View file

@ -3,8 +3,8 @@ import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.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/utils/misc.dart';
class AutomatedTestingView extends StatefulWidget {
const AutomatedTestingView({super.key});
@ -36,25 +36,27 @@ class _AutomatedTestingViewState extends State<AutomatedTestingView> {
title: const Text('Sending a lot of messages.'),
subtitle: Text(lotsOfMessagesStatus),
onTap: () async {
await twonlyDB.messageRetransmissionDao
.clearRetransmissionTable();
final username = await showUserNameDialog(context);
if (username == null) return;
final contacts =
await twonlyDB.contactsDao.getAllNotBlockedContacts();
await twonlyDB.contactsDao.getContactsByUsername(username);
for (final contact in contacts) {
for (var i = 0; i < 200; i++) {
setState(() {
lotsOfMessagesStatus =
'At message $i to ${contact.username}.';
});
await sendTextMessage(
contact.userId,
TextMessageContent(
text: 'TestMessage $i',
),
null,
);
final groups =
await twonlyDB.groupsDao.getDirectChat(contact.userId);
for (final group in groups) {
for (var i = 0; i < 200; i++) {
setState(() {
lotsOfMessagesStatus =
'At message $i to ${contact.username}.';
});
await insertAndSendTextMessage(
group.groupId,
'Message $i.',
);
}
}
}
},
@ -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:convert';
import 'dart:io';
import 'package:drift/drift.dart' hide Column;
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:twonly/globals.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 {
const RetransmissionDataView({super.key});
@ -19,33 +12,22 @@ class RetransmissionDataView extends StatefulWidget {
class RetransMsg {
RetransMsg({
required this.json,
required this.retrans,
required this.receipt,
required this.contact,
});
final MessageJson json;
final MessageRetransmission retrans;
final Receipt receipt;
final Contact? contact;
static List<RetransMsg> fromRaw(
List<MessageRetransmission> retrans,
List<Receipt> receipts,
Map<int, Contact> contacts,
) {
final res = <RetransMsg>[];
for (final retrans in retrans) {
final json = MessageJson.fromJson(
jsonDecode(
utf8.decode(
gzip.decode(retrans.plaintextContent),
),
) as Map<String, dynamic>,
);
for (final receipt in receipts) {
res.add(
RetransMsg(
json: json,
retrans: retrans,
contact: contacts[retrans.contactId],
receipt: receipt,
contact: contacts[receipt.contactId],
),
);
}
@ -54,9 +36,9 @@ class RetransMsg {
}
class _RetransmissionDataViewState extends State<RetransmissionDataView> {
List<MessageRetransmission> retransmissions = [];
List<Receipt> retransmissions = [];
Map<int, Contact> contacts = {};
StreamSubscription<List<MessageRetransmission>>? subscriptionRetransmission;
StreamSubscription<List<Receipt>>? subscriptionRetransmission;
StreamSubscription<List<Contact>>? subscriptionContacts;
List<RetransMsg> messages = [];
@ -85,7 +67,7 @@ class _RetransmissionDataViewState extends State<RetransmissionDataView> {
setState(() {});
});
subscriptionRetransmission =
twonlyDB.messageRetransmissionDao.watchAllMessages().listen((updated) {
twonlyDB.receiptsDao.watchAll().listen((updated) {
retransmissions = updated;
if (contacts.isNotEmpty) {
messages = RetransMsg.fromRaw(retransmissions, contacts);
@ -108,7 +90,7 @@ class _RetransmissionDataViewState extends State<RetransmissionDataView> {
.map(
(retrans) => ListTile(
title: Text(
'${retrans.retrans.retransmissionId}: ${retrans.json.kind}',
retrans.receipt.receiptId,
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -117,64 +99,13 @@ class _RetransmissionDataViewState extends State<RetransmissionDataView> {
'To ${retrans.contact?.username}',
),
Text(
'Server-Ack: ${retrans.retrans.acknowledgeByServerAt}',
'Server-Ack: ${retrans.receipt.ackByServerAt}',
),
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(),

View file

@ -8,8 +8,6 @@ import 'package:package_info_plus/package_info_plus.dart';
import 'package:twonly/globals.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/services/api/mediafiles/upload.service.dart'
show createDownloadToken, uint8ListToHex;
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.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 {
if (debugLogDownloadToken != null) return debugLogDownloadToken;
final downloadToken = createDownloadToken();
final downloadToken = getRandomUint8List(32);
final debugLog = await loadLogFile();