twonly-app/lib/src/services/api/client2client/groups.c2c.dart
otsmr e7301020f6
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
fix: group auto rentry when it was deleted
2026-06-16 15:04:51 +02:00

262 lines
8.3 KiB
Dart

import 'dart:async';
import 'package:drift/drift.dart';
import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/database/tables/groups.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/messages.api.dart';
import 'package:twonly/src/services/api/utils.api.dart';
import 'package:twonly/src/services/group.service.dart';
import 'package:twonly/src/utils/log.dart';
Future<void> handleGroupCreate(
int fromUserId,
String groupId,
EncryptedContent_GroupCreate newGroup,
String receiptId,
) async {
final user = await twonlyDB.contactsDao
.getContactByUserId(fromUserId)
.getSingleOrNull();
if (user == null) {
// Only contacts can invite other contacts, so this can (via the UI) not happen.
Log.error(
'[$receiptId] User is not a contact. Aborting.',
);
return;
}
// 1. Store the new group -> e.g. store the stateKey and groupPublicKey
// 2. Call function that should fetch all jobs
// 1. This function is also called in the main function, in case the state stored on the server could not be loaded
// 2. This function will also send the GroupJoin to all members -> so they get there public key
// 3. Finished
final myGroupKey = generateIdentityKeyPair();
var group = await twonlyDB.groupsDao.getGroup(groupId);
if (group == null) {
// Group state is joinedGroup -> As the current state has not yet been downloaded.
group = await twonlyDB.groupsDao.createNewGroup(
GroupsCompanion(
groupId: Value(groupId),
stateVersionId: const Value(0),
stateEncryptionKey: Value(Uint8List.fromList(newGroup.stateKey)),
myGroupPrivateKey: Value(myGroupKey.serialize()),
groupName: Value(newGroup.hasGroupName() ? newGroup.groupName : ''),
joinedGroup: const Value(false),
),
);
} else {
// In this case make a group state update and check if the fromUserId is still a admin. otherwise return with an log error message
final updatedState = await fetchGroupState(group);
if (updatedState == null) {
Log.error(
'[$receiptId] Received group invite/create for $groupId, but failed to fetch group state from server.',
);
return;
}
final (_, state) = updatedState;
if (!state.adminIds.any((id) => id.toInt() == fromUserId)) {
Log.error(
'[$receiptId] Received group invite/create for $groupId from $fromUserId, but they are not an admin of this group.',
);
return;
}
// User was already in the group, so update leftGroup back to false
await twonlyDB.groupsDao.updateGroup(
groupId,
GroupsCompanion(
stateVersionId: const Value(0),
stateEncryptionKey: Value(Uint8List.fromList(newGroup.stateKey)),
myGroupPrivateKey: Value(myGroupKey.serialize()),
joinedGroup: const Value(false),
leftGroup: const Value(false),
deletedContent: const Value(false),
),
);
}
if (group == null) {
Log.error(
'[$receiptId] Could not create new group. Probably because the group already existed.',
);
return;
}
await twonlyDB.groupsDao.insertGroupAction(
GroupHistoriesCompanion(
groupId: Value(groupId),
contactId: Value(fromUserId),
affectedContactId: const Value(null),
type: const Value(GroupActionType.addMember),
),
);
// Load group members from the server as this is the single source of truth.
// This can be done in the background, so the WebSocket message can be ACKed.
unawaited(fetchGroupStatesForUnjoinedGroups());
await sendCipherTextToGroup(
groupId,
EncryptedContent(
groupJoin: EncryptedContent_GroupJoin(
groupPublicKey: myGroupKey.getPublicKey().serialize(),
),
),
);
}
Future<void> handleGroupUpdate(
int fromUserId,
String groupId,
EncryptedContent_GroupUpdate update,
String receiptId,
) async {
Log.info('[$receiptId] Got group update for $groupId from $fromUserId');
final actionType = groupActionTypeFromString(update.groupActionType);
if (actionType == null) {
Log.error(
'[$receiptId] Group action ${update.groupActionType} is unknown ignoring.',
);
return;
}
final group = (await twonlyDB.groupsDao.getGroup(groupId))!;
if (!group.isDirectChat) {
unawaited(fetchGroupState(group));
}
switch (actionType) {
case GroupActionType.updatedGroupName:
await twonlyDB.groupsDao.insertGroupAction(
GroupHistoriesCompanion(
groupId: Value(groupId),
type: Value(actionType),
oldGroupName: Value(group.groupName),
newGroupName: Value(update.newGroupName),
contactId: Value(fromUserId),
),
);
case GroupActionType.changeDisplayMaxTime:
await twonlyDB.groupsDao.insertGroupAction(
GroupHistoriesCompanion(
groupId: Value(groupId),
type: Value(actionType),
newDeleteMessagesAfterMilliseconds: Value(
update.newDeleteMessagesAfterMilliseconds.toInt(),
),
contactId: Value(fromUserId),
),
);
if (group.isDirectChat) {
await twonlyDB.groupsDao.updateGroup(
group.groupId,
GroupsCompanion(
deleteMessagesAfterMilliseconds: Value(
update.newDeleteMessagesAfterMilliseconds.toInt(),
),
),
);
}
case GroupActionType.removedMember:
case GroupActionType.addMember:
case GroupActionType.leftGroup:
case GroupActionType.promoteToAdmin:
case GroupActionType.demoteToMember:
int? affectedContactId = update.affectedContactId.toInt();
if (affectedContactId == userService.currentUser.userId) {
affectedContactId = null;
if (actionType == GroupActionType.removedMember) {
// Oh no, I just got removed from the group...
// This state is handle this case in the fetchGroupState....
}
}
await twonlyDB.groupsDao.insertGroupAction(
GroupHistoriesCompanion(
groupId: Value(groupId),
type: Value(actionType),
affectedContactId: Value(affectedContactId),
contactId: Value(fromUserId),
),
);
case GroupActionType.createdGroup:
case GroupActionType.updatedContactUsername:
case GroupActionType.updatedContactDisplayName:
break;
}
}
Future<bool> handleGroupJoin(
int fromUserId,
String groupId,
EncryptedContent_GroupJoin join,
String receiptId,
) async {
if (await twonlyDB.contactsDao.getContactById(fromUserId) == null) {
if (!await addNewHiddenContact(fromUserId)) {
Log.error('[$receiptId] Got group join, but could not load contact.');
// This can happen in case the group join was received before the group create.
// In this case return false, which will cause the receipt to fail and the user
// will resend this message.
return false;
}
}
await twonlyDB.groupsDao.updateMember(
groupId,
fromUserId,
GroupMembersCompanion(
groupPublicKey: Value(Uint8List.fromList(join.groupPublicKey)),
),
);
return true;
}
Future<void> handleResendGroupPublicKey(
int fromUserId,
String groupId,
EncryptedContent_GroupJoin join,
String receiptId,
) async {
final group = await twonlyDB.groupsDao.getGroup(groupId);
if (group == null || group.myGroupPrivateKey == null) return;
final keyPair = IdentityKeyPair.fromSerialized(group.myGroupPrivateKey!);
await sendCipherText(
fromUserId,
EncryptedContent(
groupId: groupId,
groupJoin: EncryptedContent_GroupJoin(
groupPublicKey: keyPair.getPublicKey().serialize(),
),
),
blocking: false,
);
}
Future<void> handleTypingIndicator(
int fromUserId,
String groupId,
EncryptedContent_TypingIndicator indicator,
String receiptId,
) async {
var lastTypeIndicator = const Value<DateTime?>.absent();
if (indicator.isTyping) {
lastTypeIndicator = Value(fromTimestamp(indicator.createdAt));
}
await twonlyDB.groupsDao.updateMember(
groupId,
fromUserId,
GroupMembersCompanion(
lastChatOpened: Value(fromTimestamp(indicator.createdAt)),
lastTypeIndicator: lastTypeIndicator,
),
);
}