import 'dart:async'; import 'dart:io'; import 'package:clock/clock.dart'; import 'package:drift/drift.dart'; import 'package:hashlib/random.dart'; import 'package:mutex/mutex.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/locator.dart'; import 'package:twonly/src/database/daos/contacts.dao.dart'; import 'package:twonly/src/database/twonly.db.dart' hide Message; import 'package:twonly/src/model/protobuf/api/websocket/client_to_server.pb.dart' as client; import 'package:twonly/src/model/protobuf/api/websocket/client_to_server.pb.dart'; import 'package:twonly/src/model/protobuf/api/websocket/server_to_client.pb.dart' as server; import 'package:twonly/src/model/protobuf/api/websocket/server_to_client.pbserver.dart'; import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart'; import 'package:twonly/src/services/api/client2client/additional_data.c2c.dart'; import 'package:twonly/src/services/api/client2client/contact.c2c.dart'; import 'package:twonly/src/services/api/client2client/errors.c2c.dart'; import 'package:twonly/src/services/api/client2client/groups.c2c.dart'; import 'package:twonly/src/services/api/client2client/media.c2c.dart'; import 'package:twonly/src/services/api/client2client/messages.c2c.dart'; import 'package:twonly/src/services/api/client2client/prekeys.c2c.dart'; import 'package:twonly/src/services/api/client2client/pushkeys.c2c.dart'; import 'package:twonly/src/services/api/client2client/reaction.c2c.dart'; import 'package:twonly/src/services/api/client2client/text_message.c2c.dart'; import 'package:twonly/src/services/api/client2client/user_discovery.c2c.dart'; import 'package:twonly/src/services/api/messages.api.dart'; import 'package:twonly/src/services/group.service.dart'; import 'package:twonly/src/services/key_verification.service.dart'; import 'package:twonly/src/services/notifications/background.notifications.dart'; import 'package:twonly/src/services/notifications/fcm.notifications.dart'; import 'package:twonly/src/services/passwordless_recovery.service.dart'; import 'package:twonly/src/services/signal/encryption.signal.dart'; import 'package:twonly/src/services/signal/session.signal.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; Future handleServerMessage(server.ServerToClient msg) async { Log.info('Processing a message from the server.'); /// Returns means, that the server can delete the message from the server. final ok = client.Response_Ok()..none = true; var response = client.Response()..ok = ok; try { if (msg.v0.hasRequestNewPreKeys()) { response = await handleRequestNewPreKey(); } else if (msg.v0.hasNewMessage()) { Log.info('Got 1 message from the server.'); await handleClient2ClientMessage(msg.v0.newMessage); } else if (msg.v0.hasNewMessages()) { Log.info( 'Got ${msg.v0.newMessages.newMessages.length} messages from the server.', ); final brokenSessionsInCurrentBatch = {}; for (final newMessage in msg.v0.newMessages.newMessages) { try { await handleClient2ClientMessage( newMessage, brokenSessionsInCurrentBatch: brokenSessionsInCurrentBatch, ); } catch (e) { Log.error(e); } } } else { Log.error('Unknown server message: $msg'); } } catch (e) { Log.error(e); } final v0 = client.V0() ..seq = msg.v0.seq ..response = response; await apiService.sendResponse(ClientToServer()..v0 = v0); AppState.gotMessageFromServer = true; Log.info('All messages from the server proccessed.'); } DateTime lastPushKeyRequest = clock.now().subtract(const Duration(hours: 1)); final Map _messageLocks = {}; Future handleClient2ClientMessage( NewMessage newMessage, { Set? brokenSessionsInCurrentBatch, }) async { final body = Uint8List.fromList(newMessage.body); final message = Message.fromBuffer(body); final receiptId = message.receiptId; final mutex = _messageLocks.putIfAbsent(receiptId, Mutex.new); if (mutex.isLocked) { Log.info( '[$receiptId] Skipping — already being processed by another handler', ); return; } await mutex.protect(() async { try { await _handleClient2ClientMessage( newMessage, message, brokenSessionsInCurrentBatch: brokenSessionsInCurrentBatch, ); } finally { _messageLocks.remove(receiptId); } }); } Future _handleClient2ClientMessage( NewMessage newMessage, Message message, { Set? brokenSessionsInCurrentBatch, }) async { final fromUserId = newMessage.fromUserId.toInt(); final receiptId = message.receiptId; if (brokenSessionsInCurrentBatch?.contains(fromUserId) == true) { // This happens when a session goes out of sync (e.g. wrong message order). // We skip the remaining messages in the batch because each failed decryption // attempt is extremely slow (~1.2s) and would otherwise freeze the app. // By returning early, we skip gotReceipt() and error responses. // The server still deletes the batch since we ACK the entire batch later. // The sender keeps the message unacknowledged. Once they process our SESSION_OUT_OF_SYNC // error and establish a new session, their retry logic will automatically re-encrypt // and re-send these messages with the new keys. Log.info( 'Skipping message from $fromUserId - session known broken in this batch', ); return; } if (await twonlyDB.receiptsDao.isDuplicated(receiptId)) { return; } Log.info('[$receiptId] Started processing message'); switch (message.type) { case Message_Type.SENDER_DELIVERY_RECEIPT: Log.info('[$receiptId] Got delivery receipt!'); await twonlyDB.receiptsDao.confirmReceipt(receiptId, fromUserId); case Message_Type.PLAINTEXT_CONTENT: var retry = false; if (message.hasPlaintextContent()) { if (message.plaintextContent.hasDecryptionErrorMessage()) { if (message.plaintextContent.decryptionErrorMessage.type == PlaintextContent_DecryptionErrorMessage_Type.PREKEY_UNKNOWN) { // Get a new prekey from the server, and establish a new signal session. await handleSessionResync(fromUserId); } Log.info( '[$receiptId] Got decryption error: ${message.plaintextContent.decryptionErrorMessage.type}', ); retry = true; } if (message.plaintextContent.hasRetryControlError()) { Log.info( '[$receiptId] Got access control error. Resending message.', ); retry = true; } } if (retry) { final newReceiptId = uuid.v4(); await twonlyDB.receiptsDao.updateReceipt( receiptId, ReceiptsCompanion( receiptId: Value(newReceiptId), ackByServerAt: const Value(null), ), ); Log.info( '[$receiptId] Sending error message to the original sender with receiptId $newReceiptId.', ); await tryToSendCompleteMessage( receiptId: newReceiptId, blocking: false, ); } case Message_Type.CIPHERTEXT: case Message_Type.PREKEY_BUNDLE: if (message.hasEncryptedContent()) { Value? receiptIdDB; final encryptedContentRaw = Uint8List.fromList( message.encryptedContent, ); Message? response; final user = await twonlyDB.contactsDao .getContactByUserId(fromUserId) .getSingleOrNull(); if (user == null) { if (!await addNewHiddenContact(fromUserId)) { // in case the user could not be added, send a retry error message as this error should only happen in case // it was not possible to load the user from the server response = Message( receiptId: receiptId, type: Message_Type.PLAINTEXT_CONTENT, plaintextContent: PlaintextContent( retryControlError: PlaintextContent_RetryErrorMessage(), ), ); } } if (response == null) { final ( encryptedContent, plainTextContent, ) = await handleEncryptedMessageRaw( fromUserId, encryptedContentRaw, message.type, receiptId, brokenSessionsInCurrentBatch: brokenSessionsInCurrentBatch, ); if (plainTextContent != null) { response = Message( receiptId: receiptId, type: Message_Type.PLAINTEXT_CONTENT, plaintextContent: plainTextContent, ); } else if (encryptedContent != null) { response = Message( type: Message_Type.CIPHERTEXT, encryptedContent: encryptedContent.writeToBuffer(), ); // Use Value.absent() for CIPHERTEXT messages so that insertReceipt generates a new UUID. // This prevents receipt ID collisions and ensures the recipient's ACK is tracked correctly. receiptIdDB = const Value.absent(); } else { // Message was successful processed } } response ??= Message(type: Message_Type.SENDER_DELIVERY_RECEIPT); String? targetReceiptId; try { final inserted = await twonlyDB.receiptsDao.insertReceipt( ReceiptsCompanion( receiptId: receiptIdDB ?? Value(receiptId), contactId: Value(fromUserId), message: Value(response.writeToBuffer()), contactWillSendsReceipt: const Value(false), ), ); // Use the inserted receipt's ID because for CIPHERTEXT messages we generate a new UUID // (receiptIdDB is Value.absent()) to avoid ID collisions and properly track individual ACKs. targetReceiptId = inserted?.receiptId; } catch (e) { Log.warn('[$receiptId] Error inserting receipt: $e'); } if (targetReceiptId != null) { await tryToSendCompleteMessage( receiptId: targetReceiptId, blocking: false, ); } } case Message_Type.TEST_NOTIFICATION: break; } try { await twonlyDB.receiptsDao.gotReceipt(receiptId); Log.info('[$receiptId] Finished processing'); } catch (e) { Log.warn('[$receiptId] Error marking message as received: $e'); Log.error( 'Error marking message as received: $e', onlyIfSentryEnabled: true, ); } } Future<(EncryptedContent?, PlaintextContent?)> handleEncryptedMessageRaw( int fromUserId, Uint8List encryptedContentRaw, Message_Type messageType, String receiptId, { Set? brokenSessionsInCurrentBatch, }) async { Log.info('[$receiptId] calling signalDecryptMessage'); var (encryptedContent, decryptionErrorType) = await signalDecryptMessage( fromUserId, encryptedContentRaw, messageType.value, brokenSessionsInCurrentBatch: brokenSessionsInCurrentBatch, ); if (encryptedContent == null) { return ( null, PlaintextContent( decryptionErrorMessage: PlaintextContent_DecryptionErrorMessage( type: decryptionErrorType ??= PlaintextContent_DecryptionErrorMessage_Type.UNKNOWN, ), ), ); } Log.info('[$receiptId] Calling handleEncryptedMessage'); final result = await handleEncryptedMessage( fromUserId, encryptedContent, messageType, receiptId, ); Log.info('[$receiptId] Finished handleEncryptedMessage'); if (result.responseCipherText == null && result.responsePlaintext == null) { unawaited(FcmNotificationService.updateLastServerMessageTimestamp()); if (Platform.isAndroid && result.showPushNotification) { // Message was handled without any error. Show push notification to the user for Android. await showPushNotificationFromServerMessages( fromUserId, encryptedContent, ); } } return (result.responseCipherText, result.responsePlaintext); } Future handleEncryptedMessage( int fromUserId, EncryptedContent content, Message_Type messageType, String receiptId, ) async { // We got a valid message fromUserId, so mark all messages which where // send to the user but not yet ACK for retransmission. All marked messages // will be either transmitted again after a new server connection (minimum 20 seconds). // In case the server sends the ACK before they will be deleted. // This ensures that 1. all messages will be received by the other person and // that they will be retransmitted in case the server deleted them as they // where not downloaded within the 40 days await twonlyDB.receiptsDao.markMessagesForRetry(fromUserId); final senderProfileCounter = await checkForProfileUpdate(fromUserId, content); if (userService.currentUser.isUserDiscoveryEnabled && content.hasSenderUserDiscoveryVersion()) { await checkForUserDiscoveryChanges( fromUserId, content.senderUserDiscoveryVersion, receiptId, ); } if (content.hasAskForFriendPromotions() && content.askForFriendPromotions) { final contact = await twonlyDB.contactsDao.getContactById(fromUserId); if (contact != null && contact.askForFriendPromotions == null) { await twonlyDB.contactsDao.updateContact( fromUserId, const ContactsCompanion(askForFriendPromotions: Value(true)), ); } } if (content.hasContactRequest()) { if (!await handleContactRequest( fromUserId, content.contactRequest, receiptId, )) { return DecryptedMessageResult( responsePlaintext: PlaintextContent() ..retryControlError = PlaintextContent_RetryErrorMessage(), ); } return const DecryptedMessageResult(); } if (content.hasErrorMessages()) { await handleErrorMessage( fromUserId, content.errorMessages, receiptId, groupId: content.hasGroupId() ? content.groupId : null, ); return const DecryptedMessageResult(showPushNotification: false); } if (content.hasPasswordlessRecovery()) { await PasswordlessRecoveryService.handlePasswordlessRecovery( fromUserId, content.passwordlessRecovery, receiptId, ); return const DecryptedMessageResult(); } if (content.hasPasswordlessRecoveryHeartbeat()) { await PasswordlessRecoveryService.handlePasswordlessRecoveryHeartbeat( fromUserId, content.passwordlessRecoveryHeartbeat, receiptId, ); return const DecryptedMessageResult(); } if (content.hasContactUpdate()) { await handleContactUpdate( fromUserId, content.contactUpdate, senderProfileCounter, receiptId, ); return const DecryptedMessageResult(showPushNotification: false); } if (content.hasUserDiscoveryRequest()) { await handleUserDiscoveryRequest( fromUserId, content.userDiscoveryRequest, receiptId, ); return const DecryptedMessageResult(showPushNotification: false); } if (content.hasUserDiscoveryUpdate()) { await handleUserDiscoveryUpdate( fromUserId, content.userDiscoveryUpdate, receiptId, ); return const DecryptedMessageResult(); } if (content.hasPushKeys()) { await handlePushKey(fromUserId, content.pushKeys, receiptId); return const DecryptedMessageResult(); } if (content.hasMessageUpdate()) { await handleMessageUpdate( fromUserId, content.messageUpdate, receiptId, ); return const DecryptedMessageResult(); } if (content.hasKeyVerificationProof()) { await KeyVerificationService.handleVerificationProof( fromUserId, content.keyVerificationProof.calculatedMac, ); return const DecryptedMessageResult(); } if (content.hasMediaUpdate()) { await handleMediaUpdate( fromUserId, content.mediaUpdate, receiptId, ); return const DecryptedMessageResult(); } if (!content.hasGroupId()) { final type = _getEncryptedContentType(content); Log.warn( '[$receiptId] Messages should have a groupId $fromUserId. Type: $type', ); Log.error( 'Messages should have a groupId. Type: $type', onlyIfSentryEnabled: true, ); return const DecryptedMessageResult(); } if (content.hasGroupCreate()) { await handleGroupCreate( fromUserId, content.groupId, content.groupCreate, receiptId, ); return const DecryptedMessageResult(); } /// Verify that the user is (still) in that group... if (!await twonlyDB.groupsDao.isContactInGroup(fromUserId, content.groupId)) { // Check if this is a direct chat... if (getUUIDforDirectChat(userService.currentUser.userId, fromUserId) == content.groupId) { final contact = await twonlyDB.contactsDao .getContactByUserId(fromUserId) .getSingleOrNull(); Log.info( '[$receiptId] Contact exists?: ${contact != null} Is deleted? ${contact?.deletedByUser} Accepted? (${contact?.accepted})', ); if (contact == null || !contact.accepted || contact.deletedByUser) { await handleNewContactRequest(fromUserId); Log.warn( '[$receiptId] User tries to send message to direct chat while the user does not exist!', ); return DecryptedMessageResult( responseCipherText: EncryptedContent( errorMessages: EncryptedContent_ErrorMessages( type: EncryptedContent_ErrorMessages_Type .ERROR_PROCESSING_MESSAGE_CREATED_ACCOUNT_REQUEST_INSTEAD, relatedReceiptId: receiptId, ), ), ); } Log.info( '[$receiptId] Creating new DirectChat between two users', ); await twonlyDB.groupsDao.createNewDirectChat( fromUserId, GroupsCompanion( groupName: Value(getContactDisplayName(contact)), ), ); } else { if (content.hasGroupJoin()) { Log.warn( '[$receiptId] Got group join message, but group does not exist yet, retry later. As probably the GroupCreate was not yet received.', ); // In case the group join was received before the GroupCreate the sender should send it later again. return DecryptedMessageResult( responsePlaintext: PlaintextContent() ..retryControlError = PlaintextContent_RetryErrorMessage(), ); } Log.warn( '[$receiptId] User $fromUserId tried to access group ${content.groupId}. Sending GROUP_NOT_FOUND_OR_NOT_A_MEMBER error.', ); return DecryptedMessageResult( responseCipherText: EncryptedContent( groupId: content.groupId, errorMessages: EncryptedContent_ErrorMessages( type: EncryptedContent_ErrorMessages_Type .GROUP_NOT_FOUND_OR_NOT_A_MEMBER, relatedReceiptId: receiptId, ), ), ); } } if (content.hasFlameSync()) { await handleFlameSync(content.groupId, content.flameSync, receiptId); return const DecryptedMessageResult(); } if (content.hasGroupUpdate()) { await handleGroupUpdate( fromUserId, content.groupId, content.groupUpdate, receiptId, ); return const DecryptedMessageResult(); } if (content.hasGroupJoin()) { if (!await handleGroupJoin( fromUserId, content.groupId, content.groupJoin, receiptId, )) { return DecryptedMessageResult( responsePlaintext: PlaintextContent() ..retryControlError = PlaintextContent_RetryErrorMessage(), ); } return const DecryptedMessageResult(); } if (content.hasResendGroupPublicKey()) { await handleResendGroupPublicKey( fromUserId, content.groupId, content.groupJoin, receiptId, ); return const DecryptedMessageResult(); } if (content.hasAdditionalDataMessage()) { await handleAdditionalDataMessage( fromUserId, content.groupId, content.additionalDataMessage, receiptId, ); return const DecryptedMessageResult(); } if (content.hasTextMessage()) { final isNewText = await handleTextMessage( fromUserId, content.groupId, content.textMessage, receiptId, ); return DecryptedMessageResult(showPushNotification: isNewText); } if (content.hasReaction()) { await handleReaction( fromUserId, content.groupId, content.reaction, receiptId, ); return const DecryptedMessageResult(); } if (content.hasMedia()) { final isNewMedia = await handleMedia( fromUserId, content.groupId, content.media, receiptId, ); return DecryptedMessageResult(showPushNotification: isNewMedia); } if (content.hasTypingIndicator()) { await handleTypingIndicator( fromUserId, content.groupId, content.typingIndicator, receiptId, ); } return const DecryptedMessageResult(); } String _getEncryptedContentType(EncryptedContent content) { if (content.hasMessageUpdate()) return 'messageUpdate'; if (content.hasMedia()) return 'media'; if (content.hasMediaUpdate()) return 'mediaUpdate'; if (content.hasContactUpdate()) return 'contactUpdate'; if (content.hasContactRequest()) return 'contactRequest'; if (content.hasFlameSync()) return 'flameSync'; if (content.hasPushKeys()) return 'pushKeys'; if (content.hasReaction()) return 'reaction'; if (content.hasTextMessage()) return 'textMessage'; if (content.hasGroupCreate()) return 'groupCreate'; if (content.hasGroupJoin()) return 'groupJoin'; if (content.hasGroupUpdate()) return 'groupUpdate'; if (content.hasResendGroupPublicKey()) return 'resendGroupPublicKey'; if (content.hasErrorMessages()) return 'errorMessages'; if (content.hasAdditionalDataMessage()) return 'additionalDataMessage'; if (content.hasTypingIndicator()) return 'typingIndicator'; if (content.hasUserDiscoveryRequest()) return 'userDiscoveryRequest'; if (content.hasUserDiscoveryUpdate()) return 'userDiscoveryUpdate'; if (content.hasKeyVerificationProof()) return 'keyVerificationProof'; return 'unknown'; } class DecryptedMessageResult { const DecryptedMessageResult({ this.responseCipherText, this.responsePlaintext, this.showPushNotification = true, }); final EncryptedContent? responseCipherText; final PlaintextContent? responsePlaintext; final bool showPushNotification; }