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

@@ -1231,6 +1231,32 @@ public class SignalServiceMessageSender {
builder.bodyRanges(bodyRanges);
}
if (message.getPollCreate().isPresent()) {
SignalServiceDataMessage.PollCreate pollCreate = message.getPollCreate().get();
builder.pollCreate(new DataMessage.PollCreate.Builder()
.question(pollCreate.getQuestion())
.allowMultiple(pollCreate.getAllowMultiple())
.options(pollCreate.getOptions()).build());
}
if (message.getPollVote().isPresent()) {
SignalServiceDataMessage.PollVote pollVote = message.getPollVote().get();
builder.pollVote(new DataMessage.PollVote.Builder()
.targetSentTimestamp(pollVote.getTargetSentTimestamp())
.targetAuthorAciBinary(pollVote.getTargetAuthor().toByteString())
.voteCount(pollVote.getVoteCount())
.optionIndexes(pollVote.getOptionIndexes())
.build());
}
if (message.getPollTerminate().isPresent()) {
SignalServiceDataMessage.PollTerminate pollTerminate = message.getPollTerminate().get();
builder.pollTerminate(new DataMessage.PollTerminate.Builder()
.targetSentTimestamp(pollTerminate.getTargetSentTimestamp())
.build());
}
builder.timestamp(message.getTimestamp());
return builder;

View File

@@ -28,6 +28,9 @@ import org.whispersystems.signalservice.internal.push.TypingMessage
*/
object EnvelopeContentValidator {
private const val MAX_POLL_CHARACTER_LENGTH = 100
private const val MIN_POLL_OPTIONS = 2
fun validate(envelope: Envelope, content: Content, localAci: ACI): Result {
if (envelope.type == Envelope.Type.PLAINTEXT_CONTENT) {
validatePlaintextContent(content)?.let { return it }
@@ -138,9 +141,29 @@ object EnvelopeContentValidator {
validateGroupContextV2(dataMessage.groupV2, "[DataMessage]")?.let { return it }
}
if (dataMessage.pollCreate != null && (dataMessage.pollCreate.hasInvalidPollQuestion() || dataMessage.pollCreate.hasInvalidPollOptions() || dataMessage.pollCreate.allowMultiple == null)) {
return Result.Invalid("[DataMessage] Invalid poll create!")
}
if (dataMessage.pollTerminate != null && dataMessage.pollTerminate.targetSentTimestamp == null) {
return Result.Invalid("[DataMessage] Invalid poll terminate!")
}
if (dataMessage.pollVote != null && (dataMessage.pollVote.targetAuthorAciBinary.isNullOrInvalidAci() || dataMessage.pollVote.targetSentTimestamp == null || dataMessage.pollVote.voteCount == null)) {
return Result.Invalid("[DataMessage] Invalid poll vote!")
}
return Result.Valid
}
private fun DataMessage.PollCreate.hasInvalidPollQuestion(): Boolean {
return this.question.isNullOrBlank() || this.question.length > MAX_POLL_CHARACTER_LENGTH
}
private fun DataMessage.PollCreate.hasInvalidPollOptions(): Boolean {
return this.options.size < MIN_POLL_OPTIONS || this.options.any { option -> option.length > MAX_POLL_CHARACTER_LENGTH }
}
private fun validateSyncMessage(envelope: Envelope, syncMessage: SyncMessage, localAci: ACI): Result {
// Source serviceId was already determined to be a valid serviceId in general
val sourceServiceId = ServiceId.parseOrThrow(envelope.sourceServiceId!!)
@@ -349,6 +372,11 @@ object EnvelopeContentValidator {
return parsed == null || parsed.isUnknown
}
private fun ByteString?.isNullOrInvalidAci(): Boolean {
val parsed = this?.let { ACI.parseOrNull(this) }
return parsed == null || parsed.isUnknown
}
private fun ByteString?.isNullOrInvalidPni(): Boolean {
val parsed = ServiceId.PNI.parseOrNull(this?.toByteArray())
return parsed == null || parsed.isUnknown

View File

@@ -50,7 +50,10 @@ class SignalServiceDataMessage private constructor(
val payment: Optional<Payment>,
val storyContext: Optional<StoryContext>,
val giftBadge: Optional<GiftBadge>,
val bodyRanges: Optional<List<BodyRange>>
val bodyRanges: Optional<List<BodyRange>>,
val pollCreate: Optional<PollCreate>,
val pollVote: Optional<PollVote>,
val pollTerminate: Optional<PollTerminate>
) {
val isActivatePaymentsRequest: Boolean = payment.map { it.isActivationRequest }.orElse(false)
val isPaymentsActivated: Boolean = payment.map { it.isActivation }.orElse(false)
@@ -68,7 +71,10 @@ class SignalServiceDataMessage private constructor(
this.mentions.isPresent ||
this.sticker.isPresent ||
this.reaction.isPresent ||
this.remoteDelete.isPresent
this.remoteDelete.isPresent ||
this.pollCreate.isPresent ||
this.pollVote.isPresent ||
this.pollTerminate.isPresent
val isGroupV2Update: Boolean = groupContext.isPresent && groupContext.get().hasSignedGroupChange() && !hasRenderableContent
val isEmptyGroupV2Message: Boolean = isGroupV2Message && !isGroupV2Update && !hasRenderableContent
@@ -97,6 +103,9 @@ class SignalServiceDataMessage private constructor(
private var storyContext: StoryContext? = null
private var giftBadge: GiftBadge? = null
private var bodyRanges: MutableList<BodyRange> = LinkedList<BodyRange>()
private var pollCreate: PollCreate? = null
private var pollVote: PollVote? = null
private var pollTerminate: PollTerminate? = null
fun withTimestamp(timestamp: Long): Builder {
this.timestamp = timestamp
@@ -220,6 +229,21 @@ class SignalServiceDataMessage private constructor(
return this
}
fun withPollCreate(pollCreate: PollCreate?): Builder {
this.pollCreate = pollCreate
return this
}
fun withPollVote(pollVote: PollVote?): Builder {
this.pollVote = pollVote
return this
}
fun withPollTerminate(pollTerminate: PollTerminate?): Builder {
this.pollTerminate = pollTerminate
return this
}
fun build(): SignalServiceDataMessage {
if (timestamp == 0L) {
timestamp = System.currentTimeMillis()
@@ -248,7 +272,10 @@ class SignalServiceDataMessage private constructor(
payment = payment.asOptional(),
storyContext = storyContext.asOptional(),
giftBadge = giftBadge.asOptional(),
bodyRanges = bodyRanges.asOptional()
bodyRanges = bodyRanges.asOptional(),
pollCreate = pollCreate.asOptional(),
pollVote = pollVote.asOptional(),
pollTerminate = pollTerminate.asOptional()
)
}
}
@@ -291,6 +318,9 @@ class SignalServiceDataMessage private constructor(
}
data class StoryContext(val authorServiceId: ServiceId, val sentTimestamp: Long)
data class GiftBadge(val receiptCredentialPresentation: ReceiptCredentialPresentation)
data class PollCreate(val question: String, val allowMultiple: Boolean, val options: List<String>)
data class PollVote(val targetAuthor: ServiceId, val targetSentTimestamp: Long, val optionIndexes: List<Int>, val voteCount: Int)
data class PollTerminate(val targetSentTimestamp: Long)
companion object {
@JvmStatic

View File

@@ -302,6 +302,7 @@ message DataMessage {
CDN_SELECTOR_ATTACHMENTS = 5;
MENTIONS = 6;
PAYMENTS = 7;
POLLS = 8;
CURRENT = 7;
}
@@ -309,6 +310,23 @@ message DataMessage {
optional bytes receiptCredentialPresentation = 1;
}
message PollCreate {
optional string question = 1;
optional bool allowMultiple = 2;
repeated string options = 3;
}
message PollTerminate {
optional uint64 targetSentTimestamp = 1;
}
message PollVote {
optional bytes targetAuthorAciBinary = 1;
optional uint64 targetSentTimestamp = 2;
repeated uint32 optionIndexes = 3; // must be in the range [0, options.length) from the PollCreate
optional uint32 voteCount = 4; // increment this by 1 each time you vote on a given poll
}
optional string body = 1;
repeated AttachmentPointer attachments = 2;
reserved /*groupV1*/ 3;
@@ -331,7 +349,10 @@ message DataMessage {
optional Payment payment = 20;
optional StoryContext storyContext = 21;
optional GiftBadge giftBadge = 22;
// NEXT ID: 24
optional PollCreate pollCreate = 24;
optional PollTerminate pollTerminate = 25;
optional PollVote pollVote = 26;
// NEXT ID: 27
}
message NullMessage {

View File

@@ -5,6 +5,7 @@
package org.whispersystems.signalservice.api.messages
import okio.ByteString.Companion.toByteString
import org.junit.Test
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.internal.push.Content
@@ -32,4 +33,108 @@ class EnvelopeContentValidatorTest {
val result = EnvelopeContentValidator.validate(envelope, content, SELF_ACI)
assert(result is EnvelopeContentValidator.Result.Invalid)
}
@Test
fun `validate - ensure polls without a question are marked invalid`() {
val content = Content(
dataMessage = DataMessage(
pollCreate = DataMessage.PollCreate(
options = listOf("option1", "option2"),
allowMultiple = true
)
)
)
val result = EnvelopeContentValidator.validate(Envelope(), content, SELF_ACI)
assert(result is EnvelopeContentValidator.Result.Invalid)
}
@Test
fun `validate - ensure polls with a question exceeding 100 characters are marked invalid`() {
val content = Content(
dataMessage = DataMessage(
pollCreate = DataMessage.PollCreate(
question = "abcdefghijklmnopqrstuvwxyabcdefghijklmnopqrstuvwxyabcdefghijklmnopqrstuvwxyabcdefghijklmnopqrstuvwxyz",
options = listOf("option1", "option2"),
allowMultiple = true
)
)
)
val result = EnvelopeContentValidator.validate(Envelope(), content, SELF_ACI)
assert(result is EnvelopeContentValidator.Result.Invalid)
}
@Test
fun `validate - ensure polls without at least two options are marked invalid`() {
val content = Content(
dataMessage = DataMessage(
pollCreate = DataMessage.PollCreate(
question = "how are you?",
options = listOf("option1"),
allowMultiple = true
)
)
)
val result = EnvelopeContentValidator.validate(Envelope(), content, SELF_ACI)
assert(result is EnvelopeContentValidator.Result.Invalid)
}
@Test
fun `validate - ensure poll options that exceed 100 characters are marked invalid `() {
val content = Content(
dataMessage = DataMessage(
pollCreate = DataMessage.PollCreate(
question = "how are you",
options = listOf("abcdefghijklmnopqrstuvwxyabcdefghijklmnopqrstuvwxyabcdefghijklmnopqrstuvwxyabcdefghijklmnopqrstuvwxyz", "option2"),
allowMultiple = true
)
)
)
val result = EnvelopeContentValidator.validate(Envelope(), content, SELF_ACI)
assert(result is EnvelopeContentValidator.Result.Invalid)
}
@Test
fun `validate - ensure polls without an explicit allow multiple votes option are marked invalid `() {
val content = Content(
dataMessage = DataMessage(
pollCreate = DataMessage.PollCreate(
question = "how are you",
options = listOf("option1", "option2")
)
)
)
val result = EnvelopeContentValidator.validate(Envelope(), content, SELF_ACI)
assert(result is EnvelopeContentValidator.Result.Invalid)
}
@Test
fun `validate - ensure poll terminate without timestamps are marked invalid `() {
val content = Content(
dataMessage = DataMessage(
pollTerminate = DataMessage.PollTerminate()
)
)
val result = EnvelopeContentValidator.validate(Envelope(), content, SELF_ACI)
assert(result is EnvelopeContentValidator.Result.Invalid)
}
@Test
fun `validate - ensure poll votes without a valid aci are marked invalid`() {
val content = Content(
dataMessage = DataMessage(
pollVote = DataMessage.PollVote(
targetAuthorAciBinary = "bad".toByteArray().toByteString()
)
)
)
val result = EnvelopeContentValidator.validate(Envelope(), content, SELF_ACI)
assert(result is EnvelopeContentValidator.Result.Invalid)
}
}