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
@@ -36,6 +36,8 @@ import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange
import org.thoughtcrime.securesms.linkpreview.LinkPreview
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory
import org.thoughtcrime.securesms.polls.PollOption
import org.thoughtcrime.securesms.polls.PollRecord
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.stickers.StickerLocator
@@ -348,5 +350,11 @@ class V2ConversationItemShapeTest {
override fun onShowUnverifiedProfileSheet(forGroup: Boolean) = Unit
override fun onUpdateSignalClicked() = Unit
override fun onViewResultsClicked(pollId: Long) = Unit
override fun onViewPollClicked(messageId: Long) = Unit
override fun onToggleVote(poll: PollRecord, pollOption: PollOption, isChecked: Boolean) = Unit
}
}
@@ -0,0 +1,131 @@
package org.thoughtcrime.securesms.database
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.deleteAll
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.mms.IncomingMessage
import org.thoughtcrime.securesms.polls.PollOption
import org.thoughtcrime.securesms.polls.PollRecord
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.testing.SignalActivityRule
@RunWith(AndroidJUnit4::class)
class PollTablesTest {
@get:Rule
val harness = SignalActivityRule()
private lateinit var poll1: PollRecord
@Before
fun setUp() {
poll1 = PollRecord(
id = 1,
question = "how do you feel about unit testing?",
pollOptions = listOf(
PollOption(1, "yay", listOf(1)),
PollOption(2, "ok", emptyList()),
PollOption(3, "nay", emptyList())
),
allowMultipleVotes = false,
hasEnded = false,
authorId = 1,
messageId = 1
)
SignalDatabase.polls.writableDatabase.deleteAll(PollTables.PollTable.TABLE_NAME)
SignalDatabase.polls.writableDatabase.deleteAll(PollTables.PollOptionTable.TABLE_NAME)
SignalDatabase.polls.writableDatabase.deleteAll(PollTables.PollVoteTable.TABLE_NAME)
val message = IncomingMessage(type = MessageType.NORMAL, from = harness.others[0], sentTimeMillis = 100, serverTimeMillis = 100, receivedTimeMillis = 100)
SignalDatabase.messages.insertMessageInbox(message, SignalDatabase.threads.getOrCreateThreadIdFor(harness.others[0], isGroup = false))
}
@Test
fun givenAPollWithVoting_whenIGetPoll_thenIExpectThatPoll() {
SignalDatabase.polls.insertPoll("how do you feel about unit testing?", false, listOf("yay", "ok", "nay"), 1, 1)
SignalDatabase.polls.insertVotes(pollId = 1, pollOptionIds = listOf(1), voterId = 1, voteCount = 1, messageId = MessageId(1))
assertEquals(poll1, SignalDatabase.polls.getPoll(1))
}
@Test
fun givenAPoll_whenIGetItsOptionIds_thenIExpectAllOptionsIds() {
SignalDatabase.polls.insertPoll("how do you feel about unit testing?", false, listOf("yay", "ok", "nay"), 1, 1)
assertEquals(poll1.pollOptions.map { it.id }, SignalDatabase.polls.getPollOptionIds(1))
}
@Test
fun givenAPollAndVoter_whenIGetItsVoteCount_thenIExpectTheCorrectVoterCount() {
SignalDatabase.polls.insertPoll("how do you feel about unit testing?", false, listOf("yay", "ok", "nay"), 1, 1)
SignalDatabase.polls.insertVotes(pollId = 1, pollOptionIds = listOf(1), voterId = 1, voteCount = 1, messageId = MessageId(1))
SignalDatabase.polls.insertVotes(pollId = 1, pollOptionIds = listOf(2), voterId = 2, voteCount = 2, messageId = MessageId(1))
SignalDatabase.polls.insertVotes(pollId = 1, pollOptionIds = listOf(3), voterId = 3, voteCount = 3, messageId = MessageId(1))
assertEquals(1, SignalDatabase.polls.getCurrentPollVoteCount(1, 1))
assertEquals(2, SignalDatabase.polls.getCurrentPollVoteCount(1, 2))
assertEquals(3, SignalDatabase.polls.getCurrentPollVoteCount(1, 3))
}
@Test
fun givenMultipleRoundsOfVoting_whenIGetItsCount_thenIExpectTheMostRecentResults() {
SignalDatabase.polls.insertPoll("how do you feel about unit testing?", false, listOf("yay", "ok", "nay"), 1, 1)
SignalDatabase.polls.insertVotes(pollId = 1, pollOptionIds = listOf(2), voterId = 1, voteCount = 1, messageId = MessageId(1))
SignalDatabase.polls.insertVotes(pollId = 1, pollOptionIds = listOf(3), voterId = 1, voteCount = 2, messageId = MessageId(1))
SignalDatabase.polls.insertVotes(pollId = 1, pollOptionIds = listOf(1), voterId = 1, voteCount = 3, messageId = MessageId(1))
assertEquals(poll1, SignalDatabase.polls.getPoll(1))
}
@Test
fun givenAPoll_whenITerminateIt_thenIExpectItToEnd() {
SignalDatabase.polls.insertPoll("how do you feel about unit testing?", false, listOf("yay", "ok", "nay"), 1, 1)
SignalDatabase.polls.endPoll(1, System.currentTimeMillis())
assertEquals(true, SignalDatabase.polls.getPoll(1)!!.hasEnded)
}
@Test
fun givenAPoll_whenIIVote_thenIExpectThatVote() {
SignalDatabase.polls.insertPoll("how do you feel about unit testing?", false, listOf("yay", "ok", "nay"), 1, 1)
val poll = SignalDatabase.polls.getPoll(1)!!
val pollOption = poll.pollOptions.first()
val voteCount = SignalDatabase.polls.insertVote(poll, pollOption)
assertEquals(1, voteCount)
assertEquals(listOf(0), SignalDatabase.polls.getVotes(poll.id, false))
}
@Test
fun givenAPoll_whenIRemoveVote_thenVoteIsCleared() {
SignalDatabase.polls.insertPoll("how do you feel about unit testing?", false, listOf("yay", "ok", "nay"), 1, 1)
val poll = SignalDatabase.polls.getPoll(1)!!
val pollOption = poll.pollOptions.first()
val voteCount = SignalDatabase.polls.removeVote(poll, pollOption)
SignalDatabase.polls.markPendingAsRemoved(poll.id, Recipient.self().id.toLong(), voteCount, 1)
assertEquals(1, voteCount)
val status = SignalDatabase.polls.getPollVoteStateForGivenVote(poll.id, voteCount)
assertEquals(PollTables.VoteState.REMOVED, status)
}
@Test
fun givenAVote_whenISetPollOptionId_thenOptionIdIsUpdated() {
SignalDatabase.polls.insertPoll("how do you feel about unit testing?", false, listOf("yay", "ok", "nay"), 1, 1)
val poll = SignalDatabase.polls.getPoll(1)!!
val option = poll.pollOptions.first()
SignalDatabase.polls.insertVotes(poll.id, listOf(option.id), Recipient.self().id.toLong(), 5, MessageId(1))
SignalDatabase.polls.setPollVoteStateForGivenVote(poll.id, Recipient.self().id.toLong(), 5, 1, true)
val status = SignalDatabase.polls.getPollVoteStateForGivenVote(poll.id, 5)
assertEquals(PollTables.VoteState.ADDED, status)
}
}
@@ -0,0 +1,322 @@
package org.thoughtcrime.securesms.messages
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import assertk.assertThat
import assertk.assertions.isEqualTo
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.thoughtcrime.securesms.database.MessageTable
import org.thoughtcrime.securesms.database.MessageType
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.mms.IncomingMessage
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.testing.GroupTestingUtils
import org.thoughtcrime.securesms.testing.GroupTestingUtils.asMember
import org.thoughtcrime.securesms.testing.MessageContentFuzzer
import org.thoughtcrime.securesms.testing.SignalActivityRule
import org.whispersystems.signalservice.api.crypto.EnvelopeMetadata
import org.whispersystems.signalservice.internal.push.DataMessage
@Suppress("ClassName")
@RunWith(AndroidJUnit4::class)
class DataMessageProcessorTest_polls {
@get:Rule
val harness = SignalActivityRule(createGroup = true)
private lateinit var alice: Recipient
private lateinit var bob: Recipient
private lateinit var charlie: Recipient
private lateinit var groupId: GroupId.V2
private lateinit var groupRecipientId: RecipientId
@Before
fun setUp() {
alice = Recipient.resolved(harness.others[0])
bob = Recipient.resolved(harness.others[1])
charlie = Recipient.resolved(harness.others[2])
val groupInfo = GroupTestingUtils.insertGroup(revision = 0, harness.self.asMember(), alice.asMember(), bob.asMember())
groupId = groupInfo.groupId
groupRecipientId = groupInfo.recipientId
}
@Test
fun handlePollCreate_whenIHaveAValidPollProto_createPoll() {
val insertResult = handlePollCreate(
pollCreate = DataMessage.PollCreate(question = "question?", options = listOf("a", "b", "c"), allowMultiple = false),
senderRecipient = alice,
threadRecipient = Recipient.resolved(groupRecipientId),
groupId = groupId
)
assert(insertResult != null)
val poll = SignalDatabase.polls.getPoll(insertResult!!.messageId)
assert(poll != null)
assertThat(poll!!.question).isEqualTo("question?")
assertThat(poll.pollOptions.size).isEqualTo(3)
assertThat(poll.allowMultipleVotes).isEqualTo(false)
assertThat(poll.hasEnded).isEqualTo(false)
}
@Test
fun handlePollCreate_whenSenderIsNotInGroup_dropMessage() {
val insertResult = handlePollCreate(
pollCreate = DataMessage.PollCreate(question = "question?", options = listOf("a", "b", "c"), allowMultiple = false),
senderRecipient = charlie,
threadRecipient = Recipient.resolved(groupRecipientId),
groupId = groupId
)
assert(insertResult == null)
}
@Test
fun handlePollCreate_whenTargetRecipientIsNotAGroup_dropMessage() {
val insertResult = handlePollCreate(
pollCreate = DataMessage.PollCreate(question = "question?", options = listOf("a", "b", "c"), allowMultiple = false),
senderRecipient = alice,
threadRecipient = bob,
groupId = null
)
assert(insertResult == null)
}
@Test
fun handlePollTerminate_whenIHaveValidProto_endPoll() {
val pollMessageId = insertPoll()
val insertResult = DataMessageProcessor.handlePollTerminate(
context = ApplicationProvider.getApplicationContext(),
envelope = MessageContentFuzzer.envelope(200),
message = DataMessage(pollTerminate = DataMessage.PollTerminate(targetSentTimestamp = 100)),
senderRecipient = alice,
metadata = EnvelopeMetadata(alice.requireServiceId(), null, 1, false, null, harness.self.requireServiceId()),
threadRecipient = bob,
groupId = groupId,
receivedTime = 200
)
assert(insertResult?.messageId != null)
val poll = SignalDatabase.polls.getPoll(pollMessageId)
assert(poll != null)
assert(poll!!.hasEnded)
}
@Test
fun handlePollTerminate_whenIHaveDifferentTimestamp_dropMessage() {
insertPoll()
val insertResult = DataMessageProcessor.handlePollTerminate(
context = ApplicationProvider.getApplicationContext(),
envelope = MessageContentFuzzer.envelope(200),
message = DataMessage(pollTerminate = DataMessage.PollTerminate(200)),
senderRecipient = alice,
metadata = EnvelopeMetadata(alice.requireServiceId(), null, 1, false, null, harness.self.requireServiceId()),
threadRecipient = bob,
groupId = groupId,
receivedTime = 200
)
assert(insertResult == null)
}
@Test
fun handlePollTerminate_whenMessageIsNotFromCreatorOfPoll_dropMessage() {
insertPoll()
val insertResult = DataMessageProcessor.handlePollTerminate(
context = ApplicationProvider.getApplicationContext(),
envelope = MessageContentFuzzer.envelope(200),
message = DataMessage(pollTerminate = DataMessage.PollTerminate(100)),
senderRecipient = bob,
metadata = EnvelopeMetadata(alice.requireServiceId(), null, 1, false, null, harness.self.requireServiceId()),
threadRecipient = bob,
groupId = groupId,
receivedTime = 200
)
assert(insertResult == null)
}
@Test
fun handlePollTerminate_whenPollDoesNotExist_dropMessage() {
val insertResult = DataMessageProcessor.handlePollTerminate(
context = ApplicationProvider.getApplicationContext(),
envelope = MessageContentFuzzer.envelope(200),
message = DataMessage(pollTerminate = DataMessage.PollTerminate(100)),
senderRecipient = alice,
metadata = EnvelopeMetadata(alice.requireServiceId(), null, 1, false, null, harness.self.requireServiceId()),
threadRecipient = bob,
groupId = groupId,
receivedTime = 200
)
assert(insertResult == null)
}
@Test
fun handlePollVote_whenValidPollVote_processVote() {
insertPoll()
val messageId = handlePollVote(
DataMessage.PollVote(
targetAuthorAciBinary = alice.asMember().aciBytes,
targetSentTimestamp = 100,
optionIndexes = listOf(0),
voteCount = 1
),
bob
)
assert(messageId != null)
assertThat(messageId!!.id).isEqualTo(1)
val poll = SignalDatabase.polls.getPoll(messageId.id)
assert(poll != null)
assertThat(poll!!.pollOptions[0].voterIds).isEqualTo(listOf(bob.id.toLong()))
}
@Test
fun handlePollVote_whenMultipleVoteAllowed_processAllVote() {
insertPoll()
val messageId = handlePollVote(
DataMessage.PollVote(
targetAuthorAciBinary = alice.asMember().aciBytes,
targetSentTimestamp = 100,
optionIndexes = listOf(0, 1, 2),
voteCount = 1
),
bob
)
assert(messageId != null)
val poll = SignalDatabase.polls.getPoll(messageId!!.id)
assert(poll != null)
assertThat(poll!!.pollOptions[0].voterIds).isEqualTo(listOf(bob.id.toLong()))
assertThat(poll.pollOptions[1].voterIds).isEqualTo(listOf(bob.id.toLong()))
assertThat(poll.pollOptions[2].voterIds).isEqualTo(listOf(bob.id.toLong()))
}
@Test
fun handlePollVote_whenMultipleVoteSentToSingleVotePolls_dropMessage() {
insertPoll(false)
val messageId = handlePollVote(
DataMessage.PollVote(
targetAuthorAciBinary = alice.asMember().aciBytes,
targetSentTimestamp = 100,
optionIndexes = listOf(0, 1, 2),
voteCount = 1
),
bob
)
assert(messageId == null)
}
@Test
fun handlePollVote_whenVoteCountIsNotHigher_dropMessage() {
insertPoll()
val messageId = handlePollVote(
DataMessage.PollVote(
targetAuthorAciBinary = alice.asMember().aciBytes,
targetSentTimestamp = 100,
optionIndexes = listOf(0, 1, 2),
voteCount = -1
),
bob
)
assert(messageId == null)
}
@Test
fun handlePollVote_whenVoteOptionDoesNotExist_dropMessage() {
insertPoll()
val messageId = handlePollVote(
DataMessage.PollVote(
targetAuthorAciBinary = alice.asMember().aciBytes,
targetSentTimestamp = 100,
optionIndexes = listOf(5),
voteCount = 1
),
bob
)
assert(messageId == null)
}
@Test
fun handlePollVote_whenVoterNotInGroup_dropMessage() {
insertPoll()
val messageId = handlePollVote(
DataMessage.PollVote(
targetAuthorAciBinary = alice.asMember().aciBytes,
targetSentTimestamp = 100,
optionIndexes = listOf(0, 1, 2),
voteCount = 1
),
charlie
)
assert(messageId == null)
}
@Test
fun handlePollVote_whenPollDoesNotExist_dropMessage() {
val messageId = handlePollVote(
DataMessage.PollVote(
targetAuthorAciBinary = alice.asMember().aciBytes,
targetSentTimestamp = 100,
optionIndexes = listOf(0, 1, 2),
voteCount = 1
),
bob
)
assert(messageId == null)
}
private fun handlePollCreate(pollCreate: DataMessage.PollCreate, senderRecipient: Recipient, threadRecipient: Recipient, groupId: GroupId.V2?): MessageTable.InsertResult? {
return DataMessageProcessor.handlePollCreate(
envelope = MessageContentFuzzer.envelope(100),
message = DataMessage(pollCreate = pollCreate),
senderRecipient = senderRecipient,
threadRecipient = threadRecipient,
groupId = groupId,
receivedTime = 0,
context = ApplicationProvider.getApplicationContext(),
metadata = EnvelopeMetadata(alice.requireServiceId(), null, 1, false, null, harness.self.requireServiceId())
)
}
private fun handlePollVote(pollVote: DataMessage.PollVote, senderRecipient: Recipient): MessageId? {
return DataMessageProcessor.handlePollVote(
context = ApplicationProvider.getApplicationContext(),
envelope = MessageContentFuzzer.envelope(100),
message = DataMessage(pollVote = pollVote),
senderRecipient = senderRecipient,
earlyMessageCacheEntry = null
)
}
private fun insertPoll(allowMultiple: Boolean = true): Long {
val envelope = MessageContentFuzzer.envelope(100)
val pollMessage = IncomingMessage(type = MessageType.NORMAL, from = alice.id, sentTimeMillis = envelope.timestamp!!, serverTimeMillis = envelope.serverTimestamp!!, receivedTimeMillis = 0, groupId = groupId)
val messageId = SignalDatabase.messages.insertMessageInbox(pollMessage).get()
SignalDatabase.polls.insertPoll("question?", allowMultiple, listOf("a", "b", "c"), alice.id.toLong(), messageId.messageId)
return messageId.messageId
}
}
@@ -115,6 +115,7 @@ class ConversationElementGenerator {
null,
null,
null,
null,
-1,
null,
null,
@@ -44,6 +44,8 @@ import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange
import org.thoughtcrime.securesms.linkpreview.LinkPreview
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory
import org.thoughtcrime.securesms.polls.PollOption
import org.thoughtcrime.securesms.polls.PollRecord
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.stickers.StickerLocator
@@ -336,5 +338,17 @@ class InternalConversationTestFragment : Fragment(R.layout.conversation_test_fra
override fun onUpdateSignalClicked() {
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
}
override fun onViewResultsClicked(pollId: Long) {
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
}
override fun onViewPollClicked(messageId: Long) {
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
}
override fun onToggleVote(poll: PollRecord, pollOption: PollOption, isChecked: Boolean) {
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
}
}
}
Binary file not shown.
@@ -30,6 +30,8 @@ import org.thoughtcrime.securesms.groups.GroupId;
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange;
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory;
import org.thoughtcrime.securesms.polls.PollRecord;
import org.thoughtcrime.securesms.polls.PollOption;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.stickers.StickerLocator;
@@ -143,5 +145,8 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
void onDisplayMediaNoLongerAvailableSheet();
void onShowUnverifiedProfileSheet(boolean forGroup);
void onUpdateSignalClicked();
void onViewResultsClicked(long pollId);
void onViewPollClicked(long messageId);
void onToggleVote(@NonNull PollRecord poll, @NonNull PollOption pollOption, Boolean isChecked);
}
}
@@ -18,7 +18,6 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Checkbox
@@ -26,6 +25,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
@@ -34,6 +34,7 @@ import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.theme.SignalTheme
@@ -50,7 +51,11 @@ import org.thoughtcrime.securesms.R
fun RoundCheckbox(
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
size: Dp = 24.dp,
enabled: Boolean = true,
outlineColor: Color = MaterialTheme.colorScheme.outline,
checkedColor: Color = MaterialTheme.colorScheme.primary
) {
val contentDescription = if (checked) {
stringResource(R.string.SignalCheckbox_accessibility_checked_description)
@@ -60,15 +65,14 @@ fun RoundCheckbox(
Box(
modifier = modifier
.padding(12.dp)
.size(24.dp)
.size(size)
.aspectRatio(1f)
.border(
width = 1.5.dp,
color = if (checked) {
MaterialTheme.colorScheme.primary
checkedColor
} else {
MaterialTheme.colorScheme.outline
outlineColor
},
shape = CircleShape
)
@@ -76,7 +80,8 @@ fun RoundCheckbox(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = { onCheckedChange(!checked) },
onClickLabel = stringResource(R.string.SignalCheckbox_accessibility_on_click_label)
onClickLabel = stringResource(R.string.SignalCheckbox_accessibility_on_click_label),
enabled = enabled
)
.semantics(mergeDescendants = true) {
this.role = Role.Checkbox
@@ -90,7 +95,7 @@ fun RoundCheckbox(
) {
Image(
imageVector = ImageVector.vectorResource(id = R.drawable.ic_check_circle_solid_24),
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary),
colorFilter = ColorFilter.tint(checkedColor),
contentDescription = null,
modifier = Modifier.fillMaxSize()
)
@@ -10,5 +10,6 @@ public final class EmojiStrings {
public static final String STICKER = "\u2B50";
public static final String GIFT = "\uD83C\uDF81";
public static final String CARD = "\uD83D\uDCB3";
public static final String POLL = "\uD83D\uDCCA";
public static final String FAILED_STORY = "\u2757";
}
@@ -35,6 +35,7 @@ public class AttachmentKeyboard extends FrameLayout implements InputAwareLayout.
private static final List<AttachmentKeyboardButton> DEFAULT_BUTTONS = Arrays.asList(
AttachmentKeyboardButton.GALLERY,
AttachmentKeyboardButton.FILE,
AttachmentKeyboardButton.POLL,
AttachmentKeyboardButton.CONTACT,
AttachmentKeyboardButton.LOCATION,
AttachmentKeyboardButton.PAYMENT
@@ -11,7 +11,8 @@ public enum AttachmentKeyboardButton {
FILE(R.string.AttachmentKeyboard_file, R.drawable.symbol_file_24),
PAYMENT(R.string.AttachmentKeyboard_payment, R.drawable.symbol_payment_24),
CONTACT(R.string.AttachmentKeyboard_contact, R.drawable.symbol_person_circle_24),
LOCATION(R.string.AttachmentKeyboard_location, R.drawable.symbol_location_circle_24);
LOCATION(R.string.AttachmentKeyboard_location, R.drawable.symbol_location_circle_24),
POLL(R.string.AttachmentKeyboard_poll, R.drawable.symbol_poll_24);
private final int titleRes;
private final int iconRes;
@@ -56,6 +56,7 @@ import androidx.annotation.ColorInt;
import androidx.annotation.DimenRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.compose.ui.platform.ComposeView;
import androidx.core.content.ContextCompat;
import androidx.lifecycle.LifecycleOwner;
import androidx.media3.common.MediaItem;
@@ -73,7 +74,6 @@ import org.signal.core.util.BidiUtil;
import org.signal.core.util.DimensionUnit;
import org.signal.core.util.StringUtil;
import org.signal.core.util.logging.Log;
import org.signal.ringrtc.CallLinkRootKey;
import org.thoughtcrime.securesms.BindableConversationItem;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.attachments.Attachment;
@@ -130,6 +130,7 @@ import org.thoughtcrime.securesms.mms.SlideDeck;
import org.thoughtcrime.securesms.mms.SlidesClickedListener;
import org.thoughtcrime.securesms.mms.TextSlide;
import org.thoughtcrime.securesms.mms.VideoSlide;
import org.thoughtcrime.securesms.polls.PollRecord;
import org.thoughtcrime.securesms.reactions.ReactionsConversationView;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
@@ -236,6 +237,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
private Stub<Button> callToActionStub;
private Stub<GiftMessageView> giftViewStub;
private Stub<PaymentMessageView> paymentViewStub;
private Stub<ComposeView> pollView;
private @Nullable EventListener eventListener;
private @Nullable GestureDetector gestureDetector;
@@ -352,6 +354,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
this.quotedIndicator = findViewById(R.id.quoted_indicator);
this.paymentViewStub = new Stub<>(findViewById(R.id.payment_view_stub));
this.scheduledIndicator = findViewById(R.id.scheduled_indicator);
this.pollView = new Stub<>(findViewById(R.id.poll));
setOnClickListener(new ClickListener(null));
@@ -417,6 +420,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
setStoryReactionLabel(messageRecord);
setHasBeenQuoted(conversationMessage);
setHasBeenScheduled(conversationMessage);
setPoll(messageRecord);
if (audioViewStub.resolved()) {
audioViewStub.get().setOnLongClickListener(passthroughClickListener);
@@ -561,6 +565,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
conversationMessage.getBottomButton() == null &&
!BidiUtil.hasMixedTextDirection(bodyText.getText()) &&
!messageRecord.isRemoteDelete() &&
!MessageRecordUtil.hasPoll(messageRecord) &&
bodyText.getLastLineWidth() > 0)
{
View dateView = footer.getDateView();
@@ -1001,6 +1006,10 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
return MessageRecordUtil.hasQuote(messageRecord);
}
private boolean hasPoll(MessageRecord messageRecord) {
return MessageRecordUtil.hasPoll(messageRecord);
}
private boolean hasSharedContact(MessageRecord messageRecord) {
return MessageRecordUtil.hasSharedContact(messageRecord);
}
@@ -1054,6 +1063,10 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (messageRequestAccepted) {
linkifyMessageBody(styledText, batchSelected.isEmpty());
}
if (MessageRecordUtil.hasPoll(messageRecord)) {
styledText.setSpan(new StyleSpan(Typeface.BOLD), 0, styledText.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
bodyText.setMaxWidth(readDimen(R.dimen.media_bubble_default_dimens));
}
styledText = SearchUtil.getHighlightedSpan(locale, STYLE_FACTORY, styledText, searchQuery, SearchUtil.STRICT);
if (hasExtraText(messageRecord)) {
@@ -1627,6 +1640,31 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
}
private void setPoll(@NonNull MessageRecord messageRecord) {
if (hasPoll(messageRecord) && !messageRecord.isRemoteDelete()) {
PollRecord poll = MessageRecordUtil.getPoll(messageRecord);
PollComponentKt.setContent(pollView.get(), poll, isOutgoing(), () -> {
if (eventListener != null && batchSelected.isEmpty()) {
eventListener.onViewResultsClicked(poll.getId());
} else {
passthroughClickListener.onClick(pollView.get());
}
return null;
}, (option, isChecked) -> {
if (eventListener != null && batchSelected.isEmpty()) {
eventListener.onToggleVote(poll, option, isChecked);
} else {
passthroughClickListener.onClick(pollView.get());
}
return null;
});
pollView.setVisibility(View.VISIBLE);
} else if (pollView != null && pollView.resolved()) {
pollView.setVisibility(View.GONE);
}
}
private void setQuote(@NonNull MessageRecord current, @NonNull Optional<MessageRecord> previous, @NonNull Optional<MessageRecord> next, boolean isGroupThread) {
boolean startOfCluster = isStartOfMessageCluster(current, previous, isGroupThread);
if (hasQuote(messageRecord)) {
@@ -138,6 +138,10 @@ public class ConversationMessage {
getBottomButton() == null;
}
public boolean isPoll() {
return MessageRecordUtil.isPoll(messageRecord);
}
public long getConversationTimestamp() {
if (originalMessage != null) {
return originalMessage.getDateSent();
@@ -760,6 +760,10 @@ public final class ConversationReactionOverlay extends FrameLayout {
items.add(new ActionItem(R.drawable.symbol_info_24, getResources().getString(R.string.conversation_selection__menu_message_details), () -> handleActionItemClicked(Action.VIEW_INFO)));
}
if (menuState.shouldShowPollTerminateAction()) {
items.add(new ActionItem(R.drawable.symbol_stop_24, getResources().getString(R.string.conversation_selection__menu_end_poll), () -> handleActionItemClicked(Action.END_POLL)));
}
backgroundView.setVisibility(menuState.shouldShowReactions() ? View.VISIBLE : View.INVISIBLE);
foregroundView.setVisibility(menuState.shouldShowReactions() ? View.VISIBLE : View.INVISIBLE);
@@ -961,5 +965,6 @@ public final class ConversationReactionOverlay extends FrameLayout {
PAYMENT_DETAILS,
VIEW_INFO,
DELETE,
END_POLL
}
}
@@ -2,8 +2,12 @@ package org.thoughtcrime.securesms.conversation;
import android.content.Context;
import android.content.res.ColorStateList;
import android.graphics.drawable.Drawable;
import android.os.Handler;
import android.os.Looper;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.SpannableStringBuilder;
import android.text.method.LinkMovementMethod;
import android.util.AttributeSet;
import android.view.View;
@@ -17,6 +21,7 @@ import androidx.appcompat.content.res.AppCompatResources;
import androidx.core.content.ContextCompat;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Observer;
import androidx.lifecycle.Transformations;
@@ -43,16 +48,18 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.recipients.LiveRecipient;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.DateUtils;
import org.thoughtcrime.securesms.util.DrawableUtil;
import org.thoughtcrime.securesms.util.IdentityUtil;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.Projection;
import org.thoughtcrime.securesms.util.ProjectionList;
import org.thoughtcrime.securesms.util.SpanUtil;
import org.thoughtcrime.securesms.util.ThemeUtil;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
import org.thoughtcrime.securesms.verify.VerifyIdentityActivity;
import org.whispersystems.signalservice.api.push.ServiceId;
import org.whispersystems.signalservice.api.push.ServiceId.ACI;
import java.util.Collection;
import java.util.Locale;
@@ -81,7 +88,6 @@ public final class ConversationUpdateItem extends FrameLayout
private Optional<MessageRecord> nextMessageRecord;
private MessageRecord messageRecord;
private boolean isMessageRequestAccepted;
private LiveData<SpannableString> displayBody;
private EventListener eventListener;
private final UpdateObserver updateObserver = new UpdateObserver();
@@ -91,6 +97,14 @@ public final class ConversationUpdateItem extends FrameLayout
private final RecipientObserverManager groupObserver = new RecipientObserverManager(presentOnChange);
private final GroupDataManager groupData = new GroupDataManager();
private final Handler handler = new Handler(Looper.getMainLooper());
private final Runnable timerUpdateRunnable = new TimerUpdateRunnable();
private final MutableLiveData<SpannableString> displayBodyWithTimer = new MutableLiveData<>();
private int latestFrame;
private SpannableString displayBody;
private ExpirationTimer timer;
private final PassthroughClickListener passthroughClickListener = new PassthroughClickListener();
public ConversationUpdateItem(Context context) {
@@ -181,9 +195,10 @@ public final class ConversationUpdateItem extends FrameLayout
LiveData<SpannableString> spannableMessage = loading(liveUpdateMessage);
observeDisplayBody(lifecycleOwner, spannableMessage);
observeDisplayBodyWithTimer(lifecycleOwner);
present(conversationMessage, nextMessageRecord, conversationRecipient, isMessageRequestAccepted);
presentTimer(updateDescription);
presentBackground(shouldCollapse(messageRecord, previousMessageRecord),
shouldCollapse(messageRecord, nextMessageRecord),
hasWallpaper);
@@ -217,6 +232,8 @@ public final class ConversationUpdateItem extends FrameLayout
@Override
public void unbind() {
this.displayBodyWithTimer.removeObserver(updateObserver);
handler.removeCallbacks(timerUpdateRunnable);
}
@Override
@@ -389,20 +406,30 @@ public final class ConversationUpdateItem extends FrameLayout
return false;
}
private void observeDisplayBody(@NonNull LifecycleOwner lifecycleOwner, @Nullable LiveData<SpannableString> displayBody) {
if (this.displayBody != displayBody) {
if (this.displayBody != null) {
this.displayBody.removeObserver(updateObserver);
}
this.displayBody = displayBody;
if (this.displayBody != null) {
this.displayBody.observe(lifecycleOwner, updateObserver);
}
private void observeDisplayBody(@NonNull LifecycleOwner lifecycleOwner, @Nullable LiveData<SpannableString> message) {
if (message != null) {
message.observe(lifecycleOwner, it -> {
displayBody = it;
updateBodyWithTimer();
});
}
}
private void observeDisplayBodyWithTimer(@NonNull LifecycleOwner lifecycleOwner) {
this.displayBodyWithTimer.observe(lifecycleOwner, updateObserver);
}
private void updateBodyWithTimer() {
SpannableStringBuilder builder = new SpannableStringBuilder(displayBody);
if (latestFrame != 0) {
Drawable drawable = DrawableUtil.tint(getContext().getDrawable(latestFrame), ContextCompat.getColor(getContext(), R.color.signal_icon_tint_secondary));
SpanUtil.appendCenteredImageSpan(builder, drawable, 12, 12);
}
displayBodyWithTimer.setValue(new SpannableString(builder));
}
private void setBodyText(@Nullable CharSequence text) {
if (text == null) {
body.setVisibility(INVISIBLE);
@@ -644,6 +671,16 @@ public final class ConversationUpdateItem extends FrameLayout
passthroughClickListener.onClick(v);
}
});
} else if (MessageRecordUtil.hasPollTerminate(conversationMessage.getMessageRecord()) && conversationMessage.getMessageRecord().getMessageExtras().pollTerminate.messageId != -1) {
actionButton.setText(R.string.Poll__view_poll);
actionButton.setVisibility(VISIBLE);
actionButton.setOnClickListener(v -> {
if (batchSelected.isEmpty() && eventListener != null && MessageRecordUtil.hasPollTerminate(conversationMessage.getMessageRecord())) {
eventListener.onViewPollClicked(conversationMessage.getMessageRecord().getMessageExtras().pollTerminate.messageId);
} else {
passthroughClickListener.onClick(v);
}
});
} else {
actionButton.setVisibility(GONE);
actionButton.setOnClickListener(null);
@@ -747,6 +784,16 @@ public final class ConversationUpdateItem extends FrameLayout
(current.isChangeNumber() && candidate.isChangeNumber());
}
private void presentTimer(UpdateDescription updateDescription) {
if (updateDescription.hasExpiration() && messageRecord.getExpiresIn() > 0) {
timer = new ExpirationTimer(messageRecord.getTimestamp(), messageRecord.getExpiresIn());
handler.post(timerUpdateRunnable);
} else {
latestFrame = 0;
handler.removeCallbacks(timerUpdateRunnable);
}
}
@Override
public void setOnClickListener(View.OnClickListener l) {
super.setOnClickListener(new InternalClickListener(l));
@@ -763,6 +810,19 @@ public final class ConversationUpdateItem extends FrameLayout
}
}
private class TimerUpdateRunnable implements Runnable {
@Override
public void run() {
float progress = timer.calculateProgress();
latestFrame = ExpirationTimer.getFrame(progress);
updateBodyWithTimer();
if (progress < 1f) {
handler.postDelayed(this, timer.calculateAnimationDelay());
}
}
}
private final class UpdateObserver implements Observer<Spannable> {
@Override
@@ -0,0 +1,55 @@
package org.thoughtcrime.securesms.conversation
import org.thoughtcrime.securesms.R
import java.util.concurrent.TimeUnit
import kotlin.math.ceil
import kotlin.math.max
import kotlin.math.min
/**
* Tracks which drawables to use for the expiring timer in disappearing messages.
*/
class ExpirationTimer(
val startedAt: Long,
val expiresIn: Long
) {
companion object {
private val frames = intArrayOf(
R.drawable.ic_timer_00_12,
R.drawable.ic_timer_05_12,
R.drawable.ic_timer_10_12,
R.drawable.ic_timer_15_12,
R.drawable.ic_timer_20_12,
R.drawable.ic_timer_25_12,
R.drawable.ic_timer_30_12,
R.drawable.ic_timer_35_12,
R.drawable.ic_timer_40_12,
R.drawable.ic_timer_45_12,
R.drawable.ic_timer_50_12,
R.drawable.ic_timer_55_12,
R.drawable.ic_timer_60_12
)
@JvmStatic
fun getFrame(progress: Float): Int {
val percentFull = 1 - progress
val frame = ceil(percentFull * (frames.size - 1)).toInt()
val adjustedFrame = max(0, min(frame, frames.size - 1))
return frames[adjustedFrame]
}
}
fun calculateProgress(): Float {
val progressed = System.currentTimeMillis() - startedAt
val percentComplete = progressed.toFloat() / expiresIn.toFloat()
return max(0f, min(percentComplete, 1f))
}
fun calculateAnimationDelay(): Long {
val progressed = System.currentTimeMillis() - startedAt
val remaining = expiresIn - progressed
return (if (remaining < TimeUnit.SECONDS.toMillis(30)) 50 else 1000).toLong()
}
}
@@ -26,6 +26,7 @@ public final class MenuState {
private final boolean reactions;
private final boolean paymentDetails;
private final boolean edit;
private final boolean pollTerminate;
private MenuState(@NonNull Builder builder) {
forward = builder.forward;
@@ -38,6 +39,7 @@ public final class MenuState {
reactions = builder.reactions;
paymentDetails = builder.paymentDetails;
edit = builder.edit;
pollTerminate = builder.pollTerminate;
}
public boolean shouldShowForwardAction() {
@@ -80,23 +82,29 @@ public final class MenuState {
return edit;
}
public boolean shouldShowPollTerminateAction() {
return pollTerminate;
}
public static MenuState getMenuState(@NonNull Recipient conversationRecipient,
@NonNull Set<MultiselectPart> selectedParts,
boolean shouldShowMessageRequest,
boolean isNonAdminInAnnouncementGroup)
{
Builder builder = new Builder();
boolean actionMessage = false;
boolean hasText = false;
boolean sharedContact = false;
boolean viewOnce = false;
boolean remoteDelete = false;
boolean hasInMemory = false;
boolean hasPendingMedia = false;
boolean mediaIsSelected = false;
boolean hasGift = false;
Builder builder = new Builder();
boolean actionMessage = false;
boolean hasText = false;
boolean sharedContact = false;
boolean viewOnce = false;
boolean remoteDelete = false;
boolean hasInMemory = false;
boolean hasPendingMedia = false;
boolean mediaIsSelected = false;
boolean hasGift = false;
boolean hasPayment = false;
boolean hasPoll = false;
boolean hasPollTerminate = false;
for (MultiselectPart part : selectedParts) {
MessageRecord messageRecord = part.getMessageRecord();
@@ -138,14 +146,24 @@ public final class MenuState {
if (messageRecord.isPaymentNotification() || messageRecord.isPaymentTombstone()) {
hasPayment = true;
}
if (MessageRecordUtil.hasPoll(messageRecord)) {
hasPoll = true;
}
if (MessageRecordUtil.hasPoll(messageRecord) && !MessageRecordUtil.getPoll(messageRecord).getHasEnded() && messageRecord.isOutgoing()) {
hasPollTerminate = true;
}
}
boolean shouldShowForwardAction = !actionMessage &&
!viewOnce &&
!remoteDelete &&
!hasPendingMedia &&
!hasGift &&
!hasPayment &&
boolean shouldShowForwardAction = !actionMessage &&
!viewOnce &&
!remoteDelete &&
!hasPendingMedia &&
!hasGift &&
!hasPayment &&
!hasPoll &&
!hasPollTerminate &&
selectedParts.size() <= MAX_FORWARDABLE_COUNT;
int uniqueRecords = selectedParts.stream()
@@ -159,7 +177,8 @@ public final class MenuState {
.shouldShowDetailsAction(false)
.shouldShowSaveAttachmentAction(false)
.shouldShowResendAction(false)
.shouldShowEdit(false);
.shouldShowEdit(false)
.shouldShowPollTerminate(false);
} else {
MultiselectPart multiSelectRecord = selectedParts.iterator().next();
@@ -182,13 +201,15 @@ public final class MenuState {
builder.shouldShowEdit(!actionMessage &&
hasText &&
!multiSelectRecord.getConversationMessage().getOriginalMessage().isFailed() &&
!hasPoll &&
MessageConstraintsUtil.isValidEditMessageSend(multiSelectRecord.getConversationMessage().getOriginalMessage(), System.currentTimeMillis()));
}
return builder.shouldShowCopyAction(!actionMessage && !remoteDelete && hasText && !hasGift && !hasPayment)
return builder.shouldShowCopyAction(!actionMessage && !remoteDelete && hasText && !hasGift && !hasPayment && !hasPoll)
.shouldShowDeleteAction(!hasInMemory && onlyContainsCompleteMessages(selectedParts))
.shouldShowReactions(!conversationRecipient.isReleaseNotes())
.shouldShowPaymentDetails(hasPayment)
.shouldShowPollTerminate(hasPollTerminate)
.build();
}
@@ -233,6 +254,7 @@ public final class MenuState {
private boolean reactions;
private boolean paymentDetails;
private boolean edit;
private boolean pollTerminate;
@NonNull Builder shouldShowForwardAction(boolean forward) {
this.forward = forward;
@@ -284,6 +306,11 @@ public final class MenuState {
return this;
}
@NonNull Builder shouldShowPollTerminate(boolean pollTerminate) {
this.pollTerminate = pollTerminate;
return this;
}
@NonNull
MenuState build() {
return new MenuState(this);
@@ -0,0 +1,346 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.conversation
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.compose.RoundCheckbox
import org.thoughtcrime.securesms.polls.PollOption
import org.thoughtcrime.securesms.polls.PollRecord
import org.thoughtcrime.securesms.util.DynamicTheme
import org.thoughtcrime.securesms.util.VibrateUtil
/**
* Allows us to utilize our composeView from Java code.
*/
fun setContent(
composeView: ComposeView,
poll: PollRecord,
isOutgoing: Boolean,
onViewVotes: () -> Unit,
onToggleVote: (PollOption, Boolean) -> Unit = { _, _ -> }
) {
composeView.setContent {
SignalTheme(
isDarkMode = DynamicTheme.isDarkTheme(LocalContext.current)
) {
Poll(
poll = poll,
onViewVotes = onViewVotes,
onToggleVote = onToggleVote,
pollColors = if (isOutgoing) PollColorsType.Outgoing.getColors() else PollColorsType.Incoming.getColors()
)
}
}
}
@Composable
private fun Poll(
poll: PollRecord,
onViewVotes: () -> Unit = {},
onToggleVote: (PollOption, Boolean) -> Unit = { _, _ -> },
pollColors: PollColors = PollColorsType.Incoming.getColors()
) {
val totalVotes = remember(poll.pollOptions) { poll.pollOptions.sumOf { it.voterIds.size } }
val caption = when {
poll.hasEnded -> R.string.Poll__final_results
poll.allowMultipleVotes -> R.string.Poll__select_multiple
else -> R.string.Poll__select_one
}
Column(
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(caption),
color = pollColors.caption,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 12.dp, bottom = 4.dp)
)
poll.pollOptions.forEach {
PollOption(it, totalVotes, poll.hasEnded, onToggleVote, pollColors)
}
Spacer(Modifier.size(16.dp))
if (totalVotes == 0) {
Text(
text = stringResource(R.string.Poll__no_votes),
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.align(Alignment.CenterHorizontally).height(40.dp).wrapContentHeight(align = Alignment.CenterVertically),
textAlign = TextAlign.Center,
color = pollColors.text
)
} else {
Buttons.MediumTonal(
colors = ButtonDefaults.buttonColors(containerColor = pollColors.buttonBackground, contentColor = pollColors.button),
onClick = onViewVotes,
modifier = Modifier.align(Alignment.CenterHorizontally).height(40.dp)
) {
Text(stringResource(if (poll.hasEnded) R.string.Poll__view_results else R.string.Poll__view_votes))
}
}
Spacer(Modifier.size(4.dp))
}
}
@Composable
private fun PollOption(
option: PollOption,
totalVotes: Int,
hasEnded: Boolean,
onToggleVote: (PollOption, Boolean) -> Unit = { _, _ -> },
pollColors: PollColors
) {
val context = LocalContext.current
val haptics = LocalHapticFeedback.current
val progress = remember(option.voterIds.size, totalVotes) {
if (totalVotes > 0) (option.voterIds.size.toFloat() / totalVotes.toFloat()) else 0f
}
val progressValue by animateFloatAsState(targetValue = progress, animationSpec = tween(durationMillis = 250))
Row(
modifier = Modifier.padding(start = 12.dp, end = 16.dp, top = 8.dp, bottom = 8.dp)
) {
if (!hasEnded) {
AnimatedContent(
targetState = option.isPending,
transitionSpec = {
val enterTransition = fadeIn(tween(delayMillis = 500, durationMillis = 500))
val exitTransition = fadeOut(tween(durationMillis = 500))
enterTransition.togetherWith(exitTransition)
.using(SizeTransform(clip = false))
}
) { inProgress ->
if (inProgress) {
CircularProgressIndicator(
modifier = Modifier.padding(top = 4.dp, end = 8.dp).size(24.dp),
strokeWidth = 1.5.dp,
color = pollColors.checkbox
)
} else {
RoundCheckbox(
checked = option.isSelected,
onCheckedChange = { checked ->
if (VibrateUtil.isHapticFeedbackEnabled(context)) {
haptics.performHapticFeedback(if (checked) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff)
}
onToggleVote(option, checked)
},
modifier = Modifier.padding(top = 4.dp, end = 8.dp).height(24.dp),
outlineColor = pollColors.checkbox,
checkedColor = pollColors.checkboxBackground
)
}
}
}
Column {
Row(verticalAlignment = Alignment.Bottom) {
Text(
text = option.text,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(end = 24.dp).weight(1f),
color = pollColors.text
)
if (hasEnded && option.isSelected) {
RoundCheckbox(
checked = true,
onCheckedChange = {},
modifier = Modifier.padding(end = 4.dp),
size = 16.dp,
enabled = false,
checkedColor = pollColors.checkboxBackground
)
}
AnimatedContent(
targetState = option.voterIds.size
) { size ->
Text(
text = size.toString(),
color = pollColors.text,
style = MaterialTheme.typography.bodyMedium
)
}
}
Box(
modifier = Modifier.height(8.dp).padding(top = 4.dp).fillMaxWidth()
.background(
color = pollColors.progressBackground,
shape = RoundedCornerShape(18.dp)
)
) {
Box(
modifier = Modifier
.fillMaxWidth(progressValue)
.fillMaxHeight()
.background(
color = pollColors.progress,
shape = if (progress == 1f) RoundedCornerShape(18.dp) else RoundedCornerShape(topStart = 18.dp, bottomStart = 18.dp)
)
)
}
}
}
}
class PollColors(
val text: Color,
val caption: Color,
val progress: Color,
val progressBackground: Color,
val checkbox: Color,
val checkboxBackground: Color,
val button: Color,
val buttonBackground: Color
)
private sealed interface PollColorsType {
@Composable
fun getColors(): PollColors
data object Outgoing : PollColorsType {
@Composable
override fun getColors(): PollColors {
return PollColors(
text = colorResource(R.color.conversation_item_sent_text_primary_color),
caption = colorResource(R.color.conversation_item_sent_text_secondary_color),
progress = colorResource(R.color.conversation_item_sent_text_primary_color),
progressBackground = SignalTheme.colors.colorTransparent3,
checkbox = colorResource(R.color.conversation_item_sent_text_secondary_color),
checkboxBackground = colorResource(R.color.conversation_item_sent_text_primary_color),
button = MaterialTheme.colorScheme.primary,
buttonBackground = colorResource(R.color.conversation_item_sent_text_primary_color)
)
}
}
data object Incoming : PollColorsType {
@Composable
override fun getColors(): PollColors {
return PollColors(
text = MaterialTheme.colorScheme.onSurface,
caption = MaterialTheme.colorScheme.onSurfaceVariant,
progress = MaterialTheme.colorScheme.primary,
progressBackground = SignalTheme.colors.colorTransparentInverse3,
checkbox = MaterialTheme.colorScheme.outline,
checkboxBackground = MaterialTheme.colorScheme.primary,
button = MaterialTheme.colorScheme.onSurface,
buttonBackground = MaterialTheme.colorScheme.surface
)
}
}
}
@DayNightPreviews
@Composable
private fun PollPreview() {
Previews.Preview {
Poll(
PollRecord(
id = 1,
question = "How do you feel about compose previews?",
pollOptions = listOf(
PollOption(1, "yay", listOf(1), isSelected = true),
PollOption(2, "ok", listOf(1, 2)),
PollOption(3, "nay", listOf(2, 3, 4))
),
allowMultipleVotes = false,
hasEnded = false,
authorId = 1,
messageId = 1
)
)
}
}
@DayNightPreviews
@Composable
private fun EmptyPollPreview() {
Previews.Preview {
Poll(
PollRecord(
id = 1,
question = "How do you feel about multiple compose previews?",
pollOptions = listOf(
PollOption(1, "yay", emptyList()),
PollOption(2, "ok", emptyList(), isSelected = true),
PollOption(3, "nay", emptyList(), isSelected = true)
),
allowMultipleVotes = true,
hasEnded = false,
authorId = 1,
messageId = 1
)
)
}
}
@DayNightPreviews
@Composable
private fun FinishedPollPreview() {
Previews.Preview {
Poll(
PollRecord(
id = 1,
question = "How do you feel about finished compose previews?",
pollOptions = listOf(
PollOption(1, "yay", listOf(1)),
PollOption(2, "ok", emptyList(), isSelected = true),
PollOption(3, "nay", emptyList())
),
allowMultipleVotes = false,
hasEnded = true,
authorId = 1,
messageId = 1
)
)
}
}
@@ -0,0 +1,234 @@
package org.thoughtcrime.securesms.conversation.clicklisteners
import android.os.Bundle
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.unit.dp
import androidx.core.os.bundleOf
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.setFragmentResult
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.horizontalGutters
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.avatar.AvatarImage
import org.thoughtcrime.securesms.compose.ComposeDialogFragment
import org.thoughtcrime.securesms.conversation.clicklisteners.PollVotesFragment.Companion.MAX_INITIAL_VOTER_COUNT
import org.thoughtcrime.securesms.polls.PollOption
import org.thoughtcrime.securesms.polls.PollRecord
import org.thoughtcrime.securesms.util.viewModel
/**
* Fragment that shows the results for a given poll.
*/
class PollVotesFragment : ComposeDialogFragment() {
companion object {
const val MAX_INITIAL_VOTER_COUNT = 5
const val RESULT_KEY = "PollVotesFragment"
const val POLL_VOTES_FRAGMENT_TAG = "PollVotesFragment"
private val TAG = Log.tag(PollVotesFragment::class.java)
private const val ARG_POLL_ID = "poll_id"
fun create(pollId: Long, fragmentManager: FragmentManager) {
return PollVotesFragment().apply {
arguments = bundleOf(ARG_POLL_ID to pollId)
}.show(fragmentManager, POLL_VOTES_FRAGMENT_TAG)
}
}
private val viewModel: PollVotesViewModel by viewModel {
PollVotesViewModel(requireArguments().getLong(ARG_POLL_ID))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NO_FRAME, R.style.Signal_DayNight_Dialog_FullScreen)
}
@Composable
override fun DialogContent() {
val state by viewModel.state.collectAsStateWithLifecycle()
Scaffolds.Settings(
title = stringResource(id = R.string.Poll__poll_results),
onNavigationClick = this::dismissAllowingStateLoss,
navigationIcon = ImageVector.vectorResource(id = R.drawable.symbol_x_24),
navigationContentDescription = stringResource(id = R.string.Material3SearchToolbar__close)
) { paddingValues ->
if (state.poll == null) {
return@Settings
}
Surface(modifier = Modifier.padding(paddingValues)) {
Column {
PollResultsScreen(
state,
onEndPoll = {
setFragmentResult(RESULT_KEY, bundleOf(RESULT_KEY to true))
dismissAllowingStateLoss()
}
)
}
}
}
}
}
@Composable
private fun PollResultsScreen(
state: PollVotesState,
onEndPoll: () -> Unit = {},
modifier: Modifier = Modifier
) {
LazyColumn(
modifier = modifier
.fillMaxWidth()
.horizontalGutters(24.dp)
) {
item {
Spacer(Modifier.size(16.dp))
Text(
text = stringResource(R.string.Poll__question),
style = MaterialTheme.typography.titleSmall
)
TextField(
value = state.poll!!.question,
onValueChange = {},
modifier = Modifier.padding(top = 12.dp, bottom = 24.dp).fillMaxWidth(),
colors = TextFieldDefaults.colors(
disabledTextColor = MaterialTheme.colorScheme.onSurface,
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant
),
enabled = false
)
}
items(state.pollOptions) { PollOptionSection(it) }
if (state.isAuthor && !state.poll!!.hasEnded) {
item {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onEndPoll)
.padding(vertical = 16.dp)
) {
Icon(
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_trash_24),
contentDescription = stringResource(R.string.Poll__end_poll),
tint = MaterialTheme.colorScheme.onSurface
)
Text(text = stringResource(id = R.string.Poll__end_poll), modifier = Modifier.padding(start = 24.dp), style = MaterialTheme.typography.bodyLarge)
}
}
}
}
}
@Composable
private fun PollOptionSection(
option: PollOptionModel
) {
var expand by remember { mutableStateOf(false) }
val context = LocalContext.current
Row(
modifier = Modifier.padding(vertical = 12.dp)
) {
Text(text = option.pollOption.text, modifier = Modifier.weight(1f), style = MaterialTheme.typography.titleSmall)
Text(text = pluralStringResource(R.plurals.Poll__num_votes, option.voters.size, option.voters.size), style = MaterialTheme.typography.bodyLarge)
}
if (!expand && option.voters.size > MAX_INITIAL_VOTER_COUNT) {
option.voters.subList(0, MAX_INITIAL_VOTER_COUNT).forEach { recipient ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 12.dp)
) {
AvatarImage(recipient = recipient, modifier = Modifier.padding(end = 16.dp).size(40.dp))
Text(text = if (recipient.isSelf) stringResource(id = R.string.Recipient_you) else recipient.getShortDisplayName(context))
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 12.dp).clickable { expand = true }
) {
Image(
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface),
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_chevron_down_24),
contentDescription = stringResource(R.string.Poll__see_all),
modifier = Modifier.size(40.dp).background(color = MaterialTheme.colorScheme.surfaceVariant, shape = CircleShape).padding(8.dp)
)
Text(text = stringResource(R.string.Poll__see_all), modifier = Modifier.padding(start = 16.dp), style = MaterialTheme.typography.bodyLarge)
}
} else {
option.voters.forEach { recipient ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 12.dp)
) {
AvatarImage(recipient = recipient, modifier = Modifier.padding(end = 16.dp).size(40.dp))
Text(text = if (recipient.isSelf) stringResource(id = R.string.Recipient_you) else recipient.getShortDisplayName(context))
}
}
}
Spacer(Modifier.size(16.dp))
}
@DayNightPreviews
@Composable
private fun PollResultsScreenPreview() {
Previews.Preview {
PollResultsScreen(
state = PollVotesState(
PollRecord(
id = 1,
question = "How do you feel about finished compose previews?",
pollOptions = listOf(
PollOption(1, "Yay", listOf(1, 12, 3)),
PollOption(2, "Ok", listOf(2, 4), isSelected = true),
PollOption(3, "Nay", emptyList())
),
allowMultipleVotes = false,
hasEnded = true,
authorId = 1,
messageId = 1
),
isAuthor = true
)
)
}
}
@@ -0,0 +1,61 @@
package org.thoughtcrime.securesms.conversation.clicklisteners
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.signal.core.util.concurrent.SignalDispatchers
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.polls.PollOption
import org.thoughtcrime.securesms.polls.PollRecord
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
/**
* View model for [PollVotesFragment] which allows you to see results for a given poll.
*/
class PollVotesViewModel(pollId: Long) : ViewModel() {
companion object {
private val TAG = Log.tag(PollVotesViewModel::class)
}
private val _state = MutableStateFlow(PollVotesState())
val state = _state.asStateFlow()
init {
loadPollInfo(pollId)
}
private fun loadPollInfo(pollId: Long) {
viewModelScope.launch(SignalDispatchers.IO) {
val poll = SignalDatabase.polls.getPollFromId(pollId)!!
_state.update {
it.copy(
poll = poll,
pollOptions = poll.pollOptions.map { option ->
PollOptionModel(
pollOption = option,
voters = Recipient.resolvedList(option.voterIds.map { voter -> RecipientId.from(voter) })
)
},
isAuthor = poll.authorId == Recipient.self().id.toLong()
)
}
}
}
}
data class PollVotesState(
val poll: PollRecord? = null,
val pollOptions: List<PollOptionModel> = emptyList(),
val isAuthor: Boolean = false
)
data class PollOptionModel(
val pollOption: PollOption,
val voters: List<Recipient> = emptyList()
)
@@ -37,6 +37,8 @@ import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionRecycler
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange
import org.thoughtcrime.securesms.linkpreview.LinkPreview
import org.thoughtcrime.securesms.polls.PollOption
import org.thoughtcrime.securesms.polls.PollRecord
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.BottomSheetUtil
@@ -263,6 +265,8 @@ class MessageQuotesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment() {
override fun onReportSpamLearnMoreClicked() = Unit
override fun onMessageRequestAcceptOptionsClicked() = Unit
override fun onItemDoubleClick(item: MultiselectPart) = Unit
override fun onToggleVote(poll: PollRecord, pollOption: PollOption, isChecked: Boolean?) = Unit
override fun onViewResultsClicked(pollId: Long) = Unit
}
companion object {
@@ -184,6 +184,7 @@ import org.thoughtcrime.securesms.conversation.ScheduledMessagesBottomSheet
import org.thoughtcrime.securesms.conversation.ScheduledMessagesRepository
import org.thoughtcrime.securesms.conversation.SelectedConversationModel
import org.thoughtcrime.securesms.conversation.ShowAdminsBottomSheetDialog
import org.thoughtcrime.securesms.conversation.clicklisteners.PollVotesFragment
import org.thoughtcrime.securesms.conversation.colors.ChatColors
import org.thoughtcrime.securesms.conversation.colors.Colorizer
import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer
@@ -282,6 +283,9 @@ import org.thoughtcrime.securesms.nicknames.NicknameActivity
import org.thoughtcrime.securesms.notifications.v2.ConversationId
import org.thoughtcrime.securesms.payments.preferences.PaymentsActivity
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.polls.Poll
import org.thoughtcrime.securesms.polls.PollOption
import org.thoughtcrime.securesms.polls.PollRecord
import org.thoughtcrime.securesms.profiles.manage.EditProfileActivity
import org.thoughtcrime.securesms.profiles.spoofing.ReviewCardDialogFragment
import org.thoughtcrime.securesms.providers.BlobProvider
@@ -333,6 +337,7 @@ import org.thoughtcrime.securesms.util.atMidnight
import org.thoughtcrime.securesms.util.atUTC
import org.thoughtcrime.securesms.util.doAfterNextLayout
import org.thoughtcrime.securesms.util.fragments.requireListener
import org.thoughtcrime.securesms.util.getPoll
import org.thoughtcrime.securesms.util.getQuote
import org.thoughtcrime.securesms.util.getRecordQuoteType
import org.thoughtcrime.securesms.util.hasAudio
@@ -1951,6 +1956,16 @@ class ConversationFragment :
)
}
private fun sendPoll(recipient: Recipient, poll: Poll) {
val send = viewModel.sendPoll(recipient, poll)
disposables += send
.subscribeBy(
onComplete = { onSendComplete() },
onError = { Log.w(TAG, "Error received during poll send!", it) }
)
}
private fun sendMessage(
body: String = composeText.editableText.toString().trim(),
mentions: List<Mention> = composeText.mentions,
@@ -2554,6 +2569,21 @@ class ConversationFragment :
}
}
private fun handleEndPoll(pollId: Long?) {
if (pollId == null) {
Log.w(TAG, "Unable to find poll to end $pollId")
return
}
val endPoll = viewModel.endPoll(pollId)
disposables += endPoll
.subscribeBy(
// TODO(michelle): Error state when poll terminate fails
onError = { Log.w(TAG, "Error received during poll send!", it) }
)
}
private inner class SwipeAvailabilityProvider : ConversationItemSwipeCallback.SwipeAvailabilityProvider {
override fun isSwipeAvailable(conversationMessage: ConversationMessage): Boolean {
val recipient = viewModel.recipientSnapshot ?: return false
@@ -3051,6 +3081,32 @@ class ConversationFragment :
PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(requireContext())
}
override fun onViewResultsClicked(pollId: Long) {
if (parentFragmentManager.findFragmentByTag(PollVotesFragment.POLL_VOTES_FRAGMENT_TAG) == null) {
PollVotesFragment.create(pollId, parentFragmentManager)
parentFragmentManager.setFragmentResultListener(PollVotesFragment.RESULT_KEY, requireActivity()) { _, bundle ->
val shouldEndPoll = bundle.getBoolean(PollVotesFragment.RESULT_KEY, false)
if (shouldEndPoll) {
handleEndPoll(pollId)
}
}
}
}
override fun onViewPollClicked(messageId: Long) {
disposables += viewModel
.moveToMessage(messageId)
.subscribeBy(
onSuccess = { moveToPosition(it) },
onError = { Toast.makeText(requireContext(), R.string.Poll__unable_poll, Toast.LENGTH_LONG).show() }
)
}
override fun onToggleVote(poll: PollRecord, pollOption: PollOption, isChecked: Boolean) {
viewModel.toggleVote(poll, pollOption, isChecked)
}
override fun onJoinGroupCallClicked() {
val activity = activity ?: return
val recipient = viewModel.recipientSnapshot ?: return
@@ -3743,6 +3799,7 @@ class ConversationFragment :
ConversationReactionOverlay.Action.PAYMENT_DETAILS -> handleViewPaymentDetails(conversationMessage)
ConversationReactionOverlay.Action.VIEW_INFO -> handleDisplayDetails(conversationMessage)
ConversationReactionOverlay.Action.DELETE -> handleDeleteMessages(conversationMessage.multiselectCollection.toSet())
ConversationReactionOverlay.Action.END_POLL -> handleEndPoll(conversationMessage.messageRecord.getPoll()?.id)
}
}
}
@@ -4385,6 +4442,12 @@ class ConversationFragment :
toast(R.string.AttachmentManager_cant_open_media_selection, Toast.LENGTH_LONG)
}
}
AttachmentKeyboardButton.POLL -> {
CreatePollFragment.show(childFragmentManager)
childFragmentManager.setFragmentResultListener(CreatePollFragment.REQUEST_KEY, requireActivity()) { _, bundle ->
sendPoll(recipient, Poll.fromBundle(bundle))
}
}
}
} else if (media != null) {
conversationActivityResultContracts.launchMediaEditor(listOf(media), recipient.id, composeText.textTrimmed)
@@ -61,6 +61,8 @@ import org.thoughtcrime.securesms.database.model.MmsMessageRecord
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.MessageExtras
import org.thoughtcrime.securesms.database.model.databaseprotos.PollTerminate
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.MultiDeviceViewOnceOpenJob
import org.thoughtcrime.securesms.keyboard.KeyboardUtil
@@ -71,6 +73,7 @@ import org.thoughtcrime.securesms.mms.PartAuthority
import org.thoughtcrime.securesms.mms.QuoteModel
import org.thoughtcrime.securesms.mms.Slide
import org.thoughtcrime.securesms.mms.SlideDeck
import org.thoughtcrime.securesms.polls.Poll
import org.thoughtcrime.securesms.profiles.spoofing.ReviewRecipient
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.recipients.Recipient
@@ -82,9 +85,11 @@ import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.MessageUtil
import org.thoughtcrime.securesms.util.SignalLocalMetrics
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.getPoll
import org.thoughtcrime.securesms.util.hasLinkPreview
import org.thoughtcrime.securesms.util.hasSharedContact
import org.thoughtcrime.securesms.util.hasTextSlide
import org.thoughtcrime.securesms.util.isPoll
import org.thoughtcrime.securesms.util.isViewOnceMessage
import org.thoughtcrime.securesms.util.requireTextSlide
import java.io.IOException
@@ -164,6 +169,59 @@ class ConversationRepository(
}.subscribeOn(Schedulers.io())
}
fun sendPoll(threadRecipient: Recipient, poll: Poll): Completable {
return Completable.create { emitter ->
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(threadRecipient)
val message = OutgoingMessage.pollMessage(
threadRecipient = threadRecipient,
sentTimeMillis = System.currentTimeMillis(),
expiresIn = threadRecipient.expiresInSeconds.seconds.inWholeMilliseconds,
poll = poll.copy(authorId = Recipient.self().id.toLong()),
question = poll.question
)
Log.i(TAG, "Sending poll create to " + message.threadRecipient.id + ", thread: " + threadId)
MessageSender.sendPollAction(
AppDependencies.application,
message,
threadId,
MessageSender.SendType.SIGNAL,
null,
{ emitter.onComplete() }
)
}.subscribeOn(Schedulers.io())
}
fun endPoll(pollId: Long): Completable {
return Completable.create { emitter ->
val poll = SignalDatabase.polls.getPollFromId(pollId)
val messageRecord = SignalDatabase.messages.getMessageRecord(poll!!.messageId)
val threadRecipient = SignalDatabase.threads.getRecipientForThreadId(messageRecord.threadId)!!
val pollSentTimestamp = messageRecord.dateSent
val message = OutgoingMessage.pollTerminateMessage(
threadRecipient = threadRecipient,
sentTimeMillis = System.currentTimeMillis(),
expiresIn = threadRecipient.expiresInSeconds.seconds.inWholeMilliseconds,
messageExtras = MessageExtras(pollTerminate = PollTerminate(question = poll.question, messageId = poll.messageId, targetTimestamp = pollSentTimestamp))
)
Log.i(TAG, "Sending poll terminate to " + message.threadRecipient.id + ", thread: " + messageRecord.threadId)
MessageSender.sendPollAction(
AppDependencies.application,
message,
messageRecord.threadId,
MessageSender.SendType.SIGNAL,
null
) {
emitter.onComplete()
}
}.subscribeOn(Schedulers.io())
}
fun sendMessage(
threadId: Long,
threadRecipient: Recipient,
@@ -271,6 +329,13 @@ class ConversationRepository(
}.subscribeOn(Schedulers.io())
}
fun getMessagePosition(threadId: Long, messageId: Long): Single<Int> {
return Single.fromCallable {
val message = SignalDatabase.messages.getMessageRecord(messageId)
SignalDatabase.messages.getMessagePositionInConversation(threadId, message.dateReceived, message.fromRecipient.id)
}.subscribeOn(Schedulers.io())
}
fun getMessagePosition(threadId: Long, dateReceived: Long, authorId: RecipientId): Single<Int> {
return Single.fromCallable {
SignalDatabase.messages.getMessagePositionInConversation(threadId, dateReceived, authorId)
@@ -497,6 +562,11 @@ class ConversationRepository(
}
slideDeck to conversationMessage.getDisplayBody(context)
} else if (messageRecord.isPoll()) {
val poll = messageRecord.getPoll()!!
val slideDeck = SlideDeck()
slideDeck to SpannableStringBuilder().append(context.getString(R.string.Poll__poll_question, poll.question))
} else {
var slideDeck = if (messageRecord.isMms) {
(messageRecord as MmsMessageRecord).slideDeck
@@ -42,6 +42,7 @@ import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.rx3.asFlow
import org.signal.core.util.logging.Log
import org.signal.core.util.orNull
import org.signal.paging.ProxyPagingController
import org.thoughtcrime.securesms.banner.Banner
@@ -60,6 +61,7 @@ import org.thoughtcrime.securesms.conversation.v2.data.ConversationElementKey
import org.thoughtcrime.securesms.conversation.v2.items.ChatColorsDrawable
import org.thoughtcrime.securesms.database.DatabaseObserver
import org.thoughtcrime.securesms.database.MessageTable
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.recipients
import org.thoughtcrime.securesms.database.model.GroupRecord
import org.thoughtcrime.securesms.database.model.IdentityRecord
@@ -72,6 +74,7 @@ import org.thoughtcrime.securesms.database.model.StickerRecord
import org.thoughtcrime.securesms.database.model.StoryViewState
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobs.PollVoteJob
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob
import org.thoughtcrime.securesms.keyboard.KeyboardUtil
import org.thoughtcrime.securesms.keyvalue.SignalStore
@@ -81,6 +84,9 @@ import org.thoughtcrime.securesms.messagerequests.MessageRequestState
import org.thoughtcrime.securesms.mms.QuoteModel
import org.thoughtcrime.securesms.mms.Slide
import org.thoughtcrime.securesms.mms.SlideDeck
import org.thoughtcrime.securesms.polls.Poll
import org.thoughtcrime.securesms.polls.PollOption
import org.thoughtcrime.securesms.polls.PollRecord
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sms.MessageSender
@@ -107,6 +113,10 @@ class ConversationViewModel(
private val scheduledMessagesRepository: ScheduledMessagesRepository
) : ViewModel() {
companion object {
private val TAG = Log.tag(ConversationViewModel::class.java)
}
private val disposables = CompositeDisposable()
private val scrollButtonStateStore = RxStore(ConversationScrollButtonState()).addTo(disposables)
@@ -415,6 +425,11 @@ class ConversationViewModel(
return repository.getNextMentionPosition(threadId)
}
fun moveToMessage(messageId: Long): Single<Int> {
return repository.getMessagePosition(threadId, messageId)
.observeOn(AndroidSchedulers.mainThread())
}
fun moveToMessage(dateReceived: Long, author: RecipientId): Single<Int> {
return repository.getMessagePosition(threadId, dateReceived, author)
.observeOn(AndroidSchedulers.mainThread())
@@ -494,6 +509,18 @@ class ConversationViewModel(
return reactions.firstOrNull { it.author == Recipient.self().id }
}
fun sendPoll(threadRecipient: Recipient, poll: Poll): Completable {
return repository
.sendPoll(threadRecipient, poll)
.observeOn(AndroidSchedulers.mainThread())
}
fun endPoll(pollId: Long): Completable {
return repository
.endPoll(pollId)
.observeOn(AndroidSchedulers.mainThread())
}
fun sendMessage(
metricId: String?,
threadRecipient: Recipient,
@@ -622,6 +649,27 @@ class ConversationViewModel(
}
}
fun toggleVote(poll: PollRecord, pollOption: PollOption, isChecked: Boolean) {
viewModelScope.launch(Dispatchers.IO) {
val voteCount = if (isChecked) {
SignalDatabase.polls.insertVote(poll, pollOption)
} else {
SignalDatabase.polls.removeVote(poll, pollOption)
}
val pollVoteJob = PollVoteJob.create(
messageId = poll.messageId,
voteCount = voteCount,
isRemoval = !isChecked
)
if (pollVoteJob != null) {
AppDependencies.jobManager.add(pollVoteJob)
} else {
Log.w(TAG, "Unable to create poll vote job, ignoring.")
}
}
}
data class BackPressedState(
val isReactionDelegateShowing: Boolean = false,
val isSearchRequested: Boolean = false
@@ -0,0 +1,291 @@
package org.thoughtcrime.securesms.conversation.v2
import android.app.Dialog
import android.os.Bundle
import android.view.WindowManager
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.setFragmentResult
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.debounce
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Dividers
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Rows
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.copied.androidx.compose.DragAndDropEvent
import org.signal.core.ui.compose.copied.androidx.compose.DraggableItem
import org.signal.core.ui.compose.copied.androidx.compose.dragContainer
import org.signal.core.ui.compose.copied.androidx.compose.rememberDragDropState
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.compose.ComposeDialogFragment
import org.thoughtcrime.securesms.polls.Poll
import org.thoughtcrime.securesms.util.ViewUtil
import kotlin.time.Duration.Companion.milliseconds
/**
* Fragment to create a poll
*/
class CreatePollFragment : ComposeDialogFragment() {
companion object {
private val TAG = Log.tag(CreatePollFragment::class)
const val MAX_CHARACTER_LENGTH = 100
const val MAX_OPTIONS = 10
const val MIN_OPTIONS = 2
const val REQUEST_KEY = "CreatePollFragment"
fun show(fragmentManager: FragmentManager) {
return CreatePollFragment().show(fragmentManager, null)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setStyle(STYLE_NO_FRAME, R.style.Signal_DayNight_Dialog_FullScreen_Poll) // TODO(michelle): Finalize animation
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = super.onCreateDialog(savedInstanceState)
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
return dialog
}
@Composable
override fun DialogContent() {
Scaffolds.Settings(
title = stringResource(R.string.CreatePollFragment__new_poll),
onNavigationClick = {
dismissAllowingStateLoss()
},
navigationIcon = ImageVector.vectorResource(R.drawable.symbol_x_24),
navigationContentDescription = stringResource(R.string.Material3SearchToolbar__close)
) { paddingValues ->
CreatePollScreen(
paddingValues = paddingValues,
onSend = { question, allowMultiple, options ->
ViewUtil.hideKeyboard(requireContext(), requireView())
setFragmentResult(REQUEST_KEY, Poll(question, allowMultiple, options).toBundle())
dismissAllowingStateLoss()
},
onShowErrorSnackbar = { hasQuestion, hasOptions ->
if (!hasQuestion && !hasOptions) {
Snackbar.make(requireView(), R.string.CreatePollFragment__add_question_option, Snackbar.LENGTH_LONG).show()
} else if (!hasQuestion) {
Snackbar.make(requireView(), R.string.CreatePollFragment__add_question, Snackbar.LENGTH_LONG).show()
} else {
Snackbar.make(requireView(), R.string.CreatePollFragment__add_option, Snackbar.LENGTH_LONG).show()
}
}
)
}
}
}
@OptIn(FlowPreview::class)
@Composable
private fun CreatePollScreen(
paddingValues: PaddingValues,
onSend: (String, Boolean, List<String>) -> Unit = { _, _, _ -> },
onShowErrorSnackbar: (Boolean, Boolean) -> Unit = { _, _ -> }
) {
// Parts of poll
var question by remember { mutableStateOf("") }
val options = remember { mutableStateListOf("", "") }
var allowMultiple by remember { mutableStateOf(false) }
var hasMinimumOptions by remember { mutableStateOf(false) }
val isEnabled = question.isNotBlank() && hasMinimumOptions
var focusedOption by remember { mutableStateOf(-1) }
// Drag and drop
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
val isRtl = ViewUtil.isRtl(LocalContext.current)
val listState = rememberLazyListState()
val dragDropState = rememberDragDropState(listState, includeHeader = true, includeFooter = true, onEvent = { event ->
when (event) {
is DragAndDropEvent.OnItemMove -> {
val oldIndex = options[event.fromIndex]
options[event.fromIndex] = options[event.toIndex]
options[event.toIndex] = oldIndex
}
is DragAndDropEvent.OnItemDrop, is DragAndDropEvent.OnDragCancel -> Unit
}
})
LaunchedEffect(Unit) {
snapshotFlow { options.toList() }
.debounce(100.milliseconds)
.collect { currentOptions ->
val count = currentOptions.count { it.isNotBlank() }
if (count == currentOptions.size && currentOptions.size < CreatePollFragment.MAX_OPTIONS) {
options.add("")
}
hasMinimumOptions = count >= CreatePollFragment.MIN_OPTIONS
}
}
LaunchedEffect(focusedOption) {
val count = options.count { it.isNotBlank() }
if (count >= CreatePollFragment.MIN_OPTIONS) {
if (options.removeIf { it.isEmpty() }) {
options.add("")
}
}
}
Box(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize()
) {
LazyColumn(
modifier = Modifier
.fillMaxHeight()
.imePadding()
.dragContainer(
dragDropState = dragDropState,
leftDpOffset = if (isRtl) 0.dp else screenWidth - 56.dp,
rightDpOffset = if (isRtl) 56.dp else screenWidth
),
state = listState
) {
item {
DraggableItem(dragDropState, 0) {
Text(
text = stringResource(R.string.CreatePollFragment__question),
modifier = Modifier.padding(vertical = 12.dp, horizontal = 24.dp),
style = MaterialTheme.typography.titleSmall
)
TextField(
value = question,
label = { Text(text = stringResource(R.string.CreatePollFragment__ask_a_question)) },
onValueChange = { question = it.substring(0, minOf(it.length, CreatePollFragment.MAX_CHARACTER_LENGTH)) },
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
colors = TextFieldDefaults.colors(
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant
),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.onFocusChanged { focusState -> if (focusState.isFocused) focusedOption = -1 }
)
Spacer(modifier = Modifier.size(32.dp))
Text(
text = stringResource(R.string.CreatePollFragment__options),
modifier = Modifier.padding(vertical = 12.dp, horizontal = 24.dp),
style = MaterialTheme.typography.titleSmall
)
}
}
itemsIndexed(options) { index, option ->
DraggableItem(dragDropState, 1 + index) {
Box(modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 16.dp)) {
TextField(
value = option,
label = { Text(text = stringResource(R.string.CreatePollFragment__option_n, index + 1)) },
onValueChange = { options[index] = it.substring(0, minOf(it.length, CreatePollFragment.MAX_CHARACTER_LENGTH)) },
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
colors = TextFieldDefaults.colors(
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant
),
modifier = Modifier
.fillMaxWidth()
.onFocusChanged { focusState -> if (focusState.isFocused) focusedOption = index },
trailingIcon = {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.drag_handle),
contentDescription = stringResource(R.string.CreatePollFragment__drag_handle),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
)
}
}
}
item {
DraggableItem(dragDropState, 1 + options.size) {
Dividers.Default()
Rows.ToggleRow(checked = allowMultiple, text = stringResource(R.string.CreatePollFragment__allow_multiple_votes), onCheckChanged = { allowMultiple = it })
Spacer(modifier = Modifier.size(60.dp))
}
}
}
Buttons.MediumTonal(
colors = ButtonDefaults.filledTonalButtonColors(
contentColor = if (isEnabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f),
containerColor = if (isEnabled) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant
),
onClick = {
if (isEnabled) {
onSend(question, allowMultiple, options.filter { it.isNotBlank() })
} else {
onShowErrorSnackbar(question.isNotBlank(), hasMinimumOptions)
}
},
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(bottom = 16.dp, end = 24.dp)
.imePadding()
) {
Text(text = stringResource(R.string.conversation_activity__send))
}
}
}
@DayNightPreviews
@Composable
fun CreatePollPreview() {
Previews.Preview {
CreatePollScreen(PaddingValues(0.dp))
}
}
@@ -17,9 +17,11 @@ import org.thoughtcrime.securesms.database.model.ReactionRecord
import org.thoughtcrime.securesms.database.model.withAttachments
import org.thoughtcrime.securesms.database.model.withCall
import org.thoughtcrime.securesms.database.model.withPayment
import org.thoughtcrime.securesms.database.model.withPoll
import org.thoughtcrime.securesms.database.model.withReactions
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.payments.Payment
import org.thoughtcrime.securesms.polls.PollRecord
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.whispersystems.signalservice.api.util.UuidUtil
@@ -99,6 +101,10 @@ object MessageDataFetcher {
}
}
val pollsFuture = executor.submitTimed {
SignalDatabase.polls.getPollsForMessages(messageIds)
}
val mentionsResult = mentionsFuture.get()
val hasBeenQuotedResult = hasBeenQuotedFuture.get()
val reactionsResult = reactionsFuture.get()
@@ -106,6 +112,7 @@ object MessageDataFetcher {
val paymentsResult = paymentsFuture.get()
val callsResult = callsFuture.get()
val recipientsResult = recipientsFuture.get()
val pollsResult = pollsFuture.get()
val wallTimeMs = (System.nanoTime() - startTimeNanos).nanoseconds.toDouble(DurationUnit.MILLISECONDS)
@@ -119,6 +126,7 @@ object MessageDataFetcher {
attachments = attachmentsResult.result,
payments = paymentsResult.result,
calls = callsResult.result,
polls = pollsResult.result,
timeLog = "mentions: ${mentionsResult.duration}, is-quoted: ${hasBeenQuotedResult.duration}, reactions: ${reactionsResult.duration}, attachments: ${attachmentsResult.duration}, payments: ${paymentsResult.duration}, calls: ${callsResult.duration} >> cpuTime: ${cpuTimeMs.roundedString(2)}, wallTime: ${wallTimeMs.roundedString(2)}"
)
}
@@ -157,6 +165,10 @@ object MessageDataFetcher {
output.withCall(it)
} ?: output
output = data.polls[id]?.let {
output.withPoll(it)
} ?: output
return output
}
@@ -187,6 +199,7 @@ object MessageDataFetcher {
val attachments: Map<Long, List<DatabaseAttachment>>,
val payments: Map<Long, Payment>,
val calls: Map<Long, CallTable.Call>,
val polls: Map<Long, PollRecord>,
val timeLog: String
)
}
@@ -27,6 +27,7 @@ import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.permissions.PermissionCompat
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.RemoteConfig
import java.util.function.Predicate
/**
@@ -48,6 +49,7 @@ class AttachmentKeyboardFragment : LoggingFragment(R.layout.attachment_keyboard_
private val lifecycleDisposable = LifecycleDisposable()
private val removePaymentFilter: Predicate<AttachmentKeyboardButton> = Predicate { button -> button != AttachmentKeyboardButton.PAYMENT }
private val removePollFilter: Predicate<AttachmentKeyboardButton> = Predicate { button -> button != AttachmentKeyboardButton.POLL }
@Suppress("ReplaceGetOrSet")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@@ -72,7 +74,7 @@ class AttachmentKeyboardFragment : LoggingFragment(R.layout.attachment_keyboard_
val snapshot = conversationViewModel.recipientSnapshot
if (snapshot != null) {
updatePaymentsAvailable(snapshot)
updateButtonsAvailable(snapshot)
}
conversationViewModel
@@ -80,7 +82,7 @@ class AttachmentKeyboardFragment : LoggingFragment(R.layout.attachment_keyboard_
.observeOn(AndroidSchedulers.mainThread())
.subscribeBy {
attachmentKeyboardView.setWallpaperEnabled(it.hasWallpaper)
updatePaymentsAvailable(it)
updateButtonsAvailable(it)
}
.addTo(lifecycleDisposable)
}
@@ -126,16 +128,19 @@ class AttachmentKeyboardFragment : LoggingFragment(R.layout.attachment_keyboard_
.execute()
}
private fun updatePaymentsAvailable(recipient: Recipient) {
private fun updateButtonsAvailable(recipient: Recipient) {
val paymentsValues = SignalStore.payments
if (paymentsValues.paymentsAvailability.isSendAllowed &&
!recipient.isSelf &&
!recipient.isGroup &&
recipient.isRegistered
) {
attachmentKeyboardView.filterAttachmentKeyboardButtons(null)
} else {
val isPaymentsAvailable = paymentsValues.paymentsAvailability.isSendAllowed && !recipient.isSelf && !recipient.isGroup && recipient.isRegistered
val isPollsAvailable = recipient.isPushV2Group && RemoteConfig.polls
if (!isPaymentsAvailable && !isPollsAvailable) {
attachmentKeyboardView.filterAttachmentKeyboardButtons(removePaymentFilter.and(removePollFilter))
} else if (!isPaymentsAvailable) {
attachmentKeyboardView.filterAttachmentKeyboardButtons(removePaymentFilter)
} else if (!isPollsAvailable) (
attachmentKeyboardView.filterAttachmentKeyboardButtons(removePollFilter)
) else {
attachmentKeyboardView.filterAttachmentKeyboardButtons(null)
}
}
}
@@ -680,12 +680,16 @@ public final class ConversationListItem extends ConstraintLayout implements Bind
} else {
return emphasisAdded(context, context.getString(R.string.ThreadRecord_safety_number_changed), defaultTint);
}
} else if (MessageTypes.isPollTerminate(thread.getType())) {
return emphasisAdded(context, thread.getBody(), Glyph.POLL, defaultTint);
} else {
ThreadTable.Extra extra = thread.getExtra();
if (extra != null && extra.isViewOnce()) {
return emphasisAdded(context, getViewOnceDescription(context, thread.getContentType()), defaultTint);
} else if (extra != null && extra.isRemoteDelete()) {
return emphasisAdded(context, context.getString(thread.isOutgoing() ? R.string.ThreadRecord_you_deleted_this_message : R.string.ThreadRecord_this_message_was_deleted), defaultTint);
} else if (extra != null && extra.isPoll()) {
return emphasisAdded(context, thread.getBody(), Glyph.POLL, defaultTint);
} else {
SpannableStringBuilder sourceBody = new SpannableStringBuilder(thread.getBody());
MessageStyler.style(thread.getDate(), thread.getBodyRanges(), sourceBody);
@@ -80,6 +80,7 @@ import org.thoughtcrime.securesms.database.SignalDatabase.Companion.groupReceipt
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.groups
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.mentions
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.messages
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.polls
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.reactions
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.recipients
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.storySends
@@ -110,6 +111,7 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.database.model.databaseprotos.GroupCallUpdateDetails
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExportState
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras
import org.thoughtcrime.securesms.database.model.databaseprotos.PollTerminate
import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails
import org.thoughtcrime.securesms.database.model.databaseprotos.SessionSwitchoverEvent
import org.thoughtcrime.securesms.database.model.databaseprotos.ThreadMergeEvent
@@ -127,6 +129,8 @@ import org.thoughtcrime.securesms.mms.OutgoingMessage
import org.thoughtcrime.securesms.mms.QuoteModel
import org.thoughtcrime.securesms.mms.SlideDeck
import org.thoughtcrime.securesms.notifications.v2.DefaultMessageNotifier.StickyThread
import org.thoughtcrime.securesms.polls.Poll
import org.thoughtcrime.securesms.polls.PollRecord
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.revealable.ViewOnceExpirationInfo
@@ -211,6 +215,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
const val ORIGINAL_MESSAGE_ID = "original_message_id"
const val REVISION_NUMBER = "revision_number"
const val MESSAGE_EXTRAS = "message_extras"
const val VOTES_UNREAD = "votes_unread"
const val VOTES_LAST_SEEN = "votes_last_seen"
const val QUOTE_NOT_PRESENT_ID = 0L
const val QUOTE_TARGET_MISSING_ID = -1L
@@ -273,7 +279,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
$ORIGINAL_MESSAGE_ID INTEGER DEFAULT NULL REFERENCES $TABLE_NAME ($ID) ON DELETE CASCADE,
$REVISION_NUMBER INTEGER DEFAULT 0,
$MESSAGE_EXTRAS BLOB DEFAULT NULL,
$EXPIRE_TIMER_VERSION INTEGER DEFAULT 1 NOT NULL
$EXPIRE_TIMER_VERSION INTEGER DEFAULT 1 NOT NULL,
$VOTES_UNREAD INTEGER DEFAULT 0,
$VOTES_LAST_SEEN INTEGER DEFAULT 0
)
"""
@@ -303,7 +311,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
// This index is created specifically for getting the number of messages in a thread and therefore needs to be kept in sync with that query
"CREATE INDEX IF NOT EXISTS $INDEX_THREAD_COUNT ON $TABLE_NAME ($THREAD_ID) WHERE $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1 AND $LATEST_REVISION_ID IS NULL",
// This index is created specifically for getting the number of unread messages in a thread and therefore needs to be kept in sync with that query
"CREATE INDEX IF NOT EXISTS $INDEX_THREAD_UNREAD_COUNT ON $TABLE_NAME ($THREAD_ID) WHERE $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1 AND $ORIGINAL_MESSAGE_ID IS NULL AND $READ = 0"
"CREATE INDEX IF NOT EXISTS $INDEX_THREAD_UNREAD_COUNT ON $TABLE_NAME ($THREAD_ID) WHERE $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1 AND $ORIGINAL_MESSAGE_ID IS NULL AND $READ = 0",
"CREATE INDEX IF NOT EXISTS message_votes_unread_index ON $TABLE_NAME ($VOTES_UNREAD)"
)
private val MMS_PROJECTION_BASE = arrayOf(
@@ -356,7 +365,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
LATEST_REVISION_ID,
ORIGINAL_MESSAGE_ID,
REVISION_NUMBER,
MESSAGE_EXTRAS
MESSAGE_EXTRAS,
VOTES_UNREAD,
VOTES_LAST_SEEN
)
private val MMS_PROJECTION: Array<String> = MMS_PROJECTION_BASE + "NULL AS ${AttachmentTable.ATTACHMENT_JSON_ALIAS}"
@@ -2211,9 +2222,11 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
reactions.deleteReactions(MessageId(messageId))
deleteGroupStoryReplies(messageId)
disassociateStoryQuotes(messageId)
disassociatePollFromPollTerminate(polls.getPollTerminateMessageId(messageId))
val threadId = getThreadIdForMessage(messageId)
threads.update(threadId, false)
notifyConversationListeners(threadId)
}
OptimizeMessageSearchIndexJob.enqueue()
@@ -2303,7 +2316,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
.update(TABLE_NAME)
.values(
NOTIFIED to 1,
REACTIONS_LAST_SEEN to System.currentTimeMillis()
REACTIONS_LAST_SEEN to System.currentTimeMillis(),
VOTES_LAST_SEEN to System.currentTimeMillis()
)
.where("$ID = ? OR $ORIGINAL_MESSAGE_ID = ? OR $LATEST_REVISION_ID = ?", id, id, id)
.run()
@@ -2351,6 +2365,10 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
(
$REACTIONS_UNREAD = 1 AND
($outgoingTypeClause)
) OR
(
$VOTES_UNREAD = 1 AND
($outgoingTypeClause)
)
)
"""
@@ -2424,7 +2442,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
}
fun setAllMessagesRead(): List<MarkedMessageInfo> {
return setMessagesRead("$STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND ($READ = 0 OR ($REACTIONS_UNREAD = 1 AND ($outgoingTypeClause)))", null)
return setMessagesRead("$STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND ($READ = 0 OR ($REACTIONS_UNREAD = 1 AND ($outgoingTypeClause)) OR ($VOTES_UNREAD = 1 AND ($outgoingTypeClause)))", null)
}
private fun setMessagesRead(where: String, arguments: Array<String>?): List<MarkedMessageInfo> {
@@ -2432,7 +2450,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
return writableDatabase.rawQuery(
"""
UPDATE $TABLE_NAME INDEXED BY $INDEX_THREAD_STORY_SCHEDULED_DATE_LATEST_REVISION_ID
SET $READ = 1, $REACTIONS_UNREAD = 0, $REACTIONS_LAST_SEEN = ${System.currentTimeMillis()}
SET $READ = 1, $REACTIONS_UNREAD = 0, $REACTIONS_LAST_SEEN = ${System.currentTimeMillis()}, $VOTES_UNREAD = 0, $VOTES_LAST_SEEN = ${System.currentTimeMillis()}
WHERE $where
RETURNING $ID, $FROM_RECIPIENT_ID, $DATE_SENT, $DATE_RECEIVED, $TYPE, $EXPIRES_IN, $EXPIRE_STARTED, $THREAD_ID, $STORY_TYPE
""",
@@ -2526,6 +2544,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
fun getOutgoingMessage(messageId: Long): OutgoingMessage {
return rawQueryWithAttachments(RAW_ID_WHERE, arrayOf(messageId.toString())).readToSingleObject { cursor ->
val associatedAttachments = attachments.getAttachmentsForMessage(messageId)
val associatedPoll = polls.getPollForOutgoingMessage(messageId)
val mentions = mentions.getMentionsForMessage(messageId)
val outboxType = cursor.requireLong(TYPE)
val body = cursor.requireString(BODY)
@@ -2655,6 +2674,20 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
sentTimeMillis = timestamp,
expiresIn = expiresIn
)
} else if (associatedPoll != null) {
OutgoingMessage.pollMessage(
threadRecipient = threadRecipient,
sentTimeMillis = timestamp,
expiresIn = expiresIn,
poll = associatedPoll
)
} else if (MessageTypes.isPollTerminate(outboxType) && messageExtras != null) {
OutgoingMessage.pollTerminateMessage(
threadRecipient = threadRecipient,
sentTimeMillis = timestamp,
expiresIn = expiresIn,
messageExtras = messageExtras
)
} else {
val giftBadge: GiftBadge? = if (body != null && MessageTypes.isGiftBadge(outboxType)) {
GiftBadge.ADAPTER.decode(Base64.decode(body))
@@ -2806,7 +2839,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
contentValues = contentValues,
insertListener = null,
updateThread = retrieved.storyType === StoryType.NONE && !silent,
unarchive = true
unarchive = true,
poll = retrieved.poll,
pollTerminate = retrieved.messageExtras?.pollTerminate
)
if (messageId < 0) {
@@ -3128,6 +3163,14 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
hasSpecialType = true
}
if (message.messageExtras?.pollTerminate != null) {
if (hasSpecialType) {
throw MmsException("Cannot insert message with multiple special types.")
}
type = type or MessageTypes.SPECIAL_TYPE_POLL_TERMINATE
hasSpecialType = true
}
val earlyDeliveryReceipts: Map<RecipientId, Receipt> = earlyDeliveryReceiptCache.remove(message.sentTimeMillis)
if (earlyDeliveryReceipts.isNotEmpty()) {
@@ -3241,7 +3284,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
contentValues = contentValues,
insertListener = insertListener,
updateThread = false,
unarchive = false
unarchive = false,
poll = message.poll,
pollTerminate = message.messageExtras?.pollTerminate
)
if (messageId < 0) {
@@ -3348,7 +3393,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
contentValues: ContentValues,
insertListener: InsertListener?,
updateThread: Boolean,
unarchive: Boolean
unarchive: Boolean,
poll: Poll? = null,
pollTerminate: PollTerminate? = null
): kotlin.Pair<Long, Map<Attachment, AttachmentId>?> {
val mentionsSelf = mentions.any { Recipient.resolved(it.recipientId).isSelf }
val allAttachments: MutableList<Attachment> = mutableListOf()
@@ -3401,6 +3448,19 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
}
}
if (poll != null) {
polls.insertPoll(poll.question, poll.allowMultipleVotes, poll.pollOptions, poll.authorId, messageId)
}
if (pollTerminate != null) {
val pollId = polls.getPollId(pollTerminate.messageId)
if (pollId == null) {
Log.w(TAG, "Unable to find corresponding poll.")
} else {
polls.endPoll(pollId, messageId)
}
}
messageId to insertedAttachments
}
@@ -3486,6 +3546,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
attachments.deleteAttachmentsForMessage(messageId)
groupReceipts.deleteRowsForMessage(messageId)
mentions.deleteMentionsForMessage(messageId)
disassociatePollFromPollTerminate(polls.getPollTerminateMessageId(messageId))
writableDatabase
.delete(TABLE_NAME)
@@ -3551,6 +3612,36 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
}
}
/**
* When a poll gets deleted, remove the poll reference from its corresponding terminate message by setting it to -1.
*/
fun disassociatePollFromPollTerminate(messageId: Long) {
if (messageId == -1L) {
return
}
writableDatabase.withinTransaction { db ->
val messageExtras = db
.select(MESSAGE_EXTRAS)
.from(TABLE_NAME)
.where("$ID = ?", messageId)
.run()
.readToSingleObject { cursor ->
val messageExtraBytes = cursor.requireBlob(MESSAGE_EXTRAS)
messageExtraBytes?.let { MessageExtras.ADAPTER.decode(it) }
}
if (messageExtras?.pollTerminate != null) {
val updatedMessageExtras = messageExtras.newBuilder().pollTerminate(pollTerminate = messageExtras.pollTerminate.copy(messageId = -1)).build()
db
.update(TABLE_NAME)
.values(MESSAGE_EXTRAS to updatedMessageExtras.encode())
.where("$ID = ?", messageId)
.run()
}
}
}
fun getSerializedSharedContacts(insertedAttachmentIds: Map<Attachment, AttachmentId>, contacts: List<Contact>): String? {
if (contacts.isEmpty()) {
return null
@@ -4048,6 +4139,34 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
.run()
}
fun setVoteSeen(threadId: Long, sinceTimestamp: Long) {
val where = if (sinceTimestamp > -1) {
"$THREAD_ID = ? AND $VOTES_UNREAD = ? AND $DATE_RECEIVED <= $sinceTimestamp"
} else {
"$THREAD_ID = ? AND $VOTES_UNREAD = ?"
}
writableDatabase
.update(TABLE_NAME)
.values(
VOTES_UNREAD to 0,
VOTES_LAST_SEEN to System.currentTimeMillis()
)
.where(where, threadId, 1)
.run()
}
fun setAllVotesSeen() {
writableDatabase
.update(TABLE_NAME)
.values(
VOTES_UNREAD to 0,
VOTES_LAST_SEEN to System.currentTimeMillis()
)
.where("$VOTES_UNREAD != ?", 0)
.run()
}
fun setNotifiedTimestamp(timestamp: Long, ids: List<Long>) {
if (ids.isEmpty()) {
return
@@ -4830,7 +4949,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
val values = contentValuesOf(
READ to 1,
REACTIONS_UNREAD to 0,
REACTIONS_LAST_SEEN to System.currentTimeMillis()
REACTIONS_LAST_SEEN to System.currentTimeMillis(),
VOTES_UNREAD to 0,
VOTES_LAST_SEEN to System.currentTimeMillis()
)
if (expiresIn > 0) {
@@ -4975,7 +5096,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
($READ = 0 AND ($ORIGINAL_MESSAGE_ID IS NULL OR EXISTS (SELECT 1 FROM $TABLE_NAME AS m WHERE m.$ID = $TABLE_NAME.$ORIGINAL_MESSAGE_ID AND m.$READ = 0)))
OR $REACTIONS_UNREAD = 1
${if (stickyQuery.isNotEmpty()) "OR ($stickyQuery)" else ""}
OR ($IS_MISSED_CALL_TYPE_CLAUSE AND EXISTS (SELECT 1 FROM ${CallTable.TABLE_NAME} WHERE ${CallTable.MESSAGE_ID} = $TABLE_NAME.$ID AND ${CallTable.EVENT} = ${CallTable.Event.serialize(CallTable.Event.MISSED)} AND ${CallTable.READ} = 0))
OR ($IS_MISSED_CALL_TYPE_CLAUSE AND EXISTS (SELECT 1 FROM ${CallTable.TABLE_NAME} WHERE ${CallTable.MESSAGE_ID} = $TABLE_NAME.$ID AND ${CallTable.EVENT} = ${CallTable.Event.serialize(CallTable.Event.MISSED)} AND ${CallTable.READ} = 0))
OR $VOTES_UNREAD = 1
)
""".trimIndent()
)
@@ -5139,6 +5261,32 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
}
}
fun updateVotesUnread(db: SQLiteDatabase, messageId: Long, hasVotes: Boolean, isRemoval: Boolean) {
try {
val isOutgoing = getMessageRecord(messageId).isOutgoing
val values = ContentValues()
if (!hasVotes) {
values.put(VOTES_UNREAD, 0)
} else if (!isRemoval) {
values.put(VOTES_UNREAD, 1)
}
if (isOutgoing && hasVotes) {
values.put(NOTIFIED, 0)
}
if (values.size() > 0) {
db.update(TABLE_NAME)
.values(values)
.where("$ID = ?", messageId)
.run()
}
} catch (e: NoSuchMessageException) {
Log.w(TAG, "Failed to find message $messageId")
}
}
@Throws(IOException::class)
protected fun <D : Document<I>?, I> removeFromDocument(messageId: Long, column: String, item: I, clazz: Class<D>) {
writableDatabase.withinTransaction { db ->
@@ -5295,6 +5443,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
MessageType.IDENTITY_VERIFIED -> MessageTypes.KEY_EXCHANGE_IDENTITY_VERIFIED_BIT or MessageTypes.BASE_INBOX_TYPE
MessageType.IDENTITY_DEFAULT -> MessageTypes.KEY_EXCHANGE_IDENTITY_DEFAULT_BIT or MessageTypes.BASE_INBOX_TYPE
MessageType.END_SESSION -> MessageTypes.END_SESSION_BIT or MessageTypes.BASE_INBOX_TYPE
MessageType.POLL_TERMINATE -> MessageTypes.SPECIAL_TYPE_POLL_TERMINATE or MessageTypes.BASE_INBOX_TYPE
MessageType.GROUP_UPDATE -> {
val isOnlyGroupLeave = this.groupContext?.let { GroupV2UpdateMessageUtil.isJustAGroupLeave(it) } ?: false
@@ -5635,6 +5784,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
null
}
val poll: PollRecord? = polls.getPoll(id)
val giftBadge: GiftBadge? = if (body != null && MessageTypes.isGiftBadge(box)) {
try {
GiftBadge.ADAPTER.decode(Base64.decode(body))
@@ -5683,6 +5834,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
giftBadge,
null,
null,
poll,
scheduledDate,
latestRevisionId,
originalMessageId,
@@ -45,5 +45,8 @@ enum class MessageType {
IDENTITY_DEFAULT,
/** A manual session reset. This is no longer used and is only here for handling possible inbound/sync messages. */
END_SESSION
END_SESSION,
/** A poll has ended **/
POLL_TERMINATE
}
@@ -122,6 +122,7 @@ public interface MessageTypes {
long SPECIAL_TYPE_PAYMENTS_TOMBSTONE = 0x900000000L;
long SPECIAL_TYPE_BLOCKED = 0xA00000000L;
long SPECIAL_TYPE_UNBLOCKED = 0xB00000000L;
long SPECIAL_TYPE_POLL_TERMINATE = 0xC00000000L;
long IGNORABLE_TYPESMASK_WHEN_COUNTING = END_SESSION_BIT | KEY_EXCHANGE_IDENTITY_UPDATE_BIT | KEY_EXCHANGE_IDENTITY_VERIFIED_BIT;
@@ -165,6 +166,10 @@ public interface MessageTypes {
return (type & SPECIAL_TYPES_MASK) == SPECIAL_TYPE_UNBLOCKED;
}
static boolean isPollTerminate(long type) {
return (type & SPECIAL_TYPES_MASK) == SPECIAL_TYPE_POLL_TERMINATE;
}
static boolean isDraftMessageType(long type) {
return (type & BASE_TYPE_MASK) == BASE_DRAFT_TYPE;
}
@@ -0,0 +1,670 @@
package org.thoughtcrime.securesms.database
import android.content.ContentValues
import android.content.Context
import androidx.core.content.contentValuesOf
import org.signal.core.util.SqlUtil
import org.signal.core.util.delete
import org.signal.core.util.exists
import org.signal.core.util.groupBy
import org.signal.core.util.insertInto
import org.signal.core.util.logging.Log
import org.signal.core.util.readToList
import org.signal.core.util.readToMap
import org.signal.core.util.readToSingleBoolean
import org.signal.core.util.readToSingleInt
import org.signal.core.util.readToSingleLong
import org.signal.core.util.readToSingleLongOrNull
import org.signal.core.util.readToSingleObject
import org.signal.core.util.requireBoolean
import org.signal.core.util.requireInt
import org.signal.core.util.requireLong
import org.signal.core.util.requireNonNullString
import org.signal.core.util.requireString
import org.signal.core.util.select
import org.signal.core.util.update
import org.signal.core.util.withinTransaction
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.polls.Poll
import org.thoughtcrime.securesms.polls.PollOption
import org.thoughtcrime.securesms.polls.PollRecord
import org.thoughtcrime.securesms.polls.PollVote
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
/**
* Database table for polls
*
* Voting:
* [VOTE_COUNT] tracks how often someone has voted and is specific per poll and user.
* The first time Alice votes in Poll 1, the count is 1, the next time is 2. Removing a vote will also bump it (to 3).
* If Alice votes in Poll 2, her vote count will start at 1. If Bob votes, his own vote count starts at 1 (so no interactions between other polls or people)
* We track vote count because the server can reorder messages and we don't want to process an older vote count than what we have.
*
* For example, in three rounds of voting (in the same poll):
* 1. Alice votes for option a -> we send (a) with vote count of 1
* 2. Alice votes for option b -> we send (a,b) with vote count of 2
* 3. Alice removes option b -> we send (a) with vote count of 3
*
* If we get and process #3 before receiving #2, we will drop #2. This can be done because the voting message always contains the full state of all your votes.
*
* [VOTE_STATE] tracks the lifecycle of a single vote. Example below with added (remove is very similar).
* UI: Alice votes for Option A -> Pending Spinner on Option A -> Option A is checked/Option B is removed if single-vote poll.
* BTS: PollVoteJob runs (PENDING_ADD) PollVoteJob finishes (ADDED)
*/
class PollTables(context: Context?, databaseHelper: SignalDatabase?) : DatabaseTable(context, databaseHelper), RecipientIdDatabaseReference {
companion object {
private val TAG = Log.tag(PollTables::class.java)
@JvmField
val CREATE_TABLE: Array<String> = arrayOf(PollTable.CREATE_TABLE, PollOptionTable.CREATE_TABLE, PollVoteTable.CREATE_TABLE)
@JvmField
val CREATE_INDEXES: Array<String> = PollTable.CREATE_INDEXES + PollOptionTable.CREATE_INDEXES + PollVoteTable.CREATE_INDEXES
}
/**
* Table containing general poll information (name, deleted status, etc.)
*/
object PollTable {
const val TABLE_NAME = "poll"
const val ID = "_id"
const val AUTHOR_ID = "author_id"
const val MESSAGE_ID = "message_id"
const val QUESTION = "question"
const val ALLOW_MULTIPLE_VOTES = "allow_multiple_votes"
const val END_MESSAGE_ID = "end_message_id"
const val CREATE_TABLE = """
CREATE TABLE $TABLE_NAME (
$ID INTEGER PRIMARY KEY AUTOINCREMENT,
$AUTHOR_ID INTEGER NOT NULL REFERENCES ${RecipientTable.TABLE_NAME} (${RecipientTable.ID}) ON DELETE CASCADE,
$MESSAGE_ID INTEGER NOT NULL REFERENCES ${MessageTable.TABLE_NAME} (${MessageTable.ID}) ON DELETE CASCADE,
$QUESTION TEXT,
$ALLOW_MULTIPLE_VOTES INTEGER DEFAULT 0,
$END_MESSAGE_ID INTEGER DEFAULT 0
)
"""
val CREATE_INDEXES = arrayOf(
"CREATE INDEX poll_author_id_index ON $TABLE_NAME ($AUTHOR_ID)",
"CREATE INDEX poll_message_id_index ON $TABLE_NAME ($MESSAGE_ID)"
)
}
/**
* Table containing the options within a given poll
*/
object PollOptionTable {
const val TABLE_NAME = "poll_option"
const val ID = "_id"
const val POLL_ID = "poll_id"
const val OPTION_TEXT = "option_text"
const val OPTION_ORDER = "option_order"
const val CREATE_TABLE = """
CREATE TABLE $TABLE_NAME (
$ID INTEGER PRIMARY KEY AUTOINCREMENT,
$POLL_ID INTEGER NOT NULL REFERENCES ${PollTable.TABLE_NAME} (${PollTable.ID}) ON DELETE CASCADE,
$OPTION_TEXT TEXT,
$OPTION_ORDER INTEGER
)
"""
val CREATE_INDEXES = arrayOf(
"CREATE INDEX poll_option_poll_id_index ON $TABLE_NAME ($POLL_ID)"
)
}
/**
* Table containing the votes of a given poll
*/
object PollVoteTable {
const val TABLE_NAME = "poll_vote"
const val ID = "_id"
const val POLL_ID = "poll_id"
const val POLL_OPTION_ID = "poll_option_id"
const val VOTER_ID = "voter_id"
const val VOTE_COUNT = "vote_count"
const val DATE_RECEIVED = "date_received"
const val VOTE_STATE = "vote_state"
const val CREATE_TABLE = """
CREATE TABLE $TABLE_NAME (
$ID INTEGER PRIMARY KEY AUTOINCREMENT,
$POLL_ID INTEGER NOT NULL REFERENCES ${PollTable.TABLE_NAME} (${PollTable.ID}) ON DELETE CASCADE,
$POLL_OPTION_ID INTEGER DEFAULT NULL REFERENCES ${PollOptionTable.TABLE_NAME} (${PollOptionTable.ID}) ON DELETE CASCADE,
$VOTER_ID INTEGER NOT NULL REFERENCES ${RecipientTable.TABLE_NAME} (${RecipientTable.ID}) ON DELETE CASCADE,
$VOTE_COUNT INTEGER,
$DATE_RECEIVED INTEGER DEFAULT 0,
$VOTE_STATE INTEGER DEFAULT 0,
UNIQUE($POLL_ID, $VOTER_ID, $POLL_OPTION_ID) ON CONFLICT REPLACE
)
"""
val CREATE_INDEXES = arrayOf(
"CREATE INDEX poll_vote_poll_id_index ON $TABLE_NAME ($POLL_ID)",
"CREATE INDEX poll_vote_poll_option_id_index ON $TABLE_NAME ($POLL_OPTION_ID)",
"CREATE INDEX poll_vote_voter_id_index ON $TABLE_NAME ($VOTER_ID)"
)
}
override fun remapRecipient(fromId: RecipientId, toId: RecipientId) {
val countFromPoll = writableDatabase
.update(PollTable.TABLE_NAME)
.values(PollTable.AUTHOR_ID to toId.serialize())
.where("${PollTable.AUTHOR_ID} = ?", fromId)
.run()
val countFromVotes = writableDatabase
.update(PollVoteTable.TABLE_NAME)
.values(PollVoteTable.VOTER_ID to toId.serialize())
.where("${PollVoteTable.VOTER_ID} = ?", fromId)
.run()
Log.d(TAG, "Remapped $fromId to $toId. count from polls: $countFromPoll from poll votes: $countFromVotes")
}
/**
* Inserts a newly created poll with its options
*/
fun insertPoll(question: String, allowMultipleVotes: Boolean, options: List<String>, authorId: Long, messageId: Long) {
writableDatabase.withinTransaction { db ->
val pollId = db.insertInto(PollTable.TABLE_NAME)
.values(
contentValuesOf(
PollTable.QUESTION to question,
PollTable.ALLOW_MULTIPLE_VOTES to allowMultipleVotes,
PollTable.AUTHOR_ID to authorId,
PollTable.MESSAGE_ID to messageId
)
)
.run()
SqlUtil.buildBulkInsert(
PollOptionTable.TABLE_NAME,
arrayOf(PollOptionTable.POLL_ID, PollOptionTable.OPTION_TEXT, PollOptionTable.OPTION_ORDER),
options.toPollContentValues(pollId)
).forEach {
db.execSQL(it.where, it.whereArgs)
}
}
}
/**
* Inserts a vote in a poll and increases the vote count by 1.
* Status is marked as [VoteState.PENDING_ADD] here and then once it successfully sends, it will get updated to [VoteState.ADDED] in [markPendingAsAdded]
*/
fun insertVote(poll: PollRecord, pollOption: PollOption): Int {
val self = Recipient.self().id.toLong()
var voteCount = 0
writableDatabase.withinTransaction { db ->
voteCount = getCurrentPollVoteCount(poll.id, self) + 1
val contentValues = ContentValues().apply {
put(PollVoteTable.POLL_ID, poll.id)
put(PollVoteTable.POLL_OPTION_ID, pollOption.id)
put(PollVoteTable.VOTER_ID, self)
put(PollVoteTable.VOTE_COUNT, voteCount)
put(PollVoteTable.VOTE_STATE, VoteState.PENDING_ADD.value)
}
db.insertInto(PollVoteTable.TABLE_NAME)
.values(contentValues)
.run(SQLiteDatabase.CONFLICT_REPLACE)
}
AppDependencies.databaseObserver.notifyMessageUpdateObservers(MessageId(poll.messageId))
return voteCount
}
/**
* Once a vote is sent to at least one person, we can update the [VoteState.PENDING_ADD] state to [VoteState.ADDED].
* If the poll only allows one vote, it also clears out any old votes.
*/
fun markPendingAsAdded(pollId: Long, voterId: Long, voteCount: Int, messageId: Long) {
val poll = SignalDatabase.polls.getPollFromId(pollId)
if (poll == null) {
Log.w(TAG, "Cannot find poll anymore $pollId")
return
}
writableDatabase.updateWithOnConflict(
PollVoteTable.TABLE_NAME,
contentValuesOf(PollVoteTable.VOTE_STATE to VoteState.ADDED.value),
"${PollVoteTable.POLL_ID} = ? AND ${PollVoteTable.VOTER_ID} = ? AND ${PollVoteTable.VOTE_COUNT} = ? AND ${PollVoteTable.VOTE_STATE} = ${VoteState.PENDING_ADD.value}",
SqlUtil.buildArgs(pollId, voterId, voteCount),
SQLiteDatabase.CONFLICT_REPLACE
)
if (!poll.allowMultipleVotes) {
writableDatabase.delete(PollVoteTable.TABLE_NAME)
.where("${PollVoteTable.POLL_ID} = ? AND ${PollVoteTable.VOTER_ID} = ? AND ${PollVoteTable.VOTE_COUNT} < ?", poll.id, Recipient.self().id, voteCount)
.run()
}
AppDependencies.databaseObserver.notifyMessageUpdateObservers(MessageId(messageId))
}
/**
* Removes vote from a poll. This also increases the vote count because removal of a vote, is technically a type of vote.
* Status is marked as [VoteState.PENDING_REMOVE] here and then once it successfully sends, it will get updated to [VoteState.REMOVED] in [markPendingAsRemoved]
*/
fun removeVote(poll: PollRecord, pollOption: PollOption): Int {
val self = Recipient.self().id.toLong()
var voteCount = 0
writableDatabase.withinTransaction { db ->
voteCount = getCurrentPollVoteCount(poll.id, self) + 1
db.insertInto(PollVoteTable.TABLE_NAME)
.values(
PollVoteTable.POLL_ID to poll.id,
PollVoteTable.POLL_OPTION_ID to pollOption.id,
PollVoteTable.VOTER_ID to self,
PollVoteTable.VOTE_COUNT to voteCount,
PollVoteTable.VOTE_STATE to VoteState.PENDING_REMOVE.value
)
.run(SQLiteDatabase.CONFLICT_REPLACE)
}
AppDependencies.databaseObserver.notifyMessageUpdateObservers(MessageId(poll.messageId))
return voteCount
}
/**
* Once a vote is sent to at least one person, we can update the [VoteState.PENDING_REMOVE] state to [VoteState.REMOVED].
*/
fun markPendingAsRemoved(pollId: Long, voterId: Long, voteCount: Int, messageId: Long) {
writableDatabase.withinTransaction { db ->
db.updateWithOnConflict(
PollVoteTable.TABLE_NAME,
contentValuesOf(PollVoteTable.VOTE_STATE to VoteState.REMOVED.value),
"${PollVoteTable.POLL_ID} = ? AND ${PollVoteTable.VOTER_ID} = ? AND ${PollVoteTable.VOTE_COUNT} = ? AND ${PollVoteTable.VOTE_STATE} = ${VoteState.PENDING_REMOVE.value}",
SqlUtil.buildArgs(pollId, voterId, voteCount),
SQLiteDatabase.CONFLICT_REPLACE
)
}
AppDependencies.databaseObserver.notifyMessageUpdateObservers(MessageId(messageId))
}
/**
* For a given poll, returns the option indexes that the person has voted for
*/
fun getVotes(pollId: Long, allowMultipleVotes: Boolean): List<Int> {
val voteQuery = if (allowMultipleVotes) {
"(${PollVoteTable.VOTE_STATE} = ${VoteState.ADDED.value} OR ${PollVoteTable.VOTE_STATE} = ${VoteState.PENDING_ADD.value})"
} else {
"${PollVoteTable.VOTE_STATE} = ${VoteState.PENDING_ADD.value}"
}
return readableDatabase
.select(PollOptionTable.OPTION_ORDER)
.from("${PollVoteTable.TABLE_NAME} LEFT JOIN ${PollOptionTable.TABLE_NAME} ON ${PollVoteTable.TABLE_NAME}.${PollVoteTable.POLL_OPTION_ID} = ${PollOptionTable.TABLE_NAME}.${PollOptionTable.ID}")
.where(
"""
${PollVoteTable.TABLE_NAME}.${PollVoteTable.POLL_ID} = ? AND
${PollVoteTable.VOTER_ID} = ? AND
${PollVoteTable.POLL_OPTION_ID} IS NOT NULL AND
$voteQuery
""",
pollId,
Recipient.self().id.toLong()
)
.run()
.readToList { cursor -> cursor.requireInt(PollOptionTable.OPTION_ORDER) }
}
/**
* For a given poll, returns who has voted in the poll. If a person has voted for multiple options, only count their most recent vote.
*/
fun getAllVotes(messageId: Long): List<PollVote> {
return readableDatabase
.select()
.from("${PollTable.TABLE_NAME} INNER JOIN ${PollVoteTable.TABLE_NAME} ON ${PollTable.TABLE_NAME}.${PollTable.ID} = ${PollVoteTable.TABLE_NAME}.${PollVoteTable.POLL_ID}")
.where("${PollTable.MESSAGE_ID} = ?", messageId)
.orderBy("${PollVoteTable.DATE_RECEIVED} DESC")
.run()
.readToList { cursor ->
PollVote(
pollId = cursor.requireLong(PollVoteTable.POLL_ID),
question = cursor.requireNonNullString(PollTable.QUESTION),
voterId = RecipientId.from(cursor.requireLong(PollVoteTable.VOTER_ID)),
dateReceived = cursor.requireLong(PollVoteTable.DATE_RECEIVED)
)
}
.distinctBy { it.pollId to it.voterId }
}
/**
* Returns the [VoteState] for a given voting session (as indicated by voteCount)
*/
fun getPollVoteStateForGivenVote(pollId: Long, voteCount: Int): VoteState {
val value = readableDatabase
.select(PollVoteTable.VOTE_STATE)
.from(PollVoteTable.TABLE_NAME)
.where("${PollVoteTable.POLL_ID} = ? AND ${PollVoteTable.VOTER_ID} = ? AND ${PollVoteTable.VOTE_COUNT} = ?", pollId, Recipient.self().id.toLong(), voteCount)
.run()
.readToSingleInt()
return VoteState.fromValue(value)
}
/**
* Sets the [VoteState] for a given voting session (as indicated by voteCount)
*/
fun setPollVoteStateForGivenVote(pollId: Long, voterId: Long, voteCount: Int, messageId: Long, undoRemoval: Boolean) {
val state = if (undoRemoval) VoteState.ADDED.value else VoteState.REMOVED.value
writableDatabase.withinTransaction { db ->
db.updateWithOnConflict(
PollVoteTable.TABLE_NAME,
contentValuesOf(
PollVoteTable.VOTE_STATE to state
),
"${PollVoteTable.POLL_ID} = ? AND ${PollVoteTable.VOTER_ID} = ? AND ${PollVoteTable.VOTE_COUNT} = ?",
SqlUtil.buildArgs(pollId, voterId, voteCount),
SQLiteDatabase.CONFLICT_REPLACE
)
}
AppDependencies.databaseObserver.notifyMessageUpdateObservers(MessageId(messageId))
}
/**
* Inserts all of the votes a person has made on a poll. Clears out any old data if they voted previously.
*/
fun insertVotes(pollId: Long, pollOptionIds: List<Long>, voterId: Long, voteCount: Long, messageId: MessageId) {
writableDatabase.withinTransaction { db ->
// Delete any previous votes they had on the poll
db.delete(PollVoteTable.TABLE_NAME)
.where("${PollVoteTable.VOTER_ID} = ? AND ${PollVoteTable.POLL_ID} = ?", voterId, pollId)
.run()
SqlUtil.buildBulkInsert(
PollVoteTable.TABLE_NAME,
arrayOf(PollVoteTable.POLL_ID, PollVoteTable.POLL_OPTION_ID, PollVoteTable.VOTER_ID, PollVoteTable.VOTE_COUNT, PollVoteTable.DATE_RECEIVED, PollVoteTable.VOTE_STATE),
pollOptionIds.toPollVoteContentValues(pollId, voterId, voteCount)
).forEach {
db.execSQL(it.where, it.whereArgs)
}
}
AppDependencies.databaseObserver.notifyMessageUpdateObservers(messageId)
SignalDatabase.messages.updateVotesUnread(writableDatabase, messageId.id, hasVotes(pollId), pollOptionIds.isEmpty())
}
private fun hasVotes(pollId: Long): Boolean {
return readableDatabase
.exists(PollVoteTable.TABLE_NAME)
.where("${PollVoteTable.POLL_ID} = ?", pollId)
.run()
}
/**
* If a poll has ended, returns the message id of the poll end message. Otherwise, return -1.
*/
fun getPollTerminateMessageId(messageId: Long): Long {
return readableDatabase
.select(PollTable.END_MESSAGE_ID)
.from(PollTable.TABLE_NAME)
.where("${PollTable.MESSAGE_ID} = ?", messageId)
.run()
.readToSingleLong(-1)
}
/**
* Ends a poll
*/
fun endPoll(pollId: Long, endingMessageId: Long) {
val messageId = getMessageId(pollId)
if (messageId == null) {
Log.w(TAG, "Unable to find the poll to end.")
return
}
writableDatabase.withinTransaction { db ->
db.update(PollTable.TABLE_NAME)
.values(PollTable.END_MESSAGE_ID to endingMessageId)
.where("${PollTable.ID} = ?", pollId)
.run()
}
AppDependencies.databaseObserver.notifyMessageUpdateObservers(MessageId(messageId))
}
/**
* Returns the poll id if associated with a given message id
*/
fun getPollId(messageId: Long): Long? {
return readableDatabase
.select(PollTable.ID)
.from(PollTable.TABLE_NAME)
.where("${PollTable.MESSAGE_ID} = ?", messageId)
.run()
.readToSingleLongOrNull()
}
/**
* Returns the message id for a poll id
*/
fun getMessageId(pollId: Long): Long? {
return readableDatabase
.select(PollTable.MESSAGE_ID)
.from(PollTable.TABLE_NAME)
.where("${PollTable.ID} = ?", pollId)
.run()
.readToSingleLongOrNull()
}
/**
* Returns a poll record for a given poll id
*/
fun getPollFromId(pollId: Long): PollRecord? {
return getPoll(getMessageId(pollId))
}
/**
* Returns the minimum amount necessary to create a poll for a message id
*/
fun getPollForOutgoingMessage(messageId: Long): Poll? {
return readableDatabase.withinTransaction { db ->
db.select(PollTable.ID, PollTable.QUESTION, PollTable.ALLOW_MULTIPLE_VOTES)
.from(PollTable.TABLE_NAME)
.where("${PollTable.MESSAGE_ID} = ?", messageId)
.run()
.readToSingleObject { cursor ->
val pollId = cursor.requireLong(PollTable.ID)
Poll(
question = cursor.requireString(PollTable.QUESTION) ?: "",
allowMultipleVotes = cursor.requireBoolean(PollTable.ALLOW_MULTIPLE_VOTES),
pollOptions = getPollOptionText(pollId),
authorId = Recipient.self().id.toLong()
)
}
}
}
/**
* Returns the poll if associated with a given message id
*/
fun getPoll(messageId: Long?): PollRecord? {
return if (messageId != null) {
getPollsForMessages(listOf(messageId))[messageId]
} else {
null
}
}
/**
* Maps message ids to its associated poll (if it exists)
*/
fun getPollsForMessages(messageIds: Collection<Long>): Map<Long, PollRecord> {
if (messageIds.isEmpty()) {
return emptyMap()
}
val self = Recipient.self().id.toLong()
val query = SqlUtil.buildFastCollectionQuery(PollTable.MESSAGE_ID, messageIds)
return readableDatabase.withinTransaction { db ->
db.select(PollTable.ID, PollTable.MESSAGE_ID, PollTable.QUESTION, PollTable.ALLOW_MULTIPLE_VOTES, PollTable.END_MESSAGE_ID, PollTable.AUTHOR_ID, PollTable.MESSAGE_ID)
.from(PollTable.TABLE_NAME)
.where(query.where, query.whereArgs)
.run()
.readToMap { cursor ->
val pollId = cursor.requireLong(PollTable.ID)
val pollVotes = getPollVotes(pollId)
val pendingVotes = getPendingVotes(pollId)
val pollOptions = getPollOptions(pollId).map { option ->
val voterIds = pollVotes[option.key] ?: emptyList()
PollOption(id = option.key, text = option.value, voterIds = voterIds, isSelected = voterIds.contains(self), isPending = pendingVotes.contains(option.key))
}
val poll = PollRecord(
id = pollId,
question = cursor.requireNonNullString(PollTable.QUESTION),
pollOptions = pollOptions,
allowMultipleVotes = cursor.requireBoolean(PollTable.ALLOW_MULTIPLE_VOTES),
hasEnded = cursor.requireBoolean(PollTable.END_MESSAGE_ID),
authorId = cursor.requireLong(PollTable.AUTHOR_ID),
messageId = cursor.requireLong(PollTable.MESSAGE_ID)
)
cursor.requireLong(PollTable.MESSAGE_ID) to poll
}
}
}
/**
* Given a poll id, returns a list of all of the ids of its options
*/
fun getPollOptionIds(pollId: Long): List<Long> {
return readableDatabase
.select(PollOptionTable.ID)
.from(PollOptionTable.TABLE_NAME)
.where("${PollVoteTable.POLL_ID} = ?", pollId)
.orderBy(PollOptionTable.OPTION_ORDER)
.run()
.readToList { cursor ->
cursor.requireLong(PollOptionTable.ID)
}
}
/**
* Given a poll id and a voter id, return their vote count (how many times they have voted)
*/
fun getCurrentPollVoteCount(pollId: Long, voterId: Long): Int {
return readableDatabase
.select("MAX(${PollVoteTable.VOTE_COUNT})")
.from(PollVoteTable.TABLE_NAME)
.where("${PollVoteTable.POLL_ID} = ? AND ${PollVoteTable.VOTER_ID} = ?", pollId, voterId)
.run()
.readToSingleInt(-1)
}
/**
* Return if the poll supports multiple votes for options
*/
fun canAllowMultipleVotes(pollId: Long): Boolean {
return readableDatabase
.select(PollTable.ALLOW_MULTIPLE_VOTES)
.from(PollTable.TABLE_NAME)
.where("${PollTable.ID} = ? ", pollId)
.run()
.readToSingleBoolean()
}
/**
* Returns whether the poll has ended
*/
fun hasEnded(pollId: Long): Boolean {
return readableDatabase
.select(PollTable.END_MESSAGE_ID)
.from(PollTable.TABLE_NAME)
.where("${PollTable.ID} = ? ", pollId)
.run()
.readToSingleBoolean()
}
private fun getPollOptions(pollId: Long): Map<Long, String> {
return readableDatabase
.select(PollOptionTable.ID, PollOptionTable.OPTION_TEXT)
.from(PollOptionTable.TABLE_NAME)
.where("${PollVoteTable.POLL_ID} = ?", pollId)
.run()
.readToMap { cursor ->
cursor.requireLong(PollOptionTable.ID) to cursor.requireNonNullString(PollOptionTable.OPTION_TEXT)
}
}
private fun getPollVotes(pollId: Long): Map<Long, List<Long>> {
return readableDatabase
.select(PollVoteTable.POLL_OPTION_ID, PollVoteTable.VOTER_ID)
.from(PollVoteTable.TABLE_NAME)
.where("${PollVoteTable.POLL_ID} = ? AND (${PollVoteTable.VOTE_STATE} = ${VoteState.ADDED.value} OR ${PollVoteTable.VOTE_STATE} = ${VoteState.PENDING_REMOVE.value})", pollId)
.run()
.groupBy { cursor ->
cursor.requireLong(PollVoteTable.POLL_OPTION_ID) to cursor.requireLong(PollVoteTable.VOTER_ID)
}
}
private fun getPendingVotes(pollId: Long): List<Long> {
return readableDatabase
.select(PollVoteTable.POLL_OPTION_ID)
.from(PollVoteTable.TABLE_NAME)
.where("${PollVoteTable.POLL_ID} = ? AND ${PollVoteTable.VOTER_ID} = ? AND (${PollVoteTable.VOTE_STATE} = ${VoteState.PENDING_ADD.value} OR ${PollVoteTable.VOTE_STATE} = ${VoteState.PENDING_REMOVE.value})", pollId, Recipient.self().id)
.run()
.readToList { cursor ->
cursor.requireLong(PollVoteTable.POLL_OPTION_ID)
}
}
private fun getPollOptionText(pollId: Long): List<String> {
return readableDatabase
.select(PollOptionTable.OPTION_TEXT)
.from(PollOptionTable.TABLE_NAME)
.where("${PollOptionTable.POLL_ID} = ?", pollId)
.run()
.readToList { it.requireString(PollOptionTable.OPTION_TEXT)!! }
}
private fun <E> Collection<E>.toPollContentValues(pollId: Long): List<ContentValues> {
return this.mapIndexed { index, option ->
contentValuesOf(
PollOptionTable.POLL_ID to pollId,
PollOptionTable.OPTION_TEXT to option,
PollOptionTable.OPTION_ORDER to index
)
}
}
private fun <E> Collection<E>.toPollVoteContentValues(pollId: Long, voterId: Long, voteCount: Long): List<ContentValues> {
return this.map {
contentValuesOf(
PollVoteTable.POLL_ID to pollId,
PollVoteTable.POLL_OPTION_ID to it,
PollVoteTable.VOTER_ID to voterId,
PollVoteTable.VOTE_COUNT to voteCount,
PollVoteTable.DATE_RECEIVED to System.currentTimeMillis(),
PollVoteTable.VOTE_STATE to VoteState.ADDED.value
)
}
}
enum class VoteState(val value: Int) {
/** We have no information on the vote state */
NONE(0),
/** Vote is in the process of being removed */
PENDING_REMOVE(1),
/** Vote is in the process of being added */
PENDING_ADD(2),
/** Vote was removed */
REMOVED(3),
/** Vote was added */
ADDED(4);
companion object {
fun fromValue(value: Int) = VoteState.entries.first { it.value == value }
}
}
}
@@ -80,6 +80,7 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
val inAppPaymentSubscriberTable: InAppPaymentSubscriberTable = InAppPaymentSubscriberTable(context, this)
val chatFoldersTable: ChatFolderTables = ChatFolderTables(context, this)
val backupMediaSnapshotTable: BackupMediaSnapshotTable = BackupMediaSnapshotTable(context, this)
val pollTable: PollTables = PollTables(context, this)
override fun onOpen(db: net.zetetic.database.sqlcipher.SQLiteDatabase) {
db.setForeignKeyConstraintsEnabled(true)
@@ -147,6 +148,7 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
executeStatements(db, NotificationProfileTables.CREATE_TABLE)
executeStatements(db, DistributionListTables.CREATE_TABLE)
executeStatements(db, ChatFolderTables.CREATE_TABLE)
executeStatements(db, PollTables.CREATE_TABLE)
db.execSQL(BackupMediaSnapshotTable.CREATE_TABLE)
executeStatements(db, RecipientTable.CREATE_INDEXS)
@@ -172,6 +174,7 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
executeStatements(db, ChatFolderTables.CREATE_INDEXES)
executeStatements(db, NameCollisionTables.CREATE_INDEXES)
executeStatements(db, BackupMediaSnapshotTable.CREATE_INDEXES)
executeStatements(db, PollTables.CREATE_INDEXES)
executeStatements(db, MessageSendLogTables.CREATE_TRIGGERS)
@@ -582,5 +585,10 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
@get:JvmName("backupMediaSnapshots")
val backupMediaSnapshots: BackupMediaSnapshotTable
get() = instance!!.backupMediaSnapshotTable
@get:JvmStatic
@get:JvmName("polls")
val polls: PollTables
get() = instance!!.pollTable
}
}
@@ -71,6 +71,11 @@ public final class ThreadBodyUtil {
return new ThreadBody(getCallLogSummary(context, record));
} else if (MessageRecordUtil.isScheduled(record)) {
return new ThreadBody(context.getString(R.string.ThreadRecord_scheduled_message));
} else if (MessageRecordUtil.hasPoll(record)) {
return new ThreadBody(context.getString(R.string.Poll__poll_question, record.getPoll().getQuestion()));
} else if (MessageRecordUtil.hasPollTerminate(record)) {
String creator = record.isOutgoing() ? context.getResources().getString(R.string.MessageRecord_you) : record.getFromRecipient().getDisplayName(context);
return new ThreadBody(context.getString(R.string.Poll__poll_end, creator, record.getMessageExtras().pollTerminate.question));
}
boolean hasImage = false;
@@ -96,6 +101,14 @@ public final class ThreadBodyUtil {
}
}
public static CharSequence getFormattedBodyForPollNotification(@NonNull Context context, @NonNull MmsMessageRecord record) {
return format(EmojiStrings.POLL, context.getString(R.string.Poll__poll_question, record.getPoll().getQuestion()), null).body;
}
public static CharSequence getFormattedBodyForPollEndNotification(@NonNull Context context, @NonNull MmsMessageRecord record) {
return format(EmojiStrings.POLL, context.getString(R.string.Poll__poll_end, record.getFromRecipient().getDisplayName(context), record.getMessageExtras().pollTerminate.question), null).body;
}
private static @NonNull String getGiftSummary(@NonNull Context context, @NonNull MessageRecord messageRecord) {
if (messageRecord.isOutgoing()) {
return context.getString(R.string.ThreadRecord__you_donated_for_s, messageRecord.getToRecipient().getShortDisplayName(context));
@@ -70,6 +70,7 @@ import org.thoughtcrime.securesms.util.JsonUtils
import org.thoughtcrime.securesms.util.JsonUtils.SaneJSONObject
import org.thoughtcrime.securesms.util.LRUCache
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.isPoll
import org.thoughtcrime.securesms.util.isScheduled
import org.whispersystems.signalservice.api.push.ServiceId
import org.whispersystems.signalservice.api.storage.SignalAccountRecord
@@ -496,6 +497,7 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
}
messages.setAllReactionsSeen()
messages.setAllVotesSeen()
notifyConversationListListeners()
return messageRecords
@@ -557,6 +559,7 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
messageRecords += messages.setMessagesReadSince(threadId, sinceTimestamp)
messages.setReactionsSeen(threadId, sinceTimestamp)
messages.setVoteSeen(threadId, sinceTimestamp)
val unreadCount = messages.getUnreadCount(threadId)
val unreadMentionsCount = messages.getUnreadMentionCount(threadId)
@@ -2097,6 +2100,8 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
Extra.forSticker(slide.emoji, authorId)
} else if (record.isMms && (record as MmsMessageRecord).slideDeck.slides.size > 1) {
Extra.forAlbum(authorId)
} else if (record.isPoll()) {
Extra.forPoll(authorId)
} else if (threadRecipient != null && threadRecipient.isGroup) {
Extra.forDefault(authorId)
} else {
@@ -2280,7 +2285,8 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
individualRecipientId = jsonObject.getString("individualRecipientId")!!,
bodyRanges = jsonObject.getString("bodyRanges"),
isScheduled = jsonObject.getBoolean("isScheduled"),
isRecipientHidden = jsonObject.getBoolean("isRecipientHidden")
isRecipientHidden = jsonObject.getBoolean("isRecipientHidden"),
isPoll = jsonObject.getBoolean("isPoll")
)
} catch (exception: Exception) {
null
@@ -2291,7 +2297,7 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
return ThreadRecord.Builder(cursor.requireLong(ID))
.setRecipient(recipient)
.setType(cursor.requireInt(SNIPPET_TYPE).toLong())
.setType(cursor.requireLong(SNIPPET_TYPE))
.setDistributionType(cursor.requireInt(TYPE))
.setBody(cursor.requireString(SNIPPET) ?: "")
.setDate(cursor.requireLong(DATE))
@@ -2367,7 +2373,10 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
val isScheduled: Boolean = false,
@field:JsonProperty
@param:JsonProperty("isRecipientHidden")
val isRecipientHidden: Boolean = false
val isRecipientHidden: Boolean = false,
@field:JsonProperty
@param:JsonProperty("isPoll")
val isPoll: Boolean = false
) {
fun getIndividualRecipientId(): String {
@@ -2414,6 +2423,10 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
fun forScheduledMessage(individualRecipient: RecipientId): Extra {
return Extra(individualRecipientId = individualRecipient.serialize(), isScheduled = true)
}
fun forPoll(individualRecipient: RecipientId): Extra {
return Extra(individualRecipientId = individualRecipient.serialize(), isPoll = true)
}
}
}
@@ -146,6 +146,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V288_CopyStickerDat
import org.thoughtcrime.securesms.database.helpers.migration.V289_AddQuoteTargetContentTypeColumn
import org.thoughtcrime.securesms.database.helpers.migration.V290_AddArchiveThumbnailTransferStateColumn
import org.thoughtcrime.securesms.database.helpers.migration.V291_NullOutRemoteKeyIfEmpty
import org.thoughtcrime.securesms.database.helpers.migration.V292_AddPollTables
import org.thoughtcrime.securesms.database.SQLiteDatabase as SignalSqliteDatabase
/**
@@ -297,10 +298,11 @@ object SignalDatabaseMigrations {
288 to V288_CopyStickerDataHashStartToEnd,
289 to V289_AddQuoteTargetContentTypeColumn,
290 to V290_AddArchiveThumbnailTransferStateColumn,
291 to V291_NullOutRemoteKeyIfEmpty
291 to V291_NullOutRemoteKeyIfEmpty,
292 to V292_AddPollTables
)
const val DATABASE_VERSION = 291
const val DATABASE_VERSION = 292
@JvmStatic
fun migrate(context: Application, db: SignalSqliteDatabase, oldVersion: Int, newVersion: Int) {
@@ -0,0 +1,69 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.database.helpers.migration
import android.app.Application
import org.thoughtcrime.securesms.database.SQLiteDatabase
/**
* Adds the tables and indexes necessary for polls
*/
@Suppress("ClassName")
object V292_AddPollTables : SignalDatabaseMigration {
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
db.execSQL(
"""
CREATE TABLE poll (
_id INTEGER PRIMARY KEY AUTOINCREMENT,
author_id INTEGER NOT NULL REFERENCES recipient (_id) ON DELETE CASCADE,
message_id INTEGER NOT NULL REFERENCES message (_id) ON DELETE CASCADE,
question TEXT,
allow_multiple_votes INTEGER DEFAULT 0,
end_message_id INTEGER DEFAULT 0
)
"""
)
db.execSQL(
"""
CREATE TABLE poll_option (
_id INTEGER PRIMARY KEY AUTOINCREMENT,
poll_id INTEGER NOT NULL REFERENCES poll (_id) ON DELETE CASCADE,
option_text TEXT,
option_order INTEGER
)
"""
)
db.execSQL(
"""
CREATE TABLE poll_vote (
_id INTEGER PRIMARY KEY AUTOINCREMENT,
poll_id INTEGER NOT NULL REFERENCES poll (_id) ON DELETE CASCADE,
poll_option_id INTEGER DEFAULT NULL REFERENCES poll_option (_id) ON DELETE CASCADE,
voter_id INTEGER NOT NULL REFERENCES recipient (_id) ON DELETE CASCADE,
vote_count INTEGER,
date_received INTEGER DEFAULT 0,
vote_state INTEGER DEFAULT 0,
UNIQUE(poll_id, voter_id, poll_option_id) ON CONFLICT REPLACE
)
"""
)
db.execSQL("CREATE INDEX poll_author_id_index ON poll (author_id)")
db.execSQL("CREATE INDEX poll_message_id_index ON poll (message_id)")
db.execSQL("CREATE INDEX poll_option_poll_id_index ON poll_option (poll_id)")
db.execSQL("CREATE INDEX poll_vote_poll_id_index ON poll_vote (poll_id)")
db.execSQL("CREATE INDEX poll_vote_poll_option_id_index ON poll_vote (poll_option_id)")
db.execSQL("CREATE INDEX poll_vote_voter_id_index ON poll_vote (voter_id)")
db.execSQL("ALTER TABLE message ADD COLUMN votes_unread INTEGER DEFAULT 0")
db.execSQL("ALTER TABLE message ADD COLUMN votes_last_seen INTEGER DEFAULT 0")
db.execSQL("CREATE INDEX message_votes_unread_index ON message (votes_unread)")
}
}
@@ -260,4 +260,8 @@ public abstract class DisplayRecord {
public boolean isUnsupported() {
return MessageTypes.isUnsupportedMessageType(type);
}
public boolean isPollTerminate() {
return MessageTypes.isPollTerminate(type);
}
}
@@ -65,6 +65,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.DateUtils;
import org.thoughtcrime.securesms.util.ExpirationUtil;
import org.thoughtcrime.securesms.util.GroupUtil;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.SignalE164Util;
import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil;
@@ -293,6 +294,9 @@ public abstract class MessageRecord extends DisplayRecord {
return staticUpdateDescription(context.getString(isGroupV2() ? R.string.MessageRecord_you_unblocked_this_group : R.string.MessageRecord_you_unblocked_this_person) , Glyph.THREAD);
} else if (isUnsupported()) {
return staticUpdateDescription(context.getString(R.string.MessageRecord_unsupported_feature, getFromRecipient().getDisplayName(context)), Glyph.ERROR);
} else if (MessageRecordUtil.hasPollTerminate(this)) {
String creator = isOutgoing() ? context.getString(R.string.MessageRecord_you) : getFromRecipient().getDisplayName(context);
return staticUpdateDescriptionWithExpiration(context.getString(R.string.MessageRecord_ended_the_poll, creator, messageExtras.pollTerminate.question), Glyph.POLL);
}
return null;
@@ -476,6 +480,10 @@ public abstract class MessageRecord extends DisplayRecord {
return UpdateDescription.staticDescription(string, glyph);
}
protected static @NonNull UpdateDescription staticUpdateDescriptionWithExpiration(@NonNull String string, Glyph glyph) {
return UpdateDescription.staticDescriptionWithExpiration(string, glyph);
}
protected static @NonNull UpdateDescription staticUpdateDescription(@NonNull String string,
Glyph glyph,
@ColorInt int lightTint,
@@ -732,7 +740,7 @@ public abstract class MessageRecord extends DisplayRecord {
isProfileChange() || isGroupV1MigrationEvent() || isChatSessionRefresh() || isBadDecryptType() ||
isChangeNumber() || isReleaseChannelDonationRequest() || isThreadMergeEventType() || isSmsExportType() || isSessionSwitchoverEventType() ||
isPaymentsRequestToActivate() || isPaymentsActivated() || isReportedSpam() || isMessageRequestAccepted() ||
isBlocked() || isUnblocked() || isUnsupported();
isBlocked() || isUnblocked() || isUnsupported() || isPollTerminate();
}
public boolean isMediaPending() {
@@ -8,6 +8,7 @@ package org.thoughtcrime.securesms.database.model
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.database.CallTable
import org.thoughtcrime.securesms.payments.Payment
import org.thoughtcrime.securesms.polls.PollRecord
fun MessageRecord.withReactions(reactions: List<ReactionRecord>): MessageRecord {
return if (this is MmsMessageRecord) {
@@ -39,3 +40,11 @@ fun MessageRecord.withCall(call: CallTable.Call): MessageRecord {
this
}
}
fun MessageRecord.withPoll(poll: PollRecord): MessageRecord {
return if (this is MmsMessageRecord) {
this.withPoll(poll)
} else {
this
}
}
@@ -25,22 +25,20 @@ import org.thoughtcrime.securesms.database.MessageTypes;
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
import org.thoughtcrime.securesms.database.documents.NetworkFailure;
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList;
import org.thoughtcrime.securesms.database.model.databaseprotos.CryptoValue;
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge;
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras;
import org.thoughtcrime.securesms.fonts.SignalSymbols;
import org.thoughtcrime.securesms.fonts.SignalSymbols.Glyph;
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
import org.thoughtcrime.securesms.mms.Slide;
import org.thoughtcrime.securesms.mms.SlideDeck;
import org.thoughtcrime.securesms.payments.CryptoValueUtil;
import org.thoughtcrime.securesms.payments.Payment;
import org.thoughtcrime.securesms.polls.PollRecord;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.whispersystems.signalservice.api.payments.FormatterOptions;
import org.whispersystems.signalservice.api.payments.Money;
import java.io.IOException;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
@@ -75,6 +73,7 @@ public class MmsMessageRecord extends MessageRecord {
private final BodyRangeList messageRanges;
private final Payment payment;
private final CallTable.Call call;
private final PollRecord poll;
private final long scheduledDate;
private final MessageId latestRevisionId;
private final boolean isRead;
@@ -115,6 +114,7 @@ public class MmsMessageRecord extends MessageRecord {
@Nullable GiftBadge giftBadge,
@Nullable Payment payment,
@Nullable CallTable.Call call,
@Nullable PollRecord poll,
long scheduledDate,
@Nullable MessageId latestRevisionId,
@Nullable MessageId originalMessageId,
@@ -137,6 +137,7 @@ public class MmsMessageRecord extends MessageRecord {
this.messageRanges = messageRanges;
this.payment = payment;
this.call = call;
this.poll = poll;
this.scheduledDate = scheduledDate;
this.latestRevisionId = latestRevisionId;
this.isRead = isRead;
@@ -199,6 +200,10 @@ public class MmsMessageRecord extends MessageRecord {
return giftBadge;
}
public @Nullable PollRecord getPoll() {
return poll;
}
@Override
public boolean hasSelfMention() {
return mentionsSelf;
@@ -332,7 +337,7 @@ public class MmsMessageRecord extends MessageRecord {
return new MmsMessageRecord(getId(), getFromRecipient(), getFromDeviceId(), getToRecipient(), getDateSent(), getDateReceived(), getServerTimestamp(), hasDeliveryReceipt(), getThreadId(), getBody(), getSlideDeck(),
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(),
hasReadReceipt(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), reactions, isRemoteDelete(), mentionsSelf,
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getScheduledDate(), getLatestRevisionId(),
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getPoll(), getScheduledDate(), getLatestRevisionId(),
getOriginalMessageId(), getRevisionNumber(), isRead(), getMessageExtras());
}
@@ -340,7 +345,7 @@ public class MmsMessageRecord extends MessageRecord {
return new MmsMessageRecord(getId(), getFromRecipient(), getFromDeviceId(), getToRecipient(), getDateSent(), getDateReceived(), getServerTimestamp(), hasDeliveryReceipt(), getThreadId(), getBody(), getSlideDeck(),
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(),
hasReadReceipt(), null, getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf,
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getScheduledDate(), getLatestRevisionId(),
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getPoll(), getScheduledDate(), getLatestRevisionId(),
getOriginalMessageId(), getRevisionNumber(), isRead(), getMessageExtras());
}
@@ -362,7 +367,7 @@ public class MmsMessageRecord extends MessageRecord {
return new MmsMessageRecord(getId(), getFromRecipient(), getFromDeviceId(), getToRecipient(), getDateSent(), getDateReceived(), getServerTimestamp(), hasDeliveryReceipt(), getThreadId(), getBody(), slideDeck,
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(),
hasReadReceipt(), quote, contacts, linkPreviews, isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf,
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getScheduledDate(), getLatestRevisionId(),
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getPoll(), getScheduledDate(), getLatestRevisionId(),
getOriginalMessageId(), getRevisionNumber(), isRead(), getMessageExtras());
}
@@ -370,7 +375,7 @@ public class MmsMessageRecord extends MessageRecord {
return new MmsMessageRecord(getId(), getFromRecipient(), getFromDeviceId(), getToRecipient(), getDateSent(), getDateReceived(), getServerTimestamp(), hasDeliveryReceipt(), getThreadId(), getBody(), getSlideDeck(),
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(),
hasReadReceipt(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf,
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), payment, getCall(), getScheduledDate(), getLatestRevisionId(),
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), payment, getCall(), getPoll(), getScheduledDate(), getLatestRevisionId(),
getOriginalMessageId(), getRevisionNumber(), isRead(), getMessageExtras());
}
@@ -379,7 +384,15 @@ public class MmsMessageRecord extends MessageRecord {
return new MmsMessageRecord(getId(), getFromRecipient(), getFromDeviceId(), getToRecipient(), getDateSent(), getDateReceived(), getServerTimestamp(), hasDeliveryReceipt(), getThreadId(), getBody(), getSlideDeck(),
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(),
hasReadReceipt(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf,
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), call, getScheduledDate(), getLatestRevisionId(),
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), call, getPoll(), getScheduledDate(), getLatestRevisionId(),
getOriginalMessageId(), getRevisionNumber(), isRead(), getMessageExtras());
}
public @NonNull MmsMessageRecord withPoll(@Nullable PollRecord poll) {
return new MmsMessageRecord(getId(), getFromRecipient(), getFromDeviceId(), getToRecipient(), getDateSent(), getDateReceived(), getServerTimestamp(), hasDeliveryReceipt(), getThreadId(), getBody(), getSlideDeck(),
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(),
hasReadReceipt(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf,
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), poll, getScheduledDate(), getLatestRevisionId(),
getOriginalMessageId(), getRevisionNumber(), isRead(), getMessageExtras());
}
@@ -230,6 +230,11 @@ public final class ThreadRecord {
else return true;
}
public boolean isPoll() {
if (extra != null) return extra.isPoll();
else return false;
}
public boolean isPinned() {
return isPinned;
}
@@ -6,12 +6,10 @@ import android.text.SpannableStringBuilder;
import androidx.annotation.AnyThread;
import androidx.annotation.ColorInt;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import org.thoughtcrime.securesms.fonts.SignalSymbols;
import org.thoughtcrime.securesms.fonts.SignalSymbols.Glyph;
import org.whispersystems.signalservice.api.push.ServiceId;
@@ -35,6 +33,7 @@ public final class UpdateDescription {
private final SpannableFactory stringFactory;
private final Spannable staticString;
private final Glyph glyph;
private final boolean canExpire;
private final int lightTint;
private final int darkTint;
@@ -43,6 +42,16 @@ public final class UpdateDescription {
@Nullable Spannable staticString,
@NonNull Glyph glyph,
@ColorInt int lightTint,
@ColorInt int darkTint) {
this(mentioned, stringFactory, staticString, glyph, false, lightTint, darkTint);
}
private UpdateDescription(@NonNull Collection<ServiceId> mentioned,
@Nullable SpannableFactory stringFactory,
@Nullable Spannable staticString,
@NonNull Glyph glyph,
boolean canExpire,
@ColorInt int lightTint,
@ColorInt int darkTint)
{
if (staticString == null && stringFactory == null) {
@@ -52,6 +61,7 @@ public final class UpdateDescription {
this.stringFactory = stringFactory;
this.staticString = staticString;
this.glyph = glyph;
this.canExpire = canExpire;
this.lightTint = lightTint;
this.darkTint = darkTint;
}
@@ -84,6 +94,13 @@ public final class UpdateDescription {
return new UpdateDescription(Collections.emptyList(), null, new SpannableString(staticString), glyph, 0, 0);
}
/**
* Create an update description that's string value is fixed with a start glyph and has the ability to expire when a disappearing timer is set.
*/
public static UpdateDescription staticDescriptionWithExpiration(@NonNull String staticString, Glyph glyph) {
return new UpdateDescription(Collections.emptyList(), null, new SpannableString(staticString), glyph, true,0, 0);
}
/**
* Create an update description that's string value is fixed.
*/
@@ -144,6 +161,10 @@ public final class UpdateDescription {
return darkTint;
}
public boolean hasExpiration() {
return canExpire;
}
public static UpdateDescription concatWithNewLines(@NonNull List<UpdateDescription> updateDescriptions) {
if (updateDescriptions.size() == 0) {
throw new AssertionError();
@@ -167,6 +167,7 @@ object SignalSymbols {
PLUS('\u002B'),
PLUS_CIRCLE('\u2295'),
PLUS_SQUARE('\uE06C'),
POLL('\uE082'),
RAISE_HAND('\uE07E'),
RAISE_HAND_FILL('\uE084'),
REPLY('\uE06D'),
@@ -226,6 +226,7 @@ public final class JobManagerFactories {
put(PaymentNotificationSendJobV2.KEY, new PaymentNotificationSendJobV2.Factory());
put(PaymentSendJob.KEY, new PaymentSendJob.Factory());
put(PaymentTransactionCheckJob.KEY, new PaymentTransactionCheckJob.Factory());
put(PollVoteJob.KEY, new PollVoteJob.Factory());
put(PreKeysSyncJob.KEY, new PreKeysSyncJob.Factory());
put(ProfileKeySendJob.KEY, new ProfileKeySendJob.Factory());
put(ProfileUploadJob.KEY, new ProfileUploadJob.Factory());
@@ -0,0 +1,242 @@
package org.thoughtcrime.securesms.jobs
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.database.PollTables
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.jobmanager.Job
import org.thoughtcrime.securesms.jobmanager.impl.NetworkConstraint
import org.thoughtcrime.securesms.jobs.protos.PollVoteJobData
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.messages.GroupSendUtil
import org.thoughtcrime.securesms.polls.PollRecord
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.recipients.RecipientUtil
import org.thoughtcrime.securesms.util.GroupUtil
import org.whispersystems.signalservice.api.crypto.ContentHint
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage.Companion.newBuilder
import kotlin.time.Duration.Companion.days
/**
* Sends a poll vote for a given poll in a group. If the vote completely fails to send, we do our best to undo that vote.
*/
class PollVoteJob(
private val messageId: Long,
private val recipientIds: MutableList<Long>,
private val initialRecipientCount: Int,
private val voteCount: Int,
private val isRemoval: Boolean,
parameters: Parameters
) : Job(parameters) {
companion object {
const val KEY: String = "PollVoteJob"
private val TAG = Log.tag(PollVoteJob::class.java)
fun create(messageId: Long, voteCount: Int, isRemoval: Boolean): PollVoteJob? {
val message = SignalDatabase.messages.getMessageRecordOrNull(messageId)
if (message == null) {
Log.w(TAG, "Unable to find corresponding message")
return null
}
val conversationRecipient = SignalDatabase.threads.getRecipientForThreadId(message.threadId)
if (conversationRecipient == null) {
Log.w(TAG, "We have a message, but couldn't find the thread!")
return null
}
val recipients = conversationRecipient.participantIds.filter { it != Recipient.self().id }.map { it.toLong() }
return PollVoteJob(
messageId = messageId,
recipientIds = recipients.toMutableList(),
initialRecipientCount = recipients.size,
voteCount = voteCount,
isRemoval = isRemoval,
parameters = Parameters.Builder()
.setQueue(conversationRecipient.id.toQueueKey())
.addConstraint(NetworkConstraint.KEY)
.setMaxAttempts(Parameters.UNLIMITED)
.setLifespan(1.days.inWholeMilliseconds)
.build()
)
}
}
override fun serialize(): ByteArray {
return PollVoteJobData(messageId, recipientIds, initialRecipientCount, voteCount, isRemoval).encode()
}
override fun getFactoryKey(): String {
return KEY
}
override fun run(): Result {
if (!SignalStore.account.isRegistered) {
Log.w(TAG, "Not registered. Skipping.")
return Result.failure()
}
val message = SignalDatabase.messages.getMessageRecordOrNull(messageId)
if (message == null) {
Log.w(TAG, "Unable to find corresponding message")
return Result.failure()
}
val conversationRecipient = SignalDatabase.threads.getRecipientForThreadId(message.threadId)
if (conversationRecipient == null) {
Log.w(TAG, "We have a message, but couldn't find the thread!")
return Result.failure()
}
val poll = SignalDatabase.polls.getPoll(messageId)
if (poll == null) {
Log.w(TAG, "Unable to find corresponding poll")
return Result.failure()
}
val targetAuthor = message.fromRecipient
if (targetAuthor == null || !targetAuthor.hasServiceId) {
Log.w(TAG, "Unable to find target author")
return Result.failure()
}
val targetSentTimestamp = message.dateSent
val recipients = Recipient.resolvedList(recipientIds.filter { it != Recipient.self().id.toLong() }.map { RecipientId.from(it) })
val registered = RecipientUtil.getEligibleForSending(recipients)
val unregistered = recipients - registered.toSet()
val completions: List<Recipient> = deliver(conversationRecipient, registered, targetAuthor, targetSentTimestamp, poll)
recipientIds.removeAll(unregistered.map { it.id.toLong() })
recipientIds.removeAll(completions.map { it.id.toLong() })
Log.i(TAG, "Completed now: " + completions.size + ", Remaining: " + recipientIds.size)
if (recipientIds.isNotEmpty()) {
Log.w(TAG, "Still need to send to " + recipientIds.size + " recipients. Retrying.")
return Result.retry(defaultBackoff())
}
return Result.success()
}
private fun deliver(conversationRecipient: Recipient, destinations: List<Recipient>, targetAuthor: Recipient, targetSentTimestamp: Long, poll: PollRecord): List<Recipient> {
val votes = SignalDatabase.polls.getVotes(poll.id, poll.allowMultipleVotes)
val dataMessageBuilder = newBuilder()
.withTimestamp(System.currentTimeMillis())
.withPollVote(
buildPollVote(
targetAuthor = targetAuthor,
targetSentTimestamp = targetSentTimestamp,
optionIndexes = votes,
voteCount = voteCount
)
)
GroupUtil.setDataMessageGroupContext(context, dataMessageBuilder, conversationRecipient.requireGroupId().requirePush())
val dataMessage = dataMessageBuilder.build()
val results = GroupSendUtil.sendResendableDataMessage(
context,
conversationRecipient.groupId.map { obj: GroupId -> obj.requireV2() }.orElse(null),
null,
destinations,
false,
ContentHint.RESENDABLE,
MessageId(messageId),
dataMessage,
true,
false,
null
)
val groupResult = GroupSendJobHelper.getCompletedSends(destinations, results)
for (unregistered in groupResult.unregistered) {
SignalDatabase.recipients.markUnregistered(unregistered)
}
if (groupResult.completed.isNotEmpty()) {
if (isRemoval) {
SignalDatabase.polls.markPendingAsRemoved(
pollId = poll.id,
voterId = Recipient.self().id.toLong(),
voteCount = voteCount,
messageId = poll.messageId
)
} else {
SignalDatabase.polls.markPendingAsAdded(
pollId = poll.id,
voterId = Recipient.self().id.toLong(),
voteCount = voteCount,
messageId = poll.messageId
)
}
}
return groupResult.completed
}
override fun onFailure() {
if (recipientIds.size < initialRecipientCount) {
Log.w(TAG, "Only sent vote to " + recipientIds.size + "/" + initialRecipientCount + " recipients. Still, it sent to someone, so it stays.")
return
}
Log.w(TAG, "Failed to send to all recipients!")
val pollId = SignalDatabase.polls.getPollId(messageId)
if (pollId == null) {
Log.w(TAG, "Poll no longer exists")
return
}
val voteState = SignalDatabase.polls.getPollVoteStateForGivenVote(pollId, voteCount)
if (isRemoval && voteState == PollTables.VoteState.PENDING_REMOVE) {
Log.w(TAG, "Vote removal failed so we are adding it back")
SignalDatabase.polls.setPollVoteStateForGivenVote(pollId, Recipient.self().id.toLong(), voteCount, messageId, isRemoval)
} else if (!isRemoval && voteState == PollTables.VoteState.PENDING_ADD) {
Log.w(TAG, "Voting failed so we are removing it")
SignalDatabase.polls.setPollVoteStateForGivenVote(pollId, Recipient.self().id.toLong(), voteCount, messageId, isRemoval)
} else {
Log.w(TAG, "Voting state does not match what we'd expect, so ignoring.")
}
}
private fun buildPollVote(
targetAuthor: Recipient,
targetSentTimestamp: Long,
optionIndexes: List<Int>,
voteCount: Int
): SignalServiceDataMessage.PollVote {
return SignalServiceDataMessage.PollVote(
targetAuthor = targetAuthor.requireServiceId(),
targetSentTimestamp = targetSentTimestamp,
optionIndexes = optionIndexes,
voteCount = voteCount
)
}
class Factory : Job.Factory<PollVoteJob> {
override fun create(parameters: Parameters, serializedData: ByteArray?): PollVoteJob {
val data = PollVoteJobData.ADAPTER.decode(serializedData!!)
return PollVoteJob(
messageId = data.messageId,
recipientIds = data.recipients.toMutableList(),
initialRecipientCount = data.initialRecipientCount,
voteCount = data.voteCount,
isRemoval = data.isRemoval,
parameters = parameters
)
}
}
}
@@ -37,6 +37,7 @@ import org.thoughtcrime.securesms.messages.StorySendUtil;
import org.thoughtcrime.securesms.mms.MessageGroupContext;
import org.thoughtcrime.securesms.mms.MmsException;
import org.thoughtcrime.securesms.mms.OutgoingMessage;
import org.thoughtcrime.securesms.polls.Poll;
import org.thoughtcrime.securesms.ratelimit.ProofRequiredExceptionHandler;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
@@ -275,15 +276,17 @@ public final class PushGroupSendJob extends PushSendJob {
try {
rotateSenderCertificateIfNecessary();
GroupId.Push groupId = groupRecipient.requireGroupId().requirePush();
Optional<byte[]> profileKey = getProfileKey(groupRecipient);
Optional<SignalServiceDataMessage.Sticker> sticker = getStickerFor(message);
List<SharedContact> sharedContacts = getSharedContactsFor(message);
List<SignalServicePreview> previews = getPreviewsFor(message);
List<SignalServiceDataMessage.Mention> mentions = getMentionsFor(message.getMentions());
List<BodyRange> bodyRanges = getBodyRanges(message);
List<Attachment> attachments = Stream.of(message.getAttachments()).filterNot(Attachment::isSticker).toList();
List<SignalServiceAttachment> attachmentPointers = getAttachmentPointersFor(attachments);
GroupId.Push groupId = groupRecipient.requireGroupId().requirePush();
Optional<byte[]> profileKey = getProfileKey(groupRecipient);
Optional<SignalServiceDataMessage.Sticker> sticker = getStickerFor(message);
List<SharedContact> sharedContacts = getSharedContactsFor(message);
List<SignalServicePreview> previews = getPreviewsFor(message);
List<SignalServiceDataMessage.Mention> mentions = getMentionsFor(message.getMentions());
List<BodyRange> bodyRanges = getBodyRanges(message);
Optional<SignalServiceDataMessage.PollCreate> pollCreate = getPollCreate(message);
Optional<SignalServiceDataMessage.PollTerminate> pollTerminate = getPollTerminate(message);
List<Attachment> attachments = Stream.of(message.getAttachments()).filterNot(Attachment::isSticker).toList();
List<SignalServiceAttachment> attachmentPointers = getAttachmentPointersFor(attachments);
boolean isRecipientUpdate = Stream.of(SignalDatabase.groupReceipts().getGroupReceiptInfo(messageId))
.anyMatch(info -> info.getStatus() > GroupReceiptTable.STATUS_UNDELIVERED);
@@ -362,7 +365,9 @@ public final class PushGroupSendJob extends PushSendJob {
.withSharedContacts(sharedContacts)
.withPreviews(previews)
.withMentions(mentions)
.withBodyRanges(bodyRanges);
.withBodyRanges(bodyRanges)
.withPollCreate(pollCreate.orElse(null))
.withPollTerminate(pollTerminate.orElse(null));
if (message.getParentStoryId() != null) {
try {
@@ -407,6 +412,23 @@ public final class PushGroupSendJob extends PushSendJob {
}
}
private Optional<SignalServiceDataMessage.PollCreate> getPollCreate(OutgoingMessage message) {
Poll poll = message.getPoll();
if (poll == null) {
return Optional.empty();
}
return Optional.of(new SignalServiceDataMessage.PollCreate(poll.getQuestion(), poll.getAllowMultipleVotes(), poll.getPollOptions()));
}
private Optional<SignalServiceDataMessage.PollTerminate> getPollTerminate(OutgoingMessage message) {
if (message.getMessageExtras() == null || message.getMessageExtras().pollTerminate == null) {
return Optional.empty();
}
return Optional.of(new SignalServiceDataMessage.PollTerminate(message.getMessageExtras().pollTerminate.targetTimestamp));
}
public static long getMessageId(@Nullable byte[] serializedData) {
JsonJobData data = JsonJobData.deserialize(serializedData);
return data.getLong(KEY_MESSAGE_ID);
@@ -38,6 +38,8 @@ import org.thoughtcrime.securesms.linkpreview.LinkPreview
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory.MediaPreviewArgs
import org.thoughtcrime.securesms.messagedetails.InternalMessageDetailsFragment.Companion.create
import org.thoughtcrime.securesms.messagedetails.MessageDetailsAdapter.MessageDetailsViewState
import org.thoughtcrime.securesms.polls.PollOption
import org.thoughtcrime.securesms.polls.PollRecord
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet.forMessageRecord
@@ -396,6 +398,18 @@ class MessageDetailsFragment : FullScreenDialogFragment(), MessageDetailsAdapter
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
}
override fun onViewResultsClicked(pollId: Long) {
Log.w(TAG, "Not yet implemented!", Exception())
}
override fun onViewPollClicked(messageId: Long) {
Log.w(TAG, "Not yet implemented!", Exception())
}
override fun onToggleVote(poll: PollRecord, pollOption: PollOption, isChecked: Boolean) {
Log.w(TAG, "Not yet implemented!", Exception())
}
interface Callback {
fun onMessageDetailsFragmentDismissed()
}
@@ -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) }
}
@@ -51,7 +51,10 @@ object SignalServiceProtoUtil {
bodyRanges.isNotEmpty() ||
sticker != null ||
reaction != null ||
hasRemoteDelete
hasRemoteDelete ||
pollCreate != null ||
pollVote != null ||
pollTerminate != null
}
val DataMessage.hasDisallowedAnnouncementOnlyContent: Boolean
@@ -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 -> {
@@ -12,6 +12,7 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.linkpreview.LinkPreview
import org.thoughtcrime.securesms.polls.Poll
import org.thoughtcrime.securesms.recipients.RecipientId
class IncomingMessage(
@@ -39,7 +40,8 @@ class IncomingMessage(
mentions: List<Mention> = emptyList(),
val giftBadge: GiftBadge? = null,
val messageExtras: MessageExtras? = null,
val isGroupAdd: Boolean = false
val isGroupAdd: Boolean = false,
val poll: Poll? = null
) {
val attachments: List<Attachment> = ArrayList(attachments)
@@ -13,6 +13,7 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.GV2UpdateDescrip
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras
import org.thoughtcrime.securesms.linkpreview.LinkPreview
import org.thoughtcrime.securesms.polls.Poll
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.sms.GroupV2UpdateMessageUtil
import kotlin.time.Duration.Companion.seconds
@@ -58,6 +59,7 @@ data class OutgoingMessage(
val isMessageRequestAccept: Boolean = false,
val isBlocked: Boolean = false,
val isUnblocked: Boolean = false,
val poll: Poll? = null,
val messageExtras: MessageExtras? = null
) {
@@ -470,6 +472,31 @@ data class OutgoingMessage(
)
}
@JvmStatic
fun pollMessage(threadRecipient: Recipient, sentTimeMillis: Long, expiresIn: Long, poll: Poll, question: String = ""): OutgoingMessage {
return OutgoingMessage(
threadRecipient = threadRecipient,
sentTimeMillis = sentTimeMillis,
expiresIn = expiresIn,
poll = poll,
body = question,
isUrgent = true,
isSecure = true
)
}
@JvmStatic
fun pollTerminateMessage(threadRecipient: Recipient, sentTimeMillis: Long, expiresIn: Long, messageExtras: MessageExtras): OutgoingMessage {
return OutgoingMessage(
threadRecipient = threadRecipient,
sentTimeMillis = sentTimeMillis,
expiresIn = expiresIn,
messageExtras = messageExtras,
isUrgent = true,
isSecure = true
)
}
@JvmStatic
fun quickReply(
threadRecipient: Recipient,
@@ -10,6 +10,7 @@ import androidx.annotation.StringRes
import androidx.core.graphics.drawable.IconCompat
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.emoji.EmojiStrings
import org.thoughtcrime.securesms.contactshare.Contact
import org.thoughtcrime.securesms.contactshare.ContactUtil
import org.thoughtcrime.securesms.database.MentionUtil
@@ -24,6 +25,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mms.Slide
import org.thoughtcrime.securesms.mms.SlideDeck
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.polls.PollVote
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientUtil
import org.thoughtcrime.securesms.service.KeyCachingService
@@ -32,6 +34,8 @@ import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.hasGiftBadge
import org.thoughtcrime.securesms.util.hasPoll
import org.thoughtcrime.securesms.util.hasPollTerminate
import org.thoughtcrime.securesms.util.hasSharedContact
import org.thoughtcrime.securesms.util.hasSticker
import org.thoughtcrime.securesms.util.isMediaMessage
@@ -240,6 +244,10 @@ class MessageNotification(threadRecipient: Recipient, record: MessageRecord) : N
ThreadBodyUtil.getFormattedBodyForNotification(context, record, null)
} else if (record.isPaymentNotification || record.isPaymentTombstone) {
ThreadBodyUtil.getFormattedBodyForNotification(context, record, null)
} else if (record.hasPoll()) {
ThreadBodyUtil.getFormattedBodyForPollNotification(context, record as MmsMessageRecord)
} else if (record.hasPollTerminate()) {
ThreadBodyUtil.getFormattedBodyForPollEndNotification(context, record as MmsMessageRecord)
} else {
getBodyWithMentionsAndStyles(context, record)
}
@@ -380,3 +388,33 @@ class ReactionNotification(threadRecipient: Recipient, record: MessageRecord, va
return "ReactionNotification(timestamp=$timestamp, isNewNotification=$isNewNotification)"
}
}
/**
* Represents a notification associated with a new vote.
*/
class VoteNotification(threadRecipient: Recipient, record: MessageRecord, val vote: PollVote) : NotificationItem(threadRecipient, record) {
override val timestamp: Long = vote.dateReceived
override val authorRecipient: Recipient = Recipient.resolved(vote.voterId)
override val isNewNotification: Boolean = timestamp > notifiedTimestamp
override fun getPrimaryTextActual(context: Context): CharSequence {
return if (KeyCachingService.isLocked(context)) {
SpanUtil.italic(context.getString(R.string.MessageNotifier_locked_message))
} else {
context.getString(R.string.MessageNotifier_s_voted_in_poll, EmojiStrings.POLL, authorRecipient.getDisplayName(context), vote.question)
}
}
override fun getStartingPosition(context: Context): Int {
return SignalDatabase.messages.getMessagePositionInConversation(threadId = thread.threadId, groupStoryId = 0L, receivedTimestamp = record.dateReceived)
}
override fun getLargeIconUri(): Uri? = null
override fun getBigPictureUri(): Uri? = null
override fun getThumbnailInfo(context: Context): ThumbnailInfo = ThumbnailInfo()
override fun canReply(context: Context): Boolean = false
override fun toString(): String {
return "VoteNotification(timestamp=$timestamp, isNewNotification=$isNewNotification)"
}
}
@@ -12,6 +12,7 @@ import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.database.model.ReactionRecord
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
import org.thoughtcrime.securesms.polls.PollVote
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.isStoryReaction
@@ -37,6 +38,7 @@ object NotificationStateProvider {
val threadRecipient: Recipient? = SignalDatabase.threads.getRecipientForThreadId(record.threadId)
if (threadRecipient != null) {
val hasUnreadReactions = CursorUtil.requireInt(unreadMessages, MessageTable.REACTIONS_UNREAD) == 1
val hasUnreadVotes = CursorUtil.requireInt(unreadMessages, MessageTable.VOTES_UNREAD) == 1
val conversationId = ConversationId.fromMessageRecord(record)
val parentRecord = conversationId.groupStoryId?.let {
@@ -56,17 +58,24 @@ object NotificationStateProvider {
if (attachments.isNotEmpty()) {
record = record.withAttachments(attachments)
}
val poll = SignalDatabase.polls.getPoll(record.id)
if (poll != null) {
record = record.withPoll(poll)
}
}
messages += NotificationMessage(
messageRecord = record,
reactions = if (hasUnreadReactions) SignalDatabase.reactions.getReactions(MessageId(record.id)) else emptyList(),
pollVotes = if (hasUnreadVotes) SignalDatabase.polls.getAllVotes(record.id) else emptyList(),
threadRecipient = threadRecipient,
thread = conversationId,
stickyThread = stickyThreads.containsKey(conversationId),
isUnreadMessage = CursorUtil.requireInt(unreadMessages, MessageTable.READ) == 0,
hasUnreadReactions = hasUnreadReactions,
hasUnreadVotes = hasUnreadVotes,
lastReactionRead = CursorUtil.requireLong(unreadMessages, MessageTable.REACTIONS_LAST_SEEN),
lastVoteRead = CursorUtil.requireLong(unreadMessages, MessageTable.VOTES_LAST_SEEN),
isParentStorySentBySelf = parentRecord?.isOutgoing ?: false,
hasSelfRepliedToStory = hasSelfRepliedToGroupStory ?: false
)
@@ -108,6 +117,17 @@ object NotificationStateProvider {
}
}
}
if (notification.hasUnreadVotes) {
notification.pollVotes.forEach {
when (notification.shouldIncludeVote(it, notificationProfile)) {
MessageInclusion.INCLUDE -> notificationItems.add(VoteNotification(notification.threadRecipient, notification.messageRecord, it))
MessageInclusion.EXCLUDE -> Unit
MessageInclusion.MUTE_FILTERED -> muteFilteredMessages += NotificationState.FilteredMessage(notification.messageRecord.id, notification.messageRecord.isMms)
MessageInclusion.PROFILE_FILTERED -> profileFilteredMessages += NotificationState.FilteredMessage(notification.messageRecord.id, notification.messageRecord.isMms)
}
}
}
}
notificationItems.sort()
@@ -127,12 +147,15 @@ object NotificationStateProvider {
private data class NotificationMessage(
val messageRecord: MessageRecord,
val reactions: List<ReactionRecord>,
val pollVotes: List<PollVote>,
val threadRecipient: Recipient,
val thread: ConversationId,
val stickyThread: Boolean,
val isUnreadMessage: Boolean,
val hasUnreadReactions: Boolean,
val hasUnreadVotes: Boolean,
val lastReactionRead: Long,
val lastVoteRead: Long,
val isParentStorySentBySelf: Boolean,
val hasSelfRepliedToStory: Boolean
) {
@@ -172,6 +195,18 @@ object NotificationStateProvider {
}
}
fun shouldIncludeVote(vote: PollVote, notificationProfile: NotificationProfile?): MessageInclusion {
return if (threadRecipient.isMuted) {
MessageInclusion.MUTE_FILTERED
} else if (notificationProfile != null && !notificationProfile.isRecipientAllowed(threadRecipient.id)) {
MessageInclusion.PROFILE_FILTERED
} else if (vote.voterId != Recipient.self().id && messageRecord.isOutgoing && vote.dateReceived > lastVoteRead) {
MessageInclusion.INCLUDE
} else {
MessageInclusion.EXCLUDE
}
}
private val Recipient.isDoNotNotifyMentions: Boolean
get() = mentionSetting == RecipientTable.MentionSetting.DO_NOT_NOTIFY
}
@@ -0,0 +1,41 @@
package org.thoughtcrime.securesms.polls
import android.os.Bundle
import android.os.Parcelable
import androidx.core.os.bundleOf
import kotlinx.parcelize.Parcelize
/**
* Class to represent a poll when it's being created but not yet saved to the database
*/
@Parcelize
data class Poll(
val question: String,
val allowMultipleVotes: Boolean,
val pollOptions: List<String>,
val authorId: Long = -1
) : Parcelable {
companion object {
const val KEY_QUESTION = "question"
const val KEY_ALLOW_MULTIPLE = "allow_multiple"
const val KEY_OPTIONS = "options"
@JvmStatic
fun fromBundle(bundle: Bundle): Poll {
return Poll(
bundle.getString(KEY_QUESTION)!!,
bundle.getBoolean(KEY_ALLOW_MULTIPLE),
bundle.getStringArrayList(KEY_OPTIONS)!!
)
}
}
fun toBundle(): Bundle {
return bundleOf(
KEY_QUESTION to question,
KEY_ALLOW_MULTIPLE to allowMultipleVotes,
KEY_OPTIONS to ArrayList(pollOptions.toList())
)
}
}
@@ -0,0 +1,16 @@
package org.thoughtcrime.securesms.polls
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
/**
* Represents a poll option and a list of recipients who have voted for that option
*/
@Parcelize
data class PollOption(
val id: Long,
val text: String,
val voterIds: List<Long>,
val isSelected: Boolean = false,
val isPending: Boolean = false
) : Parcelable
@@ -0,0 +1,18 @@
package org.thoughtcrime.securesms.polls
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
/**
* Data class representing a poll entry in the db, its options, and any voting
*/
@Parcelize
data class PollRecord(
val id: Long,
val question: String,
val pollOptions: List<PollOption>,
val allowMultipleVotes: Boolean,
val hasEnded: Boolean,
val authorId: Long,
val messageId: Long
) : Parcelable
@@ -0,0 +1,18 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.polls
import org.thoughtcrime.securesms.recipients.RecipientId
/**
* Tracks general information of a poll vote including who they are and what poll they voted in. Primarily used in notifications.
*/
data class PollVote(
val pollId: Long,
val voterId: RecipientId,
val question: String,
val dateReceived: Long = 0
)
@@ -33,12 +33,11 @@ import org.thoughtcrime.securesms.attachments.AttachmentId;
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
import org.thoughtcrime.securesms.backup.v2.BackupRepository;
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
import org.thoughtcrime.securesms.contactshare.Contact;
import org.thoughtcrime.securesms.database.AttachmentTable;
import org.thoughtcrime.securesms.database.MessageTable;
import org.thoughtcrime.securesms.database.MessageTable.InsertResult;
import org.thoughtcrime.securesms.database.MessageTable.SyncMessageId;
import org.thoughtcrime.securesms.database.NoSuchMessageException;
import org.thoughtcrime.securesms.database.PollTables;
import org.thoughtcrime.securesms.database.RecipientTable;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.ThreadTable;
@@ -253,6 +252,37 @@ public class MessageSender {
}
}
public static long sendPollAction(final Context context,
final OutgoingMessage message,
final long threadId,
@NonNull SendType sendType,
@Nullable final String metricId,
@Nullable final MessageTable.InsertListener insertListener)
{
try {
Recipient recipient = message.getThreadRecipient();
long allocatedThreadId = SignalDatabase.threads().getOrCreateValidThreadId(recipient, threadId, message.getDistributionType());
InsertResult insertResult = SignalDatabase.messages().insertMessageOutbox(applyUniversalExpireTimerIfNecessary(context, recipient, message, allocatedThreadId), allocatedThreadId, sendType != SendType.SIGNAL, insertListener);
long messageId = insertResult.getMessageId();
if (!recipient.isPushV2Group()) {
Log.w(TAG, "Can only send polls to groups.");
return threadId;
}
SignalLocalMetrics.GroupMessageSend.onInsertedIntoDatabase(messageId, metricId);
sendMessageInternal(context, recipient, sendType, messageId, insertResult.getQuoteAttachmentId(), Collections.emptyList());
onMessageSent();
SignalDatabase.threads().update(allocatedThreadId, true, true);
return allocatedThreadId;
} catch (MmsException e) {
Log.w(TAG, e);
return threadId;
}
}
public static boolean sendPushWithPreUploadedMedia(final Context context,
final OutgoingMessage message,
final Collection<PreUploadResult> preUploadResults,
@@ -170,7 +170,7 @@ fun InstalledStickerPackRow(
RoundCheckbox(
checked = selected,
onCheckedChange = { onSelectionToggle(pack) },
modifier = Modifier.padding(end = 8.dp)
modifier = Modifier.padding(start = 12.dp, end = 20.dp, top = 12.dp, bottom = 12.dp)
)
}
@@ -70,7 +70,8 @@ object MessageConstraintsUtil {
!targetMessage.isViewOnceMessage() &&
!targetMessage.hasAudio() &&
!targetMessage.hasSharedContact() &&
!targetMessage.hasSticker()
!targetMessage.hasSticker() &&
!targetMessage.hasPoll()
}
/**
@@ -11,6 +11,7 @@ import org.thoughtcrime.securesms.database.model.Quote
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
import org.thoughtcrime.securesms.mms.QuoteModel
import org.thoughtcrime.securesms.mms.TextSlide
import org.thoughtcrime.securesms.polls.PollRecord
import org.thoughtcrime.securesms.stickers.StickerUrl
const val MAX_BODY_DISPLAY_LENGTH = 1000
@@ -100,6 +101,12 @@ fun MessageRecord.hasTextSlide(): Boolean =
fun MessageRecord.requireTextSlide(): TextSlide =
requireNotNull((this as MmsMessageRecord).slideDeck.textSlide)
fun MessageRecord.hasPoll(): Boolean = isMms && (this as MmsMessageRecord).poll != null
fun MessageRecord.getPoll(): PollRecord? = if (isMms) (this as MmsMessageRecord).poll else null
fun MessageRecord.hasPollTerminate(): Boolean = this.isPollTerminate && this.messageExtras != null && this.messageExtras!!.pollTerminate != null
fun MessageRecord.hasBigImageLinkPreview(context: Context): Boolean {
if (!hasLinkPreview()) {
return false
@@ -124,6 +131,10 @@ fun MessageRecord.requireGiftBadge(): GiftBadge {
return (this as MmsMessageRecord).giftBadge!!
}
fun MessageRecord.isPoll(): Boolean {
return (this as? MmsMessageRecord)?.poll != null
}
fun MessageRecord.isTextOnly(context: Context): Boolean {
return !isMms ||
(
@@ -140,7 +151,8 @@ fun MessageRecord.isTextOnly(context: Context): Boolean {
!isCaptionlessMms(context) &&
!hasGiftBadge() &&
!isPaymentNotification &&
!isPaymentTombstone
!isPaymentTombstone &&
!isPoll()
)
}
@@ -1184,5 +1184,13 @@ object RemoteConfig {
hotSwappable = true
)
@JvmStatic
@get:JvmName("polls")
val polls: Boolean by remoteBoolean(
key = "android.polls",
defaultValue = false,
hotSwappable = true
)
// endregion
}
+7
View File
@@ -532,6 +532,7 @@ message MessageExtras {
signalservice.GroupContext gv1Context = 2;
ProfileChangeDetails profileChangeDetails = 3;
PaymentTombstone paymentTombstone = 4;
PollTerminate pollTerminate = 5;
}
}
@@ -546,6 +547,12 @@ message PaymentTombstone {
CryptoValue fee = 3;
}
message PollTerminate {
string question = 1;
uint64 messageId = 2;
uint64 targetTimestamp = 3;
}
message LocalRegistrationMetadata {
bytes aciIdentityKeyPair = 1;
bytes aciSignedPreKey = 2;
+8
View File
@@ -243,3 +243,11 @@ message BackupDeleteJobData {
message SecondRoundFixupSendJobData {
uint64 messageId = 1;
}
message PollVoteJobData {
uint64 messageId = 1;
repeated uint64 recipients = 2;
uint32 initialRecipientCount = 3;
uint32 voteCount = 4;
bool isRemoval = 5;
}
+24
View File
@@ -0,0 +1,24 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="22dp"
android:height="12dp"
android:viewportWidth="22"
android:viewportHeight="12">
<path
android:pathData="M1,1H21"
android:strokeWidth="1.5"
android:fillColor="#00000000"
android:strokeColor="#808389"
android:strokeLineCap="round"/>
<path
android:pathData="M1,6.238H21"
android:strokeWidth="1.5"
android:fillColor="#00000000"
android:strokeColor="#808389"
android:strokeLineCap="round"/>
<path
android:pathData="M1,11H21"
android:strokeWidth="1.5"
android:fillColor="#00000000"
android:strokeColor="#808389"
android:strokeLineCap="round"/>
</vector>
@@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="16"
android:viewportHeight="16">
<path
android:pathData="M12.176,14.298C11.296,14.298 10.82,13.843 10.82,13.005V3.367C10.82,2.524 11.296,2.068 12.176,2.068H13.496C14.375,2.068 14.857,2.524 14.857,3.362V13.005C14.857,13.843 14.375,14.298 13.496,14.298H12.176ZM12.344,13.371H13.333C13.716,13.371 13.867,13.225 13.867,12.848V3.524C13.867,3.147 13.716,3.001 13.333,3.001H12.344C11.961,3.001 11.809,3.147 11.809,3.524V12.848C11.809,13.225 11.961,13.371 12.344,13.371ZM7.343,14.298C6.458,14.298 5.981,13.843 5.981,13.005V5.462C5.981,4.625 6.458,4.169 7.343,4.169H8.657C9.537,4.169 10.019,4.625 10.019,5.462V13.005C10.019,13.843 9.537,14.298 8.657,14.298H7.343ZM7.505,13.371H8.495C8.877,13.371 9.029,13.225 9.029,12.848V5.625C9.029,5.248 8.877,5.101 8.495,5.101H7.505C7.123,5.101 6.971,5.248 6.971,5.625V12.848C6.971,13.225 7.123,13.371 7.505,13.371ZM2.504,14.298C1.625,14.298 1.143,13.843 1.143,13.005V7.563C1.143,6.725 1.625,6.269 2.504,6.269H3.819C4.698,6.269 5.185,6.725 5.185,7.563V13.005C5.185,13.843 4.698,14.298 3.819,14.298H2.504ZM2.667,13.371H3.656C4.044,13.371 4.19,13.225 4.19,12.848V7.72C4.19,7.348 4.044,7.202 3.656,7.202H2.667C2.284,7.202 2.138,7.348 2.138,7.72V12.848C2.138,13.225 2.284,13.371 2.667,13.371Z"
android:strokeWidth="0.142857"
android:fillColor="#545863"
android:strokeColor="#545863"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M18.15 3.63c1.52 0.08 2.73 1.33 2.73 2.87v1c0 1.59-1.3 2.88-2.88 2.88h-1.35c0.14 0.34 0.23 0.72 0.23 1.12v1c0 0.43-0.1 0.83-0.26 1.2 1.29 0.27 2.25 1.42 2.25 2.8v1c0 1.59-1.28 2.88-2.87 2.88H6.5c-1.59 0-2.88-1.3-2.88-2.88v-1c0-0.78 0.31-1.48 0.82-2-0.5-0.52-0.82-1.22-0.82-2v-1c0-0.78 0.31-1.48 0.82-2-0.5-0.52-0.82-1.22-0.82-2v-1c0-1.59 1.3-2.88 2.88-2.88H18h0.15ZM6.5 15.38c-0.62 0-1.13 0.5-1.13 1.12v1c0 0.62 0.5 1.13 1.13 1.13H16c0.62 0 1.13-0.5 1.13-1.13v-1c0-0.62-0.5-1.13-1.13-1.13H6.5Zm0-5c-0.62 0-1.13 0.5-1.13 1.12v1c0 0.62 0.5 1.13 1.13 1.13H14c0.62 0 1.13-0.5 1.13-1.13v-1c0-0.62-0.5-1.13-1.13-1.13H6.5Zm0-5c-0.62 0-1.13 0.5-1.13 1.12v1c0 0.62 0.5 1.13 1.13 1.13H18c0.62 0 1.13-0.5 1.13-1.13v-1c0-0.58-0.45-1.06-1.01-1.12H18 6.5Z"/>
</vector>
+1 -1
View File
@@ -4,6 +4,6 @@
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M6.5,4.5C5.395,4.5 4.5,5.395 4.5,6.5V17.5C4.5,18.605 5.395,19.5 6.5,19.5H17.5C18.605,19.5 19.5,18.605 19.5,17.5V6.5C19.5,5.395 18.605,4.5 17.5,4.5H6.5Z"
android:pathData="M12,24C5.432,24 0,18.568 0,12C0,5.42 5.42,0 11.988,0C18.568,0 24,5.42 24,12C24,18.568 18.568,24 12,24ZM12,22.445C17.791,22.445 22.457,17.779 22.457,12C22.445,6.209 17.779,1.543 11.988,1.543C6.209,1.543 1.555,6.209 1.555,12C1.555,17.779 6.209,22.445 12,22.445ZM8.782,16.319C8.088,16.319 7.669,15.912 7.669,15.23V8.77C7.669,8.088 8.088,7.669 8.782,7.669H15.206C15.9,7.669 16.319,8.088 16.319,8.77V15.23C16.319,15.912 15.9,16.319 15.206,16.319H8.782Z"
android:fillColor="#000000"/>
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.compose.ui.platform.ComposeView android:id="@+id/poll"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -231,6 +231,12 @@
app:emoji_renderSpoilers="true"
tools:text="Mango pickle lorem ipsum" />
<ViewStub
android:id="@+id/poll"
android:layout_width="@dimen/media_bubble_max_width"
android:layout_height="wrap_content"
android:layout="@layout/conversation_item_poll" />
<ViewStub
android:id="@+id/conversation_item_join_button"
android:layout_width="match_parent"
@@ -172,6 +172,12 @@
app:scaleEmojis="true"
tools:text="Mango pickle lorem ipsum" />
<ViewStub
android:id="@+id/poll"
android:layout_width="@dimen/media_bubble_max_width"
android:layout_height="wrap_content"
android:layout="@layout/conversation_item_poll" />
<ViewStub
android:id="@+id/conversation_item_join_button"
android:layout_width="match_parent"
@@ -330,6 +330,14 @@
app:layout_constraintEnd_toEndOf="@id/parent_end_guideline"
app:layout_constraintStart_toStartOf="@id/parent_start_guideline" />
<androidx.fragment.app.FragmentContainerView
android:id="@+id/polls_create"
android:layout_width="0dp"
android:layout_height="match_parent"
app:layout_constraintBottom_toBottomOf="@id/keyboard_guideline"
app:layout_constraintEnd_toEndOf="@id/parent_end_guideline"
app:layout_constraintStart_toStartOf="@id/parent_start_guideline" />
<FrameLayout
android:id="@+id/reactions_shade"
android:layout_width="match_parent"
@@ -215,6 +215,10 @@
<item name="android:windowAnimationStyle">@style/TextSecure.Animation.FullScreenDialog</item>
</style>
<style name="Signal.DayNight.Dialog.FullScreen.Poll" parent="Signal.DayNight">
<item name="android:windowAnimationStyle">@style/TextSecure.Animation.FullScreenDialogPoll</item>
</style>
<style name="Signal.DayNight.Dialog.FullScreen.Donate">
</style>
+67
View File
@@ -89,6 +89,8 @@
<string name="AttachmentKeyboard_file">File</string>
<string name="AttachmentKeyboard_contact">Contact</string>
<string name="AttachmentKeyboard_location">Location</string>
<!-- Text for a button that will allow users to create a poll -->
<string name="AttachmentKeyboard_poll">Poll</string>
<string name="AttachmentKeyboard_Signal_needs_permission_to_show_your_photos_and_videos">Signal needs permission to show your photos and videos</string>
<!-- Text for a button prompting users to allow Signal access to their gallery storage -->
<string name="AttachmentKeyboard_allow_access">Allow Access</string>
@@ -2108,6 +2110,8 @@
<string name="MessageRecord_you_unblocked_this_group">You unblocked this group</string>
<!-- Update message shown when you receive a message that cannot be processed because your Signal version is too old. %1$s is the sender\'s name -->
<string name="MessageRecord_unsupported_feature">%1$s sent you a message that can\'t be processed or displayed because it uses a new Signal feature.</string>
<!-- Update message shown when someone ends the poll. %1$s is the person who ended the poll and %2$s is the poll question. -->
<string name="MessageRecord_ended_the_poll">%1$s ended the poll: \"%2$s\"</string>
<!-- MessageRequestBottomView -->
<string name="MessageRequestBottomView_accept">Accept</string>
@@ -3176,6 +3180,8 @@
<string name="MessageNotifier_reacted_s_to_your_payment">Reacted %1$s to your payment.</string>
<string name="MessageNotifier_reacted_s_to_your_sticker">Reacted %1$s to your sticker.</string>
<string name="MessageNotifier_this_message_was_deleted">This message was deleted.</string>
<!-- Body of a notification when someone votes in a poll. First placeholder is the poll emoji, second placeholder is the name of the voter and third is the poll question -->
<string name="MessageNotifier_s_voted_in_poll">%1$s %2$s voted in the poll \"%3$s\"</string>
<string name="TurnOffContactJoinedNotificationsActivity__turn_off_contact_joined_signal">Turn off contact joined Signal notifications? You can enable them again in Signal > Settings > Notifications.</string>
@@ -4421,6 +4427,8 @@
<string name="conversation_selection__menu_multi_select">Select</string>
<!-- Button to view a in-chat payment message\'s full payment details; Action item with hyphenation. Translation can use soft hyphen - Unicode U+00AD -->
<string name="conversation_selection__menu_payment_details">Payment details</string>
<!-- Button to end a poll -->
<string name="conversation_selection__menu_end_poll">End poll</string>
<!-- conversation_expiring_on -->
@@ -8768,5 +8776,64 @@
<item quantity="other">You havent completed a backup for %1$d days. Create a backup now.</item>
</plurals>
<!-- Polls -->
<!-- Caption in poll message to select an option -->
<string name="Poll__select_one">Poll · Select One</string>
<!-- Caption in poll message to select one or more options -->
<string name="Poll__select_multiple">Poll · Select one or more</string>
<!-- Caption in poll message once a poll has ended -->
<string name="Poll__final_results">Poll · Final results</string>
<!-- Caption in poll message that no votes have been cast -->
<string name="Poll__no_votes">No votes</string>
<!-- Button in poll message to view current votes for a poll -->
<string name="Poll__view_votes">View votes</string>
<!-- Button in poll message to view the poll after it has ended -->
<string name="Poll__view_poll">View poll</string>
<!-- Toast message shown if we cannot find the poll -->
<string name="Poll__unable_poll">Unable to find poll</string>
<!-- Button in poll message to view results for a poll after it ends -->
<string name="Poll__view_results">View results</string>
<!-- Quote preview when replying to a poll. %1$s is the question of the poll -->
<string name="Poll__poll_question">Poll: %1$s</string>
<!-- Notification message sent when a poll ends. %1$s is the creator. %2$s is the question of the poll -->
<string name="Poll__poll_end">%1$s ended the poll: \"%2$s\"</string>
<!-- Notification message sent when someone votes in the poll. %1$s is the voter. %2$s is the question of the poll -->
<string name="Poll__poll_voted">%1$s voted in the poll: \"%2$s\"</string>
<!-- Option in settings to configure notifications for polls -->
<string name="Poll__poll">Polls</string>
<!-- Title of screen that shows the poll results of a poll -->
<string name="Poll__poll_results">Poll results</string>
<!-- Header text displaying the question of the poll -->
<string name="Poll__question">Question</string>
<!-- Text displaying how many votes, %1$d, an option has gotten -->
<plurals name="Poll__num_votes">
<item quantity="one">%1$d vote</item>
<item quantity="other">%1$d votes</item>
</plurals>
<!-- Text that when pressed will end the poll -->
<string name="Poll__end_poll">End poll</string>
<!-- Button that once pressed will show all of the voters for a poll -->
<string name="Poll__see_all">See all</string>
<!-- Header when creating a new poll -->
<string name="CreatePollFragment__new_poll">New poll</string>
<!-- Section text to enter in a question for a poll -->
<string name="CreatePollFragment__question">Question</string>
<!-- Hint text for question field when creating a poll -->
<string name="CreatePollFragment__ask_a_question">Ask a question</string>
<!-- Section text to enter in options for a poll -->
<string name="CreatePollFragment__options">Options</string>
<!-- Hint text for the option field, where %1$d is the option number (max 10) -->
<string name="CreatePollFragment__option_n">Option %1$d</string>
<!-- Accessibility label for the polls drag and drop handle used to reorder the list. -->
<string name="CreatePollFragment__drag_handle">Drag and drop handle</string>
<!-- Text next to button to allow the poll to allow for multiple votes -->
<string name="CreatePollFragment__allow_multiple_votes">Allow multiple votes</string>
<!-- Snackbar text to add a question and two options before creating a poll -->
<string name="CreatePollFragment__add_question_option">Add a question and at least 2 options</string>
<!-- Snackbar text to add two options before creating a poll -->
<string name="CreatePollFragment__add_option">Add at least 2 options</string>
<!-- Snackbar text to add a question before creating a poll -->
<string name="CreatePollFragment__add_question">Add a question</string>
<!-- EOF -->
</resources>
+5
View File
@@ -66,6 +66,11 @@
<item name="android:windowExitAnimation">@anim/fade_scale_out</item>
</style>
<style name="TextSecure.Animation.FullScreenDialogPoll" parent="@android:style/Animation.Activity">
<item name="android:windowEnterAnimation">@anim/slide_from_bottom</item>
<item name="android:windowExitAnimation">@anim/slide_to_bottom</item>
</style>
<style name="TextSecure.Animation.AddMessageDialog" parent="@android:style/Animation.Activity">
<item name="android:windowEnterAnimation">@anim/slide_from_bottom</item>
<item name="android:windowExitAnimation">@anim/slide_to_bottom</item>
@@ -57,6 +57,7 @@ import org.thoughtcrime.securesms.database.MessageTypes.SPECIAL_TYPE_MESSAGE_REQ
import org.thoughtcrime.securesms.database.MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATED
import org.thoughtcrime.securesms.database.MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATE_REQUEST
import org.thoughtcrime.securesms.database.MessageTypes.SPECIAL_TYPE_PAYMENTS_NOTIFICATION
import org.thoughtcrime.securesms.database.MessageTypes.SPECIAL_TYPE_POLL_TERMINATE
import org.thoughtcrime.securesms.database.MessageTypes.SPECIAL_TYPE_REPORTED_SPAM
import org.thoughtcrime.securesms.database.MessageTypes.SPECIAL_TYPE_STORY_REACTION
import org.thoughtcrime.securesms.database.MessageTypes.SPECIAL_TYPE_UNBLOCKED
@@ -139,6 +140,7 @@ object MessageBitmaskColumnTransformer : ColumnTransformer {
isMessageRequestAccepted:${type and SPECIAL_TYPES_MASK == SPECIAL_TYPE_MESSAGE_REQUEST_ACCEPTED}
isBlockedType:${type and SPECIAL_TYPES_MASK == SPECIAL_TYPE_BLOCKED}
isUnblockedType:${type and SPECIAL_TYPES_MASK == SPECIAL_TYPE_UNBLOCKED}
isPollEnd:${type and SPECIAL_TYPES_MASK == SPECIAL_TYPE_POLL_TERMINATE}
""".trimIndent()
return "$type<br><br>" + describe.replace(Regex("is[A-Z][A-Za-z0-9]*:false\n?"), "").replace("\n", "<br>")
@@ -197,6 +197,7 @@ object FakeMessageRecords {
giftBadge,
payment,
call,
null,
-1,
null,
null,
@@ -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;
@@ -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
@@ -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
@@ -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 {
@@ -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)
}
}