Release polls behind feature flag.

This commit is contained in:
Michelle Tang
2025-10-01 12:46:37 -04:00
parent 67a693107e
commit b8e4ffb5ae
84 changed files with 4164 additions and 102 deletions

View File

@@ -41,6 +41,8 @@ import org.thoughtcrime.securesms.database.model.ReactionRecord
import org.thoughtcrime.securesms.database.model.StickerRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras
import org.thoughtcrime.securesms.database.model.databaseprotos.PollTerminate
import org.thoughtcrime.securesms.database.model.toBodyRangeList
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.groups.BadGroupIdException
@@ -83,6 +85,7 @@ import org.thoughtcrime.securesms.mms.IncomingMessage
import org.thoughtcrime.securesms.mms.MmsException
import org.thoughtcrime.securesms.mms.QuoteModel
import org.thoughtcrime.securesms.notifications.v2.ConversationId
import org.thoughtcrime.securesms.polls.Poll
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.Recipient.HiddenState
import org.thoughtcrime.securesms.recipients.RecipientId
@@ -168,6 +171,9 @@ object DataMessageProcessor {
message.isMediaMessage -> insertResult = handleMediaMessage(context, envelope, metadata, message, senderRecipient, threadRecipient, groupId, receivedTime, localMetrics)
message.body != null -> insertResult = handleTextMessage(context, envelope, metadata, message, senderRecipient, threadRecipient, groupId, receivedTime, localMetrics)
message.groupCallUpdate != null -> handleGroupCallUpdateMessage(envelope, message, senderRecipient.id, groupId)
message.pollCreate != null -> insertResult = handlePollCreate(context, envelope, metadata, message, senderRecipient, threadRecipient, groupId, receivedTime)
message.pollTerminate != null -> insertResult = handlePollTerminate(context, envelope, metadata, message, senderRecipient, earlyMessageCacheEntry, threadRecipient, groupId, receivedTime)
message.pollVote != null -> messageId = handlePollVote(context, envelope, message, senderRecipient, earlyMessageCacheEntry)
}
messageId = messageId ?: insertResult?.messageId?.let { MessageId(it) }
@@ -1040,6 +1046,178 @@ object DataMessageProcessor {
)
}
fun handlePollCreate(
context: Context,
envelope: Envelope,
metadata: EnvelopeMetadata,
message: DataMessage,
senderRecipient: Recipient,
threadRecipient: Recipient,
groupId: GroupId.V2?,
receivedTime: Long
): InsertResult? {
log(envelope.timestamp!!, "Handle poll creation")
val poll: DataMessage.PollCreate = message.pollCreate!!
handlePossibleExpirationUpdate(envelope, metadata, senderRecipient, threadRecipient, groupId, message.expireTimerDuration, message.expireTimerVersion, receivedTime)
if (groupId == null) {
warn(envelope.timestamp!!, "[handlePollCreate] Polls can only be sent to groups. author: $senderRecipient")
return null
}
val groupRecord = SignalDatabase.groups.getGroup(groupId).orNull()
if (groupRecord == null || !groupRecord.members.contains(senderRecipient.id)) {
warn(envelope.timestamp!!, "[handlePollCreate] Poll author is not in the group. author $senderRecipient")
return null
}
val pollMessage = IncomingMessage(
type = MessageType.NORMAL,
from = senderRecipient.id,
sentTimeMillis = envelope.timestamp!!,
serverTimeMillis = envelope.serverTimestamp!!,
receivedTimeMillis = receivedTime,
groupId = groupId,
expiresIn = message.expireTimerDuration.inWholeMilliseconds,
isUnidentified = metadata.sealedSender,
serverGuid = envelope.serverGuid,
poll = Poll(
question = poll.question!!,
allowMultipleVotes = poll.allowMultiple!!,
pollOptions = poll.options,
authorId = senderRecipient.id.toLong()
),
body = poll.question!!
)
val insertResult: InsertResult? = SignalDatabase.messages.insertMessageInbox(pollMessage).orNull()
return if (insertResult != null) {
AppDependencies.messageNotifier.updateNotification(context, ConversationId.forConversation(insertResult.threadId))
insertResult
} else {
null
}
}
fun handlePollTerminate(
context: Context,
envelope: Envelope,
metadata: EnvelopeMetadata,
message: DataMessage,
senderRecipient: Recipient,
earlyMessageCacheEntry: EarlyMessageCacheEntry? = null,
threadRecipient: Recipient,
groupId: GroupId.V2?,
receivedTime: Long
): InsertResult? {
val pollTerminate: DataMessage.PollTerminate = message.pollTerminate!!
val targetSentTimestamp = pollTerminate.targetSentTimestamp!!
log(envelope.timestamp!!, "Handle poll termination for poll $targetSentTimestamp")
handlePossibleExpirationUpdate(envelope, metadata, senderRecipient, threadRecipient, groupId, message.expireTimerDuration, message.expireTimerVersion, receivedTime)
val messageId = handlePollValidation(envelope = envelope, targetSentTimestamp = targetSentTimestamp, senderRecipient = senderRecipient, earlyMessageCacheEntry = earlyMessageCacheEntry, targetAuthor = senderRecipient)
if (messageId == null) {
return null
}
val poll = SignalDatabase.polls.getPoll(messageId.id)
if (poll == null) {
warn(envelope.timestamp!!, "[handlePollTerminate] Poll was not found. timestamp: $targetSentTimestamp author: ${senderRecipient.id}")
return null
}
val pollMessage = IncomingMessage(
type = MessageType.POLL_TERMINATE,
from = senderRecipient.id,
sentTimeMillis = envelope.timestamp!!,
serverTimeMillis = envelope.serverTimestamp!!,
receivedTimeMillis = receivedTime,
groupId = groupId,
expiresIn = message.expireTimerDuration.inWholeMilliseconds,
isUnidentified = metadata.sealedSender,
serverGuid = envelope.serverGuid,
messageExtras = MessageExtras(pollTerminate = PollTerminate(poll.question, poll.messageId, targetSentTimestamp))
)
val insertResult: InsertResult? = SignalDatabase.messages.insertMessageInbox(pollMessage).orNull()
return if (insertResult != null) {
AppDependencies.messageNotifier.updateNotification(context, ConversationId.forConversation(insertResult.threadId))
insertResult
} else {
null
}
}
fun handlePollVote(
context: Context,
envelope: Envelope,
message: DataMessage,
senderRecipient: Recipient,
earlyMessageCacheEntry: EarlyMessageCacheEntry?
): MessageId? {
val pollVote: DataMessage.PollVote = message.pollVote!!
val targetSentTimestamp = pollVote.targetSentTimestamp!!
log(envelope.timestamp!!, "Handle poll vote for poll $targetSentTimestamp")
val targetAuthorServiceId: ServiceId = ServiceId.parseOrThrow(pollVote.targetAuthorAciBinary!!)
if (targetAuthorServiceId.isUnknown) {
warn(envelope.timestamp!!, "[handlePollVote] Vote was to an unknown UUID! Ignoring the message.")
return null
}
val messageId = handlePollValidation(envelope, targetSentTimestamp, senderRecipient, earlyMessageCacheEntry, Recipient.externalPush(targetAuthorServiceId))
if (messageId == null) {
return null
}
val targetMessage = SignalDatabase.messages.getMessageRecord(messageId.id)
val pollId = SignalDatabase.polls.getPollId(messageId.id)
if (pollId == null) {
warn(envelope.timestamp!!, "[handlePollVote] Poll was not found. timestamp: $targetSentTimestamp author: ${senderRecipient.id}")
return null
}
val existingVoteCount = SignalDatabase.polls.getCurrentPollVoteCount(pollId, senderRecipient.id.toLong())
val currentVoteCount = pollVote.voteCount?.toLong() ?: 0
if (currentVoteCount <= existingVoteCount) {
warn(envelope.timestamp!!, "[handlePollVote] Incoming vote count was not higher. timestamp: $targetSentTimestamp author: ${senderRecipient.id}")
return null
}
val allOptionIds = SignalDatabase.polls.getPollOptionIds(pollId)
if (pollVote.optionIndexes.any { it < 0 || it >= allOptionIds.size }) {
warn(envelope.timestamp!!, "[handlePollVote] Invalid option indexes. timestamp: $targetSentTimestamp author: ${senderRecipient.id}")
return null
}
if (!SignalDatabase.polls.canAllowMultipleVotes(pollId) && pollVote.optionIndexes.size > 1) {
warn(envelope.timestamp!!, "[handlePollVote] Can not vote multiple times. timestamp: $targetSentTimestamp author: ${senderRecipient.id}")
return null
}
if (SignalDatabase.polls.hasEnded(pollId)) {
warn(envelope.timestamp!!, "[handlePollVote] Poll has already ended. timestamp: $targetSentTimestamp author: ${senderRecipient.id}")
return null
}
SignalDatabase.polls.insertVotes(
pollId = pollId,
pollOptionIds = pollVote.optionIndexes.map { index -> allOptionIds[index] },
voterId = senderRecipient.id.toLong(),
voteCount = pollVote.voteCount?.toLong() ?: 0,
messageId = messageId
)
AppDependencies.messageNotifier.updateNotification(context, ConversationId.fromMessageRecord(targetMessage))
return messageId
}
fun notifyTypingStoppedFromIncomingMessage(context: Context, senderRecipient: Recipient, threadRecipientId: RecipientId, device: Int) {
val threadId = SignalDatabase.threads.getThreadIdIfExistsFor(threadRecipientId)
@@ -1167,6 +1345,53 @@ object DataMessageProcessor {
return true
}
/**
* When ending or voting on a poll, checks validity of the message. Specifically
* that the message exists, was only sent to a group, and the sender
* is a member of the group. Returns the messageId of the poll if valid, null otherwise.
*/
private fun handlePollValidation(
envelope: Envelope,
targetSentTimestamp: Long,
senderRecipient: Recipient,
earlyMessageCacheEntry: EarlyMessageCacheEntry?,
targetAuthor: Recipient
): MessageId? {
val targetMessage = SignalDatabase.messages.getMessageFor(targetSentTimestamp, targetAuthor.id)
if (targetMessage == null) {
warn(envelope.timestamp!!, "[handlePollValidation] Could not find matching message! Putting it in the early message cache. timestamp: $targetSentTimestamp author: ${targetAuthor.id}")
if (earlyMessageCacheEntry != null) {
AppDependencies.earlyMessageCache.store(senderRecipient.id, targetSentTimestamp, earlyMessageCacheEntry)
PushProcessEarlyMessagesJob.enqueue()
}
return null
}
if (targetMessage.isRemoteDelete) {
warn(envelope.timestamp!!, "[handlePollValidation] Found a matching message, but it's flagged as remotely deleted. timestamp: $targetSentTimestamp author: ${targetAuthor.id}")
return null
}
val targetThread = SignalDatabase.threads.getThreadRecord(targetMessage.threadId)
if (targetThread == null) {
warn(envelope.timestamp!!, "[handlePollValidation] Could not find a thread for the message. timestamp: $targetSentTimestamp author: ${targetAuthor.id}")
return null
}
val groupRecord = SignalDatabase.groups.getGroup(targetThread.recipient.id).orNull()
if (groupRecord == null) {
warn(envelope.timestamp!!, "[handlePollValidation] Target thread needs to be a group. timestamp: $targetSentTimestamp author: ${targetAuthor.id}")
return null
}
if (!groupRecord.members.contains(senderRecipient.id)) {
warn(envelope.timestamp!!, "[handlePollValidation] Sender is not in the group. timestamp: $targetSentTimestamp author: ${targetAuthor.id}")
return null
}
return MessageId(targetMessage.id)
}
fun getContacts(message: DataMessage): List<Contact> {
return message.contact.map { ContactModelMapper.remoteToLocal(it) }
}

View File

@@ -51,7 +51,10 @@ object SignalServiceProtoUtil {
bodyRanges.isNotEmpty() ||
sticker != null ||
reaction != null ||
hasRemoteDelete
hasRemoteDelete ||
pollCreate != null ||
pollVote != null ||
pollTerminate != null
}
val DataMessage.hasDisallowedAnnouncementOnlyContent: Boolean

View File

@@ -41,6 +41,8 @@ import org.thoughtcrime.securesms.database.model.StickerPackId
import org.thoughtcrime.securesms.database.model.StoryType
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras
import org.thoughtcrime.securesms.database.model.databaseprotos.PollTerminate
import org.thoughtcrime.securesms.database.model.toBodyRangeList
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.groups.BadGroupIdException
@@ -87,6 +89,7 @@ import org.thoughtcrime.securesms.mms.OutgoingMessage
import org.thoughtcrime.securesms.mms.QuoteModel
import org.thoughtcrime.securesms.notifications.MarkReadReceiver
import org.thoughtcrime.securesms.payments.MobileCoinPublicAddress
import org.thoughtcrime.securesms.polls.Poll
import org.thoughtcrime.securesms.ratelimit.RateLimitUtil
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
@@ -241,6 +244,12 @@ object SyncMessageProcessor {
}
dataMessage.hasRemoteDelete -> DataMessageProcessor.handleRemoteDelete(context, envelope, dataMessage, senderRecipient.id, earlyMessageCacheEntry)
dataMessage.isMediaMessage -> threadId = handleSynchronizeSentMediaMessage(context, sent, envelope.timestamp!!, senderRecipient, threadRecipient)
dataMessage.pollCreate != null -> threadId = handleSynchronizedPollCreate(envelope, dataMessage, sent, senderRecipient)
dataMessage.pollVote != null -> {
DataMessageProcessor.handlePollVote(context, envelope, dataMessage, senderRecipient, earlyMessageCacheEntry)
threadId = SignalDatabase.threads.getOrCreateThreadIdFor(getSyncMessageDestination(sent))
}
dataMessage.pollTerminate != null -> threadId = handleSynchronizedPollEnd(envelope, dataMessage, sent, senderRecipient, earlyMessageCacheEntry)
else -> threadId = handleSynchronizeSentTextMessage(sent, envelope.timestamp!!)
}
@@ -1725,6 +1734,120 @@ object SyncMessageProcessor {
MultiDeviceAttachmentBackfillUpdateJob.enqueue(request.targetMessage!!, request.targetConversation!!, messageId)
}
private fun handleSynchronizedPollCreate(
envelope: Envelope,
message: DataMessage,
sent: Sent,
senderRecipient: Recipient
): Long {
log(envelope.timestamp!!, "Synchronize sent poll creation message.")
val recipient = getSyncMessageDestination(sent)
if (!recipient.isGroup) {
warn(envelope.timestamp!!, "Poll creation messages should only be synced in groups. Dropping.")
return -1
}
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(recipient)
val expiresInMillis = message.expireTimerDuration.inWholeMilliseconds
if (recipient.expiresInSeconds != message.expireTimerDuration.inWholeSeconds.toInt() || ((message.expireTimerVersion ?: -1) > recipient.expireTimerVersion)) {
handleSynchronizeSentExpirationUpdate(sent, sideEffect = true)
}
val poll: DataMessage.PollCreate = message.pollCreate!!
val outgoingMessage = OutgoingMessage.pollMessage(
threadRecipient = recipient,
sentTimeMillis = sent.timestamp!!,
expiresIn = recipient.expiresInSeconds.seconds.inWholeMilliseconds,
poll = Poll(
question = poll.question!!,
allowMultipleVotes = poll.allowMultiple!!,
pollOptions = poll.options,
authorId = senderRecipient.id.toLong()
),
question = poll.question!!
)
val messageId = SignalDatabase.messages.insertMessageOutbox(outgoingMessage, threadId, false, GroupReceiptTable.STATUS_UNKNOWN, null).messageId
updateGroupReceiptStatus(sent, messageId, recipient.requireGroupId())
log(envelope.timestamp!!, "Inserted sync poll create message as messageId $messageId")
SignalDatabase.messages.markAsSent(messageId, true)
if (expiresInMillis > 0) {
SignalDatabase.messages.markExpireStarted(messageId, sent.expirationStartTimestamp ?: 0)
AppDependencies.expiringMessageManager.scheduleDeletion(messageId, recipient.isGroup, sent.expirationStartTimestamp ?: 0, expiresInMillis)
}
return threadId
}
private fun handleSynchronizedPollEnd(
envelope: Envelope,
message: DataMessage,
sent: Sent,
senderRecipient: Recipient,
earlyMessageCacheEntry: EarlyMessageCacheEntry?
): Long {
log(envelope.timestamp!!, "Synchronize sent poll terminate message")
val recipient = getSyncMessageDestination(sent)
if (!recipient.isGroup) {
warn(envelope.timestamp!!, "Poll termination messages should only be synced in groups. Dropping.")
return -1
}
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(recipient)
val expiresInMillis = message.expireTimerDuration.inWholeMilliseconds
if (recipient.expiresInSeconds != message.expireTimerDuration.inWholeSeconds.toInt() || ((message.expireTimerVersion ?: -1) > recipient.expireTimerVersion)) {
handleSynchronizeSentExpirationUpdate(sent, sideEffect = true)
}
val pollTerminate = message.pollTerminate!!
val targetMessage = SignalDatabase.messages.getMessageFor(pollTerminate.targetSentTimestamp!!, Recipient.self().id)
if (targetMessage == null) {
warn(envelope.timestamp!!, "Unable to find target message for poll termination. Putting in early message cache.")
if (earlyMessageCacheEntry != null) {
AppDependencies.earlyMessageCache.store(senderRecipient.id, pollTerminate.targetSentTimestamp!!, earlyMessageCacheEntry)
PushProcessEarlyMessagesJob.enqueue()
}
return -1
}
val poll = SignalDatabase.polls.getPoll(targetMessage.id)
if (poll == null) {
warn(envelope.timestamp!!, "Unable to find poll for poll termination. Dropping.")
return -1
}
val outgoingMessage = OutgoingMessage.pollTerminateMessage(
threadRecipient = recipient,
sentTimeMillis = sent.timestamp!!,
expiresIn = recipient.expiresInSeconds.seconds.inWholeMilliseconds,
messageExtras = MessageExtras(
pollTerminate = PollTerminate(
question = poll.question,
messageId = poll.messageId,
targetTimestamp = pollTerminate.targetSentTimestamp!!
)
)
)
val messageId = SignalDatabase.messages.insertMessageOutbox(outgoingMessage, threadId, false, GroupReceiptTable.STATUS_UNKNOWN, null).messageId
SignalDatabase.messages.markAsSent(messageId, true)
log(envelope.timestamp!!, "Inserted sync poll end message as messageId $messageId")
if (expiresInMillis > 0) {
SignalDatabase.messages.markExpireStarted(messageId, sent.expirationStartTimestamp ?: 0)
AppDependencies.expiringMessageManager.scheduleDeletion(messageId, recipient.isGroup, sent.expirationStartTimestamp ?: 0, expiresInMillis)
}
return threadId
}
private fun ConversationIdentifier.toRecipientId(): RecipientId? {
return when {
threadGroupId != null -> {