Add MessageService and IndividualSendJobV2.

This commit is contained in:
Greyson Parrelli
2026-05-22 00:39:13 -04:00
committed by Michelle Tang
parent 0284da2d0f
commit f206487ede
33 changed files with 2506 additions and 43 deletions
@@ -188,7 +188,7 @@ class InternalConversationSettingsFragment : ComposeFragment(), InternalConversa
message = OutgoingMessage(threadRecipient = recipient, sentTimeMillis = time, body = "Outgoing: $i"),
threadId = targetThread
).messageId
SignalDatabase.messages.markAsSent(id, true)
SignalDatabase.messages.markAsSent(id)
} else {
SignalDatabase.messages.insertMessageInbox(
retrieved = IncomingMessage(type = MessageType.NORMAL, from = recipient.id, sentTimeMillis = time, serverTimeMillis = time, receivedTimeMillis = System.currentTimeMillis(), body = "Incoming: $i"),
@@ -218,7 +218,7 @@ class InternalConversationSettingsFragment : ComposeFragment(), InternalConversa
message = OutgoingMessage(threadRecipient = recipient, sentTimeMillis = time, body = "Outgoing: $i", attachments = listOf(attachment)),
threadId = targetThread
).messageId
SignalDatabase.messages.markAsSent(id, true)
SignalDatabase.messages.markAsSent(id)
SignalDatabase.attachments.getAttachmentsForMessage(id).forEach {
SignalDatabase.attachments.debugMakeValidForArchive(it.attachmentId)
SignalDatabase.attachments.createRemoteKeyIfNecessary(it.attachmentId)
@@ -252,7 +252,7 @@ class InternalConversationSettingsFragment : ComposeFragment(), InternalConversa
false,
null
).messageId
SignalDatabase.messages.markAsSent(messageId, true)
SignalDatabase.messages.markAsSent(messageId)
SignalDatabase.threads.update(splitThreadId, true)
@@ -273,7 +273,7 @@ class ConversationRepository(
Log.i(TAG, "Some recipients skipped when sending end poll. Resending to $filterRecipientIds")
MessageSender.resendGroupMessage(applicationContext, messageRecord, filterRecipientIds)
} else {
SignalDatabase.messages.markAsSent(messageId, true)
SignalDatabase.messages.markAsSent(messageId)
}
emitter.onComplete()
} else {
@@ -381,7 +381,7 @@ class ConversationRepository(
Log.i(TAG, "Some recipients skipped when sending pin message. Resending to $filterRecipientIds")
MessageSender.resendGroupMessage(applicationContext, messageRecord, filterRecipientIds)
} else {
SignalDatabase.messages.markAsSent(insertResult.messageId, true)
SignalDatabase.messages.markAsSent(insertResult.messageId)
}
emitter.onComplete()
} else {
@@ -2312,9 +2312,27 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
AppDependencies.databaseObserver.notifyConversationListListeners()
}
fun markAsSent(messageId: Long, secure: Boolean) {
fun markAsSent(messageId: Long) {
val threadId = getThreadIdForMessage(messageId)
updateMailboxBitmask(messageId, MessageTypes.BASE_TYPE_MASK, MessageTypes.BASE_SENT_TYPE or if (secure) MessageTypes.PUSH_MESSAGE_BIT or MessageTypes.SECURE_MESSAGE_BIT else 0, Optional.of(threadId))
updateMailboxBitmask(messageId, MessageTypes.BASE_TYPE_MASK, MessageTypes.BASE_SENT_TYPE or MessageTypes.PUSH_MESSAGE_BIT or MessageTypes.SECURE_MESSAGE_BIT, Optional.of(threadId))
AppDependencies.databaseObserver.notifyMessageUpdateObservers(MessageId(messageId))
AppDependencies.databaseObserver.notifyConversationListListeners()
}
fun markAsSent(messageId: Long, sealedSender: Boolean) {
val maskOff = MessageTypes.BASE_TYPE_MASK
val maskOn = MessageTypes.BASE_SENT_TYPE or MessageTypes.PUSH_MESSAGE_BIT or MessageTypes.SECURE_MESSAGE_BIT
writableDatabase.execSQL(
"""
UPDATE $TABLE_NAME
SET
$TYPE = ($TYPE & ${MessageTypes.TOTAL_MASK - maskOff} | $maskOn ),
$UNIDENTIFIED = ${sealedSender.toInt()}
WHERE $ID = $messageId
"""
)
AppDependencies.databaseObserver.notifyMessageUpdateObservers(MessageId(messageId))
AppDependencies.databaseObserver.notifyConversationListListeners()
}
@@ -2693,6 +2711,18 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
}
}
fun getOutgoingMessageOrNull(messageId: Long): OutgoingMessage? {
return try {
getOutgoingMessage(messageId)
} catch (e: MmsException) {
Log.w(TAG, "Hit MmsException, returning null", e)
null
} catch (e: NoSuchMessageException) {
Log.w(TAG, "Hit NoSuchMessageException, returning null", e)
null
}
}
@Throws(MmsException::class, NoSuchMessageException::class)
fun getOutgoingMessage(messageId: Long): OutgoingMessage {
return queryMessages(RAW_ID_WHERE, arrayOf(messageId.toString())).readToSingleObject { cursor ->
@@ -21,7 +21,9 @@ import org.signal.network.api.AttachmentApi
import org.signal.network.api.CallingApi
import org.signal.network.api.CdsApi
import org.signal.network.api.CertificateApi
import org.signal.network.api.KeysApiV2
import org.signal.network.api.LinkDeviceApi
import org.signal.network.api.MessageApiV2
import org.signal.network.api.PaymentsApi
import org.signal.network.api.ProvisioningApi
import org.signal.network.api.RateLimitChallengeApi
@@ -29,6 +31,7 @@ import org.signal.network.api.RemoteConfigApi
import org.signal.network.api.SvrBApi
import org.signal.network.api.UsernameApi
import org.signal.network.rest.SignalRestClient
import org.signal.network.service.MessageService
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.components.TypingStatusRepository
import org.thoughtcrime.securesms.components.TypingStatusSender
@@ -281,6 +284,10 @@ object AppDependencies {
val signalServiceMessageSender: SignalServiceMessageSender
get() = networkModule.signalServiceMessageSender
@JvmStatic
val messageService: MessageService
get() = networkModule.messageService
@JvmStatic
val signalServiceAccountManager: SignalServiceAccountManager
get() = networkModule.signalServiceAccountManager
@@ -442,6 +449,7 @@ object AppDependencies {
fun provideGroupsV2Operations(signalServiceConfiguration: SignalServiceConfiguration): GroupsV2Operations
fun provideSignalServiceAccountManager(authWebSocket: SignalWebSocket.AuthenticatedWebSocket, accountApi: AccountApi, pushServiceSocket: PushServiceSocket, groupsV2Operations: GroupsV2Operations): SignalServiceAccountManager
fun provideSignalServiceMessageSender(protocolStore: SignalServiceDataStore, pushServiceSocket: PushServiceSocket, messageApi: MessageApi, keysApi: KeysApi): SignalServiceMessageSender
fun provideMessageService(protocolStore: SignalServiceDataStore, messageApiV2: MessageApiV2, keysApiV2: KeysApiV2): MessageService
fun provideSignalServiceMessageReceiver(pushServiceSocket: PushServiceSocket): SignalServiceMessageReceiver
fun provideSignalServiceNetworkAccess(): SignalServiceNetworkAccess
fun provideRecipientCache(): LiveRecipientCache
@@ -23,6 +23,8 @@ import org.signal.libsignal.zkgroup.InvalidInputException;
import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations;
import org.signal.libsignal.zkgroup.receipts.ClientZkReceiptOperations;
import org.signal.network.api.ArchiveApi;
import org.signal.network.api.KeysApiV2;
import org.signal.network.api.MessageApiV2;
import org.signal.network.rest.SignalRestClient;
import org.signal.network.api.CallingApi;
import org.signal.network.api.CdsApi;
@@ -34,6 +36,7 @@ import org.signal.network.api.RateLimitChallengeApi;
import org.signal.network.api.RemoteConfigApi;
import org.signal.network.api.SvrBApi;
import org.signal.network.api.UsernameApi;
import org.signal.network.service.MessageService;
import org.thoughtcrime.securesms.BuildConfig;
import org.thoughtcrime.securesms.components.TypingStatusRepository;
import org.thoughtcrime.securesms.components.TypingStatusSender;
@@ -102,12 +105,14 @@ import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.video.exo.GiphyMp4Cache;
import org.thoughtcrime.securesms.video.exo.SimpleExoPlayerPool;
import org.thoughtcrime.securesms.webrtc.audio.AudioManagerCompat;
import org.whispersystems.signalservice.api.SignalServiceAccountDataStore;
import org.whispersystems.signalservice.api.SignalServiceAccountManager;
import org.whispersystems.signalservice.api.SignalServiceDataStore;
import org.whispersystems.signalservice.api.SignalServiceMessageReceiver;
import org.whispersystems.signalservice.api.SignalServiceMessageSender;
import org.whispersystems.signalservice.api.account.AccountApi;
import org.signal.network.api.AttachmentApi;
import org.whispersystems.signalservice.api.crypto.SignalServiceCipher;
import org.whispersystems.signalservice.api.donations.DonationsApi;
import org.whispersystems.signalservice.api.groupsv2.ClientZkOperations;
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations;
@@ -115,6 +120,7 @@ import org.whispersystems.signalservice.api.keys.KeysApi;
import org.whispersystems.signalservice.api.keys.PreKeyRepository;
import org.whispersystems.signalservice.api.message.MessageApi;
import org.whispersystems.signalservice.api.profiles.ProfileApi;
import org.whispersystems.signalservice.api.push.SignalServiceAddress;
import org.whispersystems.signalservice.api.registration.RegistrationApi;
import org.whispersystems.signalservice.api.services.DonationsService;
import org.whispersystems.signalservice.api.services.ProfileService;
@@ -200,6 +206,18 @@ public class ApplicationDependencyProvider implements AppDependencies.Provider {
);
}
@Override
public @NonNull MessageService provideMessageService(@NonNull SignalServiceDataStore protocolStore,
@NonNull MessageApiV2 messageApiV2,
@NonNull KeysApiV2 keysApiV2) {
SignalServiceAddress localAddress = new SignalServiceAddress(SignalStore.account().requireAci(), SignalStore.account().getE164());
int localDeviceId = SignalStore.account().getDeviceId();
SignalServiceAccountDataStore aciStore = protocolStore.aci();
SignalServiceCipher cipher = new SignalServiceCipher(localAddress, localDeviceId, aciStore, ReentrantSessionLock.INSTANCE, null);
return new MessageService(localAddress, localDeviceId, messageApiV2, keysApiV2, aciStore, ReentrantSessionLock.INSTANCE, cipher, RemoteConfig.maxEnvelopeSizeBytes());
}
@Override
public @NonNull SignalServiceMessageReceiver provideSignalServiceMessageReceiver(@NonNull PushServiceSocket pushServiceSocket) {
return new SignalServiceMessageReceiver(pushServiceSocket);
@@ -21,7 +21,9 @@ import org.signal.network.api.AttachmentApi
import org.signal.network.api.CallingApi
import org.signal.network.api.CdsApi
import org.signal.network.api.CertificateApi
import org.signal.network.api.KeysApiV2
import org.signal.network.api.LinkDeviceApi
import org.signal.network.api.MessageApiV2
import org.signal.network.api.PaymentsApi
import org.signal.network.api.ProvisioningApi
import org.signal.network.api.RateLimitChallengeApi
@@ -29,6 +31,7 @@ import org.signal.network.api.RemoteConfigApi
import org.signal.network.api.SvrBApi
import org.signal.network.api.UsernameApi
import org.signal.network.rest.SignalRestClient
import org.signal.network.service.MessageService
import org.thoughtcrime.securesms.crypto.storage.SignalServiceDataStoreImpl
import org.thoughtcrime.securesms.groups.GroupsV2Authorization
import org.thoughtcrime.securesms.groups.GroupsV2AuthorizationMemoryValueCache
@@ -95,6 +98,12 @@ class NetworkDependenciesModule(
}
val signalServiceMessageSender: SignalServiceMessageSender by _signalServiceMessageSender
val messageApiV2: MessageApiV2 by lazy { MessageApiV2(authWebSocket, unauthWebSocket) }
val keysApiV2: KeysApiV2 by lazy { KeysApiV2(authWebSocket, unauthWebSocket) }
val messageService: MessageService by lazy { provider.provideMessageService(protocolStore, messageApiV2, keysApiV2) }
val incomingMessageObserver: IncomingMessageObserver by lazy {
provider.provideIncomingMessageObserver(authWebSocket, unauthWebSocket)
}
@@ -1363,7 +1363,7 @@ final class GroupManagerV2 {
long threadId = SignalDatabase.threads().getOrCreateValidThreadId(outgoingMessage.getThreadRecipient(), -1, outgoingMessage.getDistributionType());
try {
long messageId = SignalDatabase.messages().insertMessageOutbox(outgoingMessage, threadId, false, null).getMessageId();
SignalDatabase.messages().markAsSent(messageId, true);
SignalDatabase.messages().markAsSent(messageId);
SignalDatabase.threads().update(threadId, true, true);
} catch (MmsException e) {
throw new AssertionError(e);
@@ -836,7 +836,7 @@ class GroupsV2StateProcessor private constructor(
try {
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(groupRecipient)
val id = SignalDatabase.messages.insertMessageOutbox(leaveMessage, threadId, false, null).messageId
SignalDatabase.messages.markAsSent(id, true)
SignalDatabase.messages.markAsSent(id)
SignalDatabase.drafts.clearDrafts(threadId)
SignalDatabase.threads.update(threadId, unarchive = false, allowDeletion = false)
} catch (e: MmsException) {
@@ -872,7 +872,7 @@ class GroupsV2StateProcessor private constructor(
try {
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(groupRecipient)
val id = SignalDatabase.messages.insertMessageOutbox(terminateMessage, threadId, false, null).messageId
SignalDatabase.messages.markAsSent(id, true)
SignalDatabase.messages.markAsSent(id)
SignalDatabase.threads.update(threadId, unarchive = false, allowDeletion = false)
} catch (e: MmsException) {
Log.w(TAG, "Failed to insert terminated group message for $groupId", e)
@@ -913,7 +913,7 @@ class GroupsV2StateProcessor private constructor(
try {
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(groupRecipient)
val id = SignalDatabase.messages.insertMessageOutbox(rejectedMessage, threadId, false, null).messageId
SignalDatabase.messages.markAsSent(id, true)
SignalDatabase.messages.markAsSent(id)
SignalDatabase.threads.update(threadId, unarchive = false, allowDeletion = false)
} catch (e: MmsException) {
Log.w(TAG, "Failed to insert rejected join request message for $groupId", e)
@@ -985,7 +985,7 @@ class GroupsV2StateProcessor private constructor(
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(recipient)
val messageId = SignalDatabase.messages.insertMessageOutbox(outgoingMessage, threadId, false, null).messageId
SignalDatabase.messages.markAsSent(messageId, true)
SignalDatabase.messages.markAsSent(messageId)
SignalDatabase.threads.update(threadId, unarchive = false, allowDeletion = false)
} catch (e: MmsException) {
Log.w(TAG, "Failed to insert outgoing update message!", e)
@@ -28,6 +28,7 @@ import org.thoughtcrime.securesms.recipients.RecipientUtil
import org.thoughtcrime.securesms.transport.RetryLaterException
import org.thoughtcrime.securesms.transport.UndeliverableMessageException
import org.thoughtcrime.securesms.util.MessageUtil
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.SignalLocalMetrics
import org.whispersystems.signalservice.api.SignalServiceMessageSender.IndividualSendEvents
import org.whispersystems.signalservice.api.crypto.ContentHint
@@ -67,12 +68,21 @@ class IndividualSendJob private constructor(parameters: Parameters, private val
throw AssertionError("This job does not send group messages!")
}
return IndividualSendJob(messageId, recipient, hasMedia, isScheduledSend)
return if (RemoteConfig.useIndividualSendJobV2) {
IndividualSendJobV2.create(messageId, recipient, hasMedia, isScheduledSend)
} else {
IndividualSendJob(messageId, recipient, hasMedia, isScheduledSend)
}
}
@JvmStatic
@WorkerThread
fun enqueue(context: Context, jobManager: JobManager, messageId: Long, recipient: Recipient, isScheduledSend: Boolean) {
if (RemoteConfig.useIndividualSendJobV2) {
IndividualSendJobV2.enqueue(context, messageId, recipient, isScheduledSend)
return
}
try {
val message = SignalDatabase.messages.getOutgoingMessage(messageId)
if (message.scheduledDate != -1L) {
@@ -155,7 +165,7 @@ class IndividualSendJob private constructor(parameters: Parameters, private val
val unidentified = deliver(message, originalEditedMessage)
SignalDatabase.messages.markAsSent(messageId, true)
SignalDatabase.messages.markAsSent(messageId)
markAttachmentsUploaded(messageId, message)
SignalDatabase.messages.markUnidentified(messageId, unidentified)
@@ -0,0 +1,508 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.jobs
import android.content.Context
import androidx.annotation.WorkerThread
import arrow.core.Either
import arrow.core.getOrElse
import arrow.core.raise.Raise
import arrow.core.raise.either
import okio.utf8Size
import org.signal.core.util.logging.Log
import org.signal.core.util.orNull
import org.signal.network.service.MessageService
import org.thoughtcrime.securesms.BuildConfig
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.crypto.SealedSenderAccessUtil
import org.thoughtcrime.securesms.database.MessageTypes
import org.thoughtcrime.securesms.database.RecipientTable.SealedSenderAccessMode
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.CoroutineJob
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobmanager.JobTracker
import org.thoughtcrime.securesms.jobmanager.impl.BackoffUtil
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
import org.thoughtcrime.securesms.jobmanager.impl.SealedSenderConstraint
import org.thoughtcrime.securesms.jobs.protos.IndividualSendJobV2Data
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.ratelimit.ProofRequiredExceptionHandler
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientUtil
import org.thoughtcrime.securesms.util.MessageUtil
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.util.SignalLocalMetrics
import org.thoughtcrime.securesms.util.isUrgent
import org.thoughtcrime.securesms.util.toDataMessage
import org.whispersystems.signalservice.api.crypto.ContentHint
import org.whispersystems.signalservice.api.crypto.EnvelopeContent
import org.whispersystems.signalservice.api.messages.SendMessageResult
import org.whispersystems.signalservice.api.push.SignalServiceAddress
import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException
import org.whispersystems.signalservice.internal.push.Content
import org.whispersystems.signalservice.internal.push.DataMessage
import org.whispersystems.signalservice.internal.push.EditMessage
import org.whispersystems.signalservice.internal.push.ProofRequiredResponse
import org.whispersystems.signalservice.internal.push.SyncMessage
import java.util.Optional
import java.util.concurrent.TimeUnit
import kotlin.jvm.optionals.getOrNull
/**
* Alternate implementation of [IndividualSendJob] that:
* - Extends [Job] directly rather than going through [BaseJob]/[PushSendJob].
* - Routes the actual send through the new [MessageService] (which encapsulates device resolution,
* prekey fetching, session building, encryption, and sync-transcript delivery).
*
* Used when [RemoteConfig.useIndividualSendJobV2] is true.
*
* Behavior should match [IndividualSendJob] exactly for observable state changes (marking sent,
* UD-mode updates, expiration starts, view-once cleanup, etc.). The primary divergence is the
* network layer.
*/
class IndividualSendJobV2 private constructor(parameters: Parameters, private val messageId: Long) : CoroutineJob(parameters) {
companion object {
const val KEY: String = "IndividualSendJobV2"
private val TAG = Log.tag(IndividualSendJobV2::class.java)
@JvmStatic
fun create(messageId: Long, recipient: Recipient, hasMedia: Boolean, isScheduledSend: Boolean): Job {
check(recipient.hasServiceId) { "No ServiceId!" }
check(!recipient.isGroup) { "This job does not send group messages!" }
return IndividualSendJobV2(messageId, recipient, hasMedia, isScheduledSend)
}
@JvmStatic
@WorkerThread
fun enqueue(context: Context, messageId: Long, recipient: Recipient, isScheduledSend: Boolean) {
val message = SignalDatabase.messages.getOutgoingMessageOrNull(messageId)
if (message == null) {
Log.w(TAG, "${logPrefix(null, messageId)} Failed to enqueue message.")
SignalDatabase.messages.markAsSentFailed(messageId)
PushSendJob.notifyMediaMessageDeliveryFailed(context, messageId)
return
}
if (message.scheduledDate != -1L) {
AppDependencies.scheduledMessageManager.scheduleIfNecessary()
return
}
val attachmentUploadIds: Set<String> = PushSendJob.enqueueCompressingAndUploadAttachmentsChains(AppDependencies.jobManager, message)
val hasMedia = attachmentUploadIds.isNotEmpty()
val addHardDependencies = hasMedia && !isScheduledSend
AppDependencies.jobManager.add(
create(messageId, recipient, hasMedia, isScheduledSend),
attachmentUploadIds,
if (addHardDependencies) recipient.id.toQueueKey() else null
)
}
private fun logPrefix(sentTimestamp: Long? = null, messageId: Long): String = "[${sentTimestamp ?: "?"}][$messageId]"
}
constructor(messageId: Long, recipient: Recipient, hasMedia: Boolean, isScheduledSend: Boolean) : this(
parameters = Parameters.Builder()
.setQueue(if (isScheduledSend) recipient.id.toScheduledSendQueueKey() else recipient.id.toQueueKey(hasMedia))
.addConstraint(NetworkConstraint.KEY)
.addConstraint(SealedSenderConstraint.KEY)
.setLifespan(TimeUnit.DAYS.toMillis(1))
.setMaxAttempts(Parameters.UNLIMITED)
.build(),
messageId = messageId
)
override fun serialize(): ByteArray = IndividualSendJobV2Data(messageId = messageId).encode()
override fun getFactoryKey(): String = KEY
override fun onAdded() {
SignalDatabase.messages.markAsSending(messageId)
}
override suspend fun doRun(): Result {
SignalLocalMetrics.IndividualMessageSend.onJobStarted(messageId)
val result = doWork()
SignalLocalMetrics.IndividualMessageSend.onJobFinished(messageId)
return result
}
suspend fun doWork(): Result {
syncPreKeysIfNecessary().getOrElse { return it }
if (SignalStore.misc.isClientDeprecated) {
Log.w(TAG, "${logPrefix()} Client is deprecated (build ${BuildConfig.BUILD_TIMESTAMP}); failing message.")
return Result.failure()
}
if (!Recipient.self().isRegistered) {
Log.w(TAG, "${logPrefix()} Self is not registered; failing.")
return Result.failure()
}
val message = SignalDatabase.messages.getOutgoingMessageOrNull(messageId)
if (message == null) {
Log.w(TAG, "${logPrefix()} No outgoing message found for id; failing.")
return Result.failure()
}
val messageRecord = SignalDatabase.messages.getMessageRecordOrNull(messageId)
if (messageRecord == null) {
Log.w(TAG, "${logPrefix(message.sentTimeMillis)} No message record found for id; failing.")
return Result.failure()
}
if (MessageTypes.isSentType(messageRecord.type)) {
Log.w(TAG, "${logPrefix(message.sentTimeMillis)} Message was already sent. Ignoring.")
return Result.success()
}
val threadId = messageRecord.threadId
val originalEditedMessage = if (message.messageToEdit > 0) {
SignalDatabase.messages.getMessageRecordOrNull(message.messageToEdit)
} else {
null
}
if (message.body.utf8Size() > MessageUtil.MAX_INLINE_BODY_SIZE_BYTES) {
Log.w(TAG, "${logPrefix(message.sentTimeMillis)} Body size exceeds limit of ${MessageUtil.MAX_INLINE_BODY_SIZE_BYTES} bytes; failing.")
return Result.failure()
}
val recipient = message.threadRecipient.fresh().validated(message.sentTimeMillis).getOrElse { return it }
val dataMessage = message.toDataMessage().getOrElse { error ->
Log.w(TAG, "${logPrefix(message.sentTimeMillis)} Failed to create a data message! Reason: $error")
return Result.failure()
}
RecipientUtil.shareProfileIfFirstSecureMessage(message.threadRecipient)
Log.i(TAG, "${logPrefix(message.sentTimeMillis)} Sending message. Recipient: ${message.threadRecipient.id}, Thread: $threadId, Attachments: ${buildAttachmentString(message.attachments)}, Editing: ${originalEditedMessage?.dateSent ?: "N/A"}")
SignalLocalMetrics.IndividualMessageSend.onDeliveryStarted(messageId, message.sentTimeMillis)
return sendMessage(recipient, dataMessage, originalEditedMessage?.timestamp).fold(
ifRight = { success ->
val content = success.envelopeContent.content.get()
val syntheticResult = SendMessageResult.success(
SignalServiceAddress(recipient.requireServiceId(), recipient.e164.orNull()),
success.devices,
success.sentUnidentified,
false,
0L,
Optional.of(content)
)
SignalDatabase.messageLog.insertIfPossible(
recipientId = recipient.id,
sentTimestamp = message.sentTimeMillis,
sendMessageResult = syntheticResult,
contentHint = ContentHint.RESENDABLE,
messageId = MessageId(messageId),
urgent = content.isUrgent()
)
if (recipient.needsPniSignature) {
SignalDatabase.pendingPniSignatureMessages.insertIfNecessary(recipient.id, message.sentTimeMillis, syntheticResult)
}
SignalDatabase.messages.markAsSent(messageId, success.sentUnidentified)
PushSendJob.markAttachmentsUploaded(messageId, message)
SignalDatabase.threads.updateSilently(threadId, false)
if (recipient.isSelf) {
SignalDatabase.messages.incrementDeliveryReceiptCount(message.sentTimeMillis, recipient.id, System.currentTimeMillis())
SignalDatabase.messages.incrementReadReceiptCount(message.sentTimeMillis, recipient.id, System.currentTimeMillis())
SignalDatabase.messages.incrementViewedReceiptCount(message.sentTimeMillis, recipient.id, System.currentTimeMillis())
}
val accessMode = recipient.sealedSenderAccessMode
if (success.sentUnidentified && accessMode == SealedSenderAccessMode.UNKNOWN && recipient.profileKey == null) {
SignalDatabase.recipients.setSealedSenderAccessMode(recipient.id, SealedSenderAccessMode.UNRESTRICTED)
} else if (success.sentUnidentified && accessMode == SealedSenderAccessMode.UNKNOWN) {
SignalDatabase.recipients.setSealedSenderAccessMode(recipient.id, SealedSenderAccessMode.ENABLED)
} else if (!success.sentUnidentified && accessMode != SealedSenderAccessMode.DISABLED) {
SignalDatabase.recipients.setSealedSenderAccessMode(recipient.id, SealedSenderAccessMode.DISABLED)
}
if (originalEditedMessage != null && originalEditedMessage.expireStarted > 0) {
SignalDatabase.messages.markExpireStarted(messageId, originalEditedMessage.expireStarted)
AppDependencies.expiringMessageManager.scheduleDeletion(messageId, true, originalEditedMessage.expireStarted, originalEditedMessage.expiresIn)
} else if (message.expiresIn > 0 && !message.isExpirationUpdate) {
SignalDatabase.messages.markExpireStarted(messageId)
AppDependencies.expiringMessageManager.scheduleDeletion(messageId, true, message.expiresIn)
}
if (message.isViewOnce) {
SignalDatabase.attachments.deleteAttachmentFilesForViewOnceMessage(messageId)
}
ConversationShortcutRankingUpdateJob.enqueueForOutgoingIfNecessary(recipient)
Log.i(TAG, "${logPrefix(message.sentTimeMillis)} Sent message.")
Result.success()
},
ifLeft = { error ->
when (error) {
is MessageService.SendError.IdentityMismatch -> {
Log.w(TAG, "${logPrefix(message.sentTimeMillis)} Identity mismatch for ${error.recipient.identifier}", error.cause)
val externalRecipient = Recipient.external(error.recipient.identifier)
if (externalRecipient == null) {
Log.w(TAG, "${logPrefix(message.sentTimeMillis)} Failed to create a Recipient for the identifier!")
} else {
SignalDatabase.messages.addMismatchedIdentity(messageId, externalRecipient.id, error.cause.untrustedIdentity)
SignalDatabase.messages.markAsSentFailed(messageId)
RetrieveProfileJob.enqueue(externalRecipient.id, true)
}
Result.success()
}
MessageService.SendError.NotRegistered -> {
Log.w(TAG, "${logPrefix(message.sentTimeMillis)} Recipient not registered")
SignalDatabase.messages.markAsSentFailed(messageId)
PushSendJob.notifyMediaMessageDeliveryFailed(context, messageId)
AppDependencies.jobManager.add(DirectoryRefreshJob(false))
Result.success()
}
MessageService.SendError.Unauthorized -> {
Log.w(TAG, "${logPrefix(message.sentTimeMillis)} Unauthorized send")
Result.failure()
}
is MessageService.SendError.ChallengeRequired -> {
Log.w(TAG, "${logPrefix(message.sentTimeMillis)} Challenge required (options=${error.options})")
val proofResponse = ProofRequiredResponse().apply {
token = error.token
options = error.options
}
val proofException = ProofRequiredException(proofResponse, error.retryAfter?.inWholeSeconds ?: 0L)
val threadRecipient = SignalDatabase.threads.getRecipientForThreadId(threadId)
when (ProofRequiredExceptionHandler.handle(context, proofException, threadRecipient, threadId, messageId)) {
ProofRequiredExceptionHandler.Result.RETRY_NOW -> Result.retry(0L)
ProofRequiredExceptionHandler.Result.RETRY_LATER,
ProofRequiredExceptionHandler.Result.RETHROW -> Result.retry(nextRunAttemptBackoff(runAttempt + 1))
}
}
MessageService.SendError.ServerRejected -> {
Log.w(TAG, "${logPrefix(message.sentTimeMillis)} Server rejected the send")
Result.failure()
}
is MessageService.SendError.ContentTooLarge -> {
Log.w(TAG, "${logPrefix(message.sentTimeMillis)} Content too large (${error.size} > ${error.maxAllowed} bytes); failing.")
Result.failure()
}
MessageService.SendError.SessionAttemptsExhausted -> {
Log.w(TAG, "${logPrefix(message.sentTimeMillis)} Exhausted device-resolution attempts; retrying")
Result.retry(nextRunAttemptBackoff(runAttempt + 1))
}
is MessageService.SendError.PreKeyUnavailable -> {
Log.w(TAG, "${logPrefix(message.sentTimeMillis)} Prekey unavailable: ${error.reason}")
Result.retry(nextRunAttemptBackoff(runAttempt + 1))
}
is MessageService.SendError.RateLimited -> {
val defaultBackoff = nextRunAttemptBackoff(runAttempt + 1)
val serverBackoff = error.retryAfter?.inWholeMilliseconds ?: 0L
val backoff = maxOf(defaultBackoff, serverBackoff)
Log.w(TAG, "${logPrefix(message.sentTimeMillis)} Rate limited, retryAfter=${error.retryAfter}, using backoff=${backoff}ms")
Result.retry(backoff)
}
is MessageService.SendError.NetworkError -> {
Log.w(TAG, "${logPrefix(message.sentTimeMillis)} Network error", error.cause)
Result.retry(nextRunAttemptBackoff(runAttempt + 1))
}
is MessageService.SendError.ApplicationError -> when (val cause = error.cause) {
is RuntimeException -> {
Log.e(TAG, "${logPrefix(message.sentTimeMillis)} Encountered a fatal application error. Crash imminent.", cause)
Result.fatalFailure(cause)
}
else -> {
Log.w(TAG, "${logPrefix(message.sentTimeMillis)} Application error", cause)
Result.retry(nextRunAttemptBackoff(runAttempt + 1))
}
}
}
}
)
}
private suspend fun sendMessage(recipient: Recipient, dataMessage: DataMessage, editMessageTarget: Long?): Either<MessageService.SendError, MessageService.SendSuccess> = either {
val primaryResult = sendPrimaryMessage(
recipient = recipient,
dataMessage = dataMessage,
editMessageTarget = editMessageTarget
).also {
SignalLocalMetrics.IndividualMessageSend.onMessageSent(messageId)
}
if (SignalStore.account.isMultiDevice) {
sendSyncMessage(recipient, primaryResult).also {
SignalLocalMetrics.IndividualMessageSend.onSyncMessageSent(messageId)
}
}
primaryResult
}
private suspend fun Raise<MessageService.SendError>.sendPrimaryMessage(recipient: Recipient, dataMessage: DataMessage, editMessageTarget: Long?): MessageService.SendSuccess {
val content: Content = if (editMessageTarget != null) {
Content(
editMessage = EditMessage(
targetSentTimestamp = editMessageTarget,
dataMessage = dataMessage
)
)
} else {
val pniSignature = if (recipient.needsPniSignature) {
Log.i(TAG, "${logPrefix(dataMessage.timestamp)} Including PNI signature.")
AppDependencies.signalServiceMessageSender.createPniSignatureMessage()
} else {
null
}
Content(
dataMessage = dataMessage,
pniSignatureMessage = pniSignature
)
}
val envelopeContent = EnvelopeContent.encrypted(content, ContentHint.RESENDABLE, Optional.empty())
// If this is a note to self message, don't actually send it. Instead, craft a result of what we *would* send. Then it'll be sent via sync message if appropriate.
if (SignalStore.account.aci == recipient.serviceId.getOrNull()) {
Log.i(TAG, "${logPrefix(dataMessage.timestamp)} Note to self. Skipping primary send.")
return MessageService.SendSuccess(envelopeContent, true, listOf(SignalServiceAddress.DEFAULT_DEVICE_ID))
}
return AppDependencies.messageService.sendMessage(
recipient = SignalServiceAddress(recipient.requireServiceId(), recipient.e164.orNull()),
envelopeContent = envelopeContent,
timestamp = dataMessage.timestamp!!,
sealedSenderAccess = SealedSenderAccessUtil.getSealedSenderAccessFor(recipient),
story = false,
isOnline = false,
urgent = content.isUrgent(),
onEncrypted = { SignalLocalMetrics.IndividualMessageSend.onMessageEncrypted(messageId) }
).bind()
}
private suspend fun Raise<MessageService.SendError>.sendSyncMessage(targetRecipient: Recipient, primaryResult: MessageService.SendSuccess): MessageService.SendSuccess {
val dataMessage = primaryResult.envelopeContent.content.get().dataMessage
val editMessage = primaryResult.envelopeContent.content.get().editMessage
val timestamp = dataMessage?.timestamp ?: editMessage?.dataMessage?.timestamp ?: raise(MessageService.SendError.ApplicationError(IllegalStateException("No timestamp on primary message send!")))
val syncContent = Content(
syncMessage = SyncMessage(
sent = SyncMessage.Sent(
destinationServiceId = targetRecipient.serviceId.get().toString(),
timestamp = timestamp,
message = dataMessage,
editMessage = editMessage
)
)
)
val syncEnvelope = EnvelopeContent.encrypted(syncContent, ContentHint.IMPLICIT, Optional.empty())
return AppDependencies.messageService.sendMessage(
recipient = SignalServiceAddress(SignalStore.account.requireAci()),
envelopeContent = syncEnvelope,
timestamp = timestamp,
sealedSenderAccess = null, // We don't use sealed sender for sync messages
story = false,
isOnline = false,
urgent = true,
onEncrypted = { SignalLocalMetrics.IndividualMessageSend.onSyncMessageEncrypted(messageId) }
).bind()
}
override fun onRetry() {
SignalLocalMetrics.IndividualMessageSend.cancel(messageId)
if (runAttempt > 1) {
AppDependencies.jobManager.add(ServiceOutageDetectionJob())
}
}
override fun onFailure() {
SignalLocalMetrics.IndividualMessageSend.cancel(messageId)
SignalDatabase.messages.markAsSentFailed(messageId)
PushSendJob.notifyMediaMessageDeliveryFailed(context, messageId)
}
private fun nextRunAttemptBackoff(pastAttemptCount: Int): Long {
return BackoffUtil.exponentialBackoff(pastAttemptCount, RemoteConfig.defaultMaxBackoff)
}
/**
* Syncs prekeys if we haven't done so for a long time. In practice, we shouldn't hit this -- it's a failsafe.
* @return if non-null, this should be used as the overall job result.
*/
private fun syncPreKeysIfNecessary(): Either<Result, Unit> = either {
val timeSinceAciSignedPreKeyRotation = System.currentTimeMillis() - SignalStore.account.aciPreKeys.lastSignedPreKeyRotationTime
val timeSincePniSignedPreKeyRotation = System.currentTimeMillis() - SignalStore.account.pniPreKeys.lastSignedPreKeyRotationTime
if (timeSinceAciSignedPreKeyRotation > PreKeysSyncJob.MAXIMUM_ALLOWED_SIGNED_PREKEY_AGE ||
timeSinceAciSignedPreKeyRotation < 0 ||
timeSincePniSignedPreKeyRotation > PreKeysSyncJob.MAXIMUM_ALLOWED_SIGNED_PREKEY_AGE ||
timeSincePniSignedPreKeyRotation < 0
) {
Log.w(TAG, "${logPrefix()} It's been too long since rotating our signed prekeys. Attempting to rotate now.")
val state = AppDependencies.jobManager.runSynchronously(PreKeysSyncJob.create(), TimeUnit.SECONDS.toMillis(30))
if (state.isPresent && state.get() == JobTracker.JobState.SUCCESS) {
Log.i(TAG, "${logPrefix()} Successfully refreshed prekeys. Continuing.")
} else {
Log.w(TAG, "${logPrefix()} Failed to refresh prekeys; retrying. State: ${if (state.isEmpty) "<empty>" else state.get()}")
raise(Result.retry(nextRunAttemptBackoff(runAttempt + 1)))
}
}
}
private fun Recipient.validated(sentTime: Long): Either<Result, Recipient> = either {
if (isUnregistered) {
Log.w(TAG, "${logPrefix(sentTime)} Recipient $id not registered; failing.")
raise(Result.failure())
}
if (!hasServiceId) {
Log.w(TAG, "${logPrefix(sentTime)} Recipient $id has no serviceId; failing.")
raise(Result.failure())
}
this@validated
}
private fun logPrefix(sentTimestamp: Long? = null): String = logPrefix(sentTimestamp, messageId)
private fun buildAttachmentString(attachments: List<Attachment>): String {
return attachments.joinToString(", ") { attachment ->
when {
attachment is DatabaseAttachment -> attachment.attachmentId.toString()
attachment.uri != null -> attachment.uri.toString()
else -> attachment.toString()
}
}
}
class Factory : Job.Factory<IndividualSendJobV2> {
override fun create(parameters: Parameters, serializedData: ByteArray?): IndividualSendJobV2 {
val data = IndividualSendJobV2Data.ADAPTER.decode(serializedData!!)
return IndividualSendJobV2(parameters, data.messageId)
}
}
}
@@ -198,6 +198,7 @@ public final class JobManagerFactories {
put(InAppPaymentStripeOneTimeSetupJob.KEY, new InAppPaymentStripeOneTimeSetupJob.Factory());
put(InAppPaymentStripeRecurringSetupJob.KEY, new InAppPaymentStripeRecurringSetupJob.Factory());
put(IndividualSendJob.KEY, new IndividualSendJob.Factory());
put(IndividualSendJobV2.KEY, new IndividualSendJobV2.Factory());
put(LeaveGroupV2Job.KEY, new LeaveGroupV2Job.Factory());
put(LeaveGroupV2WorkerJob.KEY, new LeaveGroupV2WorkerJob.Factory());
put(LinkedDeviceInactiveCheckJob.KEY, new LinkedDeviceInactiveCheckJob.Factory());
@@ -495,7 +495,7 @@ public final class PushGroupSendJob extends PushSendJob {
}
if (existingNetworkFailures.isEmpty() && existingIdentityMismatches.isEmpty()) {
database.markAsSent(messageId, true);
database.markAsSent(messageId);
markAttachmentsUploaded(messageId, message);
@@ -73,7 +73,7 @@ abstract class PushSendJob protected constructor(parameters: Parameters) : BaseJ
private val TAG = Log.tag(PushSendJob::class.java)
@JvmStatic
protected fun enqueueCompressingAndUploadAttachmentsChains(jobManager: JobManager, message: OutgoingMessage): Set<String> {
fun enqueueCompressingAndUploadAttachmentsChains(jobManager: JobManager, message: OutgoingMessage): Set<String> {
val attachments: MutableList<Attachment> = mutableListOf()
attachments += message.attachments
@@ -109,7 +109,7 @@ abstract class PushSendJob protected constructor(parameters: Parameters) : BaseJ
}
@JvmStatic
protected fun notifyMediaMessageDeliveryFailed(context: Context, messageId: Long) {
fun notifyMediaMessageDeliveryFailed(context: Context, messageId: Long) {
val threadId = messages.getThreadIdForMessage(messageId)
val recipient = threads.getRecipientForThreadId(threadId)
val groupReplyStoryId = messages.getParentStoryIdForGroupReply(messageId)
@@ -135,7 +135,7 @@ abstract class PushSendJob protected constructor(parameters: Parameters) : BaseJ
}
@JvmStatic
protected fun markAttachmentsUploaded(messageId: Long, message: OutgoingMessage) {
fun markAttachmentsUploaded(messageId: Long, message: OutgoingMessage) {
val attachments: MutableList<Attachment> = mutableListOf()
attachments += message.attachments
@@ -189,7 +189,7 @@ public class RemoteDeleteSendJob extends BaseJob {
}
if (recipients.isEmpty()) {
db.markAsSent(messageId, true);
db.markAsSent(messageId);
} else {
Log.w(TAG, "Still need to send to " + recipients.size() + " recipients. Retrying.");
throw new RetryLaterException();
@@ -425,7 +425,7 @@ object SyncMessageProcessor {
SignalDatabase.messages.markUnidentified(messageId, sent.isUnidentified(toRecipient.serviceId.orNull()))
}
SignalDatabase.messages.markAsSent(messageId, true)
SignalDatabase.messages.markAsSent(messageId)
if (targetMessage.expireStarted > 0) {
SignalDatabase.messages.markExpireStarted(messageId, targetMessage.expireStarted)
AppDependencies.expiringMessageManager.scheduleDeletion(messageId, true, targetMessage.expireStarted, targetMessage.expireStarted)
@@ -498,7 +498,7 @@ object SyncMessageProcessor {
SignalDatabase.messages.markUnidentified(messageId, sent.isUnidentified(toRecipient.serviceId.orNull()))
}
SignalDatabase.messages.markAsSent(messageId, true)
SignalDatabase.messages.markAsSent(messageId)
val attachments: List<DatabaseAttachment> = SignalDatabase.attachments.getAttachmentsForMessage(messageId)
@@ -605,7 +605,7 @@ object SyncMessageProcessor {
SignalDatabase.messages.markUnidentified(messageId, sent.isUnidentified(recipient.serviceId.orNull()))
}
SignalDatabase.messages.markAsSent(messageId, true)
SignalDatabase.messages.markAsSent(messageId)
val allAttachments = SignalDatabase.attachments.getAttachmentsForMessage(messageId)
val attachments: List<DatabaseAttachment> = allAttachments.filterNot { it.isSticker }
@@ -716,14 +716,14 @@ object SyncMessageProcessor {
// TODO [expireVersion] After unsupported builds expire, we can remove this branch
SignalDatabase.recipients.setExpireMessagesWithoutIncrementingVersion(recipient.id, sent.message!!.expireTimerDuration.inWholeSeconds.toInt())
val messageId: Long = SignalDatabase.messages.insertMessageOutbox(expirationUpdateMessage, threadId, false, null).messageId
SignalDatabase.messages.markAsSent(messageId, true)
SignalDatabase.messages.markAsSent(messageId)
} else if (sent.message!!.expireTimerVersion!! >= recipient.expireTimerVersion) {
SignalDatabase.recipients.setExpireMessages(recipient.id, sent.message!!.expireTimerDuration.inWholeSeconds.toInt(), sent.message!!.expireTimerVersion!!)
if (sent.message!!.expireTimerDuration != recipient.expiresInSeconds.seconds) {
log(sent.timestamp!!, "Not inserted update message as timer value did not change")
val messageId: Long = SignalDatabase.messages.insertMessageOutbox(expirationUpdateMessage, threadId, false, null).messageId
SignalDatabase.messages.markAsSent(messageId, true)
SignalDatabase.messages.markAsSent(messageId)
}
} else {
warn(sent.timestamp!!, "[SynchronizeExpiration] Ignoring expire timer update with old version. Received: ${sent.message!!.expireTimerVersion}, Current: ${recipient.expireTimerVersion}")
@@ -807,7 +807,7 @@ object SyncMessageProcessor {
SignalDatabase.messages.markUnidentified(messageId, sent.isUnidentified(recipient.serviceId.orNull()))
}
SignalDatabase.messages.markAsSent(messageId, true)
SignalDatabase.messages.markAsSent(messageId)
if (dataMessage.expireTimerDuration > Duration.ZERO) {
SignalDatabase.messages.markExpireStarted(messageId, sent.expirationStartTimestamp ?: 0)
@@ -874,7 +874,7 @@ object SyncMessageProcessor {
SignalDatabase.messages.markUnidentified(messageId, sent.isUnidentified(syncDestinationRecipient.serviceId.orNull()))
}
SignalDatabase.messages.markAsSent(messageId, true)
SignalDatabase.messages.markAsSent(messageId)
if (dataMessage.expireTimerDuration > Duration.ZERO) {
SignalDatabase.messages.markExpireStarted(messageId, sent.expirationStartTimestamp ?: 0)
@@ -949,7 +949,7 @@ object SyncMessageProcessor {
log(envelopeTimestamp, "Inserted sync message as messageId $messageId")
SignalDatabase.messages.markAsSent(messageId, true)
SignalDatabase.messages.markAsSent(messageId)
if (expiresInMillis > 0) {
SignalDatabase.messages.markExpireStarted(messageId, sent.expirationStartTimestamp ?: 0)
@@ -1889,7 +1889,7 @@ object SyncMessageProcessor {
log(envelope.clientTimestamp!!, "Inserted sync poll create message as messageId $messageId")
SignalDatabase.messages.markAsSent(messageId, true)
SignalDatabase.messages.markAsSent(messageId)
if (expiresInMillis > 0) {
SignalDatabase.messages.markExpireStarted(messageId, sent.expirationStartTimestamp ?: 0)
@@ -1947,7 +1947,7 @@ object SyncMessageProcessor {
val receiptStatus = if (recipient.isGroup) GroupReceiptTable.STATUS_UNKNOWN else GroupReceiptTable.STATUS_UNDELIVERED
val messageId = SignalDatabase.messages.insertMessageOutbox(outgoingMessage, threadId, false, receiptStatus, null).messageId
SignalDatabase.messages.markAsSent(messageId, true)
SignalDatabase.messages.markAsSent(messageId)
log(envelope.clientTimestamp!!, "Inserted sync poll end message as messageId $messageId")
@@ -2014,7 +2014,7 @@ object SyncMessageProcessor {
)
val messageId = SignalDatabase.messages.insertMessageOutbox(outgoingMessage, threadId, false, GroupReceiptTable.STATUS_UNKNOWN, null).messageId
SignalDatabase.messages.markAsSent(messageId, true)
SignalDatabase.messages.markAsSent(messageId)
log(envelope.clientTimestamp!!, "Inserted sync pin message as messageId $messageId")
@@ -1180,6 +1180,19 @@ object RemoteConfig {
hotSwappable = true
)
/**
* When true, individual 1:1 sends are routed through [IndividualSendJobV2], which uses the
* network-module [org.signal.network.service.MessageService] instead of the legacy
* [SignalServiceMessageSender] send path.
*/
@JvmStatic
@get:JvmName("useIndividualSendJobV2")
val useIndividualSendJobV2: Boolean by remoteBoolean(
key = "android.useIndividualSendJobV2",
defaultValue = false,
hotSwappable = true
)
/**
* Also determines how long an unregistered/deleted record should remain in storage service
*/
@@ -131,10 +131,10 @@ public final class SignalLocalMetrics {
private static final String SPLIT_DB_INSERT = "db-insert";
private static final String SPLIT_JOB_ENQUEUE = "job-enqueue";
private static final String SPLIT_JOB_PRE_NETWORK = "job-pre-network";
private static final String SPLIT_ENCRYPT = "encrypt";
private static final String SPLIT_NETWORK_MAIN = "network-main";
private static final String SPLIT_MAIN_ENCRYPT = "main-encrypt";
private static final String SPLIT_MAIN_NETWORK = "main-network";
private static final String SPLIT_SYNC_ENCRYPT = "sync-encrypt";
private static final String SPLIT_NETWORK_SYNC = "network-sync";
private static final String SPLIT_SYNC_NETWORK = "sync-network";
private static final String SPLIT_JOB_POST_NETWORK = "job-post-network";
private static final String SPLIT_UI_UPDATE = "ui-update";
@@ -167,11 +167,11 @@ public final class SignalLocalMetrics {
}
public static void onMessageEncrypted(long messageId) {
split(messageId, SPLIT_ENCRYPT);
split(messageId, SPLIT_MAIN_ENCRYPT);
}
public static void onMessageSent(long messageId) {
split(messageId, SPLIT_NETWORK_MAIN);
split(messageId, SPLIT_MAIN_NETWORK);
}
public static void onSyncMessageEncrypted(long messageId) {
@@ -179,7 +179,7 @@ public final class SignalLocalMetrics {
}
public static void onSyncMessageSent(long messageId) {
split(messageId, SPLIT_NETWORK_SYNC);
split(messageId, SPLIT_SYNC_NETWORK);
}
public static void onJobFinished(long messageId) {
@@ -0,0 +1,529 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.util
import arrow.core.Either
import arrow.core.raise.context.bind
import arrow.core.raise.either
import arrow.core.raise.ensure
import arrow.core.raise.ensureNotNull
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.signal.core.models.ServiceId
import org.signal.core.models.ServiceId.ACI
import org.signal.core.util.Base64
import org.signal.core.util.Hex
import org.signal.core.util.UuidUtil
import org.signal.core.util.logging.Log
import org.signal.libsignal.zkgroup.InvalidInputException
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation
import org.thoughtcrime.securesms.attachments.Attachment
import org.thoughtcrime.securesms.contactshare.Contact
import org.thoughtcrime.securesms.crypto.ProfileKeyUtil
import org.thoughtcrime.securesms.database.MessageTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.database.model.databaseprotos.PinnedMessage
import org.thoughtcrime.securesms.database.model.databaseprotos.PollTerminate
import org.thoughtcrime.securesms.linkpreview.LinkPreview
import org.thoughtcrime.securesms.mms.OutgoingMessage
import org.thoughtcrime.securesms.mms.QuoteModel
import org.thoughtcrime.securesms.polls.Poll
import org.thoughtcrime.securesms.recipients.Recipient
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId
import org.whispersystems.signalservice.internal.push.AttachmentPointer
import org.whispersystems.signalservice.internal.push.BodyRange
import org.whispersystems.signalservice.internal.push.CallMessage
import org.whispersystems.signalservice.internal.push.Content
import org.whispersystems.signalservice.internal.push.DataMessage
import org.whispersystems.signalservice.internal.push.Preview
import org.whispersystems.signalservice.internal.push.SyncMessage
import java.io.IOException
private const val TAG = "DataMessageTransforms"
/**
* Builds the wire [DataMessage] for this outgoing message. It is technically possible, though rare, that we may not be
* able to successfully construct a model. These are almost certainly data consistency bugs, and we'd rather fail the
* send than send something that doesn't match the user intent.
*/
fun OutgoingMessage.toDataMessage(): Either<DataMessageError, DataMessage> = either {
val builder = DataMessage.Builder()
builder.body = body.ifEmpty { null }
builder.timestamp = sentTimeMillis
builder.profileKey = threadRecipient.fresh().selfProfileKeyForOutgoing()
builder.sticker = attachments.toStickerIfPresent().bind()
builder.contact = sharedContacts.map { it.toProto().bind() }
builder.preview = linkPreviews.map { it.toProto().bind() }
builder.giftBadge = giftBadge?.toProto()?.bind()
builder.bodyRanges = bodyRanges?.toProto()?.bind() ?: emptyList()
builder.pollCreate = poll?.toProto()
builder.pollTerminate = messageExtras?.pollTerminate?.toProto()
builder.pinMessage = messageExtras?.pinnedMessage?.toProto()?.bind()
builder.payment = toPaymentProtoIfPresent().bind()
builder.isViewOnce = isViewOnce
builder.flags = if (isExpirationUpdate) DataMessage.Flags.EXPIRATION_TIMER_UPDATE.value else null
builder.expireTimer = (expiresIn / 1000).toInt()
builder.expireTimerVersion = expireTimerVersion
builder.attachments = attachments
.filter { !it.isSticker }
.map { it.toAttachmentPointerProto().bind() }
.capIncrementalMacs(RemoteConfig.maxIncrementalMacsPerEnvelope)
if (giftBadge != null || isPaymentsNotification) {
builder.body = null
}
if (parentStoryId != null) {
val storyRecord = ensureNotNull(SignalDatabase.messages.getMessageRecordOrNull(parentStoryId.asMessageId().id)) {
DataMessageError.MissingParentStory
}
val storyAuthor = storyRecord.fromRecipient.requireServiceId()
builder.storyContext = DataMessage.StoryContext(
authorAciBinary = storyAuthor.toByteString(),
sentTimestamp = storyRecord.dateSent
)
if (isStoryReaction) {
builder.reaction = DataMessage.Reaction(
emoji = body,
remove = false,
targetAuthorAciBinary = storyAuthor.toByteString(),
targetSentTimestamp = storyRecord.dateSent
)
builder.body = null
}
} else {
builder.quote = outgoingQuote?.toProto(isMessageEdit)?.bind()
}
builder.requiredProtocolVersion = builder.getRequiredProtocolVersion(isViewOnce)
builder.build()
}
private fun DataMessage.Builder.getRequiredProtocolVersion(isViewOnce: Boolean): Int? {
var version = 0
if (isViewOnce) {
version = maxOf(version, DataMessage.ProtocolVersion.VIEW_ONCE_VIDEO.value)
}
if (reaction != null) {
version = maxOf(version, DataMessage.ProtocolVersion.REACTIONS.value)
}
if (payment != null) {
version = maxOf(version, DataMessage.ProtocolVersion.PAYMENTS.value)
}
if (pollCreate != null) {
version = maxOf(version, DataMessage.ProtocolVersion.POLLS.value)
}
return version.takeIf { it > 0 }
}
private fun QuoteModel.toProto(isMessageEdit: Boolean): Either<DataMessageError, DataMessage.Quote> = either {
if (isMessageEdit) {
return@either DataMessage.Quote(
id = 0,
authorAciBinary = ACI.UNKNOWN.toByteString(),
text = "",
type = DataMessage.Quote.Type.NORMAL
)
}
val quoteAuthor = Recipient.resolved(author)
ensure(quoteAuthor.hasServiceId) { DataMessageError.MissingQuoteAuthorServiceId }
val mentionBodyRanges: List<BodyRange> = mentions.map { mention ->
BodyRange(
start = mention.start,
length = mention.length,
mentionAciBinary = Recipient.resolved(mention.recipientId).requireAci().toByteString()
)
}
val combinedBodyRanges: List<BodyRange> = mentionBodyRanges + (bodyRanges?.toProto()?.bind() ?: emptyList())
val quoteAttachments = attachment
?.takeUnless { MediaUtil.isViewOnceType(attachment.contentType) }
?.toQuoteAttachmentProto()
?.bind()
?.let { listOf(it) }
DataMessage.Quote(
id = id,
authorAciBinary = quoteAuthor.requireAci().toByteString(),
text = text,
attachments = quoteAttachments ?: emptyList(),
bodyRanges = combinedBodyRanges,
type = type.dataMessageType.protoType
)
}
private fun Attachment.toQuoteAttachmentProto(): Either<DataMessageError, DataMessage.Quote.QuotedAttachment> = either {
DataMessage.Quote.QuotedAttachment(
contentType = quoteTargetContentType ?: MediaUtil.IMAGE_JPEG,
fileName = fileName,
thumbnail = toAttachmentPointerProto().bind()
)
}
private fun OutgoingMessage.toPaymentProtoIfPresent(): Either<DataMessageError, DataMessage.Payment?> = either {
when {
isPaymentsNotification -> {
val paymentUuid = UuidUtil.parseOrThrow(body)
val payment = ensureNotNull(SignalDatabase.payments.getPayment(paymentUuid)) { DataMessageError.MissingPayment }
val receipt = ensureNotNull(payment.receipt) { DataMessageError.MissingPaymentReceipt }
DataMessage.Payment(
notification = DataMessage.Payment.Notification(
note = payment.note,
mobileCoin = DataMessage.Payment.Notification.MobileCoin(receipt = receipt.toByteString())
)
)
}
isRequestToActivatePayments -> {
DataMessage.Payment(activation = DataMessage.Payment.Activation(type = DataMessage.Payment.Activation.Type.REQUEST))
}
isPaymentsActivated -> {
DataMessage.Payment(activation = DataMessage.Payment.Activation(type = DataMessage.Payment.Activation.Type.ACTIVATED))
}
else -> {
null
}
}
}
private fun Recipient.selfProfileKeyForOutgoing(): ByteString? {
val resolved = this.resolve()
return if (resolved.isSystemContact || resolved.isProfileSharing) {
ProfileKeyUtil.getSelfProfileKey().serialize().toByteString()
} else {
null
}
}
private fun Attachment.toAttachmentPointerProto(): Either<DataMessageError, AttachmentPointer> = either {
if (remoteLocation.isNullOrEmpty() || remoteKey.isNullOrEmpty() || remoteDigest == null) {
raise(DataMessageError.MissingAttachmentRemoteFields)
}
val remoteIdResolved: SignalServiceAttachmentRemoteId = SignalServiceAttachmentRemoteId.from(remoteLocation)
val keyBytes: ByteArray = try {
Base64.decode(remoteKey)
} catch (_: IOException) {
raise(DataMessageError.FailedToDecodeAttachmentKey)
}
val sizeInt: Int = try {
Math.toIntExact(size)
} catch (_: ArithmeticException) {
Log.w(TAG, "Failed to parse attachment size! Skipping attachment.")
raise(DataMessageError.FailedToDecodeAttachmentSize)
}
var flags = 0
if (voiceNote) {
flags = flags or AttachmentPointer.Flags.VOICE_MESSAGE.value
}
if (borderless) {
flags = flags or AttachmentPointer.Flags.BORDERLESS.value
}
if (videoGif) {
flags = flags or AttachmentPointer.Flags.GIF.value
}
val builder = AttachmentPointer.Builder()
.cdnNumber(cdn.cdnNumber)
.contentType(contentType)
.key(keyBytes.toByteString())
.digest(remoteDigest.toByteString())
.size(sizeInt)
.uploadTimestamp(uploadTimestamp)
.flags(flags)
when (remoteIdResolved) {
is SignalServiceAttachmentRemoteId.V2 -> builder.cdnId(remoteIdResolved.cdnId)
is SignalServiceAttachmentRemoteId.V4 -> builder.cdnKey(remoteIdResolved.cdnKey)
is SignalServiceAttachmentRemoteId.S3,
is SignalServiceAttachmentRemoteId.Backup -> Unit
}
incrementalDigest?.let { builder.incrementalMac(it.toByteString()) }
incrementalMacChunkSize.takeIf { it > 0 }?.let { builder.chunkSize(incrementalMacChunkSize) }
width.takeIf { it > 0 }?.let { builder.width(it) }
height.takeIf { it > 0 }?.let { builder.height(it) }
fileName?.let { builder.fileName(it) }
caption?.let { builder.caption(it) }
blurHash?.let { builder.blurHash(it.hash) }
uuid?.let { builder.clientUuid(UuidUtil.toByteString(it)) }
builder.build()
}
private fun List<Attachment>.toStickerIfPresent(): Either<DataMessageError, DataMessage.Sticker?> = either {
val stickerAttachment = firstOrNull { it.isSticker } ?: return@either null
val locator = ensureNotNull(stickerAttachment.stickerLocator) { DataMessageError.MissingStickerLocator }
try {
val packId = Hex.fromStringCondensed(locator.packId)
val packKey = Hex.fromStringCondensed(locator.packKey)
val emoji = SignalDatabase.stickers.getSticker(locator.packId, locator.stickerId, false)?.emoji
DataMessage.Sticker(
packId = packId.toByteString(),
packKey = packKey.toByteString(),
stickerId = locator.stickerId,
emoji = emoji,
data_ = stickerAttachment.toAttachmentPointerProto().bind()
)
} catch (e: IOException) {
Log.w(TAG, "Failed to decode sticker pack fields.", e)
raise(DataMessageError.FailedToDecodeStickerPackFields)
}
}
private fun GiftBadge.toProto(): Either<DataMessageError, DataMessage.GiftBadge> = either {
try {
val presentation = ReceiptCredentialPresentation(redemptionToken.toByteArray())
DataMessage.GiftBadge(receiptCredentialPresentation = presentation.serialize().toByteString())
} catch (e: InvalidInputException) {
Log.w(TAG, "Failed to parse gift badge.", e)
raise(DataMessageError.InvalidGiftBadge)
}
}
private fun BodyRangeList.toProto(): Either<DataMessageError, List<BodyRange>> = either {
if (ranges.isEmpty()) {
return@either emptyList()
}
ranges.map { range ->
val style = when (range.style) {
BodyRangeList.BodyRange.Style.BOLD -> BodyRange.Style.BOLD
BodyRangeList.BodyRange.Style.ITALIC -> BodyRange.Style.ITALIC
BodyRangeList.BodyRange.Style.SPOILER -> BodyRange.Style.SPOILER
BodyRangeList.BodyRange.Style.STRIKETHROUGH -> BodyRange.Style.STRIKETHROUGH
BodyRangeList.BodyRange.Style.MONOSPACE -> BodyRange.Style.MONOSPACE
null -> raise(DataMessageError.InvalidBodyRange)
}
BodyRange.Builder().start(range.start).length(range.length).style(style).build()
}
}
private fun Poll.toProto(): DataMessage.PollCreate {
return DataMessage.PollCreate(
question = this.question,
allowMultiple = this.allowMultipleVotes,
options = this.pollOptions
)
}
private fun PollTerminate.toProto(): DataMessage.PollTerminate {
return DataMessage.PollTerminate(targetSentTimestamp = this.targetTimestamp)
}
private fun PinnedMessage.toProto(): Either<DataMessageError, DataMessage.PinMessage> = either {
val targetAuthor = ensureNotNull(ServiceId.parseOrNull(targetAuthorAci)) { DataMessageError.PinnedMessageInvalidAuthorAci }
val forever = pinDurationInSeconds == MessageTable.PIN_FOREVER
DataMessage.PinMessage(
targetAuthorAciBinary = targetAuthor.toByteString(),
targetSentTimestamp = targetTimestamp,
pinDurationSeconds = if (!forever) pinDurationInSeconds.toInt() else null,
pinDurationForever = if (forever) true else null
)
}
private fun LinkPreview.toProto(): Either<DataMessageError, Preview> = either {
Preview(
url = url,
title = title,
description = description,
date = date,
image = thumbnail.orElse(null)?.toAttachmentPointerProto()?.bind()
)
}
private fun Contact.toProto(): Either<DataMessageError, DataMessage.Contact> = either {
DataMessage.Contact(
name = DataMessage.Contact.Name(
givenName = name.givenName,
familyName = name.familyName,
prefix = name.prefix,
suffix = name.suffix,
middleName = name.middleName,
nickname = name.nickname
),
number = phoneNumbers.map {
DataMessage.Contact.Phone(value_ = it.number, type = it.type.toProto(), label = it.label)
},
email = emails.map {
DataMessage.Contact.Email(value_ = it.email, type = it.type.toProto(), label = it.label)
},
address = postalAddresses.map {
DataMessage.Contact.PostalAddress(
type = it.type.toProto(),
label = it.label,
street = it.street,
pobox = it.poBox,
neighborhood = it.neighborhood,
city = it.city,
region = it.region,
postcode = it.postalCode,
country = it.country
)
},
avatar = avatar?.let { avatar ->
avatar.attachment
?.toAttachmentPointerProto()
?.map { DataMessage.Contact.Avatar(avatar = it, isProfile = avatar.isProfile) }
?.bind()
},
organization = organization
)
}
private fun Contact.Phone.Type.toProto(): DataMessage.Contact.Phone.Type {
return when (this) {
Contact.Phone.Type.HOME -> DataMessage.Contact.Phone.Type.HOME
Contact.Phone.Type.MOBILE -> DataMessage.Contact.Phone.Type.MOBILE
Contact.Phone.Type.WORK -> DataMessage.Contact.Phone.Type.WORK
Contact.Phone.Type.CUSTOM -> DataMessage.Contact.Phone.Type.CUSTOM
}
}
private fun Contact.Email.Type.toProto(): DataMessage.Contact.Email.Type {
return when (this) {
Contact.Email.Type.HOME -> DataMessage.Contact.Email.Type.HOME
Contact.Email.Type.MOBILE -> DataMessage.Contact.Email.Type.MOBILE
Contact.Email.Type.WORK -> DataMessage.Contact.Email.Type.WORK
Contact.Email.Type.CUSTOM -> DataMessage.Contact.Email.Type.CUSTOM
}
}
private fun Contact.PostalAddress.Type.toProto(): DataMessage.Contact.PostalAddress.Type {
return when (this) {
Contact.PostalAddress.Type.HOME -> DataMessage.Contact.PostalAddress.Type.HOME
Contact.PostalAddress.Type.WORK -> DataMessage.Contact.PostalAddress.Type.WORK
Contact.PostalAddress.Type.CUSTOM -> DataMessage.Contact.PostalAddress.Type.CUSTOM
}
}
/**
* Strips `incrementalMac` (and its sibling `chunkSize`) from attachments past the [max]th one
* that carries an incremental MAC, mirroring `SignalServiceMessageSender.capIncrementalMacs`.
* [max] <= 0 disables the cap.
*/
private fun List<AttachmentPointer>.capIncrementalMacs(max: Int): List<AttachmentPointer> {
if (max <= 0) {
return this
}
val incrementalCount = count { it.incrementalMac != null }
if (incrementalCount <= max) {
return this
}
var kept = 0
return map { pointer ->
if (pointer.incrementalMac == null) {
pointer
} else if (kept < max) {
kept++
pointer
} else {
pointer.newBuilder().incrementalMac(null).chunkSize(null).build()
}
}
}
/**
* Whether or not the content should generate a high-priority push notification for the receiver.
*/
fun Content.isUrgent(): Boolean {
dataMessage?.let { return it.isUrgent() }
editMessage?.let { return it.dataMessage?.isUrgent() ?: false }
syncMessage?.let { return it.isUrgent() }
callMessage?.let { return it.isUrgent() }
return false
}
private fun DataMessage.isUrgent(): Boolean {
val flagsValue = this.flags ?: 0
if (flagsValue and DataMessage.Flags.EXPIRATION_TIMER_UPDATE.value != 0) {
return false
}
if (flagsValue and DataMessage.Flags.PROFILE_KEY_UPDATE.value != 0) {
return false
}
return !this.body.isNullOrEmpty() ||
this.attachments.isNotEmpty() ||
this.sticker != null ||
this.reaction != null ||
this.quote != null ||
this.contact.isNotEmpty() ||
this.giftBadge != null ||
this.pollCreate != null ||
this.pollTerminate != null ||
this.pinMessage != null ||
this.delete != null ||
this.payment?.notification != null
}
private fun SyncMessage.isUrgent(): Boolean {
if (this.read.isNotEmpty()) {
return true
}
this.request?.let { req ->
return when (req.type) {
SyncMessage.Request.Type.CONTACTS, SyncMessage.Request.Type.KEYS -> true
else -> false
}
}
this.callEvent?.let { event ->
return event.event == SyncMessage.CallEvent.Event.ACCEPTED
}
return false
}
private fun CallMessage.isUrgent(): Boolean {
if (offer != null) {
return true
}
if (opaque?.urgency == CallMessage.Opaque.Urgency.HANDLE_IMMEDIATELY) {
return true
}
return false
}
sealed interface DataMessageError {
data object MissingParentStory : DataMessageError
data object MissingQuoteAuthorServiceId : DataMessageError
data object MissingPayment : DataMessageError
data object MissingPaymentReceipt : DataMessageError
data object MissingAttachmentRemoteFields : DataMessageError
data object FailedToDecodeAttachmentKey : DataMessageError
data object FailedToDecodeAttachmentSize : DataMessageError
data object FailedToDecodeStickerPackFields : DataMessageError
data object MissingStickerLocator : DataMessageError
data object PinnedMessageInvalidAuthorAci : DataMessageError
data object InvalidGiftBadge : DataMessageError
data object InvalidBodyRange : DataMessageError
}