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 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 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 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 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 handleTypingIndicator( int fromUserId, String groupId, EncryptedContent_TypingIndicator indicator, String receiptId, ) async { var lastTypeIndicator = const Value.absent(); if (indicator.isTyping) { lastTypeIndicator = Value(fromTimestamp(indicator.createdAt)); } await twonlyDB.groupsDao.updateMember( groupId, fromUserId, GroupMembersCompanion( lastChatOpened: Value(fromTimestamp(indicator.createdAt)), lastTypeIndicator: lastTypeIndicator, ), ); }