diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt index a6747b3b10..46f473e210 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentDownloadJob.kt @@ -1,10 +1,8 @@ package org.session.libsession.messaging.jobs -import android.content.Context import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import dagger.hilt.android.qualifiers.ApplicationContext import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import org.session.libsession.database.MessageDataProvider @@ -16,7 +14,6 @@ import org.session.libsession.messaging.sending_receiving.attachments.Attachment import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.messaging.utilities.Data import org.session.libsession.snode.OnionRequestAPI -import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.Address import org.session.libsession.utilities.DecodedAudio import org.session.libsession.utilities.InputStreamMediaDataSource @@ -252,21 +249,18 @@ class AttachmentDownloadJob @AssistedInject constructor( return KEY } - class DeserializeFactory(private val factory: Factory) : Job.DeserializeFactory { + @AssistedFactory + abstract class Factory : Job.DeserializeFactory { + abstract fun create( + @Assisted("attachmentID") attachmentID: Long, + mmsMessageId: Long + ): AttachmentDownloadJob override fun create(data: Data): AttachmentDownloadJob { - return factory.create( + return create( attachmentID = data.getLong(ATTACHMENT_ID_KEY), mmsMessageId = data.getLong(TS_INCOMING_MESSAGE_ID_KEY) ) } } - - @AssistedFactory - interface Factory { - fun create( - @Assisted("attachmentID") attachmentID: Long, - mmsMessageId: Long - ): AttachmentDownloadJob - } } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt index bd31a3c3f1..86ce01eec7 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/AttachmentUploadJob.kt @@ -15,7 +15,6 @@ import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.utilities.Data -import org.session.libsession.snode.utilities.await import org.session.libsession.utilities.Address import org.session.libsession.utilities.DecodedAudio import org.session.libsession.utilities.InputStreamMediaDataSource @@ -38,6 +37,7 @@ class AttachmentUploadJob @AssistedInject constructor( private val attachmentProcessor: AttachmentProcessor, private val preferences: TextSecurePreferences, private val fileServerApi: FileServerApi, + private val messageSender: MessageSender, ) : Job { override var delegate: JobDelegate? = null override var id: String? = null @@ -219,7 +219,7 @@ class AttachmentUploadJob @AssistedInject constructor( private fun failAssociatedMessageSendJob(e: Exception) { val messageSendJob = storage.getMessageSendJob(messageSendJobID) - MessageSender.handleFailedMessageSend(this.message, e) + messageSender.handleFailedMessageSend(this.message, e) if (messageSendJob != null) { storage.markJobAsFailedPermanently(messageSendJobID) } @@ -244,7 +244,14 @@ class AttachmentUploadJob @AssistedInject constructor( return KEY } - class DeserializeFactory(private val factory: Factory): Job.DeserializeFactory { + @AssistedFactory + abstract class Factory : Job.DeserializeFactory { + abstract fun create( + attachmentID: Long, + @Assisted("threadID") threadID: String, + message: Message, + messageSendJobID: String + ): AttachmentUploadJob override fun create(data: Data): AttachmentUploadJob? { val serializedMessage = data.getByteArray(MESSAGE_KEY) @@ -259,7 +266,7 @@ class AttachmentUploadJob @AssistedInject constructor( return null } input.close() - return factory.create( + return create( attachmentID = data.getLong(ATTACHMENT_ID_KEY), threadID = data.getString(THREAD_ID_KEY)!!, message = message, @@ -267,14 +274,4 @@ class AttachmentUploadJob @AssistedInject constructor( ) } } - - @AssistedFactory - interface Factory { - fun create( - attachmentID: Long, - @Assisted("threadID") threadID: String, - message: Message, - messageSendJobID: String - ): AttachmentUploadJob - } } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt index fc1bbfac42..cdf760a7b7 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/BatchMessageReceiveJob.kt @@ -51,6 +51,7 @@ data class MessageReceiveParameters( val closedGroup: Destination.ClosedGroup? = null ) +@Deprecated("BatchMessageReceiveJob is now only here so that existing persisted jobs can be processed.") class BatchMessageReceiveJob @AssistedInject constructor( @Assisted private val messages: List, @Assisted val fromCommunity: Address.Community?, // The community the messages are received in, if any @@ -62,6 +63,7 @@ class BatchMessageReceiveJob @AssistedInject constructor( private val messageNotifier: MessageNotifier, private val threadDatabase: ThreadDatabase, private val recipientRepository: RecipientRepository, + private val messageReceiver: MessageReceiver, ) : Job { override var delegate: JobDelegate? = null @@ -105,6 +107,7 @@ class BatchMessageReceiveJob @AssistedInject constructor( fromCommunity = fromCommunity, threadDatabase = threadDatabase, recipientRepository = recipientRepository, + messageReceiver = messageReceiver, ) } @@ -157,7 +160,7 @@ class BatchMessageReceiveJob @AssistedInject constructor( messages.forEach { messageParameters -> val (data, serverHash, openGroupMessageServerID) = messageParameters try { - val (message, proto) = MessageReceiver.parse( + val (message, proto) = messageReceiver.parse( data, openGroupMessageServerID, openGroupPublicKey = serverPublicKey, @@ -358,7 +361,8 @@ class BatchMessageReceiveJob @AssistedInject constructor( @AssistedFactory abstract class Factory : Job.DeserializeFactory { - abstract fun create( + @Deprecated("New code should try to handle message directly instead of creating this job") + protected abstract fun create( messages: List, fromCommunity: Address.Community?, ): BatchMessageReceiveJob diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt index 1a4c5cc9f9..70157d8ef1 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/InviteContactsJob.kt @@ -2,6 +2,9 @@ package org.session.libsession.messaging.jobs import android.widget.Toast import com.google.protobuf.ByteString +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -16,13 +19,19 @@ import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.utilities.Data import org.session.libsession.messaging.utilities.MessageAuthentication.buildGroupInviteSignature import org.session.libsession.snode.SnodeAPI +import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.getGroup import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateInviteMessage import org.session.libsignal.protos.SignalServiceProtos.DataMessage.GroupUpdateMessage import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Log -class InviteContactsJob(val groupSessionId: String, val memberSessionIds: Array) : Job { +class InviteContactsJob @AssistedInject constructor( + @Assisted val groupSessionId: String, + @Assisted val memberSessionIds: Array, + private val configFactory: ConfigFactoryProtocol, + private val messageSender: MessageSender, +) : Job { companion object { const val KEY = "InviteContactJob" @@ -37,8 +46,7 @@ class InviteContactsJob(val groupSessionId: String, val memberSessionIds: Array< override val maxFailureCount: Int = 1 override suspend fun execute(dispatcherName: String) { - val configs = MessagingModuleConfiguration.shared.configFactory - val group = requireNotNull(configs.getGroup(AccountId(groupSessionId))) { + val group = requireNotNull(configFactory.getGroup(AccountId(groupSessionId))) { "Group must exist to invite" } @@ -54,7 +62,7 @@ class InviteContactsJob(val groupSessionId: String, val memberSessionIds: Array< runCatching { // Make the request for this member val memberId = AccountId(memberSessionId) - val (groupName, subAccount) = configs.withMutableGroupConfigs(sessionId) { configs -> + val (groupName, subAccount) = configFactory.withMutableGroupConfigs(sessionId) { configs -> configs.groupInfo.getName() to configs.groupKeys.makeSubAccount(memberSessionId) } @@ -76,14 +84,14 @@ class InviteContactsJob(val groupSessionId: String, val memberSessionIds: Array< sentTimestamp = timestamp } - MessageSender.sendNonDurably(update, Destination.Contact(memberSessionId), false) + messageSender.sendNonDurably(update, Destination.Contact(memberSessionId), false) } } } val results = memberSessionIds.zip(requests.awaitAll()) - configs.withMutableGroupConfigs(sessionId) { configs -> + configFactory.withMutableGroupConfigs(sessionId) { configs -> results.forEach { (memberSessionId, result) -> configs.groupMembers.get(memberSessionId)?.let { member -> if (result.isFailure) { @@ -96,8 +104,8 @@ class InviteContactsJob(val groupSessionId: String, val memberSessionIds: Array< } } - val groupName = configs.withGroupConfigs(sessionId) { it.groupInfo.getName() } - ?: configs.getGroup(sessionId)?.name + val groupName = configFactory.withGroupConfigs(sessionId) { it.groupInfo.getName() } + ?: configFactory.getGroup(sessionId)?.name // Gather all the exceptions, while keeping track of the invitee account IDs val failures = results.mapNotNull { (id, result) -> @@ -140,4 +148,20 @@ class InviteContactsJob(val groupSessionId: String, val memberSessionIds: Array< override fun getFactoryKey(): String = KEY + @AssistedFactory + abstract class Factory : Job.DeserializeFactory { + abstract fun create( + groupSessionId: String, + memberSessionIds: Array, + ): InviteContactsJob + + override fun create(data: Data): InviteContactsJob? { + val groupSessionId = data.getString(GROUP) ?: return null + val memberSessionIds = data.getStringArray(MEMBER) ?: return null + return create( + groupSessionId = groupSessionId, + memberSessionIds = memberSessionIds, + ) + } + } } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt b/app/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt index 238b7edb04..1cb9e88bfa 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/MessageSendJob.kt @@ -34,6 +34,7 @@ class MessageSendJob @AssistedInject constructor( private val messageDataProvider: MessageDataProvider, private val storage: StorageProtocol, private val configFactory: ConfigFactoryProtocol, + private val messageSender: MessageSender, ) : Job { object AwaitingAttachmentUploadException : Exception("Awaiting attachment upload.") @@ -97,7 +98,7 @@ class MessageSendJob @AssistedInject constructor( } } - MessageSender.sendNonDurably(this@MessageSendJob.message, destination, isSync) + messageSender.sendNonDurably(this@MessageSendJob.message, destination, isSync) this.handleSuccess(dispatcherName) statusCallback?.trySend(Result.success(Unit)) @@ -173,7 +174,14 @@ class MessageSendJob @AssistedInject constructor( return KEY } - class DeserializeFactory(private val factory: Factory) : Job.DeserializeFactory { + + @AssistedFactory + abstract class Factory : Job.DeserializeFactory { + abstract fun create( + message: Message, + destination: Destination, + statusCallback: SendChannel>? = null + ): MessageSendJob override fun create(data: Data): MessageSendJob? { val serializedMessage = data.getByteArray(MESSAGE_KEY) @@ -201,20 +209,11 @@ class MessageSendJob @AssistedInject constructor( } destinationInput.close() // Return - return factory.create( + return create( message = message, destination = destination, statusCallback = null ) } } - - @AssistedFactory - interface Factory { - fun create( - message: Message, - destination: Destination, - statusCallback: SendChannel>? = null - ): MessageSendJob - } } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/jobs/SessionJobManagerFactories.kt b/app/src/main/java/org/session/libsession/messaging/jobs/SessionJobManagerFactories.kt index cd0468d9f2..e1780ce19f 100644 --- a/app/src/main/java/org/session/libsession/messaging/jobs/SessionJobManagerFactories.kt +++ b/app/src/main/java/org/session/libsession/messaging/jobs/SessionJobManagerFactories.kt @@ -8,18 +8,20 @@ class SessionJobManagerFactories @Inject constructor( private val batchFactory: BatchMessageReceiveJob.Factory, private val trimThreadFactory: TrimThreadJob.Factory, private val messageSendJobFactory: MessageSendJob.Factory, - private val deleteJobFactory: OpenGroupDeleteJob.Factory + private val deleteJobFactory: OpenGroupDeleteJob.Factory, + private val inviteContactsJobFactory: InviteContactsJob.Factory, ) { fun getSessionJobFactories(): Map> { return mapOf( - AttachmentDownloadJob.KEY to AttachmentDownloadJob.DeserializeFactory(attachmentDownloadJobFactory), - AttachmentUploadJob.KEY to AttachmentUploadJob.DeserializeFactory(attachmentUploadJobFactory), - MessageSendJob.KEY to MessageSendJob.DeserializeFactory(messageSendJobFactory), + AttachmentDownloadJob.KEY to attachmentDownloadJobFactory, + AttachmentUploadJob.KEY to attachmentUploadJobFactory, + MessageSendJob.KEY to messageSendJobFactory, NotifyPNServerJob.KEY to NotifyPNServerJob.DeserializeFactory(), TrimThreadJob.KEY to trimThreadFactory, BatchMessageReceiveJob.KEY to batchFactory, OpenGroupDeleteJob.KEY to deleteJobFactory, + InviteContactsJob.KEY to inviteContactsJobFactory, ) } } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/messages/Destination.kt b/app/src/main/java/org/session/libsession/messaging/messages/Destination.kt index bb8c29a0ce..5ddbad9fa1 100644 --- a/app/src/main/java/org/session/libsession/messaging/messages/Destination.kt +++ b/app/src/main/java/org/session/libsession/messaging/messages/Destination.kt @@ -8,12 +8,6 @@ sealed class Destination { data class Contact(var publicKey: String) : Destination() { internal constructor(): this("") } - data class LegacyClosedGroup(var groupPublicKey: String) : Destination() { - internal constructor(): this("") - } - data class LegacyOpenGroup(var roomToken: String, var server: String) : Destination() { - internal constructor(): this("", "") - } data class ClosedGroup(var publicKey: String): Destination() { internal constructor(): this("") } @@ -39,9 +33,6 @@ sealed class Destination { is Address.Standard -> { Contact(address.address) } - is Address.LegacyGroup -> { - LegacyClosedGroup(address.groupPublicKeyHex) - } is Address.Community -> { OpenGroup(roomToken = address.room, server = address.serverUrl, fileIds = fileIds) } @@ -63,9 +54,10 @@ sealed class Destination { is Address.Group -> { ClosedGroup(address.accountId.hexString) } - else -> { - throw Exception("TODO: Handle legacy closed groups.") - } + + is Address.Blinded, + is Address.LegacyGroup, + is Address.Unknown -> error("Unsupported address as destination: $address") } } } diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/GroupMessageHandler.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/GroupMessageHandler.kt new file mode 100644 index 0000000000..abeb569713 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/GroupMessageHandler.kt @@ -0,0 +1,219 @@ +package org.session.libsession.messaging.sending_receiving + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import network.loki.messenger.libsession_util.ED25519 +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.groups.GroupManagerV2 +import org.session.libsession.messaging.messages.ProfileUpdateHandler +import org.session.libsession.messaging.messages.control.GroupUpdated +import org.session.libsession.messaging.utilities.MessageAuthentication.buildDeleteMemberContentSignature +import org.session.libsession.messaging.utilities.MessageAuthentication.buildGroupInviteSignature +import org.session.libsession.messaging.utilities.MessageAuthentication.buildInfoChangeSignature +import org.session.libsession.messaging.utilities.MessageAuthentication.buildMemberChangeSignature +import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.IdPrefix +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.dependencies.ManagerScope +import java.security.SignatureException +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class GroupMessageHandler @Inject constructor( + private val profileUpdateHandler: ProfileUpdateHandler, + private val storage: StorageProtocol, + private val groupManagerV2: GroupManagerV2, + @param:ManagerScope private val scope: CoroutineScope, +) { + fun handleGroupUpdated(message: GroupUpdated, groupId: AccountId?) { + val inner = message.inner + if (groupId == null && + !inner.hasInviteMessage() && !inner.hasPromoteMessage()) { + throw NullPointerException("Message wasn't polled from a closed group!") + } + + // Update profile if needed + ProfileUpdateHandler.Updates.create( + name = message.profile?.displayName, + picUrl = message.profile?.profilePictureURL, + picKey = message.profile?.profileKey, + blocksCommunityMessageRequests = null, + proStatus = null, + profileUpdateTime = null + )?.let { updates -> + profileUpdateHandler.handleProfileUpdate( + senderId = AccountId(message.sender!!), + updates = updates, + fromCommunity = null // Groupv2 is not a community + ) + } + + when { + inner.hasInviteMessage() -> handleNewLibSessionClosedGroupMessage(message) + inner.hasInviteResponse() -> handleInviteResponse(message, groupId!!) + inner.hasPromoteMessage() -> handlePromotionMessage(message) + inner.hasInfoChangeMessage() -> handleGroupInfoChange(message, groupId!!) + inner.hasMemberChangeMessage() -> handleMemberChange(message, groupId!!) + inner.hasMemberLeftMessage() -> handleMemberLeft(message, groupId!!) + inner.hasMemberLeftNotificationMessage() -> handleMemberLeftNotification(message, groupId!!) + inner.hasDeleteMemberContent() -> handleDeleteMemberContent(message, groupId!!) + } + } + + private fun handleNewLibSessionClosedGroupMessage(message: GroupUpdated) { + val storage = storage + val ourUserId = storage.getUserPublicKey()!! + val invite = message.inner.inviteMessage + val groupId = AccountId(invite.groupSessionId) + verifyAdminSignature( + groupSessionId = groupId, + signatureData = invite.adminSignature.toByteArray(), + messageToValidate = buildGroupInviteSignature(AccountId(ourUserId), message.sentTimestamp!!) + ) + + val sender = message.sender!! + val adminId = AccountId(sender) + scope.launch { + try { + groupManagerV2 + .handleInvitation( + groupId = groupId, + groupName = invite.name, + authData = invite.memberAuthData.toByteArray(), + inviter = adminId, + inviterName = message.profile?.displayName, + inviteMessageHash = message.serverHash!!, + inviteMessageTimestamp = message.sentTimestamp!!, + ) + } catch (e: Exception) { + Log.e("GroupUpdated", "Failed to handle invite message", e) + } + } + } + + /** + * Does nothing on successful signature verification, throws otherwise. + * Assumes the signer is using the ed25519 group key signing key + * @param groupSessionId the AccountId of the group to check the signature against + * @param signatureData the byte array supplied to us through a protobuf message from the admin + * @param messageToValidate the expected values used for this signature generation, often something like `INVITE||{inviteeSessionId}||{timestamp}` + * @throws SignatureException if signature cannot be verified with given parameters + */ + private fun verifyAdminSignature(groupSessionId: AccountId, signatureData: ByteArray, messageToValidate: ByteArray) { + val groupPubKey = groupSessionId.pubKeyBytes + if (!ED25519.verify(signature = signatureData, ed25519PublicKey = groupPubKey, message = messageToValidate)) { + throw SignatureException("Verification failed for signature data") + } + } + + private fun handleInviteResponse(message: GroupUpdated, closedGroup: AccountId) { + val sender = message.sender!! + // val profile = message // maybe we do need data to be the inner so we can access profile + val approved = message.inner.inviteResponse.isApproved + scope.launch { + try { + groupManagerV2.handleInviteResponse(closedGroup, AccountId(sender), approved) + } catch (e: Exception) { + Log.e("GroupUpdated", "Failed to handle invite response", e) + } + } + } + + + private fun handlePromotionMessage(message: GroupUpdated) { + val promotion = message.inner.promoteMessage + val seed = promotion.groupIdentitySeed.toByteArray() + val sender = message.sender!! + val adminId = AccountId(sender) + scope.launch { + try { + groupManagerV2 + .handlePromotion( + groupId = AccountId(IdPrefix.GROUP, ED25519.generate(seed).pubKey.data), + groupName = promotion.name, + adminKeySeed = seed, + promoter = adminId, + promoterName = message.profile?.displayName, + promoteMessageHash = message.serverHash!!, + promoteMessageTimestamp = message.sentTimestamp!!, + ) + } catch (e: Exception) { + Log.e("GroupUpdated", "Failed to handle promotion message", e) + } + } + } + + private fun handleGroupInfoChange(message: GroupUpdated, closedGroup: AccountId) { + val inner = message.inner + val infoChanged = inner.infoChangeMessage ?: return + if (!infoChanged.hasAdminSignature()) return Log.e("GroupUpdated", "Info changed message doesn't contain admin signature") + val adminSignature = infoChanged.adminSignature + val type = infoChanged.type + val timestamp = message.sentTimestamp!! + verifyAdminSignature(closedGroup, adminSignature.toByteArray(), buildInfoChangeSignature(type, timestamp)) + + groupManagerV2.handleGroupInfoChange(message, closedGroup) + } + + + private fun handleMemberChange(message: GroupUpdated, closedGroup: AccountId) { + val memberChange = message.inner.memberChangeMessage + val type = memberChange.type + val timestamp = message.sentTimestamp!! + verifyAdminSignature(closedGroup, + memberChange.adminSignature.toByteArray(), + buildMemberChangeSignature(type, timestamp) + ) + storage.insertGroupInfoChange(message, closedGroup) + } + + private fun handleMemberLeft(message: GroupUpdated, closedGroup: AccountId) { + scope.launch { + try { + groupManagerV2.handleMemberLeftMessage( + AccountId(message.sender!!), closedGroup + ) + } catch (e: Exception) { + Log.e("GroupUpdated", "Failed to handle member left message", e) + } + } + } + + private fun handleMemberLeftNotification(message: GroupUpdated, closedGroup: AccountId) { + storage.insertGroupInfoChange(message, closedGroup) + } + + private fun handleDeleteMemberContent(message: GroupUpdated, closedGroup: AccountId) { + val deleteMemberContent = message.inner.deleteMemberContent + val adminSig = if (deleteMemberContent.hasAdminSignature()) deleteMemberContent.adminSignature.toByteArray()!! else byteArrayOf() + + val hasValidAdminSignature = adminSig.isNotEmpty() && runCatching { + verifyAdminSignature( + closedGroup, + adminSig, + buildDeleteMemberContentSignature( + memberIds = deleteMemberContent.memberSessionIdsList.asSequence().map(::AccountId).asIterable(), + messageHashes = deleteMemberContent.messageHashesList, + timestamp = message.sentTimestamp!!, + ) + ) + }.isSuccess + + scope.launch { + try { + groupManagerV2.handleDeleteMemberContent( + groupId = closedGroup, + deleteMemberContent = deleteMemberContent, + timestamp = message.sentTimestamp!!, + sender = AccountId(message.sender!!), + senderIsVerifiedAdmin = hasValidAdminSignature + ) + } catch (e: Exception) { + Log.e("GroupUpdated", "Failed to handle delete member content", e) + } + } + } + + +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageDecrypter.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageDecrypter.kt index 4ee0cd8bc1..6418ac99bd 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageDecrypter.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageDecrypter.kt @@ -10,6 +10,7 @@ import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.hexEncodedPublicKey import org.session.libsignal.utilities.removingIdPrefixIfNeeded +@Deprecated("This class is deprecated and new code should try to decrypt/decode message using SessionProtocol API") object MessageDecrypter { /** diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageEncrypter.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageEncrypter.kt index 17f16ddbfe..cc09b12a37 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageEncrypter.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageEncrypter.kt @@ -8,6 +8,7 @@ import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.removingIdPrefixIfNeeded +@Deprecated("This class is deprecated and new code should try to encrypt/encode message using SessionProtocol API") object MessageEncrypter { /** diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageParser.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageParser.kt new file mode 100644 index 0000000000..26daf05fc4 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageParser.kt @@ -0,0 +1,240 @@ +package org.session.libsession.messaging.sending_receiving + +import network.loki.messenger.libsession_util.protocol.DecodedEnvelope +import network.loki.messenger.libsession_util.protocol.SessionProtocol +import org.session.libsession.database.StorageProtocol +import org.session.libsession.messaging.messages.Message +import org.session.libsession.messaging.messages.control.CallMessage +import org.session.libsession.messaging.messages.control.DataExtractionNotification +import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate +import org.session.libsession.messaging.messages.control.GroupUpdated +import org.session.libsession.messaging.messages.control.MessageRequestResponse +import org.session.libsession.messaging.messages.control.ReadReceipt +import org.session.libsession.messaging.messages.control.TypingIndicator +import org.session.libsession.messaging.messages.control.UnsendRequest +import org.session.libsession.messaging.messages.visible.VisibleMessage +import org.session.libsession.messaging.open_groups.OpenGroupApi +import org.session.libsession.snode.SnodeClock +import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsignal.exceptions.NonRetryableException +import org.session.libsignal.protos.SignalServiceProtos +import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.Base64 +import org.session.libsignal.utilities.IdPrefix +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.math.abs + +@Singleton +class MessageParser @Inject constructor( + private val configFactory: ConfigFactoryProtocol, + private val storage: StorageProtocol, + private val snodeClock: SnodeClock, +) { + + //TODO: Obtain proBackendKey from somewhere + private val proBackendKey = ByteArray(32) + + // A faster way to check if the user is blocked than to go through RecipientRepository + private fun isUserBlocked(accountId: AccountId): Boolean { + return configFactory.withUserConfigs { it.contacts.get(accountId.hexString) } + ?.blocked == true + } + + + private fun createMessageFromProto(proto: SignalServiceProtos.Content, isGroupMessage: Boolean): Message { + val message = ReadReceipt.fromProto(proto) ?: + TypingIndicator.fromProto(proto) ?: + DataExtractionNotification.fromProto(proto) ?: + ExpirationTimerUpdate.fromProto(proto, isGroupMessage) ?: + UnsendRequest.fromProto(proto) ?: + MessageRequestResponse.fromProto(proto) ?: + CallMessage.fromProto(proto) ?: + GroupUpdated.fromProto(proto) ?: + VisibleMessage.fromProto(proto) + + if (message == null) { + throw NonRetryableException("Unknown message type") + } + + return message + } + + private fun parseMessage( + decodedEnvelope: DecodedEnvelope, + relaxSignatureCheck: Boolean, + checkForBlockStatus: Boolean, + isForGroup: Boolean, + currentUserId: AccountId, + currentUserBlindedIDs: List, + senderIdPrefix: IdPrefix + ): Pair { + return parseMessage( + sender = AccountId(senderIdPrefix, decodedEnvelope.senderX25519PubKey.data), + contentPlaintext = decodedEnvelope.contentPlainText.data, + messageTimestampMs = decodedEnvelope.timestamp.toEpochMilli(), + relaxSignatureCheck = relaxSignatureCheck, + checkForBlockStatus = checkForBlockStatus, + isForGroup = isForGroup, + currentUserId = currentUserId, + currentUserBlindedIDs = currentUserBlindedIDs, + ) + } + + private fun parseMessage( + sender: AccountId, + contentPlaintext: ByteArray, + messageTimestampMs: Long, + relaxSignatureCheck: Boolean, + checkForBlockStatus: Boolean, + isForGroup: Boolean, + currentUserId: AccountId, + currentUserBlindedIDs: List, + ): Pair { + val proto = SignalServiceProtos.Content.parseFrom(contentPlaintext) + + // Check signature + if (proto.hasSigTimestampMs()) { + val diff = abs(proto.sigTimestampMs - messageTimestampMs) + if ( + (!relaxSignatureCheck && diff != 0L ) || + (relaxSignatureCheck && diff > TimeUnit.HOURS.toMillis(6))) { + throw NonRetryableException("Invalid signature timestamp") + } + } + + val message = createMessageFromProto(proto, isGroupMessage = isForGroup) + + // Blocked sender check + if (checkForBlockStatus && isUserBlocked(sender) && message.shouldDiscardIfBlocked()) { + throw NonRetryableException("Sender($sender) is blocked from sending message to us") + } + + // Valid self-send messages + val isSenderSelf = sender == currentUserId || sender in currentUserBlindedIDs + if (isSenderSelf && !message.isSelfSendValid) { + throw NonRetryableException("Ignoring self send message") + } + + // Fill in message fields + message.sender = sender.hexString + message.recipient = currentUserId.hexString + message.sentTimestamp = messageTimestampMs + message.receivedTimestamp = snodeClock.currentTimeMills() + message.isSenderSelf = isSenderSelf + + // Validate + var isValid = message.isValid() + // TODO: Legacy code: why this is check needed? + if (message is VisibleMessage && !isValid && proto.dataMessage.attachmentsCount != 0) { isValid = true } + if (!isValid) { + throw NonRetryableException("Invalid message") + } + + // Duplicate check + // TODO: Legacy code: this is most likely because we try to duplicate the message we just + // send (so that a new polling won't get the same message). At the moment it's the only reliable + // way to de-duplicate sent messages as we can add the "timestamp" before hand so that when + // message arrives back from server we can identify it. The logic can be removed if we can + // calculate message hash before sending it out so we can use the existing hash de-duplication + // mechanism. + if (storage.isDuplicateMessage(messageTimestampMs)) { + throw NonRetryableException("Duplicate message") + } + storage.addReceivedMessageTimestamp(messageTimestampMs) + + return message to proto + } + + + fun parse1o1Message( + data: ByteArray, + serverHash: String?, + currentUserEd25519PrivKey: ByteArray, + currentUserId: AccountId, + ): Pair { + val envelop = SessionProtocol.decodeFor1o1( + myEd25519PrivKey = currentUserEd25519PrivKey, + payload = data, + nowEpochMs = snodeClock.currentTimeMills(), + proBackendPubKey = proBackendKey, + ) + + return parseMessage( + decodedEnvelope = envelop, + relaxSignatureCheck = false, + checkForBlockStatus = true, + isForGroup = false, + senderIdPrefix = IdPrefix.STANDARD, + currentUserId = currentUserId, + currentUserBlindedIDs = emptyList(), + ).also { (message, _) -> + message.serverHash = serverHash + } + } + + fun parseGroupMessage( + data: ByteArray, + serverHash: String, + groupId: AccountId, + currentUserEd25519PrivKey: ByteArray, + currentUserId: AccountId, + ): Pair { + val keys = configFactory.withGroupConfigs(groupId) { + it.groupKeys.groupKeys() + } + + val decoded = SessionProtocol.decodeForGroup( + payload = data, + myEd25519PrivKey = currentUserEd25519PrivKey, + nowEpochMs = snodeClock.currentTimeMills(), + groupEd25519PublicKey = groupId.pubKeyBytes, + groupEd25519PrivateKeys = keys.toTypedArray(), + proBackendPubKey = proBackendKey + ) + + return parseMessage( + decodedEnvelope = decoded, + relaxSignatureCheck = false, + checkForBlockStatus = false, + isForGroup = true, + senderIdPrefix = IdPrefix.STANDARD, + currentUserId = currentUserId, + currentUserBlindedIDs = emptyList(), + ).also { (message, _) -> + message.serverHash = serverHash + } + } + + fun parseCommunityMessage( + msg: OpenGroupApi.Message, + currentUserId: AccountId, + currentUserBlindedIDs: List, + ): Pair? { + if (msg.data.isNullOrBlank()) { + return null + } + + val decoded = SessionProtocol.decodeForCommunity( + payload = Base64.decode(msg.data), + nowEpochMs = snodeClock.currentTimeMills(), + proBackendPubKey = proBackendKey, + ) + + val sender = AccountId(msg.sessionId) + + return parseMessage( + contentPlaintext = decoded.contentPlainText.data, + relaxSignatureCheck = true, + checkForBlockStatus = false, + isForGroup = false, + currentUserId = currentUserId, + sender = sender, + messageTimestampMs = (msg.posted * 1000).toLong(), + currentUserBlindedIDs = currentUserBlindedIDs, + ).also { (message, _) -> + message.openGroupServerMessageID = msg.id + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt index 9e05ecce3e..3e0946027d 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageReceiver.kt @@ -1,7 +1,7 @@ package org.session.libsession.messaging.sending_receiving import network.loki.messenger.libsession_util.util.BlindKeyAPI -import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.control.CallMessage import org.session.libsession.messaging.messages.control.DataExtractionNotification @@ -13,6 +13,7 @@ import org.session.libsession.messaging.messages.control.TypingIndicator import org.session.libsession.messaging.messages.control.UnsendRequest import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.snode.SnodeAPI +import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsignal.crypto.PushTransportDetails import org.session.libsignal.protos.SignalServiceProtos import org.session.libsignal.protos.SignalServiceProtos.Envelope @@ -21,9 +22,15 @@ import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton import kotlin.math.abs -object MessageReceiver { +@Deprecated("This class only exists so the old BatchMessageReceiver can function. New code should use MessageHandler directly.") +@Singleton +class MessageReceiver @Inject constructor( + private val storage: StorageProtocol, +) { internal sealed class Error(message: String) : Exception(message) { object DuplicateMessage: Error("Duplicate message.") @@ -60,7 +67,6 @@ object MessageReceiver { currentClosedGroups: Set?, closedGroupSessionId: String? = null, ): Pair { - val storage = MessagingModuleConfiguration.shared.storage val userPublicKey = storage.getUserPublicKey() val isOpenGroupMessage = (openGroupServerID != null) var plaintext: ByteArray? = null @@ -91,7 +97,7 @@ object MessageReceiver { plaintext = decryptionResult.first sender = decryptionResult.second } else { - val userX25519KeyPair = MessagingModuleConfiguration.shared.storage.getUserX25519KeyPair() + val userX25519KeyPair = storage.getUserX25519KeyPair() val decryptionResult = MessageDecrypter.decrypt(envelopeContent.toByteArray(), userX25519KeyPair) plaintext = decryptionResult.first sender = decryptionResult.second @@ -105,10 +111,10 @@ object MessageReceiver { sender = envelope.source groupPublicKey = hexEncodedGroupPublicKey } else { - if (!MessagingModuleConfiguration.shared.storage.isLegacyClosedGroup(hexEncodedGroupPublicKey)) { + if (!storage.isLegacyClosedGroup(hexEncodedGroupPublicKey)) { throw Error.InvalidGroupPublicKey } - val encryptionKeyPairs = MessagingModuleConfiguration.shared.storage.getClosedGroupEncryptionKeyPairs(hexEncodedGroupPublicKey) + val encryptionKeyPairs = storage.getClosedGroupEncryptionKeyPairs(hexEncodedGroupPublicKey) if (encryptionKeyPairs.isEmpty()) { throw Error.NoGroupKeyPair } @@ -172,7 +178,7 @@ object MessageReceiver { } val isUserBlindedSender = sender == openGroupPublicKey?.let { BlindKeyAPI.blind15KeyPairOrNull( - ed25519SecretKey = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair()!!.secretKey.data, + ed25519SecretKey = storage.getUserED25519KeyPair()!!.secretKey.data, serverPubKey = Hex.fromStringCondensed(it), ) }?.let { AccountId(IdPrefix.BLINDED, it.pubKey.data).hexString } diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageRequestResponseHandler.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageRequestResponseHandler.kt index 0d43629fc9..d153a1606a 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageRequestResponseHandler.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageRequestResponseHandler.kt @@ -33,12 +33,16 @@ class MessageRequestResponseHandler @Inject constructor( private val blindMappingRepository: BlindMappingRepository, ) { - suspend fun handleVisibleMessage(message: VisibleMessage) { + fun handleVisibleMessage( + ctx: ReceivedMessageProcessor.MessageProcessingContext?, + message: VisibleMessage + ) { val (sender, receiver) = fetchSenderAndReceiver(message) ?: return - val allBlindedAddresses = blindMappingRepository.calculateReverseMappings( - contactAddress = sender.address as Address.Standard - ) + val senderAddress = sender.address as Address.Standard + + val allBlindedAddresses = ctx?.getBlindIDMapping(senderAddress) + ?: blindMappingRepository.calculateReverseMappings(senderAddress) // Do we have an existing message request (including blinded requests)? val hasMessageRequest = configFactory.withUserConfigs { configs -> @@ -54,6 +58,7 @@ class MessageRequestResponseHandler @Inject constructor( if (hasMessageRequest) { handleRequestResponse( + ctx = ctx, messageSender = sender, messageReceiver = receiver, messageTimestampMs = message.sentTimestamp!!, @@ -61,10 +66,14 @@ class MessageRequestResponseHandler @Inject constructor( } } - suspend fun handleExplicitRequestResponseMessage(message: MessageRequestResponse) { + fun handleExplicitRequestResponseMessage( + ctx: ReceivedMessageProcessor.MessageProcessingContext?, + message: MessageRequestResponse + ) { val (sender, receiver) = fetchSenderAndReceiver(message) ?: return // Always handle explicit request response handleRequestResponse( + ctx = ctx, messageSender = sender, messageReceiver = receiver, messageTimestampMs = message.sentTimestamp!!, @@ -81,8 +90,8 @@ class MessageRequestResponseHandler @Inject constructor( } } - private suspend fun fetchSenderAndReceiver(message: Message): Pair? { - val messageSender = recipientRepository.getRecipient( + private fun fetchSenderAndReceiver(message: Message): Pair? { + val messageSender = recipientRepository.getRecipientSync( requireNotNull(message.sender) { "MessageRequestResponse must have a sender" }.toAddress() @@ -92,7 +101,7 @@ class MessageRequestResponseHandler @Inject constructor( Log.e(TAG, "MessageRequestResponse sender must be a standard address, but got: ${messageSender.address.debugString}") null } else { - messageSender to recipientRepository.getRecipient( + messageSender to recipientRepository.getRecipientSync( requireNotNull(message.recipient) { "MessageRequestResponse must have a receiver" }.toAddress() @@ -101,6 +110,7 @@ class MessageRequestResponseHandler @Inject constructor( } private fun handleRequestResponse( + ctx: ReceivedMessageProcessor.MessageProcessingContext?, messageSender: Recipient, messageReceiver: Recipient, messageTimestampMs: Long, @@ -164,7 +174,8 @@ class MessageRequestResponseHandler @Inject constructor( // Find all blinded conversations we have with this sender, move all the messages // from the blinded conversations to the standard conversation. - val blindedConversationAddresses = blindMappingRepository.calculateReverseMappings(messageSender.address) + val blindedConversationAddresses = (ctx?.getBlindIDMapping(messageSender.address) + ?: blindMappingRepository.calculateReverseMappings(messageSender.address)) .mapTo(hashSetOf()) { (c, id) -> Address.CommunityBlindedId( serverUrl = c.baseUrl, diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt index 0b5158ce5f..a35bc1fa95 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/MessageSender.kt @@ -1,7 +1,6 @@ package org.session.libsession.messaging.sending_receiving import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.launch @@ -9,10 +8,13 @@ import kotlinx.coroutines.supervisorScope import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_VISIBLE import network.loki.messenger.libsession_util.Namespace +import network.loki.messenger.libsession_util.protocol.SessionProtocol import network.loki.messenger.libsession_util.util.BlindKeyAPI import network.loki.messenger.libsession_util.util.ExpiryMode -import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.database.MessageDataProvider +import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.jobs.JobQueue +import org.session.libsession.messaging.jobs.MessageSendJob import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.messages.Message import org.session.libsession.messaging.messages.applyExpiryMode @@ -26,28 +28,34 @@ import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.open_groups.OpenGroupApi.Capability import org.session.libsession.messaging.open_groups.OpenGroupMessage -import org.session.libsession.messaging.utilities.MessageWrapper import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.SnodeAPI.nowWithOffset import org.session.libsession.snode.SnodeMessage import org.session.libsession.utilities.Address -import org.session.libsession.utilities.SSKEnvironment -import org.session.libsignal.crypto.PushTransportDetails -import org.session.libsignal.protos.SignalServiceProtos +import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log -import org.session.libsignal.utilities.defaultRequiresAuth -import org.session.libsignal.utilities.hasNamespaces -import org.session.libsignal.utilities.hexEncodedPublicKey +import org.thoughtcrime.securesms.database.RecipientRepository +import org.thoughtcrime.securesms.service.ExpiringMessageManager import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton import kotlin.coroutines.cancellation.CancellationException import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview as SignalLinkPreview import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel as SignalQuote -object MessageSender { +@Singleton +class MessageSender @Inject constructor( + private val storage: StorageProtocol, + private val configFactory: ConfigFactoryProtocol, + private val recipientRepository: RecipientRepository, + private val messageDataProvider: MessageDataProvider, + private val messageSendJobFactory: MessageSendJob.Factory, + private val messageExpirationManager: ExpiringMessageManager, +) { // Error sealed class Error(val description: String) : Exception(description) { @@ -71,7 +79,7 @@ object MessageSender { // Convenience suspend fun sendNonDurably(message: Message, destination: Destination, isSyncMessage: Boolean) { - return if (destination is Destination.LegacyOpenGroup || destination is Destination.OpenGroup || destination is Destination.OpenGroupInbox) { + return if (destination is Destination.OpenGroup || destination is Destination.OpenGroupInbox) { sendToOpenGroupDestination(destination, message) } else { sendToSnodeDestination(destination, message, isSyncMessage) @@ -81,9 +89,10 @@ object MessageSender { // One-on-One Chats & Closed Groups @Throws(Exception::class) fun buildWrappedMessageToSnode(destination: Destination, message: Message, isSyncMessage: Boolean): SnodeMessage { - val storage = MessagingModuleConfiguration.shared.storage - val configFactory = MessagingModuleConfiguration.shared.configFactory val userPublicKey = storage.getUserPublicKey() + val userEd25519PrivKey = requireNotNull(storage.getUserED25519KeyPair()?.secretKey?.data) { + "Missing user key" + } // Set the timestamp, sender and recipient val messageSendTime = nowWithOffset if (message.sentTimestamp == null) { @@ -95,9 +104,9 @@ object MessageSender { // SHARED CONFIG when (destination) { is Destination.Contact -> message.recipient = destination.publicKey - is Destination.LegacyClosedGroup -> message.recipient = destination.groupPublicKey is Destination.ClosedGroup -> message.recipient = destination.publicKey - else -> throw IllegalStateException("Destination should not be an open group.") + is Destination.OpenGroup, + is Destination.OpenGroupInbox -> error("Destination should not be an open group.") } val isSelfSend = (message.recipient == userPublicKey) @@ -133,59 +142,38 @@ object MessageSender { // Set the timestamp on the content so it can be verified against envelope timestamp proto.setSigTimestampMs(message.sentTimestamp!!) - // Serialize the protobuf - val plaintext = PushTransportDetails.getPaddedMessageBody(proto.build().toByteArray()) - - // Envelope information - val kind: SignalServiceProtos.Envelope.Type - val senderPublicKey: String - when (destination) { + val messageContent = when (destination) { is Destination.Contact -> { - kind = SignalServiceProtos.Envelope.Type.SESSION_MESSAGE - senderPublicKey = "" - } - is Destination.LegacyClosedGroup -> { - kind = SignalServiceProtos.Envelope.Type.CLOSED_GROUP_MESSAGE - senderPublicKey = destination.groupPublicKey - } - is Destination.ClosedGroup -> { - kind = SignalServiceProtos.Envelope.Type.CLOSED_GROUP_MESSAGE - senderPublicKey = destination.publicKey + SessionProtocol.encodeFor1o1( + plaintext = proto.build().toByteArray(), + myEd25519PrivKey = userEd25519PrivKey, + timestampMs = message.sentTimestamp!!, + recipientPubKey = Hex.fromStringCondensed(destination.publicKey), + proRotatingEd25519PrivKey = null, + ) } - else -> throw IllegalStateException("Destination should not be open group.") - } - // Encrypt the serialized protobuf - val ciphertext = when (destination) { - is Destination.Contact -> MessageEncrypter.encrypt(plaintext, destination.publicKey) - is Destination.LegacyClosedGroup -> { - val encryptionKeyPair = - MessagingModuleConfiguration.shared.storage.getLatestClosedGroupEncryptionKeyPair( - destination.groupPublicKey - )!! - MessageEncrypter.encrypt(plaintext, encryptionKeyPair.hexEncodedPublicKey) - } - is Destination.ClosedGroup -> { - val envelope = MessageWrapper.createEnvelope(kind, message.sentTimestamp!!, senderPublicKey, proto.build().toByteArray()) - configFactory.withGroupConfigs(AccountId(destination.publicKey)) { - it.groupKeys.encrypt(envelope.toByteArray()) - } - } - else -> throw IllegalStateException("Destination should not be open group.") - } - // Wrap the result using envelope information - val wrappedMessage = when (destination) { is Destination.ClosedGroup -> { - // encrypted bytes from the above closed group encryption and envelope steps - ciphertext + SessionProtocol.encodeForGroup( + plaintext = proto.build().toByteArray(), + myEd25519PrivKey = userEd25519PrivKey, + timestampMs = message.sentTimestamp!!, + groupEd25519PublicKey = Hex.fromStringCondensed(destination.publicKey), + groupEd25519PrivateKey = configFactory.withGroupConfigs(AccountId(destination.publicKey)) { + it.groupKeys.groupEncKey() + }, + proRotatingEd25519PrivKey = null + ) } - else -> MessageWrapper.wrap(kind, message.sentTimestamp!!, senderPublicKey, ciphertext) + + is Destination.OpenGroup, + is Destination.OpenGroupInbox -> error("Destination should not be an open group.") } - val base64EncodedData = Base64.encodeBytes(wrappedMessage) + // Send the result return SnodeMessage( message.recipient!!, - base64EncodedData, + data = Base64.encodeBytes(messageContent), ttl = getSpecifiedTtl(message, isSyncMessage) ?: message.ttl, messageSendTime ) @@ -193,8 +181,6 @@ object MessageSender { // One-on-One Chats & Closed Groups private suspend fun sendToSnodeDestination(destination: Destination, message: Message, isSyncMessage: Boolean = false) = supervisorScope { - val configFactory = MessagingModuleConfiguration.shared.configFactory - // Set the failure handler (need it here already for precondition failure handling) fun handleFailure(error: Exception) { handleFailedMessageSend(message, error, isSyncMessage) @@ -202,57 +188,39 @@ object MessageSender { try { val snodeMessage = buildWrappedMessageToSnode(destination, message, isSyncMessage) - // TODO: this might change in future for config messages - val forkInfo = SnodeAPI.forkInfo - val namespaces: List = when { - destination is Destination.LegacyClosedGroup - && forkInfo.defaultRequiresAuth() -> listOf(Namespace.UNAUTHENTICATED_CLOSED_GROUP()) - - destination is Destination.LegacyClosedGroup - && forkInfo.hasNamespaces() -> listOf( - Namespace.UNAUTHENTICATED_CLOSED_GROUP(), - Namespace.DEFAULT - ()) - destination is Destination.ClosedGroup -> listOf(Namespace.GROUP_MESSAGES()) - - else -> listOf(Namespace.DEFAULT()) - } + val sendResult = runCatching { + when (destination) { + is Destination.ClosedGroup -> { + val groupAuth = requireNotNull(configFactory.getGroupAuth(AccountId(destination.publicKey))) { + "Unable to authorize group message send" + } - val sendTasks = namespaces.map { namespace -> - if (destination is Destination.ClosedGroup) { - val groupAuth = requireNotNull(configFactory.getGroupAuth(AccountId(destination.publicKey))) { - "Unable to authorize group message send" - } - - async { SnodeAPI.sendMessage( auth = groupAuth, message = snodeMessage, - namespace = namespace, + namespace = Namespace.GROUP_MESSAGES(), ) } - } else { - async { - SnodeAPI.sendMessage(snodeMessage, auth = null, namespace = namespace) + is Destination.Contact -> { + SnodeAPI.sendMessage(snodeMessage, auth = null, namespace = Namespace.DEFAULT()) } + is Destination.OpenGroup, + is Destination.OpenGroupInbox -> throw IllegalStateException("Destination should not be an open group.") } } - val sendTaskResults = sendTasks.map { - runCatching { it.await() } - } - - val firstSuccess = sendTaskResults.firstOrNull { it.isSuccess }?.getOrNull() - if (firstSuccess != null) { - message.serverHash = firstSuccess.hash + if (sendResult.isSuccess) { + message.serverHash = sendResult.getOrThrow().hash handleSuccessfulMessageSend(message, destination, isSyncMessage) } else { - // If all tasks failed, throw the first exception - throw sendTaskResults.first().exceptionOrNull()!! + throw sendResult.exceptionOrNull()!! } } catch (exception: Exception) { - handleFailure(exception) + if (exception !is CancellationException) { + handleFailure(exception) + } + throw exception } } @@ -275,7 +243,7 @@ object MessageSender { return message.run { (if (isSyncMessage && this is VisibleMessage) syncTarget else recipient) ?.let(Address::fromSerialized) - ?.let(MessagingModuleConfiguration.shared.recipientRepository::getRecipientSync) + ?.let(recipientRepository::getRecipientSync) ?.expiryMode ?.takeIf { it is ExpiryMode.AfterSend || isSyncMessage } ?.expiryMillis @@ -285,8 +253,6 @@ object MessageSender { // Open Groups private suspend fun sendToOpenGroupDestination(destination: Destination, message: Message) { - val storage = MessagingModuleConfiguration.shared.storage - val configFactory = MessagingModuleConfiguration.shared.configFactory if (message.sentTimestamp == null) { message.sentTimestamp = nowWithOffset } @@ -296,10 +262,10 @@ object MessageSender { message.blocksMessageRequests = !configs.userProfile.getCommunityMessageRequests() } } - val userEdKeyPair = MessagingModuleConfiguration.shared.storage.getUserED25519KeyPair()!! + val userEdKeyPair = storage.getUserED25519KeyPair()!! var serverCapabilities = listOf() var blindedPublicKey: ByteArray? = null - when(destination) { + when (destination) { is Destination.OpenGroup -> { serverCapabilities = storage.getServerCapabilities(destination.server).orEmpty() storage.getOpenGroupPublicKey(destination.server)?.let { @@ -316,16 +282,9 @@ object MessageSender { serverPubKey = Hex.fromStringCondensed(destination.serverPublicKey), )?.pubKey?.data } - is Destination.LegacyOpenGroup -> { - serverCapabilities = storage.getServerCapabilities(destination.server).orEmpty() - storage.getOpenGroupPublicKey(destination.server)?.let { - blindedPublicKey = BlindKeyAPI.blind15KeyPairOrNull( - ed25519SecretKey = userEdKeyPair.secretKey.data, - serverPubKey = Hex.fromStringCondensed(it), - )?.pubKey?.data - } - } - else -> {} + + is Destination.ClosedGroup, + is Destination.Contact -> error("Destination must be an open group.") } val messageSender = if (serverCapabilities.contains(Capability.BLIND.name.lowercase()) && blindedPublicKey != null) { AccountId(IdPrefix.BLINDED, blindedPublicKey).hexString @@ -351,8 +310,11 @@ object MessageSender { if (message !is VisibleMessage || !message.isValid()) { throw Error.InvalidMessage } - val messageBody = content.toByteArray() - val plaintext = PushTransportDetails.getPaddedMessageBody(messageBody) + val plaintext = SessionProtocol.encodeForCommunity( + plaintext = content.toByteArray(), + proRotatingEd25519PrivKey = null + ) + val openGroupMessage = OpenGroupMessage( sender = message.sender, sentTimestamp = message.sentTimestamp!!, @@ -378,13 +340,15 @@ object MessageSender { if (message !is VisibleMessage || !message.isValid()) { throw Error.InvalidMessage } - val messageBody = content.toByteArray() - val plaintext = PushTransportDetails.getPaddedMessageBody(messageBody) - val ciphertext = MessageEncrypter.encryptBlinded( - plaintext, - destination.blindedPublicKey, - destination.serverPublicKey + val ciphertext = SessionProtocol.encodeForCommunityInbox( + plaintext = content.toByteArray(), + myEd25519PrivKey = userEdKeyPair.secretKey.data, + timestampMs = message.sentTimestamp!!, + recipientPubKey = Hex.fromStringCondensed(destination.blindedPublicKey), + communityServerPubKey = Hex.fromStringCondensed(destination.serverPublicKey), + proRotatingEd25519PrivKey = null, ) + val base64EncodedData = Base64.encodeBytes(ciphertext) val response = OpenGroupApi.sendDirectMessage( base64EncodedData, @@ -405,8 +369,7 @@ object MessageSender { } // Result Handling - fun handleSuccessfulMessageSend(message: Message, destination: Destination, isSyncMessage: Boolean = false, openGroupSentTimestamp: Long = -1) { - val storage = MessagingModuleConfiguration.shared.storage + private fun handleSuccessfulMessageSend(message: Message, destination: Destination, isSyncMessage: Boolean = false, openGroupSentTimestamp: Long = -1) { val userPublicKey = storage.getUserPublicKey()!! // Ignore future self-sends storage.addReceivedMessageTimestamp(message.sentTimestamp!!) @@ -427,21 +390,9 @@ object MessageSender { storage.clearErrorMessage(messageId) // Track the open group server message ID - val messageIsAddressedToCommunity = message.openGroupServerMessageID != null && (destination is Destination.LegacyOpenGroup || destination is Destination.OpenGroup) + val messageIsAddressedToCommunity = message.openGroupServerMessageID != null && (destination is Destination.OpenGroup) if (messageIsAddressedToCommunity) { - val address = when (destination) { - is Destination.LegacyOpenGroup -> { - Address.Community(destination.server, destination.roomToken) - } - - is Destination.OpenGroup -> { - Address.Community(destination.server, destination.roomToken) - } - - else -> { - throw Exception("Destination was a different destination than we were expecting") - } - } + val address = Address.Community(destination.server, destination.roomToken) val communityThreadID = storage.getThreadId(address) if (communityThreadID != null && communityThreadID >= 0) { storage.setOpenGroupServerMessageID( @@ -459,7 +410,7 @@ object MessageSender { storage.updateSentTimestamp(messageId, message.sentTimestamp!!) // Start the disappearing messages timer if needed - SSKEnvironment.shared.messageExpirationManager.onMessageSent(message) + messageExpirationManager.onMessageSent(message) } ?: run { storage.updateReactionIfNeeded(message, message.sender?:userPublicKey, openGroupSentTimestamp) } @@ -483,12 +434,10 @@ object MessageSender { } fun handleFailedMessageSend(message: Message, error: Exception, isSyncMessage: Boolean = false) { - val storage = MessagingModuleConfiguration.shared.storage - val messageId = message.id ?: return // no need to handle if message is marked as deleted - if(MessagingModuleConfiguration.shared.messageDataProvider.isDeletedMessage(messageId)){ + if (messageDataProvider.isDeletedMessage(messageId)){ return } @@ -497,9 +446,7 @@ object MessageSender { } // Convenience - @JvmStatic fun send(message: VisibleMessage, address: Address, quote: SignalQuote?, linkPreview: SignalLinkPreview?) { - val messageDataProvider = MessagingModuleConfiguration.shared.messageDataProvider val messageId = message.id if (messageId?.mms == true) { message.attachmentIDs.addAll(messageDataProvider.getAttachmentIDsFor(messageId.id)) @@ -517,24 +464,23 @@ object MessageSender { send(message, address) } - @JvmStatic @JvmOverloads fun send(message: Message, address: Address, statusCallback: SendChannel>? = null) { - val threadID = MessagingModuleConfiguration.shared.storage.getThreadId(address) + val threadID = storage.getThreadId(address) message.applyExpiryMode(address) message.threadID = threadID val destination = Destination.from(address) - val job = MessagingModuleConfiguration.shared.messageSendJobFactory.create(message, destination, statusCallback) + val job = messageSendJobFactory.create(message, destination, statusCallback) JobQueue.shared.add(job) // if we are sending a 'Note to Self' make sure it is not hidden if( message is VisibleMessage && - address.toString() == MessagingModuleConfiguration.shared.storage.getUserPublicKey() && + address.toString() == storage.getUserPublicKey() && // only show the NTS if it is currently marked as hidden - MessagingModuleConfiguration.shared.configFactory.withUserConfigs { it.userProfile.getNtsPriority() == PRIORITY_HIDDEN } + configFactory.withUserConfigs { it.userProfile.getNtsPriority() == PRIORITY_HIDDEN } ){ // update config in case it was marked as hidden there - MessagingModuleConfiguration.shared.configFactory.withMutableUserConfigs { + configFactory.withMutableUserConfigs { it.userProfile.setNtsPriority(PRIORITY_VISIBLE) } } @@ -547,7 +493,7 @@ object MessageSender { } suspend fun sendNonDurably(message: Message, address: Address, isSyncMessage: Boolean) { - val threadID = MessagingModuleConfiguration.shared.storage.getThreadId(address) + val threadID = storage.getThreadId(address) message.threadID = threadID val destination = Destination.from(address) sendNonDurably(message, destination, isSyncMessage) diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt index 46ab9d7a48..bf891abf98 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt @@ -84,6 +84,7 @@ internal fun MessageReceiver.isBlocked(publicKey: String): Boolean { return recipient?.blocked == true } +@Deprecated(replaceWith = ReplaceWith("ReceivedMessageProcessor"), message = "Use ReceivedMessageProcessor instead") @Singleton class ReceivedMessageHandler @Inject constructor( @param:ApplicationContext private val context: Context, @@ -131,7 +132,7 @@ class ReceivedMessageHandler @Inject constructor( } is DataExtractionNotification -> handleDataExtractionNotification(message) is UnsendRequest -> handleUnsendRequest(message) - is MessageRequestResponse -> messageRequestResponseHandler.get().handleExplicitRequestResponseMessage(message) + is MessageRequestResponse -> messageRequestResponseHandler.get().handleExplicitRequestResponseMessage(null, message) is VisibleMessage -> handleVisibleMessage( message = message, proto = proto, @@ -300,7 +301,7 @@ class ReceivedMessageHandler @Inject constructor( // Do nothing if the message was outdated if (messageIsOutdated(message, context.threadId)) { return null } - messageRequestResponseHandler.get().handleVisibleMessage(message) + messageRequestResponseHandler.get().handleVisibleMessage(null, message) // Handle group invite response if new closed group val threadRecipientAddress = context.threadAddress diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageProcessor.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageProcessor.kt new file mode 100644 index 0000000000..bbb3d408ea --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageProcessor.kt @@ -0,0 +1,563 @@ +package org.session.libsession.messaging.sending_receiving + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import network.loki.messenger.R +import network.loki.messenger.libsession_util.ConfigBase +import network.loki.messenger.libsession_util.util.BaseCommunityInfo +import network.loki.messenger.libsession_util.util.BlindKeyAPI +import network.loki.messenger.libsession_util.util.KeyPair +import okio.withLock +import org.session.libsession.database.MessageDataProvider +import org.session.libsession.database.userAuth +import org.session.libsession.messaging.messages.Message +import org.session.libsession.messaging.messages.control.CallMessage +import org.session.libsession.messaging.messages.control.DataExtractionNotification +import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate +import org.session.libsession.messaging.messages.control.GroupUpdated +import org.session.libsession.messaging.messages.control.MessageRequestResponse +import org.session.libsession.messaging.messages.control.ReadReceipt +import org.session.libsession.messaging.messages.control.TypingIndicator +import org.session.libsession.messaging.messages.control.UnsendRequest +import org.session.libsession.messaging.messages.visible.VisibleMessage +import org.session.libsession.messaging.open_groups.OpenGroupApi +import org.session.libsession.messaging.sending_receiving.data_extraction.DataExtractionNotificationInfoMessage +import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier +import org.session.libsession.messaging.utilities.WebRtcUtils +import org.session.libsession.snode.SnodeAPI +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.ConfigFactoryProtocol +import org.session.libsession.utilities.GroupUtil.doubleEncodeGroupID +import org.session.libsession.utilities.SSKEnvironment +import org.session.libsession.utilities.TextSecurePreferences +import org.session.libsession.utilities.UserConfigType +import org.session.libsession.utilities.recipients.MessageType +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsession.utilities.recipients.getType +import org.session.libsignal.protos.SignalServiceProtos +import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.database.BlindMappingRepository +import org.thoughtcrime.securesms.database.RecipientRepository +import org.thoughtcrime.securesms.database.Storage +import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.database.model.MessageId +import org.thoughtcrime.securesms.database.model.ReactionRecord +import org.thoughtcrime.securesms.dependencies.ManagerScope +import org.thoughtcrime.securesms.sskenvironment.ReadReceiptManager +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.locks.ReentrantLock +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton + +@Singleton +class ReceivedMessageProcessor @Inject constructor( + @param:ApplicationContext private val context: Context, + private val recipientRepository: RecipientRepository, + private val storage: Storage, + private val configFactory: ConfigFactoryProtocol, + private val threadDatabase: ThreadDatabase, + private val readReceiptManager: Provider, + private val typingIndicators: Provider, + private val prefs: TextSecurePreferences, + private val groupMessageHandler: Provider, + private val messageExpirationManager: Provider, + private val messageDataProvider: MessageDataProvider, + @param:ManagerScope private val scope: CoroutineScope, + private val notificationManager: MessageNotifier, + private val messageRequestResponseHandler: Provider, + private val visibleMessageHandler: Provider, + private val blindMappingRepository: BlindMappingRepository, + private val messageParser: MessageParser, +) { + private val threadMutexes = ConcurrentHashMap() + + + /** + * Start a message processing session, ensuring that thread updates and notifications are handled + * once the whole processing is complete. + * + * Note: the context passed to the block is not thread-safe, so it should not be shared between threads. + */ + fun startProcessing(debugName: String, block: (MessageProcessingContext) -> T): T { + val context = MessageProcessingContext() + val start = System.currentTimeMillis() + try { + return block(context) + } finally { + for (threadId in context.threadIDs.values) { + if (context.maxOutgoingMessageTimestamp > 0L && + context.maxOutgoingMessageTimestamp > storage.getLastSeen(threadId) + ) { + storage.markConversationAsRead( + threadId, + context.maxOutgoingMessageTimestamp, + force = true + ) + } + + storage.updateThread(threadId, true) + notificationManager.updateNotification(this.context, threadId) + } + + // Handle pending community reactions + context.pendingCommunityReactions?.let { reactions -> + storage.addReactions(reactions, replaceAll = true, notifyUnread = false) + reactions.clear() + } + + Log.d(TAG, "Processed messages for $debugName in ${System.currentTimeMillis() - start}ms") + } + } + + fun processSwarmMessage( + context: MessageProcessingContext, + threadAddress: Address.Conversable, + message: Message, + proto: SignalServiceProtos.Content, + ) = threadMutexes.getOrPut(threadAddress) { ReentrantLock() }.withLock { + // The logic to check if the message should be discarded due to being from a hidden contact. + if (threadAddress is Address.Standard && + message.sentTimestamp != null && + shouldDiscardForHiddenContact( + ctx = context, + messageTimestamp = message.sentTimestamp!!, + threadAddress = threadAddress + ) + ) { + log { "Dropping message from hidden contact ${threadAddress.debugString}" } + return@withLock + } + + // Get or create thread ID, if we aren't allowed to create it, and it doesn't exist, drop the message + val threadId = context.threadIDs[threadAddress] ?: if (shouldCreateThread(message)) { + threadDatabase.getOrCreateThreadIdFor(threadAddress) + .also { context.threadIDs[threadAddress] = it } + } else { + threadDatabase.getThreadIdIfExistsFor(threadAddress) + .also { id -> + if (id == -1L) { + log { "Dropping message for non-existing thread ${threadAddress.debugString}" } + return@withLock + } else { + context.threadIDs[threadAddress] = id + } + } + } + + when (message) { + is ReadReceipt -> handleReadReceipt(message) + is TypingIndicator -> handleTypingIndicator(message) + is GroupUpdated -> groupMessageHandler.get().handleGroupUpdated( + message = message, + groupId = (threadAddress as? Address.Group)?.accountId + ) + + is ExpirationTimerUpdate -> { + // For groupsv2, there are dedicated mechanisms for handling expiration timers, and + // we want to avoid the 1-to-1 message format which is unauthenticated in a group settings. + if (threadAddress is Address.Group) { + Log.d("MessageReceiver", "Ignoring expiration timer update for closed group") + } // also ignore it for communities since they do not support disappearing messages + else if (threadAddress is Address.Community) { + Log.d("MessageReceiver", "Ignoring expiration timer update for communities") + } else { + handleExpirationTimerUpdate(message) + } + } + + is DataExtractionNotification -> handleDataExtractionNotification(message) + is UnsendRequest -> handleUnsendRequest(message) + is MessageRequestResponse -> messageRequestResponseHandler.get() + .handleExplicitRequestResponseMessage(context, message) + + is VisibleMessage -> { + if (message.isSenderSelf && + message.sentTimestamp != null && + message.sentTimestamp!! > context.maxOutgoingMessageTimestamp + ) { + context.maxOutgoingMessageTimestamp = message.sentTimestamp!! + } + + visibleMessageHandler.get().handleVisibleMessage( + ctx = context, + message = message, + threadId = threadId, + threadAddress = threadAddress, + proto = proto, + runThreadUpdate = false, + runProfileUpdate = true, + ) + } + + is CallMessage -> handleCallMessage(message) + } + + } + + fun processCommunityInboxMessage( + context: MessageProcessingContext, + message: OpenGroupApi.DirectMessage + ) { + //TODO("Waiting for the implementation from libsession_util") + } + + fun processCommunityOutboxMessage( + context: MessageProcessingContext, + message: OpenGroupApi.DirectMessage + ) { + //TODO("Waiting for the implementation from libsession_util") + } + + fun processCommunityMessage( + context: MessageProcessingContext, + threadAddress: Address.Community, + message: OpenGroupApi.Message, + ) = threadMutexes.getOrPut(threadAddress) { ReentrantLock() }.withLock { + var messageId = messageParser.parseCommunityMessage( + msg = message, + currentUserId = context.currentUserId, + currentUserBlindedIDs = context.getCurrentUserBlindedIDsByThread(threadAddress) + )?.let { (msg, proto) -> + processSwarmMessage( + context = context, + threadAddress = threadAddress, + message = msg, + proto = proto + ) + + msg.id + } + + // For community, we have a different way of handling reaction, this is outside of + // the normal enveloped message (even though enveloped message can also contain reaction, + // it's not used by anyone at the moment). + if (messageId == null) { + Log.d(TAG, "Handling reactions only message for community ${threadAddress.debugString}") + messageId = requireNotNull( + messageDataProvider.getMessageID( + serverId = message.id, + threadId = requireNotNull(storage.getThreadId(threadAddress)) { + "No thread ID for community ${threadAddress.debugString}" + } + )) { + "No message persisted for community message ${message.id}" + } + } + + val messageServerId = message.id.toString() + + for ((emoji, reaction) in message.reactions.orEmpty()) { + // We only really want up to 5 reactors per reaction to avoid excessive database load + // Among the 5 reactors, we must include ourselves if we reacted to this message + val otherReactorsToAdd = if (reaction.you) { + context.addPendingCommunityReaction( + messageId, + ReactionRecord( + messageId = messageId, + author = context.currentUserPublicKey, + emoji = emoji, + serverId = messageServerId, + count = reaction.count, + sortId = 0, + ) + ) + + val myBlindedIDs = context.getCurrentUserBlindedIDsByThread(threadAddress) + + reaction.reactors + .asSequence() + .filterNot { reactor -> reactor == context.currentUserPublicKey || myBlindedIDs.any { it.hexString == reactor } } + .take(4) + } else { + reaction.reactors + .asSequence() + .take(5) + } + + + for (reactor in otherReactorsToAdd) { + context.addPendingCommunityReaction( + messageId, + ReactionRecord( + messageId = messageId, + author = reactor, + emoji = emoji, + serverId = messageServerId, + count = reaction.count, + sortId = reaction.index, + ) + ) + } + } + } + + private fun handleReadReceipt(message: ReadReceipt) { + readReceiptManager.get().processReadReceipts( + message.sender!!, + message.timestamps!!, + message.receivedTimestamp!! + ) + } + + private fun handleTypingIndicator(message: TypingIndicator) { + when (message.kind!!) { + TypingIndicator.Kind.STARTED -> showTypingIndicatorIfNeeded(message.sender!!) + TypingIndicator.Kind.STOPPED -> hideTypingIndicatorIfNeeded(message.sender!!) + } + } + + private fun showTypingIndicatorIfNeeded(senderPublicKey: String) { + // We don't want to show other people's indicators if the toggle is off + if (!prefs.isTypingIndicatorsEnabled()) return + + val address = Address.fromSerialized(senderPublicKey) + val threadID = storage.getThreadId(address) ?: return + typingIndicators.get().didReceiveTypingStartedMessage(threadID, address, 1) + } + + private fun hideTypingIndicatorIfNeeded(senderPublicKey: String) { + val address = Address.fromSerialized(senderPublicKey) + val threadID = storage.getThreadId(address) ?: return + typingIndicators.get().didReceiveTypingStoppedMessage(threadID, address, 1, false) + } + + + /** + * Return true if this message should result in the creation of a thread. + */ + private fun shouldCreateThread(message: Message): Boolean { + return message is VisibleMessage + } + + private fun handleExpirationTimerUpdate(message: ExpirationTimerUpdate) { + messageExpirationManager.get().run { + insertExpirationTimerMessage(message) + onMessageReceived(message) + } + } + + private fun handleDataExtractionNotification(message: DataExtractionNotification) { + // We don't handle data extraction messages for groups (they shouldn't be sent, but just in case we filter them here too) + if (message.groupPublicKey != null) return + val senderPublicKey = message.sender!! + + val notification: DataExtractionNotificationInfoMessage = when (message.kind) { + is DataExtractionNotification.Kind.MediaSaved -> DataExtractionNotificationInfoMessage( + DataExtractionNotificationInfoMessage.Kind.MEDIA_SAVED + ) + + else -> return + } + storage.insertDataExtractionNotificationMessage( + senderPublicKey, + notification, + message.sentTimestamp!! + ) + } + + fun handleUnsendRequest(message: UnsendRequest): MessageId? { + val userPublicKey = storage.getUserPublicKey() + val userAuth = storage.userAuth ?: return null + val isLegacyGroupAdmin: Boolean = message.groupPublicKey?.let { key -> + var admin = false + val groupID = doubleEncodeGroupID(key) + val group = storage.getGroup(groupID) + if (group != null) { + admin = group.admins.map { it.toString() }.contains(message.sender) + } + admin + } ?: false + + // First we need to determine the validity of the UnsendRequest + // It is valid if: + val requestIsValid = + message.sender == message.author || // the sender is the author of the message + message.author == userPublicKey || // the sender is the current user + isLegacyGroupAdmin // sender is an admin of legacy group + + if (!requestIsValid) { + return null + } + + val timestamp = message.timestamp ?: return null + val author = message.author ?: return null + val messageToDelete = storage.getMessageByTimestamp(timestamp, author, false) ?: return null + val messageIdToDelete = messageToDelete.messageId + val messageType = messageToDelete.individualRecipient?.getType() + + // send a /delete rquest for 1on1 messages + if (messageType == MessageType.ONE_ON_ONE) { + messageDataProvider.getServerHashForMessage(messageIdToDelete)?.let { serverHash -> + scope.launch(Dispatchers.IO) { // using scope as we are slowly migrating to coroutines but we can't migrate everything at once + try { + SnodeAPI.deleteMessage(author, userAuth, listOf(serverHash)) + } catch (e: Exception) { + Log.e("Loki", "Failed to delete message", e) + } + } + } + } + + // the message is marked as deleted locally + // except for 'note to self' where the message is completely deleted + if (messageType == MessageType.NOTE_TO_SELF) { + messageDataProvider.deleteMessage(messageIdToDelete) + } else { + messageDataProvider.markMessageAsDeleted( + messageIdToDelete, + displayedMessage = context.getString(R.string.deleteMessageDeletedGlobally) + ) + } + + // delete reactions + storage.deleteReactions(messageToDelete.messageId) + + // update notification + if (!messageToDelete.isOutgoing) { + notificationManager.updateNotification(context) + } + + return messageIdToDelete + } + + private fun handleCallMessage(message: CallMessage) { + // TODO: refactor this out to persistence, just to help debug the flow and send/receive in synchronous testing + WebRtcUtils.SIGNAL_QUEUE.trySend(message) + } + + + /** + * Return true if the contact is marked as hidden for given message timestamp. + */ + private fun shouldDiscardForHiddenContact( + ctx: MessageProcessingContext, + messageTimestamp: Long, + threadAddress: Address.Standard + ): Boolean { + val hidden = configFactory.withUserConfigs { configs -> + configs.contacts.get(threadAddress.address)?.priority == ConfigBase.PRIORITY_HIDDEN + } + + return hidden && + // the message's sentTimestamp is earlier than the sentTimestamp of the last config + messageTimestamp < ctx.contactConfigTimestamp + } + + /** + * A context object for processing received messages. This object is mostly used to store + * expensive data that are only valid for the duration of a processing session. + * + * It also tracks some deferred updates that should be applied once processing is complete, + * such as thread updates, reactions, and notifications. + */ + inner class MessageProcessingContext { + private var recipients: HashMap? = null + val threadIDs: HashMap = hashMapOf() + private var currentUserBlindedKeysByCommunityServer: HashMap>? = null + val currentUserId: AccountId = AccountId(requireNotNull(storage.getUserPublicKey()) { + "No current user available" + }) + + var maxOutgoingMessageTimestamp: Long = 0L + + val currentUserEd25519KeyPair: KeyPair by lazy(LazyThreadSafetyMode.NONE) { + requireNotNull(storage.getUserED25519KeyPair()) { + "No current user ED25519 key pair available" + } + } + + val currentUserPublicKey: String get() = currentUserId.hexString + + + val contactConfigTimestamp: Long by lazy(LazyThreadSafetyMode.NONE) { + configFactory.getConfigTimestamp(UserConfigType.CONTACTS, currentUserPublicKey) + } + + private var blindIDMappingCache: HashMap>>? = + null + + + var pendingCommunityReactions: HashMap>? = null + private set + + + fun getBlindIDMapping(address: Address.Standard): List> { + val cache = blindIDMappingCache + ?: hashMapOf>>().also { + blindIDMappingCache = it + } + + return cache.getOrPut(address) { + blindMappingRepository.calculateReverseMappings(address) + } + } + + + fun getThreadRecipient(threadAddress: Address.Conversable): Recipient { + val cache = recipients ?: hashMapOf().also { + recipients = it + } + + return cache.getOrPut(threadAddress) { + recipientRepository.getRecipientSync(threadAddress) + } + } + + fun getCurrentUserBlindedIDsByServer(serverUrl: String): List { + val serverPubKey = requireNotNull(storage.getOpenGroupPublicKey(serverUrl)) { + "No open group public key found" + } + + val cache = + currentUserBlindedKeysByCommunityServer ?: hashMapOf>().also { + currentUserBlindedKeysByCommunityServer = it + } + + return cache.getOrPut(serverUrl) { + BlindKeyAPI.blind15Ids( + sessionId = currentUserPublicKey, + serverPubKey = serverPubKey + ).map(::AccountId) + AccountId( + BlindKeyAPI.blind25Id( + sessionId = currentUserPublicKey, + serverPubKey = serverPubKey + ) + ) + } + } + + + fun getCurrentUserBlindedIDsByThread(address: Address.Conversable): List { + if (address !is Address.Community) return emptyList() + return getCurrentUserBlindedIDsByServer(address.serverUrl) + } + + fun addPendingCommunityReaction(messageId: MessageId, reaction: ReactionRecord) { + val reactionsMap = pendingCommunityReactions + ?: hashMapOf>().also { + pendingCommunityReactions = it + } + + reactionsMap.getOrPut(messageId) { + mutableListOf() + }.add(reaction) + } + } + + companion object { + private const val TAG = "ReceivedMessageProcessor" + + private const val DEBUG_MESSAGE_PROCESSING = true + + private inline fun log(message: () -> String) { + if (DEBUG_MESSAGE_PROCESSING) { + Log.d(TAG, message()) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/VisibleMessageHandler.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/VisibleMessageHandler.kt new file mode 100644 index 0000000000..06f9213bf0 --- /dev/null +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/VisibleMessageHandler.kt @@ -0,0 +1,258 @@ +package org.session.libsession.messaging.sending_receiving + +import android.text.TextUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_HIDDEN +import network.loki.messenger.libsession_util.ConfigBase.Companion.PRIORITY_VISIBLE +import network.loki.messenger.libsession_util.util.BaseCommunityInfo +import network.loki.messenger.libsession_util.util.ExpiryMode +import org.session.libsession.database.MessageDataProvider +import org.session.libsession.messaging.groups.GroupManagerV2 +import org.session.libsession.messaging.jobs.AttachmentDownloadJob +import org.session.libsession.messaging.jobs.JobQueue +import org.session.libsession.messaging.messages.ProfileUpdateHandler +import org.session.libsession.messaging.messages.visible.Attachment +import org.session.libsession.messaging.messages.visible.VisibleMessage +import org.session.libsession.messaging.sending_receiving.attachments.PointerAttachment +import org.session.libsession.messaging.sending_receiving.link_preview.LinkPreview +import org.session.libsession.messaging.sending_receiving.quotes.QuoteModel +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.Address.Companion.toAddress +import org.session.libsession.utilities.SSKEnvironment +import org.session.libsession.utilities.isGroupOrCommunity +import org.session.libsession.utilities.recipients.RecipientData +import org.session.libsession.utilities.updateContact +import org.session.libsession.utilities.upsertContact +import org.session.libsignal.protos.SignalServiceProtos +import org.session.libsignal.utilities.Log +import org.session.libsignal.utilities.guava.Optional +import org.thoughtcrime.securesms.database.Storage +import org.thoughtcrime.securesms.database.model.MessageId +import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.dependencies.ManagerScope +import org.thoughtcrime.securesms.pro.ProStatusManager +import javax.inject.Inject +import javax.inject.Provider + +class VisibleMessageHandler @Inject constructor( + private val storage: Storage, + private val messageRequestResponseHandler: MessageRequestResponseHandler, + @param:ManagerScope private val scope: CoroutineScope, + private val groupManagerV2: GroupManagerV2, + private val messageDataProvider: MessageDataProvider, + private val proStatusManager: ProStatusManager, + private val configFactory: ConfigFactory, + private val profileUpdateHandler: Provider, + private val attachmentDownloadJobFactory: AttachmentDownloadJob.Factory, + private val messageExpirationManager: SSKEnvironment.MessageExpirationManagerProtocol, + private val typingIndicators: SSKEnvironment.TypingIndicatorsProtocol, +){ + fun handleVisibleMessage( + ctx: ReceivedMessageProcessor.MessageProcessingContext, + message: VisibleMessage, + threadId: Long, + threadAddress: Address.Conversable, + proto: SignalServiceProtos.Content, + runThreadUpdate: Boolean, + runProfileUpdate: Boolean, + ): MessageId? { + val senderAddress = message.sender!!.toAddress() + + messageRequestResponseHandler.handleVisibleMessage(ctx, message) + + // Handle group invite response if new closed group + if (threadAddress is Address.Group && senderAddress is Address.Standard) { + scope.launch { + try { + groupManagerV2 + .handleInviteResponse( + threadAddress.accountId, + senderAddress.accountId, + approved = true + ) + } catch (e: Exception) { + Log.e("Loki", "Failed to handle invite response", e) + } + } + } + // Parse quote if needed + var quoteModel: QuoteModel? = null + var quoteMessageBody: String? = null + if (message.quote != null && proto.dataMessage.hasQuote()) { + val quote = proto.dataMessage.quote + + var author = quote.author.toAddress() + + if (author is Address.WithAccountId && author.accountId in ctx.getCurrentUserBlindedIDsByThread(threadAddress)) { + author = Address.Standard(ctx.currentUserId) + } + + val messageInfo = messageDataProvider.getMessageForQuote(threadId, quote.id, author) + quoteMessageBody = messageInfo?.third + quoteModel = if (messageInfo != null) { + val attachments = if (messageInfo.second) messageDataProvider.getAttachmentsAndLinkPreviewFor(messageInfo.first) else ArrayList() + QuoteModel(quote.id, author,null,false, attachments) + } else { + QuoteModel(quote.id, author,null, true, PointerAttachment.forPointers(proto.dataMessage.quote.attachmentsList)) + } + } + // Parse link preview if needed + val linkPreviews: MutableList = mutableListOf() + if (message.linkPreview != null && proto.dataMessage.previewCount > 0) { + for (preview in proto.dataMessage.previewList) { + val thumbnail = PointerAttachment.forPointer(preview.image) + val url = Optional.fromNullable(preview.url) + val title = Optional.fromNullable(preview.title) + val hasContent = !TextUtils.isEmpty(title.or("")) || thumbnail.isPresent + if (hasContent) { + val linkPreview = LinkPreview(url.get(), title.or(""), thumbnail) + linkPreviews.add(linkPreview) + } else { + Log.w("Loki", "Discarding an invalid link preview. hasContent: $hasContent") + } + } + } + // Parse attachments if needed + val attachments = proto.dataMessage.attachmentsList.map(Attachment::fromProto).filter { it.isValid() } + + // Cancel any typing indicators if needed + cancelTypingIndicatorsIfNeeded(message.sender!!) + + // Parse reaction if needed + val threadIsGroup = threadAddress.isGroupOrCommunity + message.reaction?.let { reaction -> + if (reaction.react == true) { + reaction.serverId = message.openGroupServerMessageID?.toString() ?: message.serverHash.orEmpty() + reaction.dateSent = message.sentTimestamp ?: 0 + reaction.dateReceived = message.receivedTimestamp ?: 0 + storage.addReaction( + threadId = threadId, + reaction = reaction, + messageSender = senderAddress.address, + notifyUnread = !threadIsGroup + ) + } else { + storage.removeReaction( + emoji = reaction.emoji!!, + messageTimestamp = reaction.timestamp!!, + threadId = threadId, + author = senderAddress.address, + notifyUnread = threadIsGroup + ) + } + } ?: run { + // A user is mentioned if their public key is in the body of a message or one of their messages + // was quoted + + // Verify the incoming message length and truncate it if needed, before saving it to the db + val maxChars = proStatusManager.getIncomingMessageMaxLength(message) + val messageText = message.text?.take(maxChars) // truncate to max char limit for this message + message.text = messageText + message.hasMention = (sequenceOf(ctx.currentUserPublicKey) + ctx.getCurrentUserBlindedIDsByThread(threadAddress).asSequence()) + .any { key -> + messageText?.contains("@$key") == true || key == (quoteModel?.author?.toString() ?: "") + } + + // Persist the message + message.threadID = threadId + + // clean up the message - For example we do not want any expiration data on messages for communities + if(message.openGroupServerMessageID != null){ + message.expiryMode = ExpiryMode.NONE + } + + val threadRecipient = ctx.getThreadRecipient(threadAddress) + val messageID = storage.persist( + threadRecipient = threadRecipient, + message = message, + quotes = quoteModel, + linkPreview = linkPreviews, + attachments = attachments, + runThreadUpdate = runThreadUpdate + ) ?: return null + + // If we have previously "hidden" the sender, we should flip the flag back to visible + if (senderAddress is Address.Standard && senderAddress.address != ctx.currentUserPublicKey) { + val existingContact = + configFactory.withUserConfigs { it.contacts.get(senderAddress.accountId.hexString) } + + if (existingContact != null && existingContact.priority == PRIORITY_HIDDEN) { + Log.d(TAG, "Flipping thread for ${senderAddress.debugString} to visible") + configFactory.withMutableUserConfigs { configs -> + configs.contacts.updateContact(senderAddress) { + priority = PRIORITY_VISIBLE + } + } + } else if (existingContact == null || !existingContact.approvedMe) { + // If we don't have the contact, create a new one with approvedMe = true + Log.d(TAG, "Creating new contact for ${senderAddress.debugString} with approvedMe = true") + configFactory.withMutableUserConfigs { configs -> + configs.contacts.upsertContact(senderAddress) { + approvedMe = true + } + } + } + } + + // Update profile if needed: + // - must be done after the message is persisted) + // - must be done after neccessary contact is created + if (runProfileUpdate && senderAddress is Address.WithAccountId) { + val updates = ProfileUpdateHandler.Updates.create( + name = message.profile?.displayName, + picUrl = message.profile?.profilePictureURL, + picKey = message.profile?.profileKey, + blocksCommunityMessageRequests = message.blocksMessageRequests, + proStatus = null, + profileUpdateTime = message.profile?.profileUpdated, + ) + + if (updates != null) { + profileUpdateHandler.get().handleProfileUpdate( + senderId = senderAddress.accountId, + updates = updates, + fromCommunity = (threadRecipient.data as? RecipientData.Community)?.let { data -> + BaseCommunityInfo(baseUrl = data.serverUrl, room = data.room, pubKeyHex = data.serverPubKey) + }, + ) + } + } + + // Parse & persist attachments + // Start attachment downloads if needed + if (messageID.mms && (threadRecipient.autoDownloadAttachments == true || senderAddress.address == ctx.currentUserPublicKey)) { + storage.getAttachmentsForMessage(messageID.id).iterator().forEach { attachment -> + attachment.attachmentId?.let { id -> + JobQueue.shared.add( + attachmentDownloadJobFactory.create( + attachmentID = id.rowId, + mmsMessageId = messageID.id + )) + } + } + } + message.openGroupServerMessageID?.let { + storage.setOpenGroupServerMessageID( + messageID = messageID, + serverID = it, + threadID = threadId + ) + } + message.id = messageID + messageExpirationManager.onMessageReceived(message) + return messageID + } + return null + } + + private fun cancelTypingIndicatorsIfNeeded(senderPublicKey: String) { + val address = Address.fromSerialized(senderPublicKey) + val threadID = storage.getThreadId(address) ?: return + typingIndicators.didReceiveIncomingMessage(threadID, address, 1) + } + + companion object { + private const val TAG = "VisibleMessageHandler" + } +} \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt index a6c56bb47c..9ad9213197 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt @@ -1,7 +1,6 @@ package org.session.libsession.messaging.sending_receiving.pollers import com.fasterxml.jackson.core.type.TypeReference -import com.google.protobuf.ByteString import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -18,14 +17,10 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import org.session.libsession.database.StorageProtocol -import org.session.libsession.messaging.jobs.BatchMessageReceiveJob import org.session.libsession.messaging.jobs.JobQueue -import org.session.libsession.messaging.jobs.MessageReceiveParameters import org.session.libsession.messaging.jobs.OpenGroupDeleteJob import org.session.libsession.messaging.jobs.TrimThreadJob import org.session.libsession.messaging.messages.Message.Companion.senderOrSync -import org.session.libsession.messaging.messages.control.ExpirationTimerUpdate -import org.session.libsession.messaging.messages.visible.VisibleMessage import org.session.libsession.messaging.open_groups.Endpoint import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.open_groups.OpenGroupApi.BatchRequest @@ -33,26 +28,19 @@ import org.session.libsession.messaging.open_groups.OpenGroupApi.BatchRequestInf import org.session.libsession.messaging.open_groups.OpenGroupApi.BatchResponse import org.session.libsession.messaging.open_groups.OpenGroupApi.Capability import org.session.libsession.messaging.open_groups.OpenGroupApi.DirectMessage -import org.session.libsession.messaging.open_groups.OpenGroupApi.Message import org.session.libsession.messaging.open_groups.OpenGroupApi.getOrFetchServerCapabilities import org.session.libsession.messaging.open_groups.OpenGroupApi.parallelBatch -import org.session.libsession.messaging.open_groups.OpenGroupMessage -import org.session.libsession.messaging.sending_receiving.MessageReceiver -import org.session.libsession.messaging.sending_receiving.ReceivedMessageHandler +import org.session.libsession.messaging.sending_receiving.MessageParser +import org.session.libsession.messaging.sending_receiving.ReceivedMessageProcessor import org.session.libsession.utilities.Address import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.ConfigFactoryProtocol -import org.session.libsignal.protos.SignalServiceProtos -import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.HTTP.Verb.GET import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.database.BlindMappingRepository import org.thoughtcrime.securesms.database.CommunityDatabase -import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.util.AppVisibilityManager -import java.util.concurrent.TimeUnit private typealias PollRequestToken = Channel>> @@ -67,14 +55,12 @@ private typealias PollRequestToken = Channel>> class OpenGroupPoller @AssistedInject constructor( private val storage: StorageProtocol, private val appVisibilityManager: AppVisibilityManager, - private val blindMappingRepository: BlindMappingRepository, - private val receivedMessageHandler: ReceivedMessageHandler, - private val batchMessageJobFactory: BatchMessageReceiveJob.Factory, private val configFactory: ConfigFactoryProtocol, - private val threadDatabase: ThreadDatabase, private val trimThreadJobFactory: TrimThreadJob.Factory, private val openGroupDeleteJobFactory: OpenGroupDeleteJob.Factory, private val communityDatabase: CommunityDatabase, + private val receivedMessageProcessor: ReceivedMessageProcessor, + private val messageParser: MessageParser, @Assisted private val server: String, @Assisted private val scope: CoroutineScope, @Assisted private val pollerSemaphore: Semaphore, @@ -168,8 +154,6 @@ class OpenGroupPoller @AssistedInject constructor( return emptyList() } - val publicKey = allCommunities.first { it.community.baseUrl == server }.community.pubKeyHex - poll(rooms) .asSequence() .filterNot { it.body == null } @@ -179,16 +163,16 @@ class OpenGroupPoller @AssistedInject constructor( handleRoomPollInfo(Address.Community(server, response.endpoint.roomToken), response.body as Map<*, *>) } is Endpoint.RoomMessagesRecent -> { - handleMessages(server, response.endpoint.roomToken, response.body as List) + handleMessages(response.endpoint.roomToken, response.body as List) } is Endpoint.RoomMessagesSince -> { - handleMessages(server, response.endpoint.roomToken, response.body as List) + handleMessages(response.endpoint.roomToken, response.body as List) } is Endpoint.Inbox, is Endpoint.InboxSince -> { - handleDirectMessages(server, false, response.body as List) + handleInboxMessages( response.body as List) } is Endpoint.Outbox, is Endpoint.OutboxSince -> { - handleDirectMessages(server, true, response.body as List) + handleOutboxMessages( response.body as List) } else -> { /* We don't care about the result of any other calls (won't be polled for) */} } @@ -228,7 +212,7 @@ class OpenGroupPoller @AssistedInject constructor( path = "/room/$room/messages/recent?t=r&reactors=5" ), endpoint = Endpoint.RoomMessagesRecent(room), - responseType = object : TypeReference>(){} + responseType = object : TypeReference>(){} ) } else { BatchRequestInfo( @@ -237,7 +221,7 @@ class OpenGroupPoller @AssistedInject constructor( path = "/room/$room/messages/since/$lastMessageServerId?t=r&reactors=5" ), endpoint = Endpoint.RoomMessagesSince(room, lastMessageServerId), - responseType = object : TypeReference>(){} + responseType = object : TypeReference>(){} ) } ) @@ -294,136 +278,104 @@ class OpenGroupPoller @AssistedInject constructor( private fun handleMessages( - server: String, roomToken: String, messages: List ) { - val sortedMessages = messages.sortedBy { it.seqno } - sortedMessages.maxOfOrNull { it.seqno }?.let { seqNo -> - storage.setLastMessageServerID(roomToken, server, seqNo) - } - val (deletions, additions) = sortedMessages.partition { it.deleted } - handleNewMessages(server, roomToken, additions.map { - OpenGroupMessage( - serverID = it.id, - sender = it.sessionId, - sentTimestamp = (it.posted * 1000).toLong(), - base64EncodedData = it.data, - base64EncodedSignature = it.signature, - reactions = it.reactions - ) - }) - handleDeletedMessages(server, roomToken, deletions.map { it.id }) - } - - private suspend fun handleDirectMessages( - server: String, - fromOutbox: Boolean, - messages: List - ) { - if (messages.isEmpty()) return - val serverPublicKey = storage.getOpenGroupPublicKey(server)!! - val sortedMessages = messages.sortedBy { it.id } - val lastMessageId = sortedMessages.last().id - if (fromOutbox) { - storage.setLastOutboxMessageId(server, lastMessageId) - } else { - storage.setLastInboxMessageId(server, lastMessageId) - } - sortedMessages.forEach { - val encodedMessage = Base64.decode(it.message) - val envelope = SignalServiceProtos.Envelope.newBuilder() - .setTimestampMs(TimeUnit.SECONDS.toMillis(it.postedAt)) - .setType(SignalServiceProtos.Envelope.Type.SESSION_MESSAGE) - .setContent(ByteString.copyFrom(encodedMessage)) - .setSource(it.sender) - .build() - try { - val (message, proto) = MessageReceiver.parse( - envelope.toByteArray(), - null, - fromOutbox, - if (fromOutbox) it.recipient else it.sender, - serverPublicKey, - emptySet() // this shouldn't be necessary as we are polling open groups here - ) - if (fromOutbox) { - val syncTarget = blindMappingRepository.getMapping( - serverUrl = server, - blindedAddress = Address.Blinded(AccountId(it.recipient)) - )?.accountId?.hexString ?: it.recipient - - if (message is VisibleMessage) { - message.syncTarget = syncTarget - } else if (message is ExpirationTimerUpdate) { - message.syncTarget = syncTarget - } - } - val threadAddress = when (val addr = message.senderOrSync.toAddress()) { - is Address.Blinded -> Address.CommunityBlindedId(serverUrl = server, blindedId = addr) - is Address.Conversable -> addr - else -> throw IllegalArgumentException("Unsupported address type: ${addr.debugString}") - } + val (deletions, additions) = messages.partition { it.deleted } - val threadId = threadDatabase.getThreadIdIfExistsFor(threadAddress) - receivedMessageHandler.handle( - message = message, - proto = proto, - threadId = threadId, - threadAddress = threadAddress, - ) - } catch (e: Exception) { - Log.e(TAG, "Couldn't handle direct message", e) - } - } - } - - private fun handleNewMessages(server: String, roomToken: String, messages: List) { val threadAddress = Address.Community(serverUrl = server, room = roomToken) // check thread still exists val threadId = storage.getThreadId(threadAddress) ?: return - val envelopes = mutableListOf?>>() - messages.sortedBy { it.serverID!! }.forEach { message -> - if (!message.base64EncodedData.isNullOrEmpty()) { - val envelope = SignalServiceProtos.Envelope.newBuilder() - .setType(SignalServiceProtos.Envelope.Type.SESSION_MESSAGE) - .setSource(message.sender!!) - .setSourceDevice(1) - .setContent(message.toProto().toByteString()) - .setTimestampMs(message.sentTimestamp) - .build() - envelopes.add(Triple( message.serverID, envelope, message.reactions)) - } - } - envelopes.chunked(BatchMessageReceiveJob.BATCH_DEFAULT_NUMBER).forEach { list -> - val parameters = list.map { (serverId, message, reactions) -> - MessageReceiveParameters(message.toByteArray(), openGroupMessageServerID = serverId, reactions = reactions) + if (additions.isNotEmpty()) { + receivedMessageProcessor.startProcessing("CommunityPoller(${threadAddress.debugString})") { ctx -> + for (msg in additions.sortedBy { it.seqno }) { + try { + // Set the last message server ID to each message as we process them, so that if processing fails halfway through, + // we don't re-process messages we've already handled. + storage.setLastMessageServerID(roomToken, server, msg.seqno) + + receivedMessageProcessor.processCommunityMessage( + context = ctx, + threadAddress = threadAddress, + message = msg, + ) + } catch (e: Exception) { + Log.e( + TAG, + "Error processing open group message ${msg.id} in ${threadAddress.debugString}", + e + ) + } + } } - JobQueue.shared.add(batchMessageJobFactory.create( - parameters, - fromCommunity = threadAddress - )) - } - if (envelopes.isNotEmpty()) { JobQueue.shared.add(trimThreadJobFactory.create(threadId)) } - } - - private fun handleDeletedMessages(server: String, roomToken: String, serverIds: List) { - val threadID = storage.getThreadId(Address.Community(serverUrl = server, room = roomToken)) ?: return - if (serverIds.isNotEmpty()) { + if (deletions.isNotEmpty()) { JobQueue.shared.add( openGroupDeleteJobFactory.create( - messageServerIds = serverIds.toLongArray(), - threadId = threadID + messageServerIds = LongArray(deletions.size) { i -> deletions[i].id }, + threadId = threadId ) ) } } + /** + * Handle messages that are sent to us directly. + */ + private fun handleInboxMessages( + messages: List + ) { + if (messages.isEmpty()) return + val sorted = messages.sortedBy { it.postedAt } + + receivedMessageProcessor.startProcessing("CommunityInbox") { ctx -> + for (apiMessage in sorted) { + try { + storage.setLastInboxMessageId(server, sorted.last().id) + + receivedMessageProcessor.processCommunityInboxMessage( + context = ctx, + message = apiMessage, + ) + + } catch (e: Exception) { + Log.e(TAG, "Error processing inbox message", e) + } + } + } + } + + /** + * Handle messages that we have sent out to others. + */ + private fun handleOutboxMessages( + messages: List + ) { + if (messages.isEmpty()) return + val sorted = messages.sortedBy { it.postedAt } + + receivedMessageProcessor.startProcessing("CommunityOutbox") { ctx -> + for (apiMessage in sorted) { + try { + storage.setLastOutboxMessageId(server, sorted.last().id) + + receivedMessageProcessor.processCommunityOutboxMessage( + context = ctx, + message = apiMessage, + ) + + } catch (e: Exception) { + Log.e(TAG, "Error processing inbox message", e) + } + } + } + } + + sealed interface PollState { data class Idle(val lastPolled: Result>?) : PollState data object Polling : PollState diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt index 13dfeea206..160137f53a 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/Poller.kt @@ -27,21 +27,23 @@ import kotlinx.coroutines.supervisorScope import network.loki.messenger.libsession_util.Namespace import org.session.libsession.database.StorageProtocol import org.session.libsession.database.userAuth -import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.messaging.jobs.BatchMessageReceiveJob -import org.session.libsession.messaging.jobs.JobQueue -import org.session.libsession.messaging.jobs.MessageReceiveParameters -import org.session.libsession.snode.RawResponse +import org.session.libsession.messaging.messages.Message.Companion.senderOrSync +import org.session.libsession.messaging.sending_receiving.MessageParser +import org.session.libsession.messaging.sending_receiving.ReceivedMessageProcessor import org.session.libsession.snode.SnodeAPI +import org.session.libsession.snode.SnodeClock +import org.session.libsession.snode.model.RetrieveMessageResponse import org.session.libsession.snode.utilities.await +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.ConfigMessage import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.UserConfigType import org.session.libsignal.database.LokiAPIDatabaseProtocol -import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Snode +import org.thoughtcrime.securesms.database.ReceivedMessageHashDatabase import org.thoughtcrime.securesms.util.AppVisibilityManager import org.thoughtcrime.securesms.util.NetworkConnectivity import kotlin.time.Duration.Companion.days @@ -57,7 +59,10 @@ class Poller @AssistedInject constructor( private val preferences: TextSecurePreferences, private val appVisibilityManager: AppVisibilityManager, private val networkConnectivity: NetworkConnectivity, - private val batchMessageReceiveJobFactory: BatchMessageReceiveJob.Factory, + private val snodeClock: SnodeClock, + private val receivedMessageHashDatabase: ReceivedMessageHashDatabase, + private val processor: ReceivedMessageProcessor, + private val messageParser: MessageParser, @Assisted scope: CoroutineScope ) { private val userPublicKey: String @@ -119,7 +124,7 @@ class Poller @AssistedInject constructor( // To migrate to multi part config, we'll need to fetch all the config messages so we // get the chance to process those multipart messages again... lokiApiDatabase.clearLastMessageHashesByNamespaces(*allConfigNamespaces) - lokiApiDatabase.clearReceivedMessageHashValuesByNamespaces(*allConfigNamespaces) + receivedMessageHashDatabase.removeAllByNamespaces(*allConfigNamespaces) preferences.migratedToMultiPartConfig = true } @@ -208,60 +213,91 @@ class Poller @AssistedInject constructor( } } - private fun processPersonalMessages(snode: Snode, rawMessages: RawResponse) { - val messages = SnodeAPI.parseRawMessagesResponse(rawMessages, snode, userPublicKey) - val parameters = messages.map { (envelope, serverHash) -> - MessageReceiveParameters(envelope.toByteArray(), serverHash = serverHash) + private fun processPersonalMessages(messages: List) { + if (messages.isEmpty()) { + Log.d(TAG, "No personal messages to process") + return } - parameters.chunked(BatchMessageReceiveJob.BATCH_DEFAULT_NUMBER).forEach { chunk -> - JobQueue.shared.add(batchMessageReceiveJobFactory.create( - messages = chunk, - fromCommunity = null - )) - } - } - private fun processConfig(snode: Snode, rawMessages: RawResponse, forConfig: UserConfigType) { - Log.d(TAG, "Received ${rawMessages.size} messages for $forConfig") - val messages = rawMessages["messages"] as? List<*> - val namespace = forConfig.namespace - val processed = if (!messages.isNullOrEmpty()) { - SnodeAPI.updateLastMessageHashValueIfPossible(snode, userPublicKey, messages, namespace) - SnodeAPI.removeDuplicates( - publicKey = userPublicKey, - messages = messages, - messageHashGetter = { (it as? Map<*, *>)?.get("hash") as? String }, - namespace = namespace, - updateStoredHashes = true - ).mapNotNull { rawMessageAsJSON -> - rawMessageAsJSON as Map<*, *> // removeDuplicates should have ensured this is always a map - val hashValue = rawMessageAsJSON["hash"] as? String ?: return@mapNotNull null - val b64EncodedBody = rawMessageAsJSON["data"] as? String ?: return@mapNotNull null - val timestamp = rawMessageAsJSON["t"] as? Long ?: SnodeAPI.nowWithOffset - val body = Base64.decode(b64EncodedBody) - ConfigMessage(data = body, hash = hashValue, timestamp = timestamp) + Log.d(TAG, "Received ${messages.size} personal messages from snode") + + processor.startProcessing("Poller") { ctx -> + for (message in messages) { + if (receivedMessageHashDatabase.checkOrUpdateDuplicateState( + swarmPublicKey = userPublicKey, + namespace = Namespace.DEFAULT(), + hash = message.hash + )) { + Log.d(TAG, "Skipping duplicated message ${message.hash}") + continue + } + + try { + val (message, proto) = messageParser.parse1o1Message( + data = message.data, + serverHash = message.hash, + currentUserEd25519PrivKey = ctx.currentUserEd25519KeyPair.secretKey.data, + currentUserId = ctx.currentUserId + ) + + processor.processSwarmMessage( + threadAddress = message.senderOrSync.toAddress() as Address.Conversable, + message = message, + proto = proto, + context = ctx, + ) + } catch (ec: Exception) { + Log.e( + TAG, + "Error while processing personal message with hash ${message.hash}", + ec + ) + } } - } else emptyList() + } + } - Log.d(TAG, "About to process ${processed.size} messages for $forConfig") + private fun processConfig(messages: List, forConfig: UserConfigType) { + if (messages.isEmpty()) { + Log.d(TAG, "No messages to process for $forConfig") + return + } - if (processed.isEmpty()) return + val newMessages = messages + .asSequence() + .filterNot { msg -> + receivedMessageHashDatabase.checkOrUpdateDuplicateState( + swarmPublicKey = userPublicKey, + namespace = forConfig.namespace, + hash = msg.hash + ) + } + .map { m-> + ConfigMessage( + data = m.data, + hash = m.hash, + timestamp = m.timestamp.toEpochMilli() + ) + } + .toList() - try { - configFactory.mergeUserConfigs( - userConfigType = forConfig, - messages = processed, - ) - } catch (e: Exception) { - Log.e(TAG, "Error while merging user configs", e) + if (newMessages.isNotEmpty()) { + try { + configFactory.mergeUserConfigs( + userConfigType = forConfig, + messages = newMessages + ) + } catch (e: Exception) { + Log.e(TAG, "Error while merging user configs for $forConfig", e) + } } - Log.d(TAG, "Completed processing messages for $forConfig") + Log.d(TAG, "Processed ${newMessages.size} new messages for config $forConfig") } private suspend fun poll(snode: Snode, pollOnlyUserProfileConfig: Boolean) = supervisorScope { - val userAuth = requireNotNull(MessagingModuleConfiguration.shared.storage.userAuth) + val userAuth = requireNotNull(storage.userAuth) // Get messages call wrapped in an async val fetchMessageTask = if (!pollOnlyUserProfileConfig) { @@ -280,7 +316,7 @@ class Poller @AssistedInject constructor( snode = snode, publicKey = userPublicKey, request = request, - responseType = Map::class.java + responseType = RetrieveMessageResponse.serializer() ) } } @@ -314,7 +350,12 @@ class Poller @AssistedInject constructor( this.async { type to runCatching { - SnodeAPI.sendBatchRequest(snode, userPublicKey, request, Map::class.java) + SnodeAPI.sendBatchRequest( + snode = snode, + publicKey = userPublicKey, + request = request, + responseType = RetrieveMessageResponse.serializer() + ) } } } @@ -329,7 +370,7 @@ class Poller @AssistedInject constructor( SnodeAPI.buildAuthenticatedAlterTtlBatchRequest( messageHashes = hashesToExtend.toList(), auth = userAuth, - newExpiry = SnodeAPI.nowWithOffset + 14.days.inWholeMilliseconds, + newExpiry = snodeClock.currentTimeMills() + 14.days.inWholeMilliseconds, extend = true ) ) @@ -350,7 +391,18 @@ class Poller @AssistedInject constructor( continue } - processConfig(snode, result.getOrThrow(), configType) + val messages = result.getOrThrow().messages + processConfig(messages = messages, forConfig = configType) + + if (messages.isNotEmpty()) { + lokiApiDatabase.setLastMessageHashValue( + snode = snode, + publicKey = userPublicKey, + newValue = messages + .maxBy { it.timestamp }.hash, + namespace = configType.namespace + ) + } } // Process the messages if we requested them @@ -359,7 +411,17 @@ class Poller @AssistedInject constructor( if (result.isFailure) { Log.e(TAG, "Error while fetching messages", result.exceptionOrNull()) } else { - processPersonalMessages(snode, result.getOrThrow()) + val messages = result.getOrThrow().messages + processPersonalMessages(messages) + + messages.maxByOrNull { it.timestamp }?.let { newest -> + lokiApiDatabase.setLastMessageHashValue( + snode = snode, + publicKey = userPublicKey, + newValue = newest.hash, + namespace = Namespace.DEFAULT() + ) + } } } } diff --git a/app/src/main/java/org/session/libsession/messaging/utilities/MessageWrapper.kt b/app/src/main/java/org/session/libsession/messaging/utilities/MessageWrapper.kt index bab6cd0c1a..3fa4a53f15 100644 --- a/app/src/main/java/org/session/libsession/messaging/utilities/MessageWrapper.kt +++ b/app/src/main/java/org/session/libsession/messaging/utilities/MessageWrapper.kt @@ -1,83 +1,14 @@ package org.session.libsession.messaging.utilities -import com.google.protobuf.ByteString import org.session.libsignal.protos.SignalServiceProtos.Envelope import org.session.libsignal.protos.WebSocketProtos.WebSocketMessage -import org.session.libsignal.protos.WebSocketProtos.WebSocketRequestMessage -import org.session.libsignal.utilities.Log -import java.security.SecureRandom object MessageWrapper { - // region Types - sealed class Error(val description: String) : Exception(description) { - object FailedToWrapData : Error("Failed to wrap data.") - object FailedToWrapMessageInEnvelope : Error("Failed to wrap message in envelope.") - object FailedToWrapEnvelopeInWebSocketMessage : Error("Failed to wrap envelope in web socket message.") - object FailedToUnwrapData : Error("Failed to unwrap data.") - } - // endregion - - // region Wrapping - /** - * Wraps `message` in a `SignalServiceProtos.Envelope` and then a `WebSocketProtos.WebSocketMessage` to match the desktop application. - */ - fun wrap(type: Envelope.Type, timestamp: Long, senderPublicKey: String, content: ByteArray): ByteArray { - try { - val envelope = createEnvelope(type, timestamp, senderPublicKey, content) - val webSocketMessage = createWebSocketMessage(envelope) - return webSocketMessage.toByteArray() - } catch (e: Exception) { - throw if (e is Error) e else Error.FailedToWrapData - } - } - - fun createEnvelope(type: Envelope.Type, timestamp: Long, senderPublicKey: String, content: ByteArray): Envelope { - try { - val builder = Envelope.newBuilder() - builder.type = type - builder.timestampMs = timestamp - builder.source = senderPublicKey - builder.sourceDevice = 1 - builder.content = ByteString.copyFrom(content) - return builder.build() - } catch (e: Exception) { - Log.d("Loki", "Failed to wrap message in envelope: ${e.message}.") - throw Error.FailedToWrapMessageInEnvelope - } - } - - private fun createWebSocketMessage(envelope: Envelope): WebSocketMessage { - try { - return WebSocketMessage.newBuilder().apply { - request = WebSocketRequestMessage.newBuilder().apply { - verb = "PUT" - path = "/api/v1/message" - id = SecureRandom().nextLong() - body = envelope.toByteString() - }.build() - type = WebSocketMessage.Type.REQUEST - }.build() - } catch (e: Exception) { - Log.d("MessageWrapper", "Failed to wrap envelope in web socket message: ${e.message}.") - throw Error.FailedToWrapEnvelopeInWebSocketMessage - } - } - // endregion - - // region Unwrapping - /** - * `data` shouldn't be base 64 encoded. - */ fun unwrap(data: ByteArray): Envelope { - try { - val webSocketMessage = WebSocketMessage.parseFrom(data) - val envelopeAsData = webSocketMessage.request.body - return Envelope.parseFrom(envelopeAsData) - } catch (e: Exception) { - Log.d("MessageWrapper", "Failed to unwrap data", e) - throw Error.FailedToUnwrapData - } + val webSocketMessage = WebSocketMessage.parseFrom(data) + val envelopeAsData = webSocketMessage.request.body + return Envelope.parseFrom(envelopeAsData) } // endregion } diff --git a/app/src/main/java/org/session/libsession/snode/SnodeAPI.kt b/app/src/main/java/org/session/libsession/snode/SnodeAPI.kt index 7b471a216c..b227885f7e 100644 --- a/app/src/main/java/org/session/libsession/snode/SnodeAPI.kt +++ b/app/src/main/java/org/session/libsession/snode/SnodeAPI.kt @@ -3,7 +3,6 @@ package org.session.libsession.snode import android.os.SystemClock -import com.fasterxml.jackson.databind.JsonNode import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -12,20 +11,20 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.SendChannel -import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import kotlinx.coroutines.selects.onTimeout import kotlinx.coroutines.selects.select +import kotlinx.serialization.DeserializationStrategy +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.decodeFromStream import network.loki.messenger.libsession_util.ED25519 import network.loki.messenger.libsession_util.Hash import network.loki.messenger.libsession_util.SessionEncrypt import nl.komponents.kovenant.Promise -import nl.komponents.kovenant.all import nl.komponents.kovenant.functional.bind import nl.komponents.kovenant.functional.map -import nl.komponents.kovenant.unwrap import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.messaging.utilities.MessageWrapper import org.session.libsession.snode.model.BatchResponse import org.session.libsession.snode.model.StoreMessageResponse import org.session.libsession.snode.utilities.asyncPromise @@ -37,17 +36,13 @@ import org.session.libsession.utilities.toByteArray import org.session.libsignal.crypto.secureRandom import org.session.libsignal.crypto.shuffledRandom import org.session.libsignal.database.LokiAPIDatabaseProtocol -import org.session.libsignal.protos.SignalServiceProtos -import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Base64 -import org.session.libsignal.utilities.Broadcaster import org.session.libsignal.utilities.HTTP import org.session.libsignal.utilities.Hex import org.session.libsignal.utilities.JsonUtil import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Snode import org.session.libsignal.utilities.prettifiedDescription -import org.session.libsignal.utilities.retryIfNeeded import org.session.libsignal.utilities.retryWithUniformInterval import java.util.Locale import kotlin.collections.component1 @@ -165,13 +160,18 @@ object SnodeAPI { method: Snode.Method, snode: Snode, parameters: Map, - responseClass: Class, + responseDeserializationStrategy: DeserializationStrategy, publicKey: String? = null, version: Version = Version.V3 ): Res = when { useOnionRequests -> { val resp = OnionRequestAPI.sendOnionRequest(method, parameters, snode, version, publicKey).await() - JsonUtil.fromJson(resp.body ?: throw Error.Generic, responseClass) + (resp.body ?: throw Error.Generic).inputStream().use { inputStream -> + MessagingModuleConfiguration.shared.json.decodeFromStream( + deserializer = responseDeserializationStrategy, + stream = inputStream + ) + } } else -> HTTP.execute( @@ -182,7 +182,10 @@ object SnodeAPI { this["params"] = parameters } ).toString().let { - JsonUtil.fromJson(it, responseClass) + MessagingModuleConfiguration.shared.json.decodeFromString( + deserializer = responseDeserializationStrategy, + string = it + ) } } @@ -355,49 +358,6 @@ object SnodeAPI { } } - /** - * Retrieve messages from the swarm. - * - * @param snode The swarm service where you want to retrieve messages from. It can be a swarm for a specific user or a group. Call [getSingleTargetSnode] to get a swarm node. - * @param auth The authentication data required to retrieve messages. This can be a user or group authentication data. - * @param namespace The namespace of the messages you want to retrieve. Default is 0. - */ - fun getRawMessages( - snode: Snode, - auth: SwarmAuth, - namespace: Int = 0 - ): RawResponsePromise { - val parameters = buildAuthenticatedParameters( - namespace = namespace, - auth = auth, - verificationData = { ns, t -> "${Snode.Method.Retrieve.rawValue}$ns$t" } - ) { - put( - "last_hash", - database.getLastMessageHashValue(snode, auth.accountId.hexString, namespace).orEmpty() - ) - } - - // Make the request - return invoke(Snode.Method.Retrieve, snode, parameters, auth.accountId.hexString) - } - - fun getUnauthenticatedRawMessages( - snode: Snode, - publicKey: String, - namespace: Int = 0 - ): RawResponsePromise { - val parameters = buildMap { - put("last_hash", database.getLastMessageHashValue(snode, publicKey, namespace).orEmpty()) - put("pubkey", publicKey) - if (namespace != 0) { - put("namespace", namespace) - } - } - - return invoke(Snode.Method.Retrieve, snode, parameters, publicKey) - } - fun buildAuthenticatedStoreBatchInfo( namespace: Int, message: SnodeMessage, @@ -541,40 +501,11 @@ object SnodeAPI { ) } - @Suppress("UNCHECKED_CAST") - fun getRawBatchResponse( - snode: Snode, - publicKey: String, - requests: List, - sequence: Boolean = false - ): RawResponsePromise { - val parameters = buildMap { this["requests"] = requests } - return invoke( - if (sequence) Snode.Method.Sequence else Snode.Method.Batch, - snode, - parameters, - publicKey - ).success { rawResponses -> - rawResponses[KEY_RESULTS].let { it as List } - .asSequence() - .filter { it[KEY_CODE] as? Int != 200 } - .forEach { response -> - Log.w("Loki", "response code was not 200") - handleSnodeError( - response[KEY_CODE] as? Int ?: 0, - response[KEY_BODY] as? Map<*, *>, - snode, - publicKey - ) - } - } - } - private data class RequestInfo( val snode: Snode, val publicKey: String, val request: SnodeBatchRequestInfo, - val responseType: Class<*>, + val responseType: DeserializationStrategy<*>, val callback: SendChannel>, val requestTime: Long = SystemClock.elapsedRealtime(), ) @@ -641,7 +572,8 @@ object SnodeAPI { throw BatchResponse.Error(resp) } - JsonUtil.fromJson(resp.body, req.responseType) + MessagingModuleConfiguration.shared.json.decodeFromJsonElement( + req.responseType, resp.body)!! } runCatching { @@ -664,7 +596,7 @@ object SnodeAPI { snode: Snode, publicKey: String, request: SnodeBatchRequestInfo, - responseType: Class, + responseType: DeserializationStrategy, ): T { val callback = Channel>(capacity = 1) @Suppress("UNCHECKED_CAST") @@ -689,8 +621,8 @@ object SnodeAPI { snode: Snode, publicKey: String, request: SnodeBatchRequestInfo, - ): JsonNode { - return sendBatchRequest(snode, publicKey, request, JsonNode::class.java) + ): JsonElement { + return sendBatchRequest(snode, publicKey, request, JsonElement.serializer()) } suspend fun getBatchResponse( @@ -703,7 +635,7 @@ object SnodeAPI { method = if (sequence) Snode.Method.Sequence else Snode.Method.Batch, snode = snode, parameters = mapOf("requests" to requests), - responseClass = BatchResponse::class.java, + responseDeserializationStrategy = BatchResponse.serializer(), publicKey = publicKey ).also { resp -> // If there's a unsuccessful response, go through specific logic to handle @@ -712,8 +644,8 @@ object SnodeAPI { if (firstError != null) { handleSnodeError( statusCode = firstError.code, - json = if (firstError.body.isObject) { - JsonUtil.fromJson(firstError.body, Map::class.java) + json = if (firstError.body is JsonObject) { + JsonUtil.fromJson(firstError.body.toString(), Map::class.java) } else { null }, @@ -724,30 +656,6 @@ object SnodeAPI { } } - fun getExpiries( - messageHashes: List, - auth: SwarmAuth, - ): RawResponsePromise { - val hashes = messageHashes.takeIf { it.size != 1 } - ?: (messageHashes + "///////////////////////////////////////////") // TODO remove this when bug is fixed on nodes. - return scope.retrySuspendAsPromise(maxRetryCount) { - val params = buildAuthenticatedParameters( - auth = auth, - namespace = null, - verificationData = { _, t -> buildString { - append(Snode.Method.GetExpiries.rawValue) - append(t) - hashes.forEach(this::append) - } }, - ) { - this["messages"] = hashes - } - - val snode = getSingleTargetSnode(auth.accountId.hexString).await() - invoke(Snode.Method.GetExpiries, snode, params, auth.accountId.hexString).await() - } - } - fun alterTtl( auth: SwarmAuth, messageHashes: List, @@ -790,12 +698,6 @@ object SnodeAPI { } } - fun getMessages(auth: SwarmAuth): MessageListPromise = scope.retrySuspendAsPromise(maxRetryCount) { - val snode = getSingleTargetSnode(auth.accountId.hexString).await() - val resp = getRawMessages(snode, auth).await() - parseRawMessagesResponse(resp, snode, auth.accountId.hexString) - } - fun getNetworkTime(snode: Snode): Promise, Exception> = invoke(Snode.Method.Info, snode, emptyMap()).map { rawResponse -> val timestamp = rawResponse["timestamp"] as? Long ?: -1 @@ -845,7 +747,7 @@ object SnodeAPI { params = params, namespace = namespace ), - responseType = StoreMessageResponse::class.java + responseType = StoreMessageResponse.serializer() ) } } @@ -959,89 +861,6 @@ object SnodeAPI { ) } - fun parseRawMessagesResponse(rawResponse: RawResponse, snode: Snode, publicKey: String, namespace: Int = 0, updateLatestHash: Boolean = true, updateStoredHashes: Boolean = true, decrypt: ((ByteArray) -> Pair?)? = null): List> = - (rawResponse["messages"] as? List<*>)?.let { messages -> - if (updateLatestHash) updateLastMessageHashValueIfPossible(snode, publicKey, messages, namespace) - removeDuplicates( - publicKey = publicKey, - messages = parseEnvelopes(messages, decrypt), - messageHashGetter = { it.second }, - namespace = namespace, - updateStoredHashes = updateStoredHashes - ) - } ?: listOf() - - fun updateLastMessageHashValueIfPossible(snode: Snode, publicKey: String, rawMessages: List<*>, namespace: Int) { - val lastMessageAsJSON = rawMessages.lastOrNull() as? Map<*, *> - val hashValue = lastMessageAsJSON?.get("hash") as? String - when { - hashValue != null -> database.setLastMessageHashValue(snode, publicKey, hashValue, namespace) - rawMessages.isNotEmpty() -> Log.d("Loki", "Failed to update last message hash value from: ${rawMessages.prettifiedDescription()}.") - } - } - - /** - * - * - * TODO Use a db transaction, synchronizing is sufficient for now because - * database#setReceivedMessageHashValues is only called here. - */ - @Synchronized - fun removeDuplicates( - publicKey: String, - messages: List, - messageHashGetter: (M) -> String?, - namespace: Int, - updateStoredHashes: Boolean - ): List { - val hashValues = database.getReceivedMessageHashValues(publicKey, namespace)?.toMutableSet() ?: mutableSetOf() - return messages - .filter { message -> - val hash = messageHashGetter(message) - if (hash == null) { - Log.d("Loki", "Missing hash value for message: ${message?.prettifiedDescription()}.") - return@filter false - } - - val isNew = hashValues.add(hash) - - if (!isNew) { - Log.d("Loki", "Duplicate message hash: $hash.") - } - - isNew - } - .also { - if (updateStoredHashes && it.isNotEmpty()) { - database.setReceivedMessageHashValues(publicKey, hashValues, namespace) - } - } - } - - private fun parseEnvelopes(rawMessages: List<*>, decrypt: ((ByteArray)->Pair?)?): List> { - return rawMessages.mapNotNull { rawMessage -> - val rawMessageAsJSON = rawMessage as? Map<*, *> - val base64EncodedData = rawMessageAsJSON?.get("data") as? String - val data = base64EncodedData?.let { Base64.decode(it) } - if (data != null) { - try { - if (decrypt != null) { - val (decrypted, sender) = decrypt(data)!! - val envelope = SignalServiceProtos.Envelope.parseFrom(decrypted).toBuilder() - envelope.source = sender.hexString - Pair(envelope.build(), rawMessageAsJSON["hash"] as? String) - } - else Pair(MessageWrapper.unwrap(data), rawMessageAsJSON["hash"] as? String) - } catch (e: Exception) { - Log.d("Loki", "Failed to unwrap data for message: ${rawMessage.prettifiedDescription()}.", e) - null - } - } else { - Log.d("Loki", "Failed to decode data for message: ${rawMessage?.prettifiedDescription()}.") - null - } - } - } @Suppress("UNCHECKED_CAST") private fun parseDeletions(userPublicKey: String, timestamp: Long, rawResponse: RawResponse): Map = @@ -1111,5 +930,4 @@ object SnodeAPI { // Type Aliases typealias RawResponse = Map<*, *> -typealias MessageListPromise = Promise>, Exception> typealias RawResponsePromise = Promise diff --git a/app/src/main/java/org/session/libsession/snode/model/BatchResponse.kt b/app/src/main/java/org/session/libsession/snode/model/BatchResponse.kt index 723abfc79f..d3fa2acd19 100644 --- a/app/src/main/java/org/session/libsession/snode/model/BatchResponse.kt +++ b/app/src/main/java/org/session/libsession/snode/model/BatchResponse.kt @@ -1,15 +1,15 @@ package org.session.libsession.snode.model -import com.fasterxml.jackson.annotation.JsonCreator -import com.fasterxml.jackson.annotation.JsonProperty -import com.fasterxml.jackson.databind.JsonNode +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement -data class BatchResponse @JsonCreator constructor( - @param:JsonProperty("results") val results: List, -) { - data class Item @JsonCreator constructor( - @param:JsonProperty("code") val code: Int, - @param:JsonProperty("body") val body: JsonNode, +@Serializable + +data class BatchResponse(val results: List, ) { + @Serializable + data class Item( + val code: Int, + val body: JsonElement, ) { val isSuccessful: Boolean get() = code in 200..299 diff --git a/app/src/main/java/org/session/libsession/snode/model/MessageResponses.kt b/app/src/main/java/org/session/libsession/snode/model/MessageResponses.kt index b9173e1462..2c7b6f6d7e 100644 --- a/app/src/main/java/org/session/libsession/snode/model/MessageResponses.kt +++ b/app/src/main/java/org/session/libsession/snode/model/MessageResponses.kt @@ -1,41 +1,45 @@ package org.session.libsession.snode.model import android.util.Base64 -import com.fasterxml.jackson.annotation.JsonCreator -import com.fasterxml.jackson.annotation.JsonProperty -import com.fasterxml.jackson.databind.JsonNode -import com.fasterxml.jackson.databind.annotation.JsonDeserialize -import com.fasterxml.jackson.databind.util.StdConverter +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.session.libsession.utilities.serializable.InstantAsMillisSerializer +import java.time.Instant -data class StoreMessageResponse @JsonCreator constructor( - @JsonProperty("hash") val hash: String, - @JsonProperty("t") val timestamp: Long, +@Serializable +data class StoreMessageResponse( + val hash: String, + @Serializable(InstantAsMillisSerializer::class) + @SerialName("t") val timestamp: Instant, ) -class RetrieveMessageResponse @JsonCreator constructor( - @JsonProperty("messages") - // Apply converter to the element so that if one of the message fails to deserialize, it will - // be a null value instead of failing the whole list. - @JsonDeserialize(contentConverter = RetrieveMessageConverter::class) - val messages: List, +@Serializable +data class RetrieveMessageResponse( + val messages: List, ) { - class Message( + @Serializable + data class Message( val hash: String, - val timestamp: Long?, - val data: ByteArray, - ) -} -internal class RetrieveMessageConverter : StdConverter() { - override fun convert(value: JsonNode?): RetrieveMessageResponse.Message? { - value ?: return null + // Some messages use "t" as timestamp field + @Serializable(InstantAsMillisSerializer::class) + @SerialName("t") + private val t1: Instant? = null, - val hash = value.get("hash")?.asText()?.takeIf { it.isNotEmpty() } ?: return null - val timestamp = value.get("t")?.asLong()?.takeIf { it > 0 } - val data = runCatching { - Base64.decode(value.get("data")?.asText().orEmpty(), Base64.DEFAULT) - }.getOrNull() ?: return null + // Some messages use "timestamp" as timestamp field + @Serializable(InstantAsMillisSerializer::class) + @SerialName("timestamp") + private val t2: Instant? = null, - return RetrieveMessageResponse.Message(hash, timestamp, data) + @SerialName("data") + val dataB64: String? = null, + ) { + val data: ByteArray by lazy(LazyThreadSafetyMode.NONE) { + Base64.decode(dataB64, Base64.DEFAULT) + } + + val timestamp: Instant get() = requireNotNull(t1 ?: t2) { + "Message timestamp is missing" + } } } \ No newline at end of file diff --git a/app/src/main/java/org/session/libsession/utilities/ConfigFactoryProtocol.kt b/app/src/main/java/org/session/libsession/utilities/ConfigFactoryProtocol.kt index d01e013bd8..37545d464b 100644 --- a/app/src/main/java/org/session/libsession/utilities/ConfigFactoryProtocol.kt +++ b/app/src/main/java/org/session/libsession/utilities/ConfigFactoryProtocol.kt @@ -29,6 +29,7 @@ import network.loki.messenger.libsession_util.util.GroupInfo import network.loki.messenger.libsession_util.util.UserPic import org.session.libsession.snode.SwarmAuth import org.session.libsignal.utilities.AccountId +import java.time.Instant interface ConfigFactoryProtocol { val configUpdateNotifications: Flow @@ -103,7 +104,7 @@ class ConfigMessage( data class ConfigPushResult( val hashes: List, - val timestamp: Long + val timestamp: Instant ) enum class UserConfigType(val namespace: Int) { diff --git a/app/src/main/java/org/session/libsignal/crypto/PushTransportDetails.java b/app/src/main/java/org/session/libsignal/crypto/PushTransportDetails.java index bdd9964d5e..536b1e13a4 100644 --- a/app/src/main/java/org/session/libsignal/crypto/PushTransportDetails.java +++ b/app/src/main/java/org/session/libsignal/crypto/PushTransportDetails.java @@ -8,6 +8,11 @@ import org.session.libsignal.utilities.Log; +/** + * @deprecated The logic here has been moved to SessionProtocol, this class only exists + * so the old persisted message queue can be read. It will be removed in a future release. + */ +@Deprecated(forRemoval = true) public class PushTransportDetails { private static final String TAG = PushTransportDetails.class.getSimpleName(); diff --git a/app/src/main/java/org/session/libsignal/database/LokiAPIDatabaseProtocol.kt b/app/src/main/java/org/session/libsignal/database/LokiAPIDatabaseProtocol.kt index 9d0d4a6241..c855d52a94 100644 --- a/app/src/main/java/org/session/libsignal/database/LokiAPIDatabaseProtocol.kt +++ b/app/src/main/java/org/session/libsignal/database/LokiAPIDatabaseProtocol.kt @@ -20,11 +20,6 @@ interface LokiAPIDatabaseProtocol { fun clearLastMessageHashes(publicKey: String) fun clearLastMessageHashesByNamespaces(vararg namespaces: Int) fun clearAllLastMessageHashes() - fun getReceivedMessageHashValues(publicKey: String, namespace: Int): Set? - fun setReceivedMessageHashValues(publicKey: String, newValue: Set, namespace: Int) - fun clearReceivedMessageHashValues(publicKey: String) - fun clearReceivedMessageHashValues() - fun clearReceivedMessageHashValuesByNamespaces(vararg namespaces: Int) fun getAuthToken(server: String): String? fun setAuthToken(server: String, newValue: String?) fun getLastMessageServerID(room: String, server: String): Long? diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.kt index 93d5841385..a3444934fc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.kt @@ -71,7 +71,7 @@ import network.loki.messenger.databinding.MediaViewPageBinding import org.session.libsession.messaging.groups.LegacyGroupDeprecationManager import org.session.libsession.messaging.messages.control.DataExtractionNotification import org.session.libsession.messaging.messages.control.DataExtractionNotification.Kind.MediaSaved -import org.session.libsession.messaging.sending_receiving.MessageSender.send +import org.session.libsession.messaging.sending_receiving.MessageSender import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.snode.SnodeAPI.nowWithOffset import org.session.libsession.utilities.Address @@ -136,6 +136,9 @@ class MediaPreviewActivity : ScreenLockActionBarActivity(), @Inject lateinit var recipientRepository: RecipientRepository + @Inject + lateinit var messageSender: MessageSender + override val applyDefaultWindowInsets: Boolean get() = false @@ -552,7 +555,7 @@ class MediaPreviewActivity : ScreenLockActionBarActivity(), nowWithOffset ) ) - send(message, conversationAddress!!) + messageSender.send(message, conversationAddress!!) } @SuppressLint("StaticFieldLeak") diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/TypingStatusSender.java b/app/src/main/java/org/thoughtcrime/securesms/components/TypingStatusSender.java index 22d9fe9310..567dc491c6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/TypingStatusSender.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/TypingStatusSender.java @@ -28,12 +28,14 @@ public class TypingStatusSender { private final Map selfTypingTimers; private final ThreadDatabase threadDatabase; private final RecipientRepository recipientRepository; + private final MessageSender messageSender; @Inject - public TypingStatusSender(ThreadDatabase threadDatabase, RecipientRepository recipientRepository) { + public TypingStatusSender(ThreadDatabase threadDatabase, RecipientRepository recipientRepository, MessageSender messageSender) { this.threadDatabase = threadDatabase; this.recipientRepository = recipientRepository; this.selfTypingTimers = new HashMap<>(); + this.messageSender = messageSender; } public synchronized void onTypingStarted(long threadId) { @@ -94,7 +96,7 @@ private void sendTyping(long threadId, boolean typingStarted) { } else { typingIndicator = new TypingIndicator(TypingIndicator.Kind.STOPPED); } - MessageSender.send(typingIndicator, recipient.getAddress()); + messageSender.send(typingIndicator, recipient.getAddress()); } private class StartRunnable implements Runnable { diff --git a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt index 4fae3a59fb..d2eed7ef9f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigToDatabaseSync.kt @@ -47,6 +47,7 @@ import org.thoughtcrime.securesms.database.LokiAPIDatabase import org.thoughtcrime.securesms.database.LokiMessageDatabase import org.thoughtcrime.securesms.database.MmsDatabase import org.thoughtcrime.securesms.database.MmsSmsDatabase +import org.thoughtcrime.securesms.database.ReceivedMessageHashDatabase import org.thoughtcrime.securesms.database.RecipientSettingsDatabase import org.thoughtcrime.securesms.database.SmsDatabase import org.thoughtcrime.securesms.database.ThreadDatabase @@ -81,6 +82,7 @@ class ConfigToDatabaseSync @Inject constructor( private val groupMemberDatabase: GroupMemberDatabase, private val communityDatabase: CommunityDatabase, private val lokiAPIDatabase: LokiAPIDatabase, + private val receivedMessageHashDatabase: ReceivedMessageHashDatabase, private val clock: SnodeClock, private val preferences: TextSecurePreferences, private val conversationRepository: ConversationRepository, @@ -195,7 +197,7 @@ class ConfigToDatabaseSync @Inject constructor( private fun deleteGroupData(address: Address.Group) { lokiAPIDatabase.clearLastMessageHashes(address.accountId.hexString) - lokiAPIDatabase.clearReceivedMessageHashValues(address.accountId.hexString) + receivedMessageHashDatabase.removeAllByPublicKey(address.accountId.hexString) } private fun onLegacyGroupAdded( diff --git a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt index d7300a1e4d..2fa7ba3202 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/configs/ConfigUploader.kt @@ -250,7 +250,7 @@ class ConfigUploader @Inject constructor( ), auth ), - responseType = StoreMessageResponse::class.java + responseType = StoreMessageResponse.serializer() ).let(::listOf).toConfigPushResult() } @@ -284,7 +284,7 @@ class ConfigUploader @Inject constructor( val pendingConfig = configs.groupKeys.pendingConfig() if (pendingConfig != null) { for (hash in hashes) { - configs.groupKeys.loadKey(pendingConfig, hash, timestamp) + configs.groupKeys.loadKey(pendingConfig, hash, timestamp.toEpochMilli()) } } } @@ -329,7 +329,7 @@ class ConfigUploader @Inject constructor( ), auth, ), - responseType = StoreMessageResponse::class.java + responseType = StoreMessageResponse.serializer() ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt index ac4257cd92..fe43d12f71 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/disappearingmessages/DisappearingMessages.kt @@ -29,6 +29,7 @@ class DisappearingMessages @Inject constructor( private val storage: StorageProtocol, private val groupManagerV2: GroupManagerV2, private val clock: SnodeClock, + private val messageSender: MessageSender, ) { fun set(address: Address, mode: ExpiryMode, isGroup: Boolean) { storage.setExpirationConfiguration(address, mode) @@ -45,7 +46,7 @@ class DisappearingMessages @Inject constructor( } messageExpirationManager.insertExpirationTimerMessage(message) - MessageSender.send(message, address) + messageSender.send(message, address) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index ba0903f2b0..9d7059d622 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -264,6 +264,8 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, @Inject lateinit var openGroupManager: OpenGroupManager @Inject lateinit var attachmentDatabase: AttachmentDatabase @Inject lateinit var clock: SnodeClock + @Inject lateinit var messageSender: MessageSender + @Inject lateinit var resendMessageUtilities: ResendMessageUtilities @Inject @ManagerScope lateinit var scope: CoroutineScope @@ -1790,7 +1792,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } } } else { - MessageSender.send(reactionMessage, recipient.address) + messageSender.send(reactionMessage, recipient.address) } LoaderManager.getInstance(this).restartLoader(0, null, this) @@ -1843,7 +1845,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, } } } else { - MessageSender.send(message, recipient.address) + messageSender.send(message, recipient.address) } LoaderManager.getInstance(this).restartLoader(0, null, this) } @@ -2137,7 +2139,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, ), false) waitForApprovalJobToBeSubmitted() - MessageSender.send(message, recipient.address) + messageSender.send(message, recipient.address) } // Send a typing stopped message typingStatusSender.onTypingStopped(viewModel.threadId) @@ -2215,7 +2217,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, waitForApprovalJobToBeSubmitted() - MessageSender.send(message, recipient.address, quote, linkPreview) + messageSender.send(message, recipient.address, quote, linkPreview) }.onFailure { withContext(Dispatchers.Main){ when (it) { @@ -2558,7 +2560,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, scope.launch { messages.iterator().forEach { messageRecord -> runCatching { - ResendMessageUtilities.resend( + resendMessageUtilities.resend( accountId, messageRecord, viewModel.blindedPublicKey, @@ -2576,7 +2578,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, scope.launch { messages.iterator().forEach { messageRecord -> runCatching { - ResendMessageUtilities.resend( + resendMessageUtilities.resend( accountId, messageRecord, viewModel.blindedPublicKey @@ -2731,7 +2733,7 @@ class ConversationActivityV2 : ScreenLockActionBarActivity(), InputBarDelegate, val timestamp = SnodeAPI.nowWithOffset val kind = DataExtractionNotification.Kind.MediaSaved(timestamp) val message = DataExtractionNotification(kind) - MessageSender.send(message, recipient.address) + messageSender.send(message, recipient.address) } private fun endActionMode() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ResendMessageUtilities.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ResendMessageUtilities.kt index 2f47041204..1e3fb60b8c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ResendMessageUtilities.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/utilities/ResendMessageUtilities.kt @@ -1,6 +1,6 @@ package org.thoughtcrime.securesms.conversation.v2.utilities -import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.database.StorageProtocol import org.session.libsession.messaging.messages.Destination import org.session.libsession.messaging.messages.visible.LinkPreview import org.session.libsession.messaging.messages.visible.OpenGroupInvitation @@ -12,8 +12,12 @@ import org.session.libsession.utilities.isGroupOrCommunity import org.session.libsession.utilities.toGroupString import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import javax.inject.Inject -object ResendMessageUtilities { +class ResendMessageUtilities @Inject constructor( + private val messageSender: MessageSender, + private val storage: StorageProtocol, +) { suspend fun resend(accountId: String?, messageRecord: MessageRecord, userBlindedKey: String?, isResync: Boolean = false) { val recipient = messageRecord.recipient.address @@ -51,14 +55,14 @@ object ResendMessageUtilities { message.addSignalAttachments(messageRecord.slideDeck.asAttachments()) } val sentTimestamp = message.sentTimestamp - val sender = MessagingModuleConfiguration.shared.storage.getUserPublicKey() + val sender = storage.getUserPublicKey() if (sentTimestamp != null && sender != null) { if (isResync) { - MessagingModuleConfiguration.shared.storage.markAsResyncing(messageRecord.messageId) - MessageSender.sendNonDurably(message, Destination.from(recipient), isSyncMessage = true) + storage.markAsResyncing(messageRecord.messageId) + messageSender.sendNonDurably(message, Destination.from(recipient), isSyncMessage = true) } else { - MessagingModuleConfiguration.shared.storage.markAsSending(messageRecord.messageId) - MessageSender.send(message, recipient) + storage.markAsSending(messageRecord.messageId) + messageSender.send(message, recipient) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt index 6a16e68a7d..eadffadb3d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/LokiAPIDatabase.kt @@ -49,6 +49,8 @@ class LokiAPIDatabase(context: Context, helper: Provider) : = "CREATE TABLE $legacyLastMessageHashValueTable2 ($snode TEXT, $publicKey TEXT, $lastMessageHashValue TEXT, PRIMARY KEY ($snode, $publicKey));" // Received message hash values private const val legacyReceivedMessageHashValuesTable3 = "received_message_hash_values_table_3" + + @Deprecated("This table is deleted and replaced by ReceivedMessageHashDatabase") private const val receivedMessageHashValuesTable = "session_received_message_hash_values_table" private const val receivedMessageHashValues = "received_message_hash_values" private const val receivedMessageHashNamespace = "received_message_namespace" @@ -128,6 +130,7 @@ class LokiAPIDatabase(context: Context, helper: Provider) : const val INSERT_LAST_HASH_DATA = "INSERT OR IGNORE INTO $lastMessageHashValueTable2($snode, $publicKey, $lastMessageHashValue) SELECT $snode, $publicKey, $lastMessageHashValue FROM $legacyLastMessageHashValueTable2;" const val DROP_LEGACY_LAST_HASH = "DROP TABLE $legacyLastMessageHashValueTable2;" + @Deprecated("This table is deleted and replaced by ReceivedMessageHashDatabase, keeping here just for migration purpose") const val UPDATE_RECEIVED_INCLUDE_NAMESPACE_COMMAND = """ CREATE TABLE IF NOT EXISTS $receivedMessageHashValuesTable( $publicKey STRING, $receivedMessageHashValues TEXT, $receivedMessageHashNamespace INTEGER DEFAULT 0, PRIMARY KEY ($publicKey, $receivedMessageHashNamespace) @@ -311,43 +314,6 @@ class LokiAPIDatabase(context: Context, helper: Provider) : database.delete(lastMessageHashValueTable2, null, null) } - override fun getReceivedMessageHashValues(publicKey: String, namespace: Int): Set? { - val database = readableDatabase - val query = "${Companion.publicKey} = ? AND ${Companion.receivedMessageHashNamespace} = ?" - return database.get(receivedMessageHashValuesTable, query, arrayOf( publicKey, namespace.toString() )) { cursor -> - val receivedMessageHashValuesAsString = cursor.getString(cursor.getColumnIndexOrThrow(Companion.receivedMessageHashValues)) - receivedMessageHashValuesAsString.split("-").toSet() - } - } - - override fun setReceivedMessageHashValues(publicKey: String, newValue: Set, namespace: Int) { - val database = writableDatabase - val receivedMessageHashValuesAsString = newValue.joinToString("-") - val row = wrap(mapOf( - Companion.publicKey to publicKey, - Companion.receivedMessageHashValues to receivedMessageHashValuesAsString, - Companion.receivedMessageHashNamespace to namespace.toString() - )) - val query = "${Companion.publicKey} = ? AND $receivedMessageHashNamespace = ?" - database.insertOrUpdate(receivedMessageHashValuesTable, row, query, arrayOf( publicKey, namespace.toString() )) - } - - override fun clearReceivedMessageHashValues(publicKey: String) { - writableDatabase - .delete(receivedMessageHashValuesTable, "${Companion.publicKey} = ?", arrayOf(publicKey)) - } - - override fun clearReceivedMessageHashValues() { - val database = writableDatabase - database.delete(receivedMessageHashValuesTable, null, null) - } - - override fun clearReceivedMessageHashValuesByNamespaces(vararg namespaces: Int) { - // Note that we don't use SQL parameter as the given namespaces are integer anyway so there's little chance of SQL injection - writableDatabase - .delete(receivedMessageHashValuesTable, "$receivedMessageHashNamespace IN (${namespaces.joinToString(",")})", null) - } - override fun getAuthToken(server: String): String? { val database = readableDatabase return database.get(openGroupAuthTokenTable, "${Companion.server} = ?", wrap(server)) { cursor -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ReceivedMessageHashDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ReceivedMessageHashDatabase.kt new file mode 100644 index 0000000000..9436481548 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ReceivedMessageHashDatabase.kt @@ -0,0 +1,146 @@ +package org.thoughtcrime.securesms.database + +import android.content.Context +import androidx.collection.LruCache +import androidx.sqlite.db.SupportSQLiteDatabase +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.serialization.json.Json +import org.thoughtcrime.securesms.database.helpers.SQLCipherOpenHelper +import javax.inject.Inject +import javax.inject.Provider +import javax.inject.Singleton + +/** + * This database table keeps track of which message hashes we've already received for + * particular senders (swarm public keys) and namespaces. This is used to prevent + * processing the same message multiple times. + * + * To use this class, call [checkOrUpdateDuplicateState] to atomically check if a message hash + * has already been seen, and if not, add it to the database. + */ +@Singleton +class ReceivedMessageHashDatabase @Inject constructor( + @ApplicationContext context: Context, + databaseHelper: Provider, + private val json: Json, +) : Database(context, databaseHelper) { + + private data class CacheKey(val publicKey: String, val namespace: Int, val hash: String) + + private val cache = LruCache(1024) + + fun removeAllByNamespaces(vararg namespaces: Int) { + synchronized(cache) { + cache.evictAll() + } + + //language=roomsql + writableDatabase.rawExecSQL(""" + DELETE FROM received_messages + WHERE namespace IN (SELECT value FROM json_each(?)) + """, json.encodeToString(namespaces)) + } + + fun removeAllByPublicKey(publicKey: String) { + synchronized(cache) { + cache.evictAll() + } + + //language=roomsql + writableDatabase.rawExecSQL(""" + DELETE FROM received_messages + WHERE swarm_pub_key = ? + """, publicKey) + } + + fun removeAll() { + synchronized(cache) { + cache.evictAll() + } + + //language=roomsql + writableDatabase.rawExecSQL("DELETE FROM received_messages WHERE 1") + } + + /** + * Checks if the given [hash] is already present in the database for the given + * [swarmPublicKey] and [namespace]. If not, adds it to the database. + * + * This implementation is atomic. + * + * @return true if the hash was already in the db + */ + fun checkOrUpdateDuplicateState( + swarmPublicKey: String, + namespace: Int, + hash: String + ): Boolean { + val key = CacheKey(swarmPublicKey, namespace, hash) + synchronized(cache) { + if (cache[key] != null) { + return true + } + } + + //language=roomsql + return writableDatabase.compileStatement(""" + INSERT OR IGNORE INTO received_messages (swarm_pub_key, namespace, hash) + VALUES (?, ?, ?) + """).use { stmt -> + stmt.bindString(1, swarmPublicKey) + stmt.bindLong(2, namespace.toLong()) + stmt.bindString(3, hash) + stmt.executeUpdateDelete() == 0 + }.also { + synchronized(cache) { + cache.put(key, Unit) + } + } + } + + companion object { + fun createAndMigrateTable(db: SupportSQLiteDatabase) { + //language=roomsql + db.execSQL(""" + CREATE TABLE IF NOT EXISTS received_messages( + swarm_pub_key TEXT NOT NULL, + namespace INTEGER NOT NULL, + hash TEXT NOT NULL, + PRIMARY KEY (swarm_pub_key, namespace, hash) + ) WITHOUT ROWID; + """) + + //language=roomsql + db.compileStatement(""" + INSERT OR IGNORE INTO received_messages (swarm_pub_key, namespace, hash) + VALUES (?, ?, ?) + """).use { stmt -> + + //language=roomsql + db.query(""" + SELECT public_key, received_message_hash_values, received_message_namespace + FROM session_received_message_hash_values_table + """, arrayOf()).use { cursor -> + while (cursor.moveToNext()) { + val publicKey = cursor.getString(0) + val hashValuesString = cursor.getString(1) + val namespace = cursor.getInt(2) + + val hashValues = hashValuesString.splitToSequence('-') + + for (hash in hashValues) { + stmt.bindString(1, publicKey) + stmt.bindLong(2, namespace.toLong()) + stmt.bindString(3, hash) + stmt.execute() + stmt.clearBindings() + } + } + } + } + + //language=roomsql + db.execSQL("DROP TABLE session_received_message_hash_values_table") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java index ec0cdbf912..8c85d55df5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -56,6 +56,7 @@ import org.thoughtcrime.securesms.dependencies.OnAppStartupComponent; import org.thoughtcrime.securesms.mms.Slide; import org.thoughtcrime.securesms.mms.SlideDeck; +import org.thoughtcrime.securesms.notifications.MarkReadProcessor; import org.thoughtcrime.securesms.notifications.MarkReadReceiver; import org.thoughtcrime.securesms.util.SharedConfigUtilsKt; @@ -239,6 +240,7 @@ public static void migrateLegacyCommunityAddresses(final SQLiteDatabase db) { private final Lazy<@NonNull MessageNotifier> messageNotifier; private final Lazy<@NonNull MmsDatabase> mmsDatabase; private final Lazy<@NonNull SmsDatabase> smsDatabase; + private final Lazy<@NonNull MarkReadProcessor> markReadProcessor; @Inject public ThreadDatabase(@dagger.hilt.android.qualifiers.ApplicationContext Context context, @@ -249,6 +251,7 @@ public ThreadDatabase(@dagger.hilt.android.qualifiers.ApplicationContext Context Lazy<@NonNull MessageNotifier> messageNotifier, Lazy<@NonNull MmsDatabase> mmsDatabase, Lazy<@NonNull SmsDatabase> smsDatabase, + Lazy<@NonNull MarkReadProcessor> markReadProcessor, TextSecurePreferences prefs, Json json) { super(context, databaseHelper); @@ -258,6 +261,7 @@ public ThreadDatabase(@dagger.hilt.android.qualifiers.ApplicationContext Context this.messageNotifier = messageNotifier; this.mmsDatabase = mmsDatabase; this.smsDatabase = smsDatabase; + this.markReadProcessor = markReadProcessor; this.json = json; this.prefs = prefs; @@ -768,7 +772,7 @@ public boolean isRead(long threadId) { public boolean markAllAsRead(long threadId, long lastSeenTime, boolean force, boolean updateNotifications) { if (mmsSmsDatabase.get().getConversationCount(threadId) <= 0 && !force) return false; List messages = setRead(threadId, lastSeenTime); - MarkReadReceiver.process(context, messages); + markReadProcessor.get().process(messages); if(updateNotifications) messageNotifier.get().updateNotification(context, threadId); return setLastSeen(threadId, lastSeenTime); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java index 0ab54cbdca..3816b3920f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SQLCipherOpenHelper.java @@ -33,6 +33,7 @@ import org.thoughtcrime.securesms.database.PushDatabase; import org.thoughtcrime.securesms.database.PushRegistrationDatabase; import org.thoughtcrime.securesms.database.ReactionDatabase; +import org.thoughtcrime.securesms.database.ReceivedMessageHashDatabase; import org.thoughtcrime.securesms.database.RecipientDatabase; import org.thoughtcrime.securesms.database.RecipientSettingsDatabase; import org.thoughtcrime.securesms.database.SearchDatabase; @@ -102,9 +103,10 @@ public class SQLCipherOpenHelper extends SQLiteOpenHelper { private static final int lokiV53 = 74; private static final int lokiV54 = 75; private static final int lokiV55 = 76; + private static final int lokiV56 = 77; // Loki - onUpgrade(...) must be updated to use Loki version numbers if Signal makes any database changes - private static final int DATABASE_VERSION = lokiV55; + private static final int DATABASE_VERSION = lokiV56; private static final int MIN_DATABASE_VERSION = lokiV7; public static final String DATABASE_NAME = "session.db"; @@ -266,6 +268,8 @@ public void onCreate(SQLiteDatabase db) { db.execSQL(MmsDatabase.ADD_LAST_MESSAGE_INDEX); executeStatements(db, PushRegistrationDatabase.Companion.createTableStatements()); + + ReceivedMessageHashDatabase.Companion.createAndMigrateTable(db); } @Override @@ -604,6 +608,10 @@ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { executeStatements(db, PushRegistrationDatabase.Companion.createTableStatements()); } + if (oldVersion < lokiV56) { + ReceivedMessageHashDatabase.Companion.createAndMigrateTable(db); + } + db.setTransactionSuccessful(); } finally { db.endTransaction(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt index 8b421e3644..d41121879c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ConfigFactory.kt @@ -390,7 +390,7 @@ class ConfigFactory @Inject constructor( // We need to persist the data to the database to save timestamp after the push val userAccountId = requiresCurrentUserAccountId() for ((variant, data, timestamp) in dump) { - configDatabase.storeConfig(variant, userAccountId.hexString, data, timestamp) + configDatabase.storeConfig(variant, userAccountId.hexString, data, timestamp.toEpochMilli()) } } @@ -412,11 +412,11 @@ class ConfigFactory @Inject constructor( if (pendingConfig != null) { for (hash in hashes) { configs.groupKeys.loadKey( - pendingConfig, - hash, - timestamp, - configs.groupInfo.pointer, - configs.groupMembers.pointer + message = pendingConfig, + hash = hash, + timestampMs = timestamp.toEpochMilli(), + infoPtr = configs.groupInfo.pointer, + membersPtr = configs.groupMembers.pointer ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupLeavingWorker.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupLeavingWorker.kt index 141cb8233b..476b70925d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupLeavingWorker.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupLeavingWorker.kt @@ -42,6 +42,7 @@ class GroupLeavingWorker @AssistedInject constructor( private val groupScope: GroupScope, private val tokenFetcher: TokenFetcher, private val pushRegistryV2: PushRegistryV2, + private val messageSender: MessageSender, ) : CoroutineWorker(context, params) { override suspend fun doWork(): Result { val groupId = requireNotNull(inputData.getString(KEY_GROUP_ID)) { @@ -98,7 +99,7 @@ class GroupLeavingWorker @AssistedInject constructor( val statusChannel = Channel>() // Always send a "XXX left" message to the group if we can - MessageSender.send( + messageSender.send( GroupUpdated( GroupUpdateMessage.newBuilder() .setMemberLeftNotificationMessage(DataMessage.GroupUpdateMemberLeftNotificationMessage.getDefaultInstance()) @@ -110,7 +111,7 @@ class GroupLeavingWorker @AssistedInject constructor( // If we are not the only admin, send a left message for other admin to handle the member removal // We'll have to wait for this message to be sent before going ahead to delete the group - MessageSender.send( + messageSender.send( GroupUpdated( GroupUpdateMessage.newBuilder() .setMemberLeftMessage(DataMessage.GroupUpdateMemberLeftMessage.getDefaultInstance()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt index c5e3acf8dc..89efd74846 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupManagerV2Impl.kt @@ -63,6 +63,7 @@ import org.thoughtcrime.securesms.configs.ConfigUploader import org.thoughtcrime.securesms.database.LokiAPIDatabase import org.thoughtcrime.securesms.database.LokiMessageDatabase import org.thoughtcrime.securesms.database.MmsSmsDatabase +import org.thoughtcrime.securesms.database.ReceivedMessageHashDatabase import org.thoughtcrime.securesms.database.RecipientRepository import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.dependencies.ConfigFactory @@ -84,10 +85,13 @@ class GroupManagerV2Impl @Inject constructor( private val clock: SnodeClock, private val messageDataProvider: MessageDataProvider, private val lokiAPIDatabase: LokiAPIDatabase, + private val receivedMessageHashDatabase: ReceivedMessageHashDatabase, private val configUploader: ConfigUploader, private val scope: GroupScope, private val groupPollerManager: GroupPollerManager, private val recipientRepository: RecipientRepository, + private val messageSender: MessageSender, + private val inviteContactJobFactory: InviteContactsJob.Factory, ) : GroupManagerV2 { private val dispatcher = Dispatchers.Default @@ -201,7 +205,7 @@ class GroupManagerV2Impl @Inject constructor( // Invite members JobQueue.shared.add( - InviteContactsJob( + inviteContactJobFactory.create( groupSessionId = groupId.hexString, memberSessionIds = members.map { it.hexString }.toTypedArray() ) @@ -321,9 +325,9 @@ class GroupManagerV2Impl @Inject constructor( // Send the invitation message to the new members JobQueue.shared.add( - InviteContactsJob( - group.hexString, - newMembers.map { it.hexString }.toTypedArray() + inviteContactJobFactory.create( + groupSessionId = group.hexString, + memberSessionIds = newMembers.map { it.hexString }.toTypedArray() ) ) } @@ -355,7 +359,7 @@ class GroupManagerV2Impl @Inject constructor( storage.insertGroupInfoChange(updatedMessage, group) - MessageSender.send(updatedMessage, Address.fromSerialized(group.hexString)) + messageSender.send(updatedMessage, Address.fromSerialized(group.hexString)) } override suspend fun removeMembers( @@ -394,7 +398,7 @@ class GroupManagerV2Impl @Inject constructor( updateMessage ).apply { sentTimestamp = timestamp } - MessageSender.send(message, Address.fromSerialized(groupAccountId.hexString)) + messageSender.send(message, Address.fromSerialized(groupAccountId.hexString)) storage.insertGroupInfoChange(message, groupAccountId) } @@ -524,7 +528,7 @@ class GroupManagerV2Impl @Inject constructor( val promotionDeferred = members.associateWith { member -> async { // The promotion message shouldn't be persisted to avoid being retried automatically - MessageSender.sendNonDurably( + messageSender.sendNonDurably( message = promoteMessage, address = Address.fromSerialized(member.hexString), isSyncMessage = false, @@ -555,7 +559,7 @@ class GroupManagerV2Impl @Inject constructor( if (!isRepromote) { - MessageSender.sendAndAwait(message, Address.fromSerialized(group.hexString)) + messageSender.sendAndAwait(message, Address.fromSerialized(group.hexString)) } } } @@ -659,7 +663,7 @@ class GroupManagerV2Impl @Inject constructor( val responseMessage = GroupUpdated(responseData.build(), profile = storage.getUserProfile()) // this will fail the first couple of times :) runCatching { - MessageSender.sendNonDurably( + messageSender.sendNonDurably( responseMessage, Destination.ClosedGroup(group.groupAccountId), isSyncMessage = false @@ -883,7 +887,7 @@ class GroupManagerV2Impl @Inject constructor( // Clear all polling states lokiAPIDatabase.clearLastMessageHashes(groupId.hexString) - lokiAPIDatabase.clearReceivedMessageHashValues(groupId.hexString) + receivedMessageHashDatabase.removeAllByPublicKey(groupId.hexString) SessionMetaProtocol.clearReceivedMessages() configFactory.deleteGroupConfigs(groupId) @@ -926,7 +930,7 @@ class GroupManagerV2Impl @Inject constructor( } storage.insertGroupInfoChange(message, groupId) - MessageSender.sendAndAwait(message, Address.fromSerialized(groupId.hexString)) + messageSender.sendAndAwait(message, Address.fromSerialized(groupId.hexString)) } override suspend fun setDescription(groupId: AccountId, newDescription: String): Unit = @@ -1008,7 +1012,7 @@ class GroupManagerV2Impl @Inject constructor( sentTimestamp = timestamp } - MessageSender.sendAndAwait(message, Address.fromSerialized(groupId.hexString)) + messageSender.sendAndAwait(message, Address.fromSerialized(groupId.hexString)) } override suspend fun handleDeleteMemberContent( @@ -1141,7 +1145,7 @@ class GroupManagerV2Impl @Inject constructor( sentTimestamp = timestamp } - MessageSender.send(message, Address.fromSerialized(groupId.hexString)) + messageSender.send(message, Address.fromSerialized(groupId.hexString)) storage.deleteGroupInfoMessages(groupId, UpdateMessageData.Kind.GroupExpirationUpdated::class.java) storage.insertGroupInfoChange(message, groupId) diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPoller.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPoller.kt index f6c44c9a9f..76954f62e8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPoller.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/GroupPoller.kt @@ -6,6 +6,7 @@ import dagger.assisted.AssistedInject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.delay @@ -19,15 +20,13 @@ import kotlinx.coroutines.supervisorScope import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import network.loki.messenger.libsession_util.Namespace -import org.session.libsession.messaging.jobs.BatchMessageReceiveJob -import org.session.libsession.messaging.jobs.JobQueue -import org.session.libsession.messaging.jobs.MessageReceiveParameters -import org.session.libsession.messaging.messages.Destination -import org.session.libsession.snode.RawResponse +import org.session.libsession.messaging.sending_receiving.MessageParser +import org.session.libsession.messaging.sending_receiving.ReceivedMessageProcessor import org.session.libsession.snode.SnodeAPI import org.session.libsession.snode.SnodeClock import org.session.libsession.snode.model.BatchResponse import org.session.libsession.snode.model.RetrieveMessageResponse +import org.session.libsession.utilities.Address import org.session.libsession.utilities.ConfigFactoryProtocol import org.session.libsession.utilities.ConfigMessage import org.session.libsession.utilities.getGroup @@ -36,6 +35,7 @@ import org.session.libsignal.exceptions.NonRetryableException import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.Snode +import org.thoughtcrime.securesms.database.ReceivedMessageHashDatabase import org.thoughtcrime.securesms.util.AppVisibilityManager import org.thoughtcrime.securesms.util.getRootCause import java.time.Instant @@ -51,7 +51,9 @@ class GroupPoller @AssistedInject constructor( private val clock: SnodeClock, private val appVisibilityManager: AppVisibilityManager, private val groupRevokedMessageHandler: GroupRevokedMessageHandler, - private val batchMessageReceiveJobFactory: BatchMessageReceiveJob.Factory, + private val receivedMessageHashDatabase: ReceivedMessageHashDatabase, + private val messageParser: MessageParser, + private val receivedMessageProcessor: ReceivedMessageProcessor, ) { companion object { private const val POLL_INTERVAL = 3_000L @@ -254,8 +256,8 @@ class GroupPoller @AssistedInject constructor( namespace = Namespace.REVOKED_GROUP_MESSAGES(), maxSize = null, ), - RetrieveMessageResponse::class.java - ).messages.filterNotNull() + RetrieveMessageResponse.serializer() + ).messages } if (configHashesToExtends.isNotEmpty() && adminKey != null) { @@ -290,7 +292,7 @@ class GroupPoller @AssistedInject constructor( namespace = Namespace.GROUP_MESSAGES(), maxSize = null, ), - responseType = Map::class.java + responseType = RetrieveMessageResponse.serializer() ) } @@ -313,8 +315,8 @@ class GroupPoller @AssistedInject constructor( namespace = ns, maxSize = null, ), - responseType = RetrieveMessageResponse::class.java - ).messages.filterNotNull() + responseType = RetrieveMessageResponse.serializer() + ).messages } } @@ -323,7 +325,7 @@ class GroupPoller @AssistedInject constructor( // must be processed first. pollingTasks += "polling and handling group config keys and messages" to async { val result = runCatching { - val (keysMessage, infoMessage, membersMessage) = groupConfigRetrieval.map { it.await() } + val (keysMessage, infoMessage, membersMessage) = groupConfigRetrieval.awaitAll() handleGroupConfigMessages(keysMessage, infoMessage, membersMessage) saveLastMessageHash(snode, keysMessage, Namespace.GROUP_KEYS()) saveLastMessageHash(snode, infoMessage, Namespace.GROUP_INFO()) @@ -334,7 +336,16 @@ class GroupPoller @AssistedInject constructor( } val regularMessages = groupMessageRetrieval.await() - handleMessages(regularMessages, snode) + handleMessages(regularMessages.messages) + + regularMessages.messages.maxByOrNull { it.timestamp }?.let { newest -> + lokiApiDatabase.setLastMessageHashValue( + snode = snode, + publicKey = groupId.hexString, + newValue = newest.hash, + namespace = Namespace.GROUP_MESSAGES() + ) + } } // Revoke message must be handled regardless, and at the end @@ -381,7 +392,7 @@ class GroupPoller @AssistedInject constructor( if (badResponse != null) { Log.e(TAG, "Group polling failed due to a server error", badResponse) - pollState.swarmNodes -= currentSnode!! + pollState.swarmNodes -= currentSnode } } } @@ -397,7 +408,7 @@ class GroupPoller @AssistedInject constructor( } private fun RetrieveMessageResponse.Message.toConfigMessage(): ConfigMessage { - return ConfigMessage(hash, data, timestamp ?: clock.currentTimeMills()) + return ConfigMessage(hash, data, timestamp.toEpochMilli()) } private fun saveLastMessageHash( @@ -443,38 +454,47 @@ class GroupPoller @AssistedInject constructor( ) } - private fun handleMessages(body: RawResponse, snode: Snode) { - val messages = configFactoryProtocol.withGroupConfigs(groupId) { - SnodeAPI.parseRawMessagesResponse( - rawResponse = body, - snode = snode, - publicKey = groupId.hexString, - decrypt = { data -> - val (decrypted, sender) = it.groupKeys.decrypt(data) ?: return@parseRawMessagesResponse null - decrypted to AccountId(sender) - }, - namespace = Namespace.GROUP_MESSAGES(), - ) + private fun handleMessages(messages: List) { + if (messages.isEmpty()) { + return } - val parameters = messages.map { (envelope, serverHash) -> - MessageReceiveParameters( - envelope.toByteArray(), - serverHash = serverHash, - closedGroup = Destination.ClosedGroup(groupId.hexString) - ) - } + val start = System.currentTimeMillis() + val threadAddress = Address.Group(groupId) + + receivedMessageProcessor.startProcessing("GroupPoller($groupId)") { ctx -> + for (message in messages) { + if (receivedMessageHashDatabase.checkOrUpdateDuplicateState( + swarmPublicKey = groupId.hexString, + namespace = Namespace.GROUP_MESSAGES(), + hash = message.hash + )) { + Log.v(TAG, "Skipping duplicated group message ${message.hash} for group $groupId") + continue + } - parameters.chunked(BatchMessageReceiveJob.BATCH_DEFAULT_NUMBER).forEach { chunk -> - JobQueue.shared.add(batchMessageReceiveJobFactory.create( - messages = chunk, - fromCommunity = null - )) - } + try { + val (msg, proto) = messageParser.parseGroupMessage( + data = message.data, + serverHash = message.hash, + groupId = groupId, + currentUserId = ctx.currentUserId, + currentUserEd25519PrivKey = ctx.currentUserEd25519KeyPair.secretKey.data, + ) - if (messages.isNotEmpty()) { - Log.d(TAG, "Received and handled ${messages.size} group messages") + receivedMessageProcessor.processSwarmMessage( + threadAddress = threadAddress, + message = msg, + proto = proto, + context = ctx, + ) + } catch (e: Exception) { + Log.e(TAG, "Error handling group message", e) + } + } } + + Log.d(TAG, "Handled ${messages.size} group messages for $groupId in ${System.currentTimeMillis() - start}ms") } /** diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt index c118a0983a..09242a4967 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/handler/RemoveGroupMemberHandler.kt @@ -62,6 +62,7 @@ class RemoveGroupMemberHandler @Inject constructor( private val storage: StorageProtocol, private val groupScope: GroupScope, @ManagerScope scope: CoroutineScope, + private val messageSender: MessageSender, ) : OnAppStartupComponent { init { scope.launch { @@ -220,7 +221,7 @@ class RemoveGroupMemberHandler @Inject constructor( ): SnodeMessage { val timestamp = clock.currentTimeMills() - return MessageSender.buildWrappedMessageToSnode( + return messageSender.buildWrappedMessageToSnode( destination = Destination.ClosedGroup(groupAccountId), message = GroupUpdated( SignalServiceProtos.DataMessage.GroupUpdateMessage.newBuilder() diff --git a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewViewModel.kt index 807fff6de5..6d8c645b3e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/media/MediaOverviewViewModel.kt @@ -57,7 +57,8 @@ class MediaOverviewViewModel @AssistedInject constructor( private val threadDatabase: ThreadDatabase, private val mediaDatabase: MediaDatabase, private val dateUtils: DateUtils, - private val recipientRepository: RecipientRepository, + recipientRepository: RecipientRepository, + private val messageSender: MessageSender, ) : AndroidViewModel(application) { private val timeBuckets by lazy { FixedTimeBuckets() } @@ -293,7 +294,7 @@ class MediaOverviewViewModel @AssistedInject constructor( val timestamp = SnodeAPI.nowWithOffset val kind = DataExtractionNotification.Kind.MediaSaved(timestamp) val message = DataExtractionNotification(kind) - MessageSender.send(message, address) + messageSender.send(message, address) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoHeardReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoHeardReceiver.java index 2d22a13532..62173b3390 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoHeardReceiver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoHeardReceiver.java @@ -25,17 +25,24 @@ import androidx.core.app.NotificationManagerCompat; +import org.session.libsession.messaging.sending_receiving.notifications.MessageNotifier; import org.session.libsignal.utilities.Log; import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.database.MarkedMessageInfo; +import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.dependencies.DatabaseComponent; import java.util.LinkedList; import java.util.List; +import javax.inject.Inject; + +import dagger.hilt.android.AndroidEntryPoint; + /** * Marks an Android Auto as read after the driver have listened to it */ +@AndroidEntryPoint public class AndroidAutoHeardReceiver extends BroadcastReceiver { public static final String TAG = AndroidAutoHeardReceiver.class.getSimpleName(); @@ -43,6 +50,10 @@ public class AndroidAutoHeardReceiver extends BroadcastReceiver { public static final String THREAD_IDS_EXTRA = "car_heard_thread_ids"; public static final String NOTIFICATION_ID_EXTRA = "car_notification_id"; + @Inject MarkReadProcessor markReadProcessor; + @Inject MessageNotifier messageNotifier; + @Inject ThreadDatabase threadDb; + @SuppressLint("StaticFieldLeak") @Override public void onReceive(final Context context, Intent intent) @@ -63,13 +74,13 @@ protected Void doInBackground(Void... params) { for (long threadId : threadIds) { Log.i(TAG, "Marking meassage as read: " + threadId); - List messageIds = DatabaseComponent.get(context).threadDatabase().setRead(threadId, true); + List messageIds = threadDb.setRead(threadId, true); messageIdsCollection.addAll(messageIds); } - ApplicationContext.getInstance(context).getMessageNotifier().updateNotification(context); - MarkReadReceiver.process(context, messageIdsCollection); + messageNotifier.updateNotification(context); + markReadProcessor.process(messageIdsCollection); return null; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java index 26561eee2a..42b3d406dc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/AndroidAutoReplyReceiver.java @@ -77,6 +77,12 @@ public class AndroidAutoReplyReceiver extends BroadcastReceiver { @Inject MessageNotifier messageNotifier; + @Inject + MarkReadProcessor markReadProcessor; + + @Inject + MessageSender messageSender; + @SuppressLint("StaticFieldLeak") @Override public void onReceive(final Context context, Intent intent) @@ -107,7 +113,7 @@ protected Void doInBackground(Void... params) { VisibleMessage message = new VisibleMessage(); message.setText(responseText.toString()); message.setSentTimestamp(SnodeAPI.getNowWithOffset()); - MessageSender.send(message, address); + messageSender.send(message, address); ExpiryMode expiryMode = recipientRepository.getRecipientSync(address).getExpiryMode(); long expiresInMillis = expiryMode.getExpiryMillis(); long expireStartedAt = expiryMode instanceof ExpiryMode.AfterSend ? message.getSentTimestamp() : 0L; @@ -129,7 +135,7 @@ protected Void doInBackground(Void... params) { List messageIds = threadDatabase.setRead(replyThreadId, true); messageNotifier.updateNotification(context); - MarkReadReceiver.process(context, messageIds); + markReadProcessor.process(messageIds); return null; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadProcessor.kt new file mode 100644 index 0000000000..ba0795b630 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadProcessor.kt @@ -0,0 +1,132 @@ +package org.thoughtcrime.securesms.notifications + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import org.session.libsession.database.StorageProtocol +import org.session.libsession.database.userAuth +import org.session.libsession.messaging.messages.control.ReadReceipt +import org.session.libsession.messaging.sending_receiving.MessageSender +import org.session.libsession.snode.SnodeAPI +import org.session.libsession.snode.SnodeAPI.nowWithOffset +import org.session.libsession.snode.SnodeClock +import org.session.libsession.utilities.TextSecurePreferences.Companion.isReadReceiptsEnabled +import org.session.libsession.utilities.associateByNotNull +import org.session.libsession.utilities.isGroupOrCommunity +import org.session.libsession.utilities.recipients.Recipient +import org.session.libsession.utilities.recipients.RecipientData +import org.session.libsignal.utilities.Log +import org.thoughtcrime.securesms.conversation.disappearingmessages.ExpiryType +import org.thoughtcrime.securesms.database.MarkedMessageInfo +import org.thoughtcrime.securesms.database.MmsSmsDatabase +import org.thoughtcrime.securesms.database.RecipientRepository +import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.database.model.content.DisappearingMessageUpdate +import org.thoughtcrime.securesms.dependencies.DatabaseComponent +import javax.inject.Inject + +class MarkReadProcessor @Inject constructor( + @param:ApplicationContext private val context: Context, + private val recipientRepository: RecipientRepository, + private val messageSender: MessageSender, + private val mmsSmsDatabase: MmsSmsDatabase, + private val threadDb: ThreadDatabase, + private val storage: StorageProtocol, + private val snodeClock: SnodeClock, +) { + fun process( + markedReadMessages: List + ) { + if (markedReadMessages.isEmpty()) return + + sendReadReceipts( + markedReadMessages = markedReadMessages + ) + + + // start disappear after read messages except TimerUpdates in groups. + markedReadMessages + .asSequence() + .filter { it.expiryType == ExpiryType.AFTER_READ } + .filter { mmsSmsDatabase.getMessageById(it.expirationInfo.id)?.run { + (messageContent is DisappearingMessageUpdate) + && threadDb.getRecipientForThreadId(threadId)?.isGroupOrCommunity == true } == false + } + .forEach { + val db = if (it.expirationInfo.id.mms) { + DatabaseComponent.get(context).mmsDatabase() + } else { + DatabaseComponent.get(context).smsDatabase() + } + + db.markExpireStarted(it.expirationInfo.id.id, nowWithOffset) + } + + hashToDisappearAfterReadMessage(context, markedReadMessages)?.let { hashToMessages -> + GlobalScope.launch { + try { + shortenExpiryOfDisappearingAfterRead(hashToMessages) + } catch (e: Exception) { + Log.e(TAG, "Failed to fetch updated expiries and schedule deletion", e) + } + } + } + } + + private fun hashToDisappearAfterReadMessage( + context: Context, + markedReadMessages: List + ): Map? { + val loki = DatabaseComponent.get(context).lokiMessageDatabase() + + return markedReadMessages + .filter { it.expiryType == ExpiryType.AFTER_READ } + .associateByNotNull { it.expirationInfo.run { loki.getMessageServerHash(id) } } + .takeIf { it.isNotEmpty() } + } + + private fun shortenExpiryOfDisappearingAfterRead( + hashToMessage: Map + ) { + hashToMessage.entries + .groupBy( + keySelector = { it.value.expirationInfo.expiresIn }, + valueTransform = { it.key } + ).forEach { (expiresIn, hashes) -> + SnodeAPI.alterTtl( + messageHashes = hashes, + newExpiry = snodeClock.currentTimeMills() + expiresIn, + auth = checkNotNull(storage.userAuth) { "No authorized user" }, + shorten = true + ) + } + } + + private val Recipient.shouldSendReadReceipt: Boolean + get() = when (data) { + is RecipientData.Contact -> approved && !blocked + is RecipientData.Generic -> !isGroupOrCommunityRecipient && !blocked + else -> false + } + + private fun sendReadReceipts( + markedReadMessages: List + ) { + if (!isReadReceiptsEnabled(context)) return + + markedReadMessages.map { it.syncMessageId } + .filter { recipientRepository.getRecipientSync(it.address).shouldSendReadReceipt } + .groupBy { it.address } + .forEach { (address, messages) -> + messages.map { it.timetamp } + .let(::ReadReceipt) + .apply { sentTimestamp = snodeClock.currentTimeMills() } + .let { messageSender.send(it, address) } + } + } + + companion object { + private const val TAG = "MarkReadProcessor" + } +} \ No newline at end of file diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt index 1b8cb58d23..6b1cd80d4d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/MarkReadReceiver.kt @@ -8,24 +8,8 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import org.session.libsession.database.StorageProtocol -import org.session.libsession.database.userAuth -import org.session.libsession.messaging.MessagingModuleConfiguration -import org.session.libsession.messaging.MessagingModuleConfiguration.Companion.shared -import org.session.libsession.messaging.messages.control.ReadReceipt -import org.session.libsession.messaging.sending_receiving.MessageSender.send -import org.session.libsession.snode.SnodeAPI -import org.session.libsession.snode.SnodeAPI.nowWithOffset import org.session.libsession.snode.SnodeClock -import org.session.libsession.utilities.TextSecurePreferences.Companion.isReadReceiptsEnabled -import org.session.libsession.utilities.associateByNotNull -import org.session.libsession.utilities.isGroupOrCommunity -import org.session.libsession.utilities.recipients.RecipientData -import org.session.libsession.utilities.recipients.Recipient import org.session.libsignal.utilities.Log -import org.thoughtcrime.securesms.conversation.disappearingmessages.ExpiryType -import org.thoughtcrime.securesms.database.MarkedMessageInfo -import org.thoughtcrime.securesms.database.model.content.DisappearingMessageUpdate -import org.thoughtcrime.securesms.dependencies.DatabaseComponent import javax.inject.Inject @AndroidEntryPoint @@ -36,6 +20,7 @@ class MarkReadReceiver : BroadcastReceiver() { @Inject lateinit var clock: SnodeClock + override fun onReceive(context: Context, intent: Intent) { if (CLEAR_ACTION != intent.action) return val threadIds = intent.getLongArrayExtra(THREAD_IDS_EXTRA) ?: return @@ -59,101 +44,5 @@ class MarkReadReceiver : BroadcastReceiver() { const val THREAD_IDS_EXTRA = "thread_ids" const val NOTIFICATION_ID_EXTRA = "notification_id" - @JvmStatic - fun process( - context: Context, - markedReadMessages: List - ) { - if (markedReadMessages.isEmpty()) return - - sendReadReceipts(context, markedReadMessages) - - val mmsSmsDatabase = DatabaseComponent.get(context).mmsSmsDatabase() - - val threadDb = DatabaseComponent.get(context).threadDatabase() - - // start disappear after read messages except TimerUpdates in groups. - markedReadMessages - .asSequence() - .filter { it.expiryType == ExpiryType.AFTER_READ } - .filter { mmsSmsDatabase.getMessageById(it.expirationInfo.id)?.run { - (messageContent is DisappearingMessageUpdate) - && threadDb.getRecipientForThreadId(threadId)?.isGroupOrCommunity == true } == false - } - .forEach { - val db = if (it.expirationInfo.id.mms) { - DatabaseComponent.get(context).mmsDatabase() - } else { - DatabaseComponent.get(context).smsDatabase() - } - - db.markExpireStarted(it.expirationInfo.id.id, nowWithOffset) - } - - hashToDisappearAfterReadMessage(context, markedReadMessages)?.let { hashToMessages -> - GlobalScope.launch { - try { - shortenExpiryOfDisappearingAfterRead(hashToMessages) - } catch (e: Exception) { - Log.e(TAG, "Failed to fetch updated expiries and schedule deletion", e) - } - } - } - } - - private fun hashToDisappearAfterReadMessage( - context: Context, - markedReadMessages: List - ): Map? { - val loki = DatabaseComponent.get(context).lokiMessageDatabase() - - return markedReadMessages - .filter { it.expiryType == ExpiryType.AFTER_READ } - .associateByNotNull { it.expirationInfo.run { loki.getMessageServerHash(id) } } - .takeIf { it.isNotEmpty() } - } - - private fun shortenExpiryOfDisappearingAfterRead( - hashToMessage: Map - ) { - hashToMessage.entries - .groupBy( - keySelector = { it.value.expirationInfo.expiresIn }, - valueTransform = { it.key } - ).forEach { (expiresIn, hashes) -> - SnodeAPI.alterTtl( - messageHashes = hashes, - newExpiry = nowWithOffset + expiresIn, - auth = checkNotNull(shared.storage.userAuth) { "No authorized user" }, - shorten = true - ) - } - } - - private val Recipient.shouldSendReadReceipt: Boolean - get() = when (data) { - is RecipientData.Contact -> approved && !blocked - is RecipientData.Generic -> !isGroupOrCommunityRecipient && !blocked - else -> false - } - - private fun sendReadReceipts( - context: Context, - markedReadMessages: List - ) { - if (!isReadReceiptsEnabled(context)) return - - val recipientRepository = MessagingModuleConfiguration.shared.recipientRepository - - markedReadMessages.map { it.syncMessageId } - .filter { recipientRepository.getRecipientSync(it.address)?.shouldSendReadReceipt == true } - .groupBy { it.address } - .forEach { (address, messages) -> - messages.map { it.timetamp } - .let(::ReadReceipt) - .apply { sentTimestamp = nowWithOffset } - .let { send(it, address) } - } - } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt index 8c34519359..162c6cc8fb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt @@ -10,31 +10,32 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat.getString import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import kotlinx.serialization.json.Json import network.loki.messenger.R import network.loki.messenger.libsession_util.Namespace import network.loki.messenger.libsession_util.SessionEncrypt import okio.ByteString.Companion.decodeHex -import org.session.libsession.messaging.jobs.BatchMessageReceiveJob -import org.session.libsession.messaging.jobs.JobQueue -import org.session.libsession.messaging.jobs.MessageReceiveParameters -import org.session.libsession.messaging.messages.Destination +import org.session.libsession.messaging.messages.Message.Companion.senderOrSync +import org.session.libsession.messaging.sending_receiving.MessageParser +import org.session.libsession.messaging.sending_receiving.ReceivedMessageProcessor import org.session.libsession.messaging.sending_receiving.notifications.PushNotificationMetadata -import org.session.libsession.messaging.utilities.MessageWrapper +import org.session.libsession.utilities.Address +import org.session.libsession.utilities.Address.Companion.toAddress import org.session.libsession.utilities.ConfigMessage import org.session.libsession.utilities.bencode.Bencode import org.session.libsession.utilities.bencode.BencodeList import org.session.libsession.utilities.bencode.BencodeString import org.session.libsession.utilities.getGroup -import org.session.libsignal.protos.SignalServiceProtos.Envelope import org.session.libsignal.utilities.AccountId import org.session.libsignal.utilities.Base64 import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.toHexString import org.thoughtcrime.securesms.crypto.IdentityKeyUtil +import org.thoughtcrime.securesms.database.ReceivedMessageHashDatabase import org.thoughtcrime.securesms.dependencies.ConfigFactory +import org.thoughtcrime.securesms.dependencies.ManagerScope import org.thoughtcrime.securesms.groups.GroupRevokedMessageHandler import org.thoughtcrime.securesms.home.HomeActivity import java.security.SecureRandom @@ -47,7 +48,10 @@ class PushReceiver @Inject constructor( private val configFactory: ConfigFactory, private val groupRevokedMessageHandler: GroupRevokedMessageHandler, private val json: Json, - private val batchJobFactory: BatchMessageReceiveJob.Factory, + private val messageParser: MessageParser, + private val receivedMessageProcessor: ReceivedMessageProcessor, + private val receivedMessageHashDatabase: ReceivedMessageHashDatabase, + @param:ManagerScope private val scope: CoroutineScope, ) { /** @@ -70,7 +74,7 @@ class PushReceiver @Inject constructor( private fun addMessageReceiveJob(pushData: PushData?) { try { val namespace = pushData?.metadata?.namespace - val params = when { + when { namespace == Namespace.GROUP_MESSAGES() || namespace == Namespace.REVOKED_GROUP_MESSAGES() || namespace == Namespace.GROUP_INFO() || @@ -91,48 +95,63 @@ class PushReceiver @Inject constructor( return } - if (namespace == Namespace.GROUP_MESSAGES()) { - val envelope = checkNotNull(tryDecryptGroupEnvelope(groupId, pushData.data)) { - "Unable to decrypt closed group message" + when (namespace) { + Namespace.GROUP_MESSAGES() -> { + if (!receivedMessageHashDatabase.checkOrUpdateDuplicateState( + swarmPublicKey = groupId.hexString, + namespace = namespace, + hash = pushData.metadata.msg_hash + )) { + receivedMessageProcessor.startProcessing("GroupPushReceive($groupId)") { ctx -> + val (msg, proto) = messageParser.parseGroupMessage( + data = pushData.data, + serverHash = pushData.metadata.msg_hash, + groupId = groupId, + currentUserId = ctx.currentUserId, + currentUserEd25519PrivKey = ctx.currentUserEd25519KeyPair.secretKey.data + ) + + receivedMessageProcessor.processSwarmMessage( + threadAddress = Address.Group(groupId), + message = msg, + proto = proto, + context = ctx, + ) + } + } } - MessageReceiveParameters( - data = envelope.toByteArray(), - serverHash = pushData.metadata.msg_hash, - closedGroup = Destination.ClosedGroup(groupId.hexString) - ) - } else if (namespace == Namespace.REVOKED_GROUP_MESSAGES()) { - GlobalScope.launch { - groupRevokedMessageHandler.handleRevokeMessage(groupId, listOf(pushData.data)) + Namespace.REVOKED_GROUP_MESSAGES() -> { + scope.launch { + groupRevokedMessageHandler.handleRevokeMessage(groupId, listOf(pushData.data)) + } } - null - } else { - val hash = requireNotNull(pushData.metadata.msg_hash) { - "Received a closed group config push notification without a message hash" - } + else -> { + val hash = requireNotNull(pushData.metadata.msg_hash) { + "Received a closed group config push notification without a message hash" + } + + // If we receive group config messages from notification, try to merge + // them directly + val configMessage = listOf( + ConfigMessage( + hash = hash, + data = pushData.data, + timestamp = pushData.metadata.timestampSeconds + ) + ) - // If we receive group config messages from notification, try to merge - // them directly - val configMessage = listOf( - ConfigMessage( - hash = hash, - data = pushData.data, - timestamp = pushData.metadata.timestampSeconds + configFactory.mergeGroupConfigMessages( + groupId = groupId, + keys = configMessage.takeIf { namespace == Namespace.GROUP_KEYS() } + .orEmpty(), + members = configMessage.takeIf { namespace == Namespace.GROUP_MEMBERS() } + .orEmpty(), + info = configMessage.takeIf { namespace == Namespace.GROUP_INFO() } + .orEmpty(), ) - ) - - configFactory.mergeGroupConfigMessages( - groupId = groupId, - keys = configMessage.takeIf { namespace == Namespace.GROUP_KEYS() } - .orEmpty(), - members = configMessage.takeIf { namespace == Namespace.GROUP_MEMBERS() } - .orEmpty(), - info = configMessage.takeIf { namespace == Namespace.GROUP_INFO() } - .orEmpty(), - ) - - null + } } } @@ -146,11 +165,29 @@ class PushReceiver @Inject constructor( return } - val envelopeAsData = MessageWrapper.unwrap(pushData.data).toByteArray() - MessageReceiveParameters( - data = envelopeAsData, - serverHash = pushData.metadata?.msg_hash + val isDuplicated = pushData.metadata?.msg_hash != null && receivedMessageHashDatabase.checkOrUpdateDuplicateState( + swarmPublicKey = pushData.metadata.account, + namespace = Namespace.DEFAULT(), + hash = pushData.metadata.msg_hash ) + + if (!isDuplicated) { + receivedMessageProcessor.startProcessing("PushReceiver") { ctx -> + val (message, proto) = messageParser.parse1o1Message( + data = pushData.data, + serverHash = pushData.metadata?.msg_hash, + currentUserId = ctx.currentUserId, + currentUserEd25519PrivKey = ctx.currentUserEd25519KeyPair.secretKey.data, + ) + + receivedMessageProcessor.processSwarmMessage( + threadAddress = message.senderOrSync.toAddress() as Address.Conversable, + message = message, + proto = proto, + context = ctx, + ) + } + } } else -> { @@ -159,33 +196,12 @@ class PushReceiver @Inject constructor( } } - if (params != null) { - JobQueue.shared.add(batchJobFactory.create( - messages = listOf(params), - fromCommunity = null - )) - } } catch (e: Exception) { Log.d(TAG, "Failed to unwrap data for message due to error.", e) } } - private fun tryDecryptGroupEnvelope(groupId: AccountId, data: ByteArray): Envelope? { - val (envelopBytes, sender) = checkNotNull(configFactory.withGroupConfigs(groupId) { - it.groupKeys.decrypt( - data - ) - }) { - "Failed to decrypt group message" - } - - Log.d(TAG, "Successfully decrypted group message from $sender") - return Envelope.parseFrom(envelopBytes) - .toBuilder() - .setSource(sender) - .build() - } private fun sendGenericNotification() { // no need to do anything if notification permissions are not granted @@ -265,7 +281,7 @@ class PushReceiver @Inject constructor( return key } - data class PushData( + class PushData( val data: ByteArray?, val metadata: PushNotificationMetadata? ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java index 216eadaae0..9d10f23a9e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java @@ -76,6 +76,10 @@ public class RemoteReplyReceiver extends BroadcastReceiver { SnodeClock clock; @Inject RecipientRepository recipientRepository; + @Inject + MarkReadProcessor markReadProcessor; + @Inject + MessageSender messageSender; @SuppressLint("StaticFieldLeak") @Override @@ -110,7 +114,7 @@ protected Void doInBackground(Void... params) { OutgoingMediaMessage reply = OutgoingMediaMessage.from(message, address, Collections.emptyList(), null, null, expiresInMillis, 0); try { message.setId(new MessageId(mmsDatabase.insertMessageOutbox(reply, threadId, false, true), true)); - MessageSender.send(message, address); + messageSender.send(message, address); } catch (MmsException e) { Log.w(TAG, e); } @@ -119,7 +123,7 @@ protected Void doInBackground(Void... params) { case SecureMessage: { OutgoingTextMessage reply = OutgoingTextMessage.from(message, address, expiresInMillis, expireStartedAt); message.setId(new MessageId(smsDatabase.insertMessageOutbox(threadId, reply, false, System.currentTimeMillis(), true), false)); - MessageSender.send(message, address); + messageSender.send(message, address); break; } default: @@ -129,7 +133,7 @@ protected Void doInBackground(Void... params) { List messageIds = threadDatabase.setRead(threadId, true); messageNotifier.updateNotification(context); - MarkReadReceiver.process(context, messageIds); + markReadProcessor.process(messageIds); return null; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/CreateAccountManager.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/CreateAccountManager.kt index 5602ccb9d7..6c9f34166a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/CreateAccountManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/CreateAccountManager.kt @@ -9,6 +9,7 @@ import org.session.libsignal.database.LokiAPIDatabaseProtocol import org.session.libsignal.utilities.KeyHelper import org.session.libsignal.utilities.hexEncodedPublicKey import org.thoughtcrime.securesms.crypto.KeyPairUtilities +import org.thoughtcrime.securesms.database.ReceivedMessageHashDatabase import org.thoughtcrime.securesms.util.VersionDataFetcher import javax.inject.Inject import javax.inject.Singleton @@ -18,7 +19,8 @@ class CreateAccountManager @Inject constructor( private val application: Application, private val prefs: TextSecurePreferences, private val versionDataFetcher: VersionDataFetcher, - private val configFactory: ConfigFactoryProtocol + private val configFactory: ConfigFactoryProtocol, + private val receivedMessageHashDatabase: ReceivedMessageHashDatabase, ) { private val database: LokiAPIDatabaseProtocol get() = SnodeModule.shared.storage @@ -27,7 +29,7 @@ class CreateAccountManager @Inject constructor( // This is here to resolve a case where the app restarts before a user completes onboarding // which can result in an invalid database state database.clearAllLastMessageHashes() - database.clearReceivedMessageHashValues() + receivedMessageHashDatabase.removeAll() val keyPairGenerationResult = KeyPairUtilities.generate() val seed = keyPairGenerationResult.seed diff --git a/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/LoadAccountManager.kt b/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/LoadAccountManager.kt index 489742a9a2..c7dd30f462 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/LoadAccountManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/onboarding/manager/LoadAccountManager.kt @@ -11,6 +11,7 @@ import org.session.libsignal.database.LokiAPIDatabaseProtocol import org.session.libsignal.utilities.hexEncodedPublicKey import org.thoughtcrime.securesms.ApplicationContext import org.thoughtcrime.securesms.crypto.KeyPairUtilities +import org.thoughtcrime.securesms.database.ReceivedMessageHashDatabase import org.thoughtcrime.securesms.dependencies.ConfigFactory import org.thoughtcrime.securesms.util.VersionDataFetcher import javax.inject.Inject @@ -20,7 +21,8 @@ import javax.inject.Singleton class LoadAccountManager @Inject constructor( @dagger.hilt.android.qualifiers.ApplicationContext private val context: Context, private val prefs: TextSecurePreferences, - private val versionDataFetcher: VersionDataFetcher + private val versionDataFetcher: VersionDataFetcher, + private val receivedMessageHashDatabase: ReceivedMessageHashDatabase, ) { private val database: LokiAPIDatabaseProtocol get() = SnodeModule.shared.storage @@ -37,7 +39,7 @@ class LoadAccountManager @Inject constructor( // This is here to resolve a case where the app restarts before a user completes onboarding // which can result in an invalid database state database.clearAllLastMessageHashes() - database.clearReceivedMessageHashValues() + receivedMessageHashDatabase.removeAll() // RestoreActivity handles seed this way val keyPairGenerationResult = KeyPairUtilities.generate(seed) diff --git a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt index 56b8d0bef7..c6a0fa7cb0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt @@ -144,6 +144,7 @@ class DefaultConversationRepository @Inject constructor( private val recipientDatabase: RecipientSettingsDatabase, private val recipientRepository: RecipientRepository, @param:ManagerScope private val scope: CoroutineScope, + private val messageSender: MessageSender, ) : ConversationRepository { override val conversationListAddressesFlow = configFactory @@ -285,7 +286,7 @@ class DefaultConversationRepository @Inject constructor( false ) - MessageSender.send(message, contact) + messageSender.send(message, contact) } } @@ -410,12 +411,12 @@ class DefaultConversationRepository @Inject constructor( // send an UnsendRequest to user's swarm buildUnsendRequest(message).let { unsendRequest -> - userAddress?.let { MessageSender.send(unsendRequest, it) } + userAddress?.let { messageSender.send(unsendRequest, it) } } // send an UnsendRequest to recipient's swarm buildUnsendRequest(message).let { unsendRequest -> - MessageSender.send(unsendRequest, recipient) + messageSender.send(unsendRequest, recipient) } } } @@ -428,7 +429,7 @@ class DefaultConversationRepository @Inject constructor( messages.forEach { message -> // send an UnsendRequest to group's swarm buildUnsendRequest(message).let { unsendRequest -> - MessageSender.send(unsendRequest, recipient) + messageSender.send(unsendRequest, recipient) } } } @@ -467,7 +468,7 @@ class DefaultConversationRepository @Inject constructor( // send an UnsendRequest to user's swarm buildUnsendRequest(message).let { unsendRequest -> - userAddress?.let { MessageSender.send(unsendRequest, it) } + userAddress?.let { messageSender.send(unsendRequest, it) } } } } @@ -551,7 +552,7 @@ class DefaultConversationRepository @Inject constructor( } withContext(Dispatchers.Default) { - MessageSender.send(message = MessageRequestResponse(true), address = recipient) + messageSender.send(message = MessageRequestResponse(true), address = recipient) // add a control message for our user storage.insertMessageRequestResponseFromYou(threadDb.getOrCreateThreadIdFor(recipient)) diff --git a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt index 5a864b0f80..168e46276d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/webrtc/CallManager.kt @@ -74,6 +74,7 @@ class CallManager @Inject constructor( @param:ManagerScope private val scope: CoroutineScope, audioManager: AudioManagerCompat, private val storage: StorageProtocol, + private val messageSender: MessageSender, ): PeerConnection.Observer, SignalAudioManager.EventListener, CameraEventListener, DataChannel.Observer { @@ -340,7 +341,7 @@ class CallManager @Inject constructor( .also { scope.launch { runCatching { - MessageSender.sendNonDurably( + messageSender.sendNonDurably( it, currentRecipient, isSyncMessage = currentRecipient.isLocalNumber @@ -491,7 +492,7 @@ class CallManager @Inject constructor( Log.i("Loki", "Posting new answer") runCatching { - MessageSender.sendNonDurably( + messageSender.sendNonDurably( answerMessage, recipient, isSyncMessage = recipient.isLocalNumber @@ -545,7 +546,7 @@ class CallManager @Inject constructor( val userAddress = storage.getUserPublicKey() ?: throw NullPointerException("No user public key") runCatching { - MessageSender.sendNonDurably( + messageSender.sendNonDurably( answerMessage, Address.fromSerialized(userAddress), isSyncMessage = true @@ -553,7 +554,7 @@ class CallManager @Inject constructor( } runCatching { - MessageSender.sendNonDurably( + messageSender.sendNonDurably( CallMessage.answer( answer.description, callId @@ -609,7 +610,7 @@ class CallManager @Inject constructor( Log.d("Loki", "Sending pre-offer") try { - MessageSender.sendNonDurably( + messageSender.sendNonDurably( CallMessage.preOffer( callId ).applyExpiryMode(recipient), recipient, isSyncMessage = recipient.isLocalNumber @@ -619,7 +620,7 @@ class CallManager @Inject constructor( Log.d("Loki", "Sending offer") postViewModelState(CallViewModel.State.CALL_OFFER_OUTGOING) - MessageSender.sendNonDurably(CallMessage.offer( + messageSender.sendNonDurably(CallMessage.offer( offer.description, callId ).applyExpiryMode(recipient), recipient, isSyncMessage = recipient.isLocalNumber) @@ -639,7 +640,7 @@ class CallManager @Inject constructor( stateProcessor.processEvent(Event.DeclineCall) { scope.launch { runCatching { - MessageSender.sendNonDurably( + messageSender.sendNonDurably( CallMessage.endCall(callId).applyExpiryMode(recipient), Address.fromSerialized(userAddress), isSyncMessage = true @@ -648,7 +649,7 @@ class CallManager @Inject constructor( } scope.launch { runCatching { - MessageSender.sendNonDurably( + messageSender.sendNonDurably( CallMessage.endCall(callId).applyExpiryMode(recipient), recipient, isSyncMessage = recipient.isLocalNumber @@ -680,7 +681,7 @@ class CallManager @Inject constructor( scope.launch { runCatching { - MessageSender.sendNonDurably( + messageSender.sendNonDurably( CallMessage.endCall(callId).applyExpiryMode(recipient), recipient, isSyncMessage = recipient.isLocalNumber @@ -919,7 +920,7 @@ class CallManager @Inject constructor( connection.setLocalDescription(offer) scope.launch { runCatching { - MessageSender.sendNonDurably( + messageSender.sendNonDurably( CallMessage.offer(offer.description, callId).applyExpiryMode(recipient), recipient, isSyncMessage = recipient.isLocalNumber diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ca93ac2782..7b13062bc6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,7 +29,7 @@ kotlinVersion = "2.2.20" kryoVersion = "5.6.2" kspVersion = "2.3.0" legacySupportV13Version = "1.0.0" -libsessionUtilAndroidVersion = "1.0.9-2-g8c03d1e" +libsessionUtilAndroidVersion = "1.0.9-22-gc15b19c" media3ExoplayerVersion = "1.8.0" mockitoCoreVersion = "5.20.0" navVersion = "2.9.5"