Release polls behind feature flag.

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

View File

@@ -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);
}
}

View File

@@ -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()
)

View File

@@ -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";
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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)) {

View File

@@ -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();

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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()
}
}

View File

@@ -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);

View File

@@ -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
)
)
}
}

View File

@@ -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
)
)
}
}

View File

@@ -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()
)

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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))
}
}

View File

@@ -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
)
}

View File

@@ -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)
}
}
}

View File

@@ -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);

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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;
}

View File

@@ -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 }
}
}
}

View File

@@ -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
}
}

View File

@@ -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));

View File

@@ -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)
}
}
}

View File

@@ -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) {

View File

@@ -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)")
}
}

View File

@@ -260,4 +260,8 @@ public abstract class DisplayRecord {
public boolean isUnsupported() {
return MessageTypes.isUnsupportedMessageType(type);
}
public boolean isPollTerminate() {
return MessageTypes.isPollTerminate(type);
}
}

View File

@@ -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() {

View File

@@ -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
}
}

View File

@@ -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());
}

View File

@@ -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;
}

View File

@@ -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();

View File

@@ -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'),

View File

@@ -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());

View File

@@ -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
)
}
}
}

View File

@@ -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);

View File

@@ -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()
}

View File

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

View File

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

View File

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

View File

@@ -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)

View File

@@ -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,

View File

@@ -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)"
}
}

View File

@@ -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
}

View File

@@ -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())
)
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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
)

View File

@@ -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,

View File

@@ -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)
)
}

View File

@@ -70,7 +70,8 @@ object MessageConstraintsUtil {
!targetMessage.isViewOnceMessage() &&
!targetMessage.hasAudio() &&
!targetMessage.hasSharedContact() &&
!targetMessage.hasSticker()
!targetMessage.hasSticker() &&
!targetMessage.hasPoll()
}
/**

View File

@@ -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()
)
}

View File

@@ -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
}