mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-23 10:20:25 +01:00
Release polls behind feature flag.
This commit is contained in:
@@ -30,6 +30,8 @@ import org.thoughtcrime.securesms.groups.GroupId;
|
||||
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange;
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
|
||||
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory;
|
||||
import org.thoughtcrime.securesms.polls.PollRecord;
|
||||
import org.thoughtcrime.securesms.polls.PollOption;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.stickers.StickerLocator;
|
||||
@@ -143,5 +145,8 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
|
||||
void onDisplayMediaNoLongerAvailableSheet();
|
||||
void onShowUnverifiedProfileSheet(boolean forGroup);
|
||||
void onUpdateSignalClicked();
|
||||
void onViewResultsClicked(long pollId);
|
||||
void onViewPollClicked(long messageId);
|
||||
void onToggleVote(@NonNull PollRecord poll, @NonNull PollOption pollOption, Boolean isChecked);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Checkbox
|
||||
@@ -26,6 +25,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -34,6 +34,7 @@ import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.role
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
@@ -50,7 +51,11 @@ import org.thoughtcrime.securesms.R
|
||||
fun RoundCheckbox(
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
modifier: Modifier = Modifier,
|
||||
size: Dp = 24.dp,
|
||||
enabled: Boolean = true,
|
||||
outlineColor: Color = MaterialTheme.colorScheme.outline,
|
||||
checkedColor: Color = MaterialTheme.colorScheme.primary
|
||||
) {
|
||||
val contentDescription = if (checked) {
|
||||
stringResource(R.string.SignalCheckbox_accessibility_checked_description)
|
||||
@@ -60,15 +65,14 @@ fun RoundCheckbox(
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.padding(12.dp)
|
||||
.size(24.dp)
|
||||
.size(size)
|
||||
.aspectRatio(1f)
|
||||
.border(
|
||||
width = 1.5.dp,
|
||||
color = if (checked) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
checkedColor
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outline
|
||||
outlineColor
|
||||
},
|
||||
shape = CircleShape
|
||||
)
|
||||
@@ -76,7 +80,8 @@ fun RoundCheckbox(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = { onCheckedChange(!checked) },
|
||||
onClickLabel = stringResource(R.string.SignalCheckbox_accessibility_on_click_label)
|
||||
onClickLabel = stringResource(R.string.SignalCheckbox_accessibility_on_click_label),
|
||||
enabled = enabled
|
||||
)
|
||||
.semantics(mergeDescendants = true) {
|
||||
this.role = Role.Checkbox
|
||||
@@ -90,7 +95,7 @@ fun RoundCheckbox(
|
||||
) {
|
||||
Image(
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.ic_check_circle_solid_24),
|
||||
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary),
|
||||
colorFilter = ColorFilter.tint(checkedColor),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
|
||||
@@ -10,5 +10,6 @@ public final class EmojiStrings {
|
||||
public static final String STICKER = "\u2B50";
|
||||
public static final String GIFT = "\uD83C\uDF81";
|
||||
public static final String CARD = "\uD83D\uDCB3";
|
||||
public static final String POLL = "\uD83D\uDCCA";
|
||||
public static final String FAILED_STORY = "\u2757";
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ public class AttachmentKeyboard extends FrameLayout implements InputAwareLayout.
|
||||
private static final List<AttachmentKeyboardButton> DEFAULT_BUTTONS = Arrays.asList(
|
||||
AttachmentKeyboardButton.GALLERY,
|
||||
AttachmentKeyboardButton.FILE,
|
||||
AttachmentKeyboardButton.POLL,
|
||||
AttachmentKeyboardButton.CONTACT,
|
||||
AttachmentKeyboardButton.LOCATION,
|
||||
AttachmentKeyboardButton.PAYMENT
|
||||
|
||||
@@ -11,7 +11,8 @@ public enum AttachmentKeyboardButton {
|
||||
FILE(R.string.AttachmentKeyboard_file, R.drawable.symbol_file_24),
|
||||
PAYMENT(R.string.AttachmentKeyboard_payment, R.drawable.symbol_payment_24),
|
||||
CONTACT(R.string.AttachmentKeyboard_contact, R.drawable.symbol_person_circle_24),
|
||||
LOCATION(R.string.AttachmentKeyboard_location, R.drawable.symbol_location_circle_24);
|
||||
LOCATION(R.string.AttachmentKeyboard_location, R.drawable.symbol_location_circle_24),
|
||||
POLL(R.string.AttachmentKeyboard_poll, R.drawable.symbol_poll_24);
|
||||
|
||||
private final int titleRes;
|
||||
private final int iconRes;
|
||||
|
||||
@@ -56,6 +56,7 @@ import androidx.annotation.ColorInt;
|
||||
import androidx.annotation.DimenRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.compose.ui.platform.ComposeView;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.lifecycle.LifecycleOwner;
|
||||
import androidx.media3.common.MediaItem;
|
||||
@@ -73,7 +74,6 @@ import org.signal.core.util.BidiUtil;
|
||||
import org.signal.core.util.DimensionUnit;
|
||||
import org.signal.core.util.StringUtil;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.signal.ringrtc.CallLinkRootKey;
|
||||
import org.thoughtcrime.securesms.BindableConversationItem;
|
||||
import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.attachments.Attachment;
|
||||
@@ -130,6 +130,7 @@ import org.thoughtcrime.securesms.mms.SlideDeck;
|
||||
import org.thoughtcrime.securesms.mms.SlidesClickedListener;
|
||||
import org.thoughtcrime.securesms.mms.TextSlide;
|
||||
import org.thoughtcrime.securesms.mms.VideoSlide;
|
||||
import org.thoughtcrime.securesms.polls.PollRecord;
|
||||
import org.thoughtcrime.securesms.reactions.ReactionsConversationView;
|
||||
import org.thoughtcrime.securesms.recipients.LiveRecipient;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
@@ -236,6 +237,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
private Stub<Button> callToActionStub;
|
||||
private Stub<GiftMessageView> giftViewStub;
|
||||
private Stub<PaymentMessageView> paymentViewStub;
|
||||
private Stub<ComposeView> pollView;
|
||||
private @Nullable EventListener eventListener;
|
||||
private @Nullable GestureDetector gestureDetector;
|
||||
|
||||
@@ -352,6 +354,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
this.quotedIndicator = findViewById(R.id.quoted_indicator);
|
||||
this.paymentViewStub = new Stub<>(findViewById(R.id.payment_view_stub));
|
||||
this.scheduledIndicator = findViewById(R.id.scheduled_indicator);
|
||||
this.pollView = new Stub<>(findViewById(R.id.poll));
|
||||
|
||||
setOnClickListener(new ClickListener(null));
|
||||
|
||||
@@ -417,6 +420,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
setStoryReactionLabel(messageRecord);
|
||||
setHasBeenQuoted(conversationMessage);
|
||||
setHasBeenScheduled(conversationMessage);
|
||||
setPoll(messageRecord);
|
||||
|
||||
if (audioViewStub.resolved()) {
|
||||
audioViewStub.get().setOnLongClickListener(passthroughClickListener);
|
||||
@@ -561,6 +565,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
conversationMessage.getBottomButton() == null &&
|
||||
!BidiUtil.hasMixedTextDirection(bodyText.getText()) &&
|
||||
!messageRecord.isRemoteDelete() &&
|
||||
!MessageRecordUtil.hasPoll(messageRecord) &&
|
||||
bodyText.getLastLineWidth() > 0)
|
||||
{
|
||||
View dateView = footer.getDateView();
|
||||
@@ -1001,6 +1006,10 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
return MessageRecordUtil.hasQuote(messageRecord);
|
||||
}
|
||||
|
||||
private boolean hasPoll(MessageRecord messageRecord) {
|
||||
return MessageRecordUtil.hasPoll(messageRecord);
|
||||
}
|
||||
|
||||
private boolean hasSharedContact(MessageRecord messageRecord) {
|
||||
return MessageRecordUtil.hasSharedContact(messageRecord);
|
||||
}
|
||||
@@ -1054,6 +1063,10 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
if (messageRequestAccepted) {
|
||||
linkifyMessageBody(styledText, batchSelected.isEmpty());
|
||||
}
|
||||
if (MessageRecordUtil.hasPoll(messageRecord)) {
|
||||
styledText.setSpan(new StyleSpan(Typeface.BOLD), 0, styledText.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||
bodyText.setMaxWidth(readDimen(R.dimen.media_bubble_default_dimens));
|
||||
}
|
||||
styledText = SearchUtil.getHighlightedSpan(locale, STYLE_FACTORY, styledText, searchQuery, SearchUtil.STRICT);
|
||||
|
||||
if (hasExtraText(messageRecord)) {
|
||||
@@ -1627,6 +1640,31 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
}
|
||||
}
|
||||
|
||||
private void setPoll(@NonNull MessageRecord messageRecord) {
|
||||
if (hasPoll(messageRecord) && !messageRecord.isRemoteDelete()) {
|
||||
PollRecord poll = MessageRecordUtil.getPoll(messageRecord);
|
||||
PollComponentKt.setContent(pollView.get(), poll, isOutgoing(), () -> {
|
||||
if (eventListener != null && batchSelected.isEmpty()) {
|
||||
eventListener.onViewResultsClicked(poll.getId());
|
||||
} else {
|
||||
passthroughClickListener.onClick(pollView.get());
|
||||
}
|
||||
return null;
|
||||
}, (option, isChecked) -> {
|
||||
if (eventListener != null && batchSelected.isEmpty()) {
|
||||
eventListener.onToggleVote(poll, option, isChecked);
|
||||
} else {
|
||||
passthroughClickListener.onClick(pollView.get());
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
pollView.setVisibility(View.VISIBLE);
|
||||
} else if (pollView != null && pollView.resolved()) {
|
||||
pollView.setVisibility(View.GONE);
|
||||
}
|
||||
}
|
||||
|
||||
private void setQuote(@NonNull MessageRecord current, @NonNull Optional<MessageRecord> previous, @NonNull Optional<MessageRecord> next, boolean isGroupThread) {
|
||||
boolean startOfCluster = isStartOfMessageCluster(current, previous, isGroupThread);
|
||||
if (hasQuote(messageRecord)) {
|
||||
|
||||
@@ -138,6 +138,10 @@ public class ConversationMessage {
|
||||
getBottomButton() == null;
|
||||
}
|
||||
|
||||
public boolean isPoll() {
|
||||
return MessageRecordUtil.isPoll(messageRecord);
|
||||
}
|
||||
|
||||
public long getConversationTimestamp() {
|
||||
if (originalMessage != null) {
|
||||
return originalMessage.getDateSent();
|
||||
|
||||
@@ -760,6 +760,10 @@ public final class ConversationReactionOverlay extends FrameLayout {
|
||||
items.add(new ActionItem(R.drawable.symbol_info_24, getResources().getString(R.string.conversation_selection__menu_message_details), () -> handleActionItemClicked(Action.VIEW_INFO)));
|
||||
}
|
||||
|
||||
if (menuState.shouldShowPollTerminateAction()) {
|
||||
items.add(new ActionItem(R.drawable.symbol_stop_24, getResources().getString(R.string.conversation_selection__menu_end_poll), () -> handleActionItemClicked(Action.END_POLL)));
|
||||
}
|
||||
|
||||
backgroundView.setVisibility(menuState.shouldShowReactions() ? View.VISIBLE : View.INVISIBLE);
|
||||
foregroundView.setVisibility(menuState.shouldShowReactions() ? View.VISIBLE : View.INVISIBLE);
|
||||
|
||||
@@ -961,5 +965,6 @@ public final class ConversationReactionOverlay extends FrameLayout {
|
||||
PAYMENT_DETAILS,
|
||||
VIEW_INFO,
|
||||
DELETE,
|
||||
END_POLL
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,12 @@ package org.thoughtcrime.securesms.conversation;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.res.ColorStateList;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.text.Spannable;
|
||||
import android.text.SpannableString;
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.text.method.LinkMovementMethod;
|
||||
import android.util.AttributeSet;
|
||||
import android.view.View;
|
||||
@@ -17,6 +21,7 @@ import androidx.appcompat.content.res.AppCompatResources;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.lifecycle.LifecycleOwner;
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
import androidx.lifecycle.Observer;
|
||||
import androidx.lifecycle.Transformations;
|
||||
|
||||
@@ -43,16 +48,18 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore;
|
||||
import org.thoughtcrime.securesms.recipients.LiveRecipient;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.util.DateUtils;
|
||||
import org.thoughtcrime.securesms.util.DrawableUtil;
|
||||
import org.thoughtcrime.securesms.util.IdentityUtil;
|
||||
import org.thoughtcrime.securesms.util.MessageRecordUtil;
|
||||
import org.thoughtcrime.securesms.util.Projection;
|
||||
import org.thoughtcrime.securesms.util.ProjectionList;
|
||||
import org.thoughtcrime.securesms.util.SpanUtil;
|
||||
import org.thoughtcrime.securesms.util.ThemeUtil;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.thoughtcrime.securesms.util.ViewUtil;
|
||||
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil;
|
||||
import org.thoughtcrime.securesms.verify.VerifyIdentityActivity;
|
||||
import org.whispersystems.signalservice.api.push.ServiceId;
|
||||
import org.whispersystems.signalservice.api.push.ServiceId.ACI;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Locale;
|
||||
@@ -81,7 +88,6 @@ public final class ConversationUpdateItem extends FrameLayout
|
||||
private Optional<MessageRecord> nextMessageRecord;
|
||||
private MessageRecord messageRecord;
|
||||
private boolean isMessageRequestAccepted;
|
||||
private LiveData<SpannableString> displayBody;
|
||||
private EventListener eventListener;
|
||||
|
||||
private final UpdateObserver updateObserver = new UpdateObserver();
|
||||
@@ -91,6 +97,14 @@ public final class ConversationUpdateItem extends FrameLayout
|
||||
private final RecipientObserverManager groupObserver = new RecipientObserverManager(presentOnChange);
|
||||
private final GroupDataManager groupData = new GroupDataManager();
|
||||
|
||||
private final Handler handler = new Handler(Looper.getMainLooper());
|
||||
private final Runnable timerUpdateRunnable = new TimerUpdateRunnable();
|
||||
private final MutableLiveData<SpannableString> displayBodyWithTimer = new MutableLiveData<>();
|
||||
|
||||
private int latestFrame;
|
||||
private SpannableString displayBody;
|
||||
private ExpirationTimer timer;
|
||||
|
||||
private final PassthroughClickListener passthroughClickListener = new PassthroughClickListener();
|
||||
|
||||
public ConversationUpdateItem(Context context) {
|
||||
@@ -181,9 +195,10 @@ public final class ConversationUpdateItem extends FrameLayout
|
||||
LiveData<SpannableString> spannableMessage = loading(liveUpdateMessage);
|
||||
|
||||
observeDisplayBody(lifecycleOwner, spannableMessage);
|
||||
observeDisplayBodyWithTimer(lifecycleOwner);
|
||||
|
||||
present(conversationMessage, nextMessageRecord, conversationRecipient, isMessageRequestAccepted);
|
||||
|
||||
presentTimer(updateDescription);
|
||||
presentBackground(shouldCollapse(messageRecord, previousMessageRecord),
|
||||
shouldCollapse(messageRecord, nextMessageRecord),
|
||||
hasWallpaper);
|
||||
@@ -217,6 +232,8 @@ public final class ConversationUpdateItem extends FrameLayout
|
||||
|
||||
@Override
|
||||
public void unbind() {
|
||||
this.displayBodyWithTimer.removeObserver(updateObserver);
|
||||
handler.removeCallbacks(timerUpdateRunnable);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -389,20 +406,30 @@ public final class ConversationUpdateItem extends FrameLayout
|
||||
return false;
|
||||
}
|
||||
|
||||
private void observeDisplayBody(@NonNull LifecycleOwner lifecycleOwner, @Nullable LiveData<SpannableString> displayBody) {
|
||||
if (this.displayBody != displayBody) {
|
||||
if (this.displayBody != null) {
|
||||
this.displayBody.removeObserver(updateObserver);
|
||||
}
|
||||
|
||||
this.displayBody = displayBody;
|
||||
|
||||
if (this.displayBody != null) {
|
||||
this.displayBody.observe(lifecycleOwner, updateObserver);
|
||||
}
|
||||
private void observeDisplayBody(@NonNull LifecycleOwner lifecycleOwner, @Nullable LiveData<SpannableString> message) {
|
||||
if (message != null) {
|
||||
message.observe(lifecycleOwner, it -> {
|
||||
displayBody = it;
|
||||
updateBodyWithTimer();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void observeDisplayBodyWithTimer(@NonNull LifecycleOwner lifecycleOwner) {
|
||||
this.displayBodyWithTimer.observe(lifecycleOwner, updateObserver);
|
||||
}
|
||||
|
||||
private void updateBodyWithTimer() {
|
||||
SpannableStringBuilder builder = new SpannableStringBuilder(displayBody);
|
||||
|
||||
if (latestFrame != 0) {
|
||||
Drawable drawable = DrawableUtil.tint(getContext().getDrawable(latestFrame), ContextCompat.getColor(getContext(), R.color.signal_icon_tint_secondary));
|
||||
SpanUtil.appendCenteredImageSpan(builder, drawable, 12, 12);
|
||||
}
|
||||
|
||||
displayBodyWithTimer.setValue(new SpannableString(builder));
|
||||
}
|
||||
|
||||
private void setBodyText(@Nullable CharSequence text) {
|
||||
if (text == null) {
|
||||
body.setVisibility(INVISIBLE);
|
||||
@@ -644,6 +671,16 @@ public final class ConversationUpdateItem extends FrameLayout
|
||||
passthroughClickListener.onClick(v);
|
||||
}
|
||||
});
|
||||
} else if (MessageRecordUtil.hasPollTerminate(conversationMessage.getMessageRecord()) && conversationMessage.getMessageRecord().getMessageExtras().pollTerminate.messageId != -1) {
|
||||
actionButton.setText(R.string.Poll__view_poll);
|
||||
actionButton.setVisibility(VISIBLE);
|
||||
actionButton.setOnClickListener(v -> {
|
||||
if (batchSelected.isEmpty() && eventListener != null && MessageRecordUtil.hasPollTerminate(conversationMessage.getMessageRecord())) {
|
||||
eventListener.onViewPollClicked(conversationMessage.getMessageRecord().getMessageExtras().pollTerminate.messageId);
|
||||
} else {
|
||||
passthroughClickListener.onClick(v);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
actionButton.setVisibility(GONE);
|
||||
actionButton.setOnClickListener(null);
|
||||
@@ -747,6 +784,16 @@ public final class ConversationUpdateItem extends FrameLayout
|
||||
(current.isChangeNumber() && candidate.isChangeNumber());
|
||||
}
|
||||
|
||||
private void presentTimer(UpdateDescription updateDescription) {
|
||||
if (updateDescription.hasExpiration() && messageRecord.getExpiresIn() > 0) {
|
||||
timer = new ExpirationTimer(messageRecord.getTimestamp(), messageRecord.getExpiresIn());
|
||||
handler.post(timerUpdateRunnable);
|
||||
} else {
|
||||
latestFrame = 0;
|
||||
handler.removeCallbacks(timerUpdateRunnable);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setOnClickListener(View.OnClickListener l) {
|
||||
super.setOnClickListener(new InternalClickListener(l));
|
||||
@@ -763,6 +810,19 @@ public final class ConversationUpdateItem extends FrameLayout
|
||||
}
|
||||
}
|
||||
|
||||
private class TimerUpdateRunnable implements Runnable {
|
||||
@Override
|
||||
public void run() {
|
||||
float progress = timer.calculateProgress();
|
||||
latestFrame = ExpirationTimer.getFrame(progress);
|
||||
updateBodyWithTimer();
|
||||
|
||||
if (progress < 1f) {
|
||||
handler.postDelayed(this, timer.calculateAnimationDelay());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class UpdateObserver implements Observer<Spannable> {
|
||||
|
||||
@Override
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package org.thoughtcrime.securesms.conversation
|
||||
|
||||
import org.thoughtcrime.securesms.R
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* Tracks which drawables to use for the expiring timer in disappearing messages.
|
||||
*/
|
||||
class ExpirationTimer(
|
||||
val startedAt: Long,
|
||||
val expiresIn: Long
|
||||
) {
|
||||
companion object {
|
||||
private val frames = intArrayOf(
|
||||
R.drawable.ic_timer_00_12,
|
||||
R.drawable.ic_timer_05_12,
|
||||
R.drawable.ic_timer_10_12,
|
||||
R.drawable.ic_timer_15_12,
|
||||
R.drawable.ic_timer_20_12,
|
||||
R.drawable.ic_timer_25_12,
|
||||
R.drawable.ic_timer_30_12,
|
||||
R.drawable.ic_timer_35_12,
|
||||
R.drawable.ic_timer_40_12,
|
||||
R.drawable.ic_timer_45_12,
|
||||
R.drawable.ic_timer_50_12,
|
||||
R.drawable.ic_timer_55_12,
|
||||
R.drawable.ic_timer_60_12
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
fun getFrame(progress: Float): Int {
|
||||
val percentFull = 1 - progress
|
||||
val frame = ceil(percentFull * (frames.size - 1)).toInt()
|
||||
|
||||
val adjustedFrame = max(0, min(frame, frames.size - 1))
|
||||
return frames[adjustedFrame]
|
||||
}
|
||||
}
|
||||
|
||||
fun calculateProgress(): Float {
|
||||
val progressed = System.currentTimeMillis() - startedAt
|
||||
val percentComplete = progressed.toFloat() / expiresIn.toFloat()
|
||||
|
||||
return max(0f, min(percentComplete, 1f))
|
||||
}
|
||||
|
||||
fun calculateAnimationDelay(): Long {
|
||||
val progressed = System.currentTimeMillis() - startedAt
|
||||
val remaining = expiresIn - progressed
|
||||
return (if (remaining < TimeUnit.SECONDS.toMillis(30)) 50 else 1000).toLong()
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ public final class MenuState {
|
||||
private final boolean reactions;
|
||||
private final boolean paymentDetails;
|
||||
private final boolean edit;
|
||||
private final boolean pollTerminate;
|
||||
|
||||
private MenuState(@NonNull Builder builder) {
|
||||
forward = builder.forward;
|
||||
@@ -38,6 +39,7 @@ public final class MenuState {
|
||||
reactions = builder.reactions;
|
||||
paymentDetails = builder.paymentDetails;
|
||||
edit = builder.edit;
|
||||
pollTerminate = builder.pollTerminate;
|
||||
}
|
||||
|
||||
public boolean shouldShowForwardAction() {
|
||||
@@ -80,23 +82,29 @@ public final class MenuState {
|
||||
return edit;
|
||||
}
|
||||
|
||||
public boolean shouldShowPollTerminateAction() {
|
||||
return pollTerminate;
|
||||
}
|
||||
|
||||
public static MenuState getMenuState(@NonNull Recipient conversationRecipient,
|
||||
@NonNull Set<MultiselectPart> selectedParts,
|
||||
boolean shouldShowMessageRequest,
|
||||
boolean isNonAdminInAnnouncementGroup)
|
||||
{
|
||||
|
||||
Builder builder = new Builder();
|
||||
boolean actionMessage = false;
|
||||
boolean hasText = false;
|
||||
boolean sharedContact = false;
|
||||
boolean viewOnce = false;
|
||||
boolean remoteDelete = false;
|
||||
boolean hasInMemory = false;
|
||||
boolean hasPendingMedia = false;
|
||||
boolean mediaIsSelected = false;
|
||||
boolean hasGift = false;
|
||||
Builder builder = new Builder();
|
||||
boolean actionMessage = false;
|
||||
boolean hasText = false;
|
||||
boolean sharedContact = false;
|
||||
boolean viewOnce = false;
|
||||
boolean remoteDelete = false;
|
||||
boolean hasInMemory = false;
|
||||
boolean hasPendingMedia = false;
|
||||
boolean mediaIsSelected = false;
|
||||
boolean hasGift = false;
|
||||
boolean hasPayment = false;
|
||||
boolean hasPoll = false;
|
||||
boolean hasPollTerminate = false;
|
||||
|
||||
for (MultiselectPart part : selectedParts) {
|
||||
MessageRecord messageRecord = part.getMessageRecord();
|
||||
@@ -138,14 +146,24 @@ public final class MenuState {
|
||||
if (messageRecord.isPaymentNotification() || messageRecord.isPaymentTombstone()) {
|
||||
hasPayment = true;
|
||||
}
|
||||
|
||||
if (MessageRecordUtil.hasPoll(messageRecord)) {
|
||||
hasPoll = true;
|
||||
}
|
||||
|
||||
if (MessageRecordUtil.hasPoll(messageRecord) && !MessageRecordUtil.getPoll(messageRecord).getHasEnded() && messageRecord.isOutgoing()) {
|
||||
hasPollTerminate = true;
|
||||
}
|
||||
}
|
||||
|
||||
boolean shouldShowForwardAction = !actionMessage &&
|
||||
!viewOnce &&
|
||||
!remoteDelete &&
|
||||
!hasPendingMedia &&
|
||||
!hasGift &&
|
||||
!hasPayment &&
|
||||
boolean shouldShowForwardAction = !actionMessage &&
|
||||
!viewOnce &&
|
||||
!remoteDelete &&
|
||||
!hasPendingMedia &&
|
||||
!hasGift &&
|
||||
!hasPayment &&
|
||||
!hasPoll &&
|
||||
!hasPollTerminate &&
|
||||
selectedParts.size() <= MAX_FORWARDABLE_COUNT;
|
||||
|
||||
int uniqueRecords = selectedParts.stream()
|
||||
@@ -159,7 +177,8 @@ public final class MenuState {
|
||||
.shouldShowDetailsAction(false)
|
||||
.shouldShowSaveAttachmentAction(false)
|
||||
.shouldShowResendAction(false)
|
||||
.shouldShowEdit(false);
|
||||
.shouldShowEdit(false)
|
||||
.shouldShowPollTerminate(false);
|
||||
} else {
|
||||
MultiselectPart multiSelectRecord = selectedParts.iterator().next();
|
||||
|
||||
@@ -182,13 +201,15 @@ public final class MenuState {
|
||||
builder.shouldShowEdit(!actionMessage &&
|
||||
hasText &&
|
||||
!multiSelectRecord.getConversationMessage().getOriginalMessage().isFailed() &&
|
||||
!hasPoll &&
|
||||
MessageConstraintsUtil.isValidEditMessageSend(multiSelectRecord.getConversationMessage().getOriginalMessage(), System.currentTimeMillis()));
|
||||
}
|
||||
|
||||
return builder.shouldShowCopyAction(!actionMessage && !remoteDelete && hasText && !hasGift && !hasPayment)
|
||||
return builder.shouldShowCopyAction(!actionMessage && !remoteDelete && hasText && !hasGift && !hasPayment && !hasPoll)
|
||||
.shouldShowDeleteAction(!hasInMemory && onlyContainsCompleteMessages(selectedParts))
|
||||
.shouldShowReactions(!conversationRecipient.isReleaseNotes())
|
||||
.shouldShowPaymentDetails(hasPayment)
|
||||
.shouldShowPollTerminate(hasPollTerminate)
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -233,6 +254,7 @@ public final class MenuState {
|
||||
private boolean reactions;
|
||||
private boolean paymentDetails;
|
||||
private boolean edit;
|
||||
private boolean pollTerminate;
|
||||
|
||||
@NonNull Builder shouldShowForwardAction(boolean forward) {
|
||||
this.forward = forward;
|
||||
@@ -284,6 +306,11 @@ public final class MenuState {
|
||||
return this;
|
||||
}
|
||||
|
||||
@NonNull Builder shouldShowPollTerminate(boolean pollTerminate) {
|
||||
this.pollTerminate = pollTerminate;
|
||||
return this;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
MenuState build() {
|
||||
return new MenuState(this);
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.conversation
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.SizeTransform
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.colorResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.compose.RoundCheckbox
|
||||
import org.thoughtcrime.securesms.polls.PollOption
|
||||
import org.thoughtcrime.securesms.polls.PollRecord
|
||||
import org.thoughtcrime.securesms.util.DynamicTheme
|
||||
import org.thoughtcrime.securesms.util.VibrateUtil
|
||||
|
||||
/**
|
||||
* Allows us to utilize our composeView from Java code.
|
||||
*/
|
||||
fun setContent(
|
||||
composeView: ComposeView,
|
||||
poll: PollRecord,
|
||||
isOutgoing: Boolean,
|
||||
onViewVotes: () -> Unit,
|
||||
onToggleVote: (PollOption, Boolean) -> Unit = { _, _ -> }
|
||||
) {
|
||||
composeView.setContent {
|
||||
SignalTheme(
|
||||
isDarkMode = DynamicTheme.isDarkTheme(LocalContext.current)
|
||||
) {
|
||||
Poll(
|
||||
poll = poll,
|
||||
onViewVotes = onViewVotes,
|
||||
onToggleVote = onToggleVote,
|
||||
pollColors = if (isOutgoing) PollColorsType.Outgoing.getColors() else PollColorsType.Incoming.getColors()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Poll(
|
||||
poll: PollRecord,
|
||||
onViewVotes: () -> Unit = {},
|
||||
onToggleVote: (PollOption, Boolean) -> Unit = { _, _ -> },
|
||||
pollColors: PollColors = PollColorsType.Incoming.getColors()
|
||||
) {
|
||||
val totalVotes = remember(poll.pollOptions) { poll.pollOptions.sumOf { it.voterIds.size } }
|
||||
val caption = when {
|
||||
poll.hasEnded -> R.string.Poll__final_results
|
||||
poll.allowMultipleVotes -> R.string.Poll__select_multiple
|
||||
else -> R.string.Poll__select_one
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(caption),
|
||||
color = pollColors.caption,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(start = 12.dp, bottom = 4.dp)
|
||||
)
|
||||
|
||||
poll.pollOptions.forEach {
|
||||
PollOption(it, totalVotes, poll.hasEnded, onToggleVote, pollColors)
|
||||
}
|
||||
|
||||
Spacer(Modifier.size(16.dp))
|
||||
|
||||
if (totalVotes == 0) {
|
||||
Text(
|
||||
text = stringResource(R.string.Poll__no_votes),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally).height(40.dp).wrapContentHeight(align = Alignment.CenterVertically),
|
||||
textAlign = TextAlign.Center,
|
||||
color = pollColors.text
|
||||
)
|
||||
} else {
|
||||
Buttons.MediumTonal(
|
||||
colors = ButtonDefaults.buttonColors(containerColor = pollColors.buttonBackground, contentColor = pollColors.button),
|
||||
onClick = onViewVotes,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally).height(40.dp)
|
||||
) {
|
||||
Text(stringResource(if (poll.hasEnded) R.string.Poll__view_results else R.string.Poll__view_votes))
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.size(4.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PollOption(
|
||||
option: PollOption,
|
||||
totalVotes: Int,
|
||||
hasEnded: Boolean,
|
||||
onToggleVote: (PollOption, Boolean) -> Unit = { _, _ -> },
|
||||
pollColors: PollColors
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val haptics = LocalHapticFeedback.current
|
||||
val progress = remember(option.voterIds.size, totalVotes) {
|
||||
if (totalVotes > 0) (option.voterIds.size.toFloat() / totalVotes.toFloat()) else 0f
|
||||
}
|
||||
val progressValue by animateFloatAsState(targetValue = progress, animationSpec = tween(durationMillis = 250))
|
||||
|
||||
Row(
|
||||
modifier = Modifier.padding(start = 12.dp, end = 16.dp, top = 8.dp, bottom = 8.dp)
|
||||
) {
|
||||
if (!hasEnded) {
|
||||
AnimatedContent(
|
||||
targetState = option.isPending,
|
||||
transitionSpec = {
|
||||
val enterTransition = fadeIn(tween(delayMillis = 500, durationMillis = 500))
|
||||
val exitTransition = fadeOut(tween(durationMillis = 500))
|
||||
enterTransition.togetherWith(exitTransition)
|
||||
.using(SizeTransform(clip = false))
|
||||
}
|
||||
) { inProgress ->
|
||||
if (inProgress) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.padding(top = 4.dp, end = 8.dp).size(24.dp),
|
||||
strokeWidth = 1.5.dp,
|
||||
color = pollColors.checkbox
|
||||
)
|
||||
} else {
|
||||
RoundCheckbox(
|
||||
checked = option.isSelected,
|
||||
onCheckedChange = { checked ->
|
||||
if (VibrateUtil.isHapticFeedbackEnabled(context)) {
|
||||
haptics.performHapticFeedback(if (checked) HapticFeedbackType.ToggleOn else HapticFeedbackType.ToggleOff)
|
||||
}
|
||||
onToggleVote(option, checked)
|
||||
},
|
||||
modifier = Modifier.padding(top = 4.dp, end = 8.dp).height(24.dp),
|
||||
outlineColor = pollColors.checkbox,
|
||||
checkedColor = pollColors.checkboxBackground
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
Text(
|
||||
text = option.text,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(end = 24.dp).weight(1f),
|
||||
color = pollColors.text
|
||||
)
|
||||
|
||||
if (hasEnded && option.isSelected) {
|
||||
RoundCheckbox(
|
||||
checked = true,
|
||||
onCheckedChange = {},
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
size = 16.dp,
|
||||
enabled = false,
|
||||
checkedColor = pollColors.checkboxBackground
|
||||
)
|
||||
}
|
||||
|
||||
AnimatedContent(
|
||||
targetState = option.voterIds.size
|
||||
) { size ->
|
||||
Text(
|
||||
text = size.toString(),
|
||||
color = pollColors.text,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier.height(8.dp).padding(top = 4.dp).fillMaxWidth()
|
||||
.background(
|
||||
color = pollColors.progressBackground,
|
||||
shape = RoundedCornerShape(18.dp)
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(progressValue)
|
||||
.fillMaxHeight()
|
||||
.background(
|
||||
color = pollColors.progress,
|
||||
shape = if (progress == 1f) RoundedCornerShape(18.dp) else RoundedCornerShape(topStart = 18.dp, bottomStart = 18.dp)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PollColors(
|
||||
val text: Color,
|
||||
val caption: Color,
|
||||
val progress: Color,
|
||||
val progressBackground: Color,
|
||||
val checkbox: Color,
|
||||
val checkboxBackground: Color,
|
||||
val button: Color,
|
||||
val buttonBackground: Color
|
||||
)
|
||||
|
||||
private sealed interface PollColorsType {
|
||||
@Composable
|
||||
fun getColors(): PollColors
|
||||
|
||||
data object Outgoing : PollColorsType {
|
||||
@Composable
|
||||
override fun getColors(): PollColors {
|
||||
return PollColors(
|
||||
text = colorResource(R.color.conversation_item_sent_text_primary_color),
|
||||
caption = colorResource(R.color.conversation_item_sent_text_secondary_color),
|
||||
progress = colorResource(R.color.conversation_item_sent_text_primary_color),
|
||||
progressBackground = SignalTheme.colors.colorTransparent3,
|
||||
checkbox = colorResource(R.color.conversation_item_sent_text_secondary_color),
|
||||
checkboxBackground = colorResource(R.color.conversation_item_sent_text_primary_color),
|
||||
button = MaterialTheme.colorScheme.primary,
|
||||
buttonBackground = colorResource(R.color.conversation_item_sent_text_primary_color)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data object Incoming : PollColorsType {
|
||||
@Composable
|
||||
override fun getColors(): PollColors {
|
||||
return PollColors(
|
||||
text = MaterialTheme.colorScheme.onSurface,
|
||||
caption = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
progress = MaterialTheme.colorScheme.primary,
|
||||
progressBackground = SignalTheme.colors.colorTransparentInverse3,
|
||||
checkbox = MaterialTheme.colorScheme.outline,
|
||||
checkboxBackground = MaterialTheme.colorScheme.primary,
|
||||
button = MaterialTheme.colorScheme.onSurface,
|
||||
buttonBackground = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun PollPreview() {
|
||||
Previews.Preview {
|
||||
Poll(
|
||||
PollRecord(
|
||||
id = 1,
|
||||
question = "How do you feel about compose previews?",
|
||||
pollOptions = listOf(
|
||||
PollOption(1, "yay", listOf(1), isSelected = true),
|
||||
PollOption(2, "ok", listOf(1, 2)),
|
||||
PollOption(3, "nay", listOf(2, 3, 4))
|
||||
),
|
||||
allowMultipleVotes = false,
|
||||
hasEnded = false,
|
||||
authorId = 1,
|
||||
messageId = 1
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun EmptyPollPreview() {
|
||||
Previews.Preview {
|
||||
Poll(
|
||||
PollRecord(
|
||||
id = 1,
|
||||
question = "How do you feel about multiple compose previews?",
|
||||
pollOptions = listOf(
|
||||
PollOption(1, "yay", emptyList()),
|
||||
PollOption(2, "ok", emptyList(), isSelected = true),
|
||||
PollOption(3, "nay", emptyList(), isSelected = true)
|
||||
),
|
||||
allowMultipleVotes = true,
|
||||
hasEnded = false,
|
||||
authorId = 1,
|
||||
messageId = 1
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun FinishedPollPreview() {
|
||||
Previews.Preview {
|
||||
Poll(
|
||||
PollRecord(
|
||||
id = 1,
|
||||
question = "How do you feel about finished compose previews?",
|
||||
pollOptions = listOf(
|
||||
PollOption(1, "yay", listOf(1)),
|
||||
PollOption(2, "ok", emptyList(), isSelected = true),
|
||||
PollOption(3, "nay", emptyList())
|
||||
),
|
||||
allowMultipleVotes = false,
|
||||
hasEnded = true,
|
||||
authorId = 1,
|
||||
messageId = 1
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package org.thoughtcrime.securesms.conversation.clicklisteners
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.horizontalGutters
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.avatar.AvatarImage
|
||||
import org.thoughtcrime.securesms.compose.ComposeDialogFragment
|
||||
import org.thoughtcrime.securesms.conversation.clicklisteners.PollVotesFragment.Companion.MAX_INITIAL_VOTER_COUNT
|
||||
import org.thoughtcrime.securesms.polls.PollOption
|
||||
import org.thoughtcrime.securesms.polls.PollRecord
|
||||
import org.thoughtcrime.securesms.util.viewModel
|
||||
|
||||
/**
|
||||
* Fragment that shows the results for a given poll.
|
||||
*/
|
||||
class PollVotesFragment : ComposeDialogFragment() {
|
||||
|
||||
companion object {
|
||||
const val MAX_INITIAL_VOTER_COUNT = 5
|
||||
const val RESULT_KEY = "PollVotesFragment"
|
||||
const val POLL_VOTES_FRAGMENT_TAG = "PollVotesFragment"
|
||||
|
||||
private val TAG = Log.tag(PollVotesFragment::class.java)
|
||||
private const val ARG_POLL_ID = "poll_id"
|
||||
|
||||
fun create(pollId: Long, fragmentManager: FragmentManager) {
|
||||
return PollVotesFragment().apply {
|
||||
arguments = bundleOf(ARG_POLL_ID to pollId)
|
||||
}.show(fragmentManager, POLL_VOTES_FRAGMENT_TAG)
|
||||
}
|
||||
}
|
||||
|
||||
private val viewModel: PollVotesViewModel by viewModel {
|
||||
PollVotesViewModel(requireArguments().getLong(ARG_POLL_ID))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setStyle(STYLE_NO_FRAME, R.style.Signal_DayNight_Dialog_FullScreen)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun DialogContent() {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
Scaffolds.Settings(
|
||||
title = stringResource(id = R.string.Poll__poll_results),
|
||||
onNavigationClick = this::dismissAllowingStateLoss,
|
||||
navigationIcon = ImageVector.vectorResource(id = R.drawable.symbol_x_24),
|
||||
navigationContentDescription = stringResource(id = R.string.Material3SearchToolbar__close)
|
||||
) { paddingValues ->
|
||||
if (state.poll == null) {
|
||||
return@Settings
|
||||
}
|
||||
Surface(modifier = Modifier.padding(paddingValues)) {
|
||||
Column {
|
||||
PollResultsScreen(
|
||||
state,
|
||||
onEndPoll = {
|
||||
setFragmentResult(RESULT_KEY, bundleOf(RESULT_KEY to true))
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PollResultsScreen(
|
||||
state: PollVotesState,
|
||||
onEndPoll: () -> Unit = {},
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalGutters(24.dp)
|
||||
) {
|
||||
item {
|
||||
Spacer(Modifier.size(16.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.Poll__question),
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
TextField(
|
||||
value = state.poll!!.question,
|
||||
onValueChange = {},
|
||||
modifier = Modifier.padding(top = 12.dp, bottom = 24.dp).fillMaxWidth(),
|
||||
colors = TextFieldDefaults.colors(
|
||||
disabledTextColor = MaterialTheme.colorScheme.onSurface,
|
||||
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
),
|
||||
enabled = false
|
||||
)
|
||||
}
|
||||
|
||||
items(state.pollOptions) { PollOptionSection(it) }
|
||||
|
||||
if (state.isAuthor && !state.poll!!.hasEnded) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onEndPoll)
|
||||
.padding(vertical = 16.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_trash_24),
|
||||
contentDescription = stringResource(R.string.Poll__end_poll),
|
||||
tint = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Text(text = stringResource(id = R.string.Poll__end_poll), modifier = Modifier.padding(start = 24.dp), style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PollOptionSection(
|
||||
option: PollOptionModel
|
||||
) {
|
||||
var expand by remember { mutableStateOf(false) }
|
||||
val context = LocalContext.current
|
||||
Row(
|
||||
modifier = Modifier.padding(vertical = 12.dp)
|
||||
) {
|
||||
Text(text = option.pollOption.text, modifier = Modifier.weight(1f), style = MaterialTheme.typography.titleSmall)
|
||||
Text(text = pluralStringResource(R.plurals.Poll__num_votes, option.voters.size, option.voters.size), style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
|
||||
if (!expand && option.voters.size > MAX_INITIAL_VOTER_COUNT) {
|
||||
option.voters.subList(0, MAX_INITIAL_VOTER_COUNT).forEach { recipient ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 12.dp)
|
||||
) {
|
||||
AvatarImage(recipient = recipient, modifier = Modifier.padding(end = 16.dp).size(40.dp))
|
||||
Text(text = if (recipient.isSelf) stringResource(id = R.string.Recipient_you) else recipient.getShortDisplayName(context))
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 12.dp).clickable { expand = true }
|
||||
) {
|
||||
Image(
|
||||
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface),
|
||||
imageVector = ImageVector.vectorResource(id = R.drawable.symbol_chevron_down_24),
|
||||
contentDescription = stringResource(R.string.Poll__see_all),
|
||||
modifier = Modifier.size(40.dp).background(color = MaterialTheme.colorScheme.surfaceVariant, shape = CircleShape).padding(8.dp)
|
||||
)
|
||||
Text(text = stringResource(R.string.Poll__see_all), modifier = Modifier.padding(start = 16.dp), style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
} else {
|
||||
option.voters.forEach { recipient ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(vertical = 12.dp)
|
||||
) {
|
||||
AvatarImage(recipient = recipient, modifier = Modifier.padding(end = 16.dp).size(40.dp))
|
||||
Text(text = if (recipient.isSelf) stringResource(id = R.string.Recipient_you) else recipient.getShortDisplayName(context))
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.size(16.dp))
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun PollResultsScreenPreview() {
|
||||
Previews.Preview {
|
||||
PollResultsScreen(
|
||||
state = PollVotesState(
|
||||
PollRecord(
|
||||
id = 1,
|
||||
question = "How do you feel about finished compose previews?",
|
||||
pollOptions = listOf(
|
||||
PollOption(1, "Yay", listOf(1, 12, 3)),
|
||||
PollOption(2, "Ok", listOf(2, 4), isSelected = true),
|
||||
PollOption(3, "Nay", emptyList())
|
||||
),
|
||||
allowMultipleVotes = false,
|
||||
hasEnded = true,
|
||||
authorId = 1,
|
||||
messageId = 1
|
||||
),
|
||||
isAuthor = true
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package org.thoughtcrime.securesms.conversation.clicklisteners
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.util.concurrent.SignalDispatchers
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.polls.PollOption
|
||||
import org.thoughtcrime.securesms.polls.PollRecord
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
||||
/**
|
||||
* View model for [PollVotesFragment] which allows you to see results for a given poll.
|
||||
*/
|
||||
class PollVotesViewModel(pollId: Long) : ViewModel() {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(PollVotesViewModel::class)
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow(PollVotesState())
|
||||
val state = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
loadPollInfo(pollId)
|
||||
}
|
||||
|
||||
private fun loadPollInfo(pollId: Long) {
|
||||
viewModelScope.launch(SignalDispatchers.IO) {
|
||||
val poll = SignalDatabase.polls.getPollFromId(pollId)!!
|
||||
_state.update {
|
||||
it.copy(
|
||||
poll = poll,
|
||||
pollOptions = poll.pollOptions.map { option ->
|
||||
PollOptionModel(
|
||||
pollOption = option,
|
||||
voters = Recipient.resolvedList(option.voterIds.map { voter -> RecipientId.from(voter) })
|
||||
)
|
||||
},
|
||||
isAuthor = poll.authorId == Recipient.self().id.toLong()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class PollVotesState(
|
||||
val poll: PollRecord? = null,
|
||||
val pollOptions: List<PollOptionModel> = emptyList(),
|
||||
val isAuthor: Boolean = false
|
||||
)
|
||||
|
||||
data class PollOptionModel(
|
||||
val pollOption: PollOption,
|
||||
val voters: List<Recipient> = emptyList()
|
||||
)
|
||||
@@ -37,6 +37,8 @@ import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionRecycler
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview
|
||||
import org.thoughtcrime.securesms.polls.PollOption
|
||||
import org.thoughtcrime.securesms.polls.PollRecord
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||
@@ -263,6 +265,8 @@ class MessageQuotesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment() {
|
||||
override fun onReportSpamLearnMoreClicked() = Unit
|
||||
override fun onMessageRequestAcceptOptionsClicked() = Unit
|
||||
override fun onItemDoubleClick(item: MultiselectPart) = Unit
|
||||
override fun onToggleVote(poll: PollRecord, pollOption: PollOption, isChecked: Boolean?) = Unit
|
||||
override fun onViewResultsClicked(pollId: Long) = Unit
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@@ -184,6 +184,7 @@ import org.thoughtcrime.securesms.conversation.ScheduledMessagesBottomSheet
|
||||
import org.thoughtcrime.securesms.conversation.ScheduledMessagesRepository
|
||||
import org.thoughtcrime.securesms.conversation.SelectedConversationModel
|
||||
import org.thoughtcrime.securesms.conversation.ShowAdminsBottomSheetDialog
|
||||
import org.thoughtcrime.securesms.conversation.clicklisteners.PollVotesFragment
|
||||
import org.thoughtcrime.securesms.conversation.colors.ChatColors
|
||||
import org.thoughtcrime.securesms.conversation.colors.Colorizer
|
||||
import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer
|
||||
@@ -282,6 +283,9 @@ import org.thoughtcrime.securesms.nicknames.NicknameActivity
|
||||
import org.thoughtcrime.securesms.notifications.v2.ConversationId
|
||||
import org.thoughtcrime.securesms.payments.preferences.PaymentsActivity
|
||||
import org.thoughtcrime.securesms.permissions.Permissions
|
||||
import org.thoughtcrime.securesms.polls.Poll
|
||||
import org.thoughtcrime.securesms.polls.PollOption
|
||||
import org.thoughtcrime.securesms.polls.PollRecord
|
||||
import org.thoughtcrime.securesms.profiles.manage.EditProfileActivity
|
||||
import org.thoughtcrime.securesms.profiles.spoofing.ReviewCardDialogFragment
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider
|
||||
@@ -333,6 +337,7 @@ import org.thoughtcrime.securesms.util.atMidnight
|
||||
import org.thoughtcrime.securesms.util.atUTC
|
||||
import org.thoughtcrime.securesms.util.doAfterNextLayout
|
||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||
import org.thoughtcrime.securesms.util.getPoll
|
||||
import org.thoughtcrime.securesms.util.getQuote
|
||||
import org.thoughtcrime.securesms.util.getRecordQuoteType
|
||||
import org.thoughtcrime.securesms.util.hasAudio
|
||||
@@ -1951,6 +1956,16 @@ class ConversationFragment :
|
||||
)
|
||||
}
|
||||
|
||||
private fun sendPoll(recipient: Recipient, poll: Poll) {
|
||||
val send = viewModel.sendPoll(recipient, poll)
|
||||
|
||||
disposables += send
|
||||
.subscribeBy(
|
||||
onComplete = { onSendComplete() },
|
||||
onError = { Log.w(TAG, "Error received during poll send!", it) }
|
||||
)
|
||||
}
|
||||
|
||||
private fun sendMessage(
|
||||
body: String = composeText.editableText.toString().trim(),
|
||||
mentions: List<Mention> = composeText.mentions,
|
||||
@@ -2554,6 +2569,21 @@ class ConversationFragment :
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleEndPoll(pollId: Long?) {
|
||||
if (pollId == null) {
|
||||
Log.w(TAG, "Unable to find poll to end $pollId")
|
||||
return
|
||||
}
|
||||
|
||||
val endPoll = viewModel.endPoll(pollId)
|
||||
|
||||
disposables += endPoll
|
||||
.subscribeBy(
|
||||
// TODO(michelle): Error state when poll terminate fails
|
||||
onError = { Log.w(TAG, "Error received during poll send!", it) }
|
||||
)
|
||||
}
|
||||
|
||||
private inner class SwipeAvailabilityProvider : ConversationItemSwipeCallback.SwipeAvailabilityProvider {
|
||||
override fun isSwipeAvailable(conversationMessage: ConversationMessage): Boolean {
|
||||
val recipient = viewModel.recipientSnapshot ?: return false
|
||||
@@ -3051,6 +3081,32 @@ class ConversationFragment :
|
||||
PlayStoreUtil.openPlayStoreOrOurApkDownloadPage(requireContext())
|
||||
}
|
||||
|
||||
override fun onViewResultsClicked(pollId: Long) {
|
||||
if (parentFragmentManager.findFragmentByTag(PollVotesFragment.POLL_VOTES_FRAGMENT_TAG) == null) {
|
||||
PollVotesFragment.create(pollId, parentFragmentManager)
|
||||
|
||||
parentFragmentManager.setFragmentResultListener(PollVotesFragment.RESULT_KEY, requireActivity()) { _, bundle ->
|
||||
val shouldEndPoll = bundle.getBoolean(PollVotesFragment.RESULT_KEY, false)
|
||||
if (shouldEndPoll) {
|
||||
handleEndPoll(pollId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewPollClicked(messageId: Long) {
|
||||
disposables += viewModel
|
||||
.moveToMessage(messageId)
|
||||
.subscribeBy(
|
||||
onSuccess = { moveToPosition(it) },
|
||||
onError = { Toast.makeText(requireContext(), R.string.Poll__unable_poll, Toast.LENGTH_LONG).show() }
|
||||
)
|
||||
}
|
||||
|
||||
override fun onToggleVote(poll: PollRecord, pollOption: PollOption, isChecked: Boolean) {
|
||||
viewModel.toggleVote(poll, pollOption, isChecked)
|
||||
}
|
||||
|
||||
override fun onJoinGroupCallClicked() {
|
||||
val activity = activity ?: return
|
||||
val recipient = viewModel.recipientSnapshot ?: return
|
||||
@@ -3743,6 +3799,7 @@ class ConversationFragment :
|
||||
ConversationReactionOverlay.Action.PAYMENT_DETAILS -> handleViewPaymentDetails(conversationMessage)
|
||||
ConversationReactionOverlay.Action.VIEW_INFO -> handleDisplayDetails(conversationMessage)
|
||||
ConversationReactionOverlay.Action.DELETE -> handleDeleteMessages(conversationMessage.multiselectCollection.toSet())
|
||||
ConversationReactionOverlay.Action.END_POLL -> handleEndPoll(conversationMessage.messageRecord.getPoll()?.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4385,6 +4442,12 @@ class ConversationFragment :
|
||||
toast(R.string.AttachmentManager_cant_open_media_selection, Toast.LENGTH_LONG)
|
||||
}
|
||||
}
|
||||
AttachmentKeyboardButton.POLL -> {
|
||||
CreatePollFragment.show(childFragmentManager)
|
||||
childFragmentManager.setFragmentResultListener(CreatePollFragment.REQUEST_KEY, requireActivity()) { _, bundle ->
|
||||
sendPoll(recipient, Poll.fromBundle(bundle))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (media != null) {
|
||||
conversationActivityResultContracts.launchMediaEditor(listOf(media), recipient.id, composeText.textTrimmed)
|
||||
|
||||
@@ -61,6 +61,8 @@ import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.ReactionRecord
|
||||
import org.thoughtcrime.securesms.database.model.StickerRecord
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.PollTerminate
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.jobs.MultiDeviceViewOnceOpenJob
|
||||
import org.thoughtcrime.securesms.keyboard.KeyboardUtil
|
||||
@@ -71,6 +73,7 @@ import org.thoughtcrime.securesms.mms.PartAuthority
|
||||
import org.thoughtcrime.securesms.mms.QuoteModel
|
||||
import org.thoughtcrime.securesms.mms.Slide
|
||||
import org.thoughtcrime.securesms.mms.SlideDeck
|
||||
import org.thoughtcrime.securesms.polls.Poll
|
||||
import org.thoughtcrime.securesms.profiles.spoofing.ReviewRecipient
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
@@ -82,9 +85,11 @@ import org.thoughtcrime.securesms.util.MediaUtil
|
||||
import org.thoughtcrime.securesms.util.MessageUtil
|
||||
import org.thoughtcrime.securesms.util.SignalLocalMetrics
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.thoughtcrime.securesms.util.getPoll
|
||||
import org.thoughtcrime.securesms.util.hasLinkPreview
|
||||
import org.thoughtcrime.securesms.util.hasSharedContact
|
||||
import org.thoughtcrime.securesms.util.hasTextSlide
|
||||
import org.thoughtcrime.securesms.util.isPoll
|
||||
import org.thoughtcrime.securesms.util.isViewOnceMessage
|
||||
import org.thoughtcrime.securesms.util.requireTextSlide
|
||||
import java.io.IOException
|
||||
@@ -164,6 +169,59 @@ class ConversationRepository(
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun sendPoll(threadRecipient: Recipient, poll: Poll): Completable {
|
||||
return Completable.create { emitter ->
|
||||
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(threadRecipient)
|
||||
val message = OutgoingMessage.pollMessage(
|
||||
threadRecipient = threadRecipient,
|
||||
sentTimeMillis = System.currentTimeMillis(),
|
||||
expiresIn = threadRecipient.expiresInSeconds.seconds.inWholeMilliseconds,
|
||||
poll = poll.copy(authorId = Recipient.self().id.toLong()),
|
||||
question = poll.question
|
||||
)
|
||||
|
||||
Log.i(TAG, "Sending poll create to " + message.threadRecipient.id + ", thread: " + threadId)
|
||||
|
||||
MessageSender.sendPollAction(
|
||||
AppDependencies.application,
|
||||
message,
|
||||
threadId,
|
||||
MessageSender.SendType.SIGNAL,
|
||||
null,
|
||||
{ emitter.onComplete() }
|
||||
)
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun endPoll(pollId: Long): Completable {
|
||||
return Completable.create { emitter ->
|
||||
val poll = SignalDatabase.polls.getPollFromId(pollId)
|
||||
val messageRecord = SignalDatabase.messages.getMessageRecord(poll!!.messageId)
|
||||
val threadRecipient = SignalDatabase.threads.getRecipientForThreadId(messageRecord.threadId)!!
|
||||
val pollSentTimestamp = messageRecord.dateSent
|
||||
|
||||
val message = OutgoingMessage.pollTerminateMessage(
|
||||
threadRecipient = threadRecipient,
|
||||
sentTimeMillis = System.currentTimeMillis(),
|
||||
expiresIn = threadRecipient.expiresInSeconds.seconds.inWholeMilliseconds,
|
||||
messageExtras = MessageExtras(pollTerminate = PollTerminate(question = poll.question, messageId = poll.messageId, targetTimestamp = pollSentTimestamp))
|
||||
)
|
||||
|
||||
Log.i(TAG, "Sending poll terminate to " + message.threadRecipient.id + ", thread: " + messageRecord.threadId)
|
||||
|
||||
MessageSender.sendPollAction(
|
||||
AppDependencies.application,
|
||||
message,
|
||||
messageRecord.threadId,
|
||||
MessageSender.SendType.SIGNAL,
|
||||
null
|
||||
) {
|
||||
emitter.onComplete()
|
||||
}
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun sendMessage(
|
||||
threadId: Long,
|
||||
threadRecipient: Recipient,
|
||||
@@ -271,6 +329,13 @@ class ConversationRepository(
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun getMessagePosition(threadId: Long, messageId: Long): Single<Int> {
|
||||
return Single.fromCallable {
|
||||
val message = SignalDatabase.messages.getMessageRecord(messageId)
|
||||
SignalDatabase.messages.getMessagePositionInConversation(threadId, message.dateReceived, message.fromRecipient.id)
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun getMessagePosition(threadId: Long, dateReceived: Long, authorId: RecipientId): Single<Int> {
|
||||
return Single.fromCallable {
|
||||
SignalDatabase.messages.getMessagePositionInConversation(threadId, dateReceived, authorId)
|
||||
@@ -497,6 +562,11 @@ class ConversationRepository(
|
||||
}
|
||||
|
||||
slideDeck to conversationMessage.getDisplayBody(context)
|
||||
} else if (messageRecord.isPoll()) {
|
||||
val poll = messageRecord.getPoll()!!
|
||||
val slideDeck = SlideDeck()
|
||||
|
||||
slideDeck to SpannableStringBuilder().append(context.getString(R.string.Poll__poll_question, poll.question))
|
||||
} else {
|
||||
var slideDeck = if (messageRecord.isMms) {
|
||||
(messageRecord as MmsMessageRecord).slideDeck
|
||||
|
||||
@@ -42,6 +42,7 @@ import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.rx3.asFlow
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.orNull
|
||||
import org.signal.paging.ProxyPagingController
|
||||
import org.thoughtcrime.securesms.banner.Banner
|
||||
@@ -60,6 +61,7 @@ import org.thoughtcrime.securesms.conversation.v2.data.ConversationElementKey
|
||||
import org.thoughtcrime.securesms.conversation.v2.items.ChatColorsDrawable
|
||||
import org.thoughtcrime.securesms.database.DatabaseObserver
|
||||
import org.thoughtcrime.securesms.database.MessageTable
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.recipients
|
||||
import org.thoughtcrime.securesms.database.model.GroupRecord
|
||||
import org.thoughtcrime.securesms.database.model.IdentityRecord
|
||||
@@ -72,6 +74,7 @@ import org.thoughtcrime.securesms.database.model.StickerRecord
|
||||
import org.thoughtcrime.securesms.database.model.StoryViewState
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.jobs.PollVoteJob
|
||||
import org.thoughtcrime.securesms.jobs.RetrieveProfileJob
|
||||
import org.thoughtcrime.securesms.keyboard.KeyboardUtil
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
@@ -81,6 +84,9 @@ import org.thoughtcrime.securesms.messagerequests.MessageRequestState
|
||||
import org.thoughtcrime.securesms.mms.QuoteModel
|
||||
import org.thoughtcrime.securesms.mms.Slide
|
||||
import org.thoughtcrime.securesms.mms.SlideDeck
|
||||
import org.thoughtcrime.securesms.polls.Poll
|
||||
import org.thoughtcrime.securesms.polls.PollOption
|
||||
import org.thoughtcrime.securesms.polls.PollRecord
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.sms.MessageSender
|
||||
@@ -107,6 +113,10 @@ class ConversationViewModel(
|
||||
private val scheduledMessagesRepository: ScheduledMessagesRepository
|
||||
) : ViewModel() {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(ConversationViewModel::class.java)
|
||||
}
|
||||
|
||||
private val disposables = CompositeDisposable()
|
||||
|
||||
private val scrollButtonStateStore = RxStore(ConversationScrollButtonState()).addTo(disposables)
|
||||
@@ -415,6 +425,11 @@ class ConversationViewModel(
|
||||
return repository.getNextMentionPosition(threadId)
|
||||
}
|
||||
|
||||
fun moveToMessage(messageId: Long): Single<Int> {
|
||||
return repository.getMessagePosition(threadId, messageId)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
fun moveToMessage(dateReceived: Long, author: RecipientId): Single<Int> {
|
||||
return repository.getMessagePosition(threadId, dateReceived, author)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
@@ -494,6 +509,18 @@ class ConversationViewModel(
|
||||
return reactions.firstOrNull { it.author == Recipient.self().id }
|
||||
}
|
||||
|
||||
fun sendPoll(threadRecipient: Recipient, poll: Poll): Completable {
|
||||
return repository
|
||||
.sendPoll(threadRecipient, poll)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
fun endPoll(pollId: Long): Completable {
|
||||
return repository
|
||||
.endPoll(pollId)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
fun sendMessage(
|
||||
metricId: String?,
|
||||
threadRecipient: Recipient,
|
||||
@@ -622,6 +649,27 @@ class ConversationViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleVote(poll: PollRecord, pollOption: PollOption, isChecked: Boolean) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val voteCount = if (isChecked) {
|
||||
SignalDatabase.polls.insertVote(poll, pollOption)
|
||||
} else {
|
||||
SignalDatabase.polls.removeVote(poll, pollOption)
|
||||
}
|
||||
val pollVoteJob = PollVoteJob.create(
|
||||
messageId = poll.messageId,
|
||||
voteCount = voteCount,
|
||||
isRemoval = !isChecked
|
||||
)
|
||||
|
||||
if (pollVoteJob != null) {
|
||||
AppDependencies.jobManager.add(pollVoteJob)
|
||||
} else {
|
||||
Log.w(TAG, "Unable to create poll vote job, ignoring.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class BackPressedState(
|
||||
val isReactionDelegateShowing: Boolean = false,
|
||||
val isSearchRequested: Boolean = false
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
package org.thoughtcrime.securesms.conversation.v2
|
||||
|
||||
import android.app.Dialog
|
||||
import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Dividers
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Rows
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.copied.androidx.compose.DragAndDropEvent
|
||||
import org.signal.core.ui.compose.copied.androidx.compose.DraggableItem
|
||||
import org.signal.core.ui.compose.copied.androidx.compose.dragContainer
|
||||
import org.signal.core.ui.compose.copied.androidx.compose.rememberDragDropState
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.compose.ComposeDialogFragment
|
||||
import org.thoughtcrime.securesms.polls.Poll
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* Fragment to create a poll
|
||||
*/
|
||||
class CreatePollFragment : ComposeDialogFragment() {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(CreatePollFragment::class)
|
||||
|
||||
const val MAX_CHARACTER_LENGTH = 100
|
||||
const val MAX_OPTIONS = 10
|
||||
const val MIN_OPTIONS = 2
|
||||
const val REQUEST_KEY = "CreatePollFragment"
|
||||
|
||||
fun show(fragmentManager: FragmentManager) {
|
||||
return CreatePollFragment().show(fragmentManager, null)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setStyle(STYLE_NO_FRAME, R.style.Signal_DayNight_Dialog_FullScreen_Poll) // TODO(michelle): Finalize animation
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val dialog = super.onCreateDialog(savedInstanceState)
|
||||
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)
|
||||
return dialog
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun DialogContent() {
|
||||
Scaffolds.Settings(
|
||||
title = stringResource(R.string.CreatePollFragment__new_poll),
|
||||
onNavigationClick = {
|
||||
dismissAllowingStateLoss()
|
||||
},
|
||||
navigationIcon = ImageVector.vectorResource(R.drawable.symbol_x_24),
|
||||
navigationContentDescription = stringResource(R.string.Material3SearchToolbar__close)
|
||||
) { paddingValues ->
|
||||
CreatePollScreen(
|
||||
paddingValues = paddingValues,
|
||||
onSend = { question, allowMultiple, options ->
|
||||
ViewUtil.hideKeyboard(requireContext(), requireView())
|
||||
setFragmentResult(REQUEST_KEY, Poll(question, allowMultiple, options).toBundle())
|
||||
dismissAllowingStateLoss()
|
||||
},
|
||||
onShowErrorSnackbar = { hasQuestion, hasOptions ->
|
||||
if (!hasQuestion && !hasOptions) {
|
||||
Snackbar.make(requireView(), R.string.CreatePollFragment__add_question_option, Snackbar.LENGTH_LONG).show()
|
||||
} else if (!hasQuestion) {
|
||||
Snackbar.make(requireView(), R.string.CreatePollFragment__add_question, Snackbar.LENGTH_LONG).show()
|
||||
} else {
|
||||
Snackbar.make(requireView(), R.string.CreatePollFragment__add_option, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
@Composable
|
||||
private fun CreatePollScreen(
|
||||
paddingValues: PaddingValues,
|
||||
onSend: (String, Boolean, List<String>) -> Unit = { _, _, _ -> },
|
||||
onShowErrorSnackbar: (Boolean, Boolean) -> Unit = { _, _ -> }
|
||||
) {
|
||||
// Parts of poll
|
||||
var question by remember { mutableStateOf("") }
|
||||
val options = remember { mutableStateListOf("", "") }
|
||||
var allowMultiple by remember { mutableStateOf(false) }
|
||||
|
||||
var hasMinimumOptions by remember { mutableStateOf(false) }
|
||||
val isEnabled = question.isNotBlank() && hasMinimumOptions
|
||||
var focusedOption by remember { mutableStateOf(-1) }
|
||||
|
||||
// Drag and drop
|
||||
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
||||
val isRtl = ViewUtil.isRtl(LocalContext.current)
|
||||
val listState = rememberLazyListState()
|
||||
val dragDropState = rememberDragDropState(listState, includeHeader = true, includeFooter = true, onEvent = { event ->
|
||||
when (event) {
|
||||
is DragAndDropEvent.OnItemMove -> {
|
||||
val oldIndex = options[event.fromIndex]
|
||||
options[event.fromIndex] = options[event.toIndex]
|
||||
options[event.toIndex] = oldIndex
|
||||
}
|
||||
is DragAndDropEvent.OnItemDrop, is DragAndDropEvent.OnDragCancel -> Unit
|
||||
}
|
||||
})
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { options.toList() }
|
||||
.debounce(100.milliseconds)
|
||||
.collect { currentOptions ->
|
||||
val count = currentOptions.count { it.isNotBlank() }
|
||||
if (count == currentOptions.size && currentOptions.size < CreatePollFragment.MAX_OPTIONS) {
|
||||
options.add("")
|
||||
}
|
||||
hasMinimumOptions = count >= CreatePollFragment.MIN_OPTIONS
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(focusedOption) {
|
||||
val count = options.count { it.isNotBlank() }
|
||||
if (count >= CreatePollFragment.MIN_OPTIONS) {
|
||||
if (options.removeIf { it.isEmpty() }) {
|
||||
options.add("")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.imePadding()
|
||||
.dragContainer(
|
||||
dragDropState = dragDropState,
|
||||
leftDpOffset = if (isRtl) 0.dp else screenWidth - 56.dp,
|
||||
rightDpOffset = if (isRtl) 56.dp else screenWidth
|
||||
),
|
||||
state = listState
|
||||
) {
|
||||
item {
|
||||
DraggableItem(dragDropState, 0) {
|
||||
Text(
|
||||
text = stringResource(R.string.CreatePollFragment__question),
|
||||
modifier = Modifier.padding(vertical = 12.dp, horizontal = 24.dp),
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
|
||||
TextField(
|
||||
value = question,
|
||||
label = { Text(text = stringResource(R.string.CreatePollFragment__ask_a_question)) },
|
||||
onValueChange = { question = it.substring(0, minOf(it.length, CreatePollFragment.MAX_CHARACTER_LENGTH)) },
|
||||
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
|
||||
colors = TextFieldDefaults.colors(
|
||||
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.onFocusChanged { focusState -> if (focusState.isFocused) focusedOption = -1 }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.size(32.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.CreatePollFragment__options),
|
||||
modifier = Modifier.padding(vertical = 12.dp, horizontal = 24.dp),
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
itemsIndexed(options) { index, option ->
|
||||
DraggableItem(dragDropState, 1 + index) {
|
||||
Box(modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 16.dp)) {
|
||||
TextField(
|
||||
value = option,
|
||||
label = { Text(text = stringResource(R.string.CreatePollFragment__option_n, index + 1)) },
|
||||
onValueChange = { options[index] = it.substring(0, minOf(it.length, CreatePollFragment.MAX_CHARACTER_LENGTH)) },
|
||||
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences),
|
||||
colors = TextFieldDefaults.colors(
|
||||
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onFocusChanged { focusState -> if (focusState.isFocused) focusedOption = index },
|
||||
trailingIcon = {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.drag_handle),
|
||||
contentDescription = stringResource(R.string.CreatePollFragment__drag_handle),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
DraggableItem(dragDropState, 1 + options.size) {
|
||||
Dividers.Default()
|
||||
Rows.ToggleRow(checked = allowMultiple, text = stringResource(R.string.CreatePollFragment__allow_multiple_votes), onCheckChanged = { allowMultiple = it })
|
||||
Spacer(modifier = Modifier.size(60.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Buttons.MediumTonal(
|
||||
colors = ButtonDefaults.filledTonalButtonColors(
|
||||
contentColor = if (isEnabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f),
|
||||
containerColor = if (isEnabled) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
|
||||
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||
),
|
||||
onClick = {
|
||||
if (isEnabled) {
|
||||
onSend(question, allowMultiple, options.filter { it.isNotBlank() })
|
||||
} else {
|
||||
onShowErrorSnackbar(question.isNotBlank(), hasMinimumOptions)
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(bottom = 16.dp, end = 24.dp)
|
||||
.imePadding()
|
||||
) {
|
||||
Text(text = stringResource(R.string.conversation_activity__send))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
fun CreatePollPreview() {
|
||||
Previews.Preview {
|
||||
CreatePollScreen(PaddingValues(0.dp))
|
||||
}
|
||||
}
|
||||
@@ -17,9 +17,11 @@ import org.thoughtcrime.securesms.database.model.ReactionRecord
|
||||
import org.thoughtcrime.securesms.database.model.withAttachments
|
||||
import org.thoughtcrime.securesms.database.model.withCall
|
||||
import org.thoughtcrime.securesms.database.model.withPayment
|
||||
import org.thoughtcrime.securesms.database.model.withPoll
|
||||
import org.thoughtcrime.securesms.database.model.withReactions
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.payments.Payment
|
||||
import org.thoughtcrime.securesms.polls.PollRecord
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.whispersystems.signalservice.api.util.UuidUtil
|
||||
@@ -99,6 +101,10 @@ object MessageDataFetcher {
|
||||
}
|
||||
}
|
||||
|
||||
val pollsFuture = executor.submitTimed {
|
||||
SignalDatabase.polls.getPollsForMessages(messageIds)
|
||||
}
|
||||
|
||||
val mentionsResult = mentionsFuture.get()
|
||||
val hasBeenQuotedResult = hasBeenQuotedFuture.get()
|
||||
val reactionsResult = reactionsFuture.get()
|
||||
@@ -106,6 +112,7 @@ object MessageDataFetcher {
|
||||
val paymentsResult = paymentsFuture.get()
|
||||
val callsResult = callsFuture.get()
|
||||
val recipientsResult = recipientsFuture.get()
|
||||
val pollsResult = pollsFuture.get()
|
||||
|
||||
val wallTimeMs = (System.nanoTime() - startTimeNanos).nanoseconds.toDouble(DurationUnit.MILLISECONDS)
|
||||
|
||||
@@ -119,6 +126,7 @@ object MessageDataFetcher {
|
||||
attachments = attachmentsResult.result,
|
||||
payments = paymentsResult.result,
|
||||
calls = callsResult.result,
|
||||
polls = pollsResult.result,
|
||||
timeLog = "mentions: ${mentionsResult.duration}, is-quoted: ${hasBeenQuotedResult.duration}, reactions: ${reactionsResult.duration}, attachments: ${attachmentsResult.duration}, payments: ${paymentsResult.duration}, calls: ${callsResult.duration} >> cpuTime: ${cpuTimeMs.roundedString(2)}, wallTime: ${wallTimeMs.roundedString(2)}"
|
||||
)
|
||||
}
|
||||
@@ -157,6 +165,10 @@ object MessageDataFetcher {
|
||||
output.withCall(it)
|
||||
} ?: output
|
||||
|
||||
output = data.polls[id]?.let {
|
||||
output.withPoll(it)
|
||||
} ?: output
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
@@ -187,6 +199,7 @@ object MessageDataFetcher {
|
||||
val attachments: Map<Long, List<DatabaseAttachment>>,
|
||||
val payments: Map<Long, Payment>,
|
||||
val calls: Map<Long, CallTable.Call>,
|
||||
val polls: Map<Long, PollRecord>,
|
||||
val timeLog: String
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import org.thoughtcrime.securesms.mediasend.Media
|
||||
import org.thoughtcrime.securesms.permissions.PermissionCompat
|
||||
import org.thoughtcrime.securesms.permissions.Permissions
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import java.util.function.Predicate
|
||||
|
||||
/**
|
||||
@@ -48,6 +49,7 @@ class AttachmentKeyboardFragment : LoggingFragment(R.layout.attachment_keyboard_
|
||||
|
||||
private val lifecycleDisposable = LifecycleDisposable()
|
||||
private val removePaymentFilter: Predicate<AttachmentKeyboardButton> = Predicate { button -> button != AttachmentKeyboardButton.PAYMENT }
|
||||
private val removePollFilter: Predicate<AttachmentKeyboardButton> = Predicate { button -> button != AttachmentKeyboardButton.POLL }
|
||||
|
||||
@Suppress("ReplaceGetOrSet")
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
@@ -72,7 +74,7 @@ class AttachmentKeyboardFragment : LoggingFragment(R.layout.attachment_keyboard_
|
||||
|
||||
val snapshot = conversationViewModel.recipientSnapshot
|
||||
if (snapshot != null) {
|
||||
updatePaymentsAvailable(snapshot)
|
||||
updateButtonsAvailable(snapshot)
|
||||
}
|
||||
|
||||
conversationViewModel
|
||||
@@ -80,7 +82,7 @@ class AttachmentKeyboardFragment : LoggingFragment(R.layout.attachment_keyboard_
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribeBy {
|
||||
attachmentKeyboardView.setWallpaperEnabled(it.hasWallpaper)
|
||||
updatePaymentsAvailable(it)
|
||||
updateButtonsAvailable(it)
|
||||
}
|
||||
.addTo(lifecycleDisposable)
|
||||
}
|
||||
@@ -126,16 +128,19 @@ class AttachmentKeyboardFragment : LoggingFragment(R.layout.attachment_keyboard_
|
||||
.execute()
|
||||
}
|
||||
|
||||
private fun updatePaymentsAvailable(recipient: Recipient) {
|
||||
private fun updateButtonsAvailable(recipient: Recipient) {
|
||||
val paymentsValues = SignalStore.payments
|
||||
if (paymentsValues.paymentsAvailability.isSendAllowed &&
|
||||
!recipient.isSelf &&
|
||||
!recipient.isGroup &&
|
||||
recipient.isRegistered
|
||||
) {
|
||||
attachmentKeyboardView.filterAttachmentKeyboardButtons(null)
|
||||
} else {
|
||||
val isPaymentsAvailable = paymentsValues.paymentsAvailability.isSendAllowed && !recipient.isSelf && !recipient.isGroup && recipient.isRegistered
|
||||
val isPollsAvailable = recipient.isPushV2Group && RemoteConfig.polls
|
||||
|
||||
if (!isPaymentsAvailable && !isPollsAvailable) {
|
||||
attachmentKeyboardView.filterAttachmentKeyboardButtons(removePaymentFilter.and(removePollFilter))
|
||||
} else if (!isPaymentsAvailable) {
|
||||
attachmentKeyboardView.filterAttachmentKeyboardButtons(removePaymentFilter)
|
||||
} else if (!isPollsAvailable) (
|
||||
attachmentKeyboardView.filterAttachmentKeyboardButtons(removePollFilter)
|
||||
) else {
|
||||
attachmentKeyboardView.filterAttachmentKeyboardButtons(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -680,12 +680,16 @@ public final class ConversationListItem extends ConstraintLayout implements Bind
|
||||
} else {
|
||||
return emphasisAdded(context, context.getString(R.string.ThreadRecord_safety_number_changed), defaultTint);
|
||||
}
|
||||
} else if (MessageTypes.isPollTerminate(thread.getType())) {
|
||||
return emphasisAdded(context, thread.getBody(), Glyph.POLL, defaultTint);
|
||||
} else {
|
||||
ThreadTable.Extra extra = thread.getExtra();
|
||||
if (extra != null && extra.isViewOnce()) {
|
||||
return emphasisAdded(context, getViewOnceDescription(context, thread.getContentType()), defaultTint);
|
||||
} else if (extra != null && extra.isRemoteDelete()) {
|
||||
return emphasisAdded(context, context.getString(thread.isOutgoing() ? R.string.ThreadRecord_you_deleted_this_message : R.string.ThreadRecord_this_message_was_deleted), defaultTint);
|
||||
} else if (extra != null && extra.isPoll()) {
|
||||
return emphasisAdded(context, thread.getBody(), Glyph.POLL, defaultTint);
|
||||
} else {
|
||||
SpannableStringBuilder sourceBody = new SpannableStringBuilder(thread.getBody());
|
||||
MessageStyler.style(thread.getDate(), thread.getBodyRanges(), sourceBody);
|
||||
|
||||
@@ -80,6 +80,7 @@ import org.thoughtcrime.securesms.database.SignalDatabase.Companion.groupReceipt
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.groups
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.mentions
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.messages
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.polls
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.reactions
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.recipients
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase.Companion.storySends
|
||||
@@ -110,6 +111,7 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GroupCallUpdateDetails
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExportState
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.PollTerminate
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.ProfileChangeDetails
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.SessionSwitchoverEvent
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.ThreadMergeEvent
|
||||
@@ -127,6 +129,8 @@ import org.thoughtcrime.securesms.mms.OutgoingMessage
|
||||
import org.thoughtcrime.securesms.mms.QuoteModel
|
||||
import org.thoughtcrime.securesms.mms.SlideDeck
|
||||
import org.thoughtcrime.securesms.notifications.v2.DefaultMessageNotifier.StickyThread
|
||||
import org.thoughtcrime.securesms.polls.Poll
|
||||
import org.thoughtcrime.securesms.polls.PollRecord
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.revealable.ViewOnceExpirationInfo
|
||||
@@ -211,6 +215,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
const val ORIGINAL_MESSAGE_ID = "original_message_id"
|
||||
const val REVISION_NUMBER = "revision_number"
|
||||
const val MESSAGE_EXTRAS = "message_extras"
|
||||
const val VOTES_UNREAD = "votes_unread"
|
||||
const val VOTES_LAST_SEEN = "votes_last_seen"
|
||||
|
||||
const val QUOTE_NOT_PRESENT_ID = 0L
|
||||
const val QUOTE_TARGET_MISSING_ID = -1L
|
||||
@@ -273,7 +279,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
$ORIGINAL_MESSAGE_ID INTEGER DEFAULT NULL REFERENCES $TABLE_NAME ($ID) ON DELETE CASCADE,
|
||||
$REVISION_NUMBER INTEGER DEFAULT 0,
|
||||
$MESSAGE_EXTRAS BLOB DEFAULT NULL,
|
||||
$EXPIRE_TIMER_VERSION INTEGER DEFAULT 1 NOT NULL
|
||||
$EXPIRE_TIMER_VERSION INTEGER DEFAULT 1 NOT NULL,
|
||||
$VOTES_UNREAD INTEGER DEFAULT 0,
|
||||
$VOTES_LAST_SEEN INTEGER DEFAULT 0
|
||||
)
|
||||
"""
|
||||
|
||||
@@ -303,7 +311,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
// This index is created specifically for getting the number of messages in a thread and therefore needs to be kept in sync with that query
|
||||
"CREATE INDEX IF NOT EXISTS $INDEX_THREAD_COUNT ON $TABLE_NAME ($THREAD_ID) WHERE $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1 AND $LATEST_REVISION_ID IS NULL",
|
||||
// This index is created specifically for getting the number of unread messages in a thread and therefore needs to be kept in sync with that query
|
||||
"CREATE INDEX IF NOT EXISTS $INDEX_THREAD_UNREAD_COUNT ON $TABLE_NAME ($THREAD_ID) WHERE $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1 AND $ORIGINAL_MESSAGE_ID IS NULL AND $READ = 0"
|
||||
"CREATE INDEX IF NOT EXISTS $INDEX_THREAD_UNREAD_COUNT ON $TABLE_NAME ($THREAD_ID) WHERE $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1 AND $ORIGINAL_MESSAGE_ID IS NULL AND $READ = 0",
|
||||
"CREATE INDEX IF NOT EXISTS message_votes_unread_index ON $TABLE_NAME ($VOTES_UNREAD)"
|
||||
)
|
||||
|
||||
private val MMS_PROJECTION_BASE = arrayOf(
|
||||
@@ -356,7 +365,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
LATEST_REVISION_ID,
|
||||
ORIGINAL_MESSAGE_ID,
|
||||
REVISION_NUMBER,
|
||||
MESSAGE_EXTRAS
|
||||
MESSAGE_EXTRAS,
|
||||
VOTES_UNREAD,
|
||||
VOTES_LAST_SEEN
|
||||
)
|
||||
|
||||
private val MMS_PROJECTION: Array<String> = MMS_PROJECTION_BASE + "NULL AS ${AttachmentTable.ATTACHMENT_JSON_ALIAS}"
|
||||
@@ -2211,9 +2222,11 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
reactions.deleteReactions(MessageId(messageId))
|
||||
deleteGroupStoryReplies(messageId)
|
||||
disassociateStoryQuotes(messageId)
|
||||
disassociatePollFromPollTerminate(polls.getPollTerminateMessageId(messageId))
|
||||
|
||||
val threadId = getThreadIdForMessage(messageId)
|
||||
threads.update(threadId, false)
|
||||
notifyConversationListeners(threadId)
|
||||
}
|
||||
|
||||
OptimizeMessageSearchIndexJob.enqueue()
|
||||
@@ -2303,7 +2316,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
.update(TABLE_NAME)
|
||||
.values(
|
||||
NOTIFIED to 1,
|
||||
REACTIONS_LAST_SEEN to System.currentTimeMillis()
|
||||
REACTIONS_LAST_SEEN to System.currentTimeMillis(),
|
||||
VOTES_LAST_SEEN to System.currentTimeMillis()
|
||||
)
|
||||
.where("$ID = ? OR $ORIGINAL_MESSAGE_ID = ? OR $LATEST_REVISION_ID = ?", id, id, id)
|
||||
.run()
|
||||
@@ -2351,6 +2365,10 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
(
|
||||
$REACTIONS_UNREAD = 1 AND
|
||||
($outgoingTypeClause)
|
||||
) OR
|
||||
(
|
||||
$VOTES_UNREAD = 1 AND
|
||||
($outgoingTypeClause)
|
||||
)
|
||||
)
|
||||
"""
|
||||
@@ -2424,7 +2442,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
}
|
||||
|
||||
fun setAllMessagesRead(): List<MarkedMessageInfo> {
|
||||
return setMessagesRead("$STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND ($READ = 0 OR ($REACTIONS_UNREAD = 1 AND ($outgoingTypeClause)))", null)
|
||||
return setMessagesRead("$STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND ($READ = 0 OR ($REACTIONS_UNREAD = 1 AND ($outgoingTypeClause)) OR ($VOTES_UNREAD = 1 AND ($outgoingTypeClause)))", null)
|
||||
}
|
||||
|
||||
private fun setMessagesRead(where: String, arguments: Array<String>?): List<MarkedMessageInfo> {
|
||||
@@ -2432,7 +2450,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
return writableDatabase.rawQuery(
|
||||
"""
|
||||
UPDATE $TABLE_NAME INDEXED BY $INDEX_THREAD_STORY_SCHEDULED_DATE_LATEST_REVISION_ID
|
||||
SET $READ = 1, $REACTIONS_UNREAD = 0, $REACTIONS_LAST_SEEN = ${System.currentTimeMillis()}
|
||||
SET $READ = 1, $REACTIONS_UNREAD = 0, $REACTIONS_LAST_SEEN = ${System.currentTimeMillis()}, $VOTES_UNREAD = 0, $VOTES_LAST_SEEN = ${System.currentTimeMillis()}
|
||||
WHERE $where
|
||||
RETURNING $ID, $FROM_RECIPIENT_ID, $DATE_SENT, $DATE_RECEIVED, $TYPE, $EXPIRES_IN, $EXPIRE_STARTED, $THREAD_ID, $STORY_TYPE
|
||||
""",
|
||||
@@ -2526,6 +2544,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
fun getOutgoingMessage(messageId: Long): OutgoingMessage {
|
||||
return rawQueryWithAttachments(RAW_ID_WHERE, arrayOf(messageId.toString())).readToSingleObject { cursor ->
|
||||
val associatedAttachments = attachments.getAttachmentsForMessage(messageId)
|
||||
val associatedPoll = polls.getPollForOutgoingMessage(messageId)
|
||||
val mentions = mentions.getMentionsForMessage(messageId)
|
||||
val outboxType = cursor.requireLong(TYPE)
|
||||
val body = cursor.requireString(BODY)
|
||||
@@ -2655,6 +2674,20 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
sentTimeMillis = timestamp,
|
||||
expiresIn = expiresIn
|
||||
)
|
||||
} else if (associatedPoll != null) {
|
||||
OutgoingMessage.pollMessage(
|
||||
threadRecipient = threadRecipient,
|
||||
sentTimeMillis = timestamp,
|
||||
expiresIn = expiresIn,
|
||||
poll = associatedPoll
|
||||
)
|
||||
} else if (MessageTypes.isPollTerminate(outboxType) && messageExtras != null) {
|
||||
OutgoingMessage.pollTerminateMessage(
|
||||
threadRecipient = threadRecipient,
|
||||
sentTimeMillis = timestamp,
|
||||
expiresIn = expiresIn,
|
||||
messageExtras = messageExtras
|
||||
)
|
||||
} else {
|
||||
val giftBadge: GiftBadge? = if (body != null && MessageTypes.isGiftBadge(outboxType)) {
|
||||
GiftBadge.ADAPTER.decode(Base64.decode(body))
|
||||
@@ -2806,7 +2839,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
contentValues = contentValues,
|
||||
insertListener = null,
|
||||
updateThread = retrieved.storyType === StoryType.NONE && !silent,
|
||||
unarchive = true
|
||||
unarchive = true,
|
||||
poll = retrieved.poll,
|
||||
pollTerminate = retrieved.messageExtras?.pollTerminate
|
||||
)
|
||||
|
||||
if (messageId < 0) {
|
||||
@@ -3128,6 +3163,14 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
hasSpecialType = true
|
||||
}
|
||||
|
||||
if (message.messageExtras?.pollTerminate != null) {
|
||||
if (hasSpecialType) {
|
||||
throw MmsException("Cannot insert message with multiple special types.")
|
||||
}
|
||||
type = type or MessageTypes.SPECIAL_TYPE_POLL_TERMINATE
|
||||
hasSpecialType = true
|
||||
}
|
||||
|
||||
val earlyDeliveryReceipts: Map<RecipientId, Receipt> = earlyDeliveryReceiptCache.remove(message.sentTimeMillis)
|
||||
|
||||
if (earlyDeliveryReceipts.isNotEmpty()) {
|
||||
@@ -3241,7 +3284,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
contentValues = contentValues,
|
||||
insertListener = insertListener,
|
||||
updateThread = false,
|
||||
unarchive = false
|
||||
unarchive = false,
|
||||
poll = message.poll,
|
||||
pollTerminate = message.messageExtras?.pollTerminate
|
||||
)
|
||||
|
||||
if (messageId < 0) {
|
||||
@@ -3348,7 +3393,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
contentValues: ContentValues,
|
||||
insertListener: InsertListener?,
|
||||
updateThread: Boolean,
|
||||
unarchive: Boolean
|
||||
unarchive: Boolean,
|
||||
poll: Poll? = null,
|
||||
pollTerminate: PollTerminate? = null
|
||||
): kotlin.Pair<Long, Map<Attachment, AttachmentId>?> {
|
||||
val mentionsSelf = mentions.any { Recipient.resolved(it.recipientId).isSelf }
|
||||
val allAttachments: MutableList<Attachment> = mutableListOf()
|
||||
@@ -3401,6 +3448,19 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
}
|
||||
}
|
||||
|
||||
if (poll != null) {
|
||||
polls.insertPoll(poll.question, poll.allowMultipleVotes, poll.pollOptions, poll.authorId, messageId)
|
||||
}
|
||||
|
||||
if (pollTerminate != null) {
|
||||
val pollId = polls.getPollId(pollTerminate.messageId)
|
||||
if (pollId == null) {
|
||||
Log.w(TAG, "Unable to find corresponding poll.")
|
||||
} else {
|
||||
polls.endPoll(pollId, messageId)
|
||||
}
|
||||
}
|
||||
|
||||
messageId to insertedAttachments
|
||||
}
|
||||
|
||||
@@ -3486,6 +3546,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
attachments.deleteAttachmentsForMessage(messageId)
|
||||
groupReceipts.deleteRowsForMessage(messageId)
|
||||
mentions.deleteMentionsForMessage(messageId)
|
||||
disassociatePollFromPollTerminate(polls.getPollTerminateMessageId(messageId))
|
||||
|
||||
writableDatabase
|
||||
.delete(TABLE_NAME)
|
||||
@@ -3551,6 +3612,36 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When a poll gets deleted, remove the poll reference from its corresponding terminate message by setting it to -1.
|
||||
*/
|
||||
fun disassociatePollFromPollTerminate(messageId: Long) {
|
||||
if (messageId == -1L) {
|
||||
return
|
||||
}
|
||||
|
||||
writableDatabase.withinTransaction { db ->
|
||||
val messageExtras = db
|
||||
.select(MESSAGE_EXTRAS)
|
||||
.from(TABLE_NAME)
|
||||
.where("$ID = ?", messageId)
|
||||
.run()
|
||||
.readToSingleObject { cursor ->
|
||||
val messageExtraBytes = cursor.requireBlob(MESSAGE_EXTRAS)
|
||||
messageExtraBytes?.let { MessageExtras.ADAPTER.decode(it) }
|
||||
}
|
||||
|
||||
if (messageExtras?.pollTerminate != null) {
|
||||
val updatedMessageExtras = messageExtras.newBuilder().pollTerminate(pollTerminate = messageExtras.pollTerminate.copy(messageId = -1)).build()
|
||||
db
|
||||
.update(TABLE_NAME)
|
||||
.values(MESSAGE_EXTRAS to updatedMessageExtras.encode())
|
||||
.where("$ID = ?", messageId)
|
||||
.run()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getSerializedSharedContacts(insertedAttachmentIds: Map<Attachment, AttachmentId>, contacts: List<Contact>): String? {
|
||||
if (contacts.isEmpty()) {
|
||||
return null
|
||||
@@ -4048,6 +4139,34 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
.run()
|
||||
}
|
||||
|
||||
fun setVoteSeen(threadId: Long, sinceTimestamp: Long) {
|
||||
val where = if (sinceTimestamp > -1) {
|
||||
"$THREAD_ID = ? AND $VOTES_UNREAD = ? AND $DATE_RECEIVED <= $sinceTimestamp"
|
||||
} else {
|
||||
"$THREAD_ID = ? AND $VOTES_UNREAD = ?"
|
||||
}
|
||||
|
||||
writableDatabase
|
||||
.update(TABLE_NAME)
|
||||
.values(
|
||||
VOTES_UNREAD to 0,
|
||||
VOTES_LAST_SEEN to System.currentTimeMillis()
|
||||
)
|
||||
.where(where, threadId, 1)
|
||||
.run()
|
||||
}
|
||||
|
||||
fun setAllVotesSeen() {
|
||||
writableDatabase
|
||||
.update(TABLE_NAME)
|
||||
.values(
|
||||
VOTES_UNREAD to 0,
|
||||
VOTES_LAST_SEEN to System.currentTimeMillis()
|
||||
)
|
||||
.where("$VOTES_UNREAD != ?", 0)
|
||||
.run()
|
||||
}
|
||||
|
||||
fun setNotifiedTimestamp(timestamp: Long, ids: List<Long>) {
|
||||
if (ids.isEmpty()) {
|
||||
return
|
||||
@@ -4830,7 +4949,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
val values = contentValuesOf(
|
||||
READ to 1,
|
||||
REACTIONS_UNREAD to 0,
|
||||
REACTIONS_LAST_SEEN to System.currentTimeMillis()
|
||||
REACTIONS_LAST_SEEN to System.currentTimeMillis(),
|
||||
VOTES_UNREAD to 0,
|
||||
VOTES_LAST_SEEN to System.currentTimeMillis()
|
||||
)
|
||||
|
||||
if (expiresIn > 0) {
|
||||
@@ -4975,7 +5096,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
($READ = 0 AND ($ORIGINAL_MESSAGE_ID IS NULL OR EXISTS (SELECT 1 FROM $TABLE_NAME AS m WHERE m.$ID = $TABLE_NAME.$ORIGINAL_MESSAGE_ID AND m.$READ = 0)))
|
||||
OR $REACTIONS_UNREAD = 1
|
||||
${if (stickyQuery.isNotEmpty()) "OR ($stickyQuery)" else ""}
|
||||
OR ($IS_MISSED_CALL_TYPE_CLAUSE AND EXISTS (SELECT 1 FROM ${CallTable.TABLE_NAME} WHERE ${CallTable.MESSAGE_ID} = $TABLE_NAME.$ID AND ${CallTable.EVENT} = ${CallTable.Event.serialize(CallTable.Event.MISSED)} AND ${CallTable.READ} = 0))
|
||||
OR ($IS_MISSED_CALL_TYPE_CLAUSE AND EXISTS (SELECT 1 FROM ${CallTable.TABLE_NAME} WHERE ${CallTable.MESSAGE_ID} = $TABLE_NAME.$ID AND ${CallTable.EVENT} = ${CallTable.Event.serialize(CallTable.Event.MISSED)} AND ${CallTable.READ} = 0))
|
||||
OR $VOTES_UNREAD = 1
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
@@ -5139,6 +5261,32 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
}
|
||||
}
|
||||
|
||||
fun updateVotesUnread(db: SQLiteDatabase, messageId: Long, hasVotes: Boolean, isRemoval: Boolean) {
|
||||
try {
|
||||
val isOutgoing = getMessageRecord(messageId).isOutgoing
|
||||
val values = ContentValues()
|
||||
|
||||
if (!hasVotes) {
|
||||
values.put(VOTES_UNREAD, 0)
|
||||
} else if (!isRemoval) {
|
||||
values.put(VOTES_UNREAD, 1)
|
||||
}
|
||||
|
||||
if (isOutgoing && hasVotes) {
|
||||
values.put(NOTIFIED, 0)
|
||||
}
|
||||
|
||||
if (values.size() > 0) {
|
||||
db.update(TABLE_NAME)
|
||||
.values(values)
|
||||
.where("$ID = ?", messageId)
|
||||
.run()
|
||||
}
|
||||
} catch (e: NoSuchMessageException) {
|
||||
Log.w(TAG, "Failed to find message $messageId")
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(IOException::class)
|
||||
protected fun <D : Document<I>?, I> removeFromDocument(messageId: Long, column: String, item: I, clazz: Class<D>) {
|
||||
writableDatabase.withinTransaction { db ->
|
||||
@@ -5295,6 +5443,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
MessageType.IDENTITY_VERIFIED -> MessageTypes.KEY_EXCHANGE_IDENTITY_VERIFIED_BIT or MessageTypes.BASE_INBOX_TYPE
|
||||
MessageType.IDENTITY_DEFAULT -> MessageTypes.KEY_EXCHANGE_IDENTITY_DEFAULT_BIT or MessageTypes.BASE_INBOX_TYPE
|
||||
MessageType.END_SESSION -> MessageTypes.END_SESSION_BIT or MessageTypes.BASE_INBOX_TYPE
|
||||
MessageType.POLL_TERMINATE -> MessageTypes.SPECIAL_TYPE_POLL_TERMINATE or MessageTypes.BASE_INBOX_TYPE
|
||||
MessageType.GROUP_UPDATE -> {
|
||||
val isOnlyGroupLeave = this.groupContext?.let { GroupV2UpdateMessageUtil.isJustAGroupLeave(it) } ?: false
|
||||
|
||||
@@ -5635,6 +5784,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
null
|
||||
}
|
||||
|
||||
val poll: PollRecord? = polls.getPoll(id)
|
||||
|
||||
val giftBadge: GiftBadge? = if (body != null && MessageTypes.isGiftBadge(box)) {
|
||||
try {
|
||||
GiftBadge.ADAPTER.decode(Base64.decode(body))
|
||||
@@ -5683,6 +5834,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
|
||||
giftBadge,
|
||||
null,
|
||||
null,
|
||||
poll,
|
||||
scheduledDate,
|
||||
latestRevisionId,
|
||||
originalMessageId,
|
||||
|
||||
@@ -45,5 +45,8 @@ enum class MessageType {
|
||||
IDENTITY_DEFAULT,
|
||||
|
||||
/** A manual session reset. This is no longer used and is only here for handling possible inbound/sync messages. */
|
||||
END_SESSION
|
||||
END_SESSION,
|
||||
|
||||
/** A poll has ended **/
|
||||
POLL_TERMINATE
|
||||
}
|
||||
|
||||
@@ -122,6 +122,7 @@ public interface MessageTypes {
|
||||
long SPECIAL_TYPE_PAYMENTS_TOMBSTONE = 0x900000000L;
|
||||
long SPECIAL_TYPE_BLOCKED = 0xA00000000L;
|
||||
long SPECIAL_TYPE_UNBLOCKED = 0xB00000000L;
|
||||
long SPECIAL_TYPE_POLL_TERMINATE = 0xC00000000L;
|
||||
|
||||
long IGNORABLE_TYPESMASK_WHEN_COUNTING = END_SESSION_BIT | KEY_EXCHANGE_IDENTITY_UPDATE_BIT | KEY_EXCHANGE_IDENTITY_VERIFIED_BIT;
|
||||
|
||||
@@ -165,6 +166,10 @@ public interface MessageTypes {
|
||||
return (type & SPECIAL_TYPES_MASK) == SPECIAL_TYPE_UNBLOCKED;
|
||||
}
|
||||
|
||||
static boolean isPollTerminate(long type) {
|
||||
return (type & SPECIAL_TYPES_MASK) == SPECIAL_TYPE_POLL_TERMINATE;
|
||||
}
|
||||
|
||||
static boolean isDraftMessageType(long type) {
|
||||
return (type & BASE_TYPE_MASK) == BASE_DRAFT_TYPE;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,670 @@
|
||||
package org.thoughtcrime.securesms.database
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import androidx.core.content.contentValuesOf
|
||||
import org.signal.core.util.SqlUtil
|
||||
import org.signal.core.util.delete
|
||||
import org.signal.core.util.exists
|
||||
import org.signal.core.util.groupBy
|
||||
import org.signal.core.util.insertInto
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.core.util.readToList
|
||||
import org.signal.core.util.readToMap
|
||||
import org.signal.core.util.readToSingleBoolean
|
||||
import org.signal.core.util.readToSingleInt
|
||||
import org.signal.core.util.readToSingleLong
|
||||
import org.signal.core.util.readToSingleLongOrNull
|
||||
import org.signal.core.util.readToSingleObject
|
||||
import org.signal.core.util.requireBoolean
|
||||
import org.signal.core.util.requireInt
|
||||
import org.signal.core.util.requireLong
|
||||
import org.signal.core.util.requireNonNullString
|
||||
import org.signal.core.util.requireString
|
||||
import org.signal.core.util.select
|
||||
import org.signal.core.util.update
|
||||
import org.signal.core.util.withinTransaction
|
||||
import org.thoughtcrime.securesms.database.model.MessageId
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.polls.Poll
|
||||
import org.thoughtcrime.securesms.polls.PollOption
|
||||
import org.thoughtcrime.securesms.polls.PollRecord
|
||||
import org.thoughtcrime.securesms.polls.PollVote
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
||||
/**
|
||||
* Database table for polls
|
||||
*
|
||||
* Voting:
|
||||
* [VOTE_COUNT] tracks how often someone has voted and is specific per poll and user.
|
||||
* The first time Alice votes in Poll 1, the count is 1, the next time is 2. Removing a vote will also bump it (to 3).
|
||||
* If Alice votes in Poll 2, her vote count will start at 1. If Bob votes, his own vote count starts at 1 (so no interactions between other polls or people)
|
||||
* We track vote count because the server can reorder messages and we don't want to process an older vote count than what we have.
|
||||
*
|
||||
* For example, in three rounds of voting (in the same poll):
|
||||
* 1. Alice votes for option a -> we send (a) with vote count of 1
|
||||
* 2. Alice votes for option b -> we send (a,b) with vote count of 2
|
||||
* 3. Alice removes option b -> we send (a) with vote count of 3
|
||||
*
|
||||
* If we get and process #3 before receiving #2, we will drop #2. This can be done because the voting message always contains the full state of all your votes.
|
||||
*
|
||||
* [VOTE_STATE] tracks the lifecycle of a single vote. Example below with added (remove is very similar).
|
||||
* UI: Alice votes for Option A -> Pending Spinner on Option A -> Option A is checked/Option B is removed if single-vote poll.
|
||||
* BTS: PollVoteJob runs (PENDING_ADD) PollVoteJob finishes (ADDED)
|
||||
*/
|
||||
class PollTables(context: Context?, databaseHelper: SignalDatabase?) : DatabaseTable(context, databaseHelper), RecipientIdDatabaseReference {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(PollTables::class.java)
|
||||
|
||||
@JvmField
|
||||
val CREATE_TABLE: Array<String> = arrayOf(PollTable.CREATE_TABLE, PollOptionTable.CREATE_TABLE, PollVoteTable.CREATE_TABLE)
|
||||
|
||||
@JvmField
|
||||
val CREATE_INDEXES: Array<String> = PollTable.CREATE_INDEXES + PollOptionTable.CREATE_INDEXES + PollVoteTable.CREATE_INDEXES
|
||||
}
|
||||
|
||||
/**
|
||||
* Table containing general poll information (name, deleted status, etc.)
|
||||
*/
|
||||
object PollTable {
|
||||
const val TABLE_NAME = "poll"
|
||||
|
||||
const val ID = "_id"
|
||||
const val AUTHOR_ID = "author_id"
|
||||
const val MESSAGE_ID = "message_id"
|
||||
const val QUESTION = "question"
|
||||
const val ALLOW_MULTIPLE_VOTES = "allow_multiple_votes"
|
||||
const val END_MESSAGE_ID = "end_message_id"
|
||||
|
||||
const val CREATE_TABLE = """
|
||||
CREATE TABLE $TABLE_NAME (
|
||||
$ID INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
$AUTHOR_ID INTEGER NOT NULL REFERENCES ${RecipientTable.TABLE_NAME} (${RecipientTable.ID}) ON DELETE CASCADE,
|
||||
$MESSAGE_ID INTEGER NOT NULL REFERENCES ${MessageTable.TABLE_NAME} (${MessageTable.ID}) ON DELETE CASCADE,
|
||||
$QUESTION TEXT,
|
||||
$ALLOW_MULTIPLE_VOTES INTEGER DEFAULT 0,
|
||||
$END_MESSAGE_ID INTEGER DEFAULT 0
|
||||
)
|
||||
"""
|
||||
|
||||
val CREATE_INDEXES = arrayOf(
|
||||
"CREATE INDEX poll_author_id_index ON $TABLE_NAME ($AUTHOR_ID)",
|
||||
"CREATE INDEX poll_message_id_index ON $TABLE_NAME ($MESSAGE_ID)"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Table containing the options within a given poll
|
||||
*/
|
||||
object PollOptionTable {
|
||||
const val TABLE_NAME = "poll_option"
|
||||
|
||||
const val ID = "_id"
|
||||
const val POLL_ID = "poll_id"
|
||||
const val OPTION_TEXT = "option_text"
|
||||
const val OPTION_ORDER = "option_order"
|
||||
|
||||
const val CREATE_TABLE = """
|
||||
CREATE TABLE $TABLE_NAME (
|
||||
$ID INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
$POLL_ID INTEGER NOT NULL REFERENCES ${PollTable.TABLE_NAME} (${PollTable.ID}) ON DELETE CASCADE,
|
||||
$OPTION_TEXT TEXT,
|
||||
$OPTION_ORDER INTEGER
|
||||
)
|
||||
"""
|
||||
|
||||
val CREATE_INDEXES = arrayOf(
|
||||
"CREATE INDEX poll_option_poll_id_index ON $TABLE_NAME ($POLL_ID)"
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Table containing the votes of a given poll
|
||||
*/
|
||||
object PollVoteTable {
|
||||
const val TABLE_NAME = "poll_vote"
|
||||
|
||||
const val ID = "_id"
|
||||
const val POLL_ID = "poll_id"
|
||||
const val POLL_OPTION_ID = "poll_option_id"
|
||||
const val VOTER_ID = "voter_id"
|
||||
const val VOTE_COUNT = "vote_count"
|
||||
const val DATE_RECEIVED = "date_received"
|
||||
const val VOTE_STATE = "vote_state"
|
||||
|
||||
const val CREATE_TABLE = """
|
||||
CREATE TABLE $TABLE_NAME (
|
||||
$ID INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
$POLL_ID INTEGER NOT NULL REFERENCES ${PollTable.TABLE_NAME} (${PollTable.ID}) ON DELETE CASCADE,
|
||||
$POLL_OPTION_ID INTEGER DEFAULT NULL REFERENCES ${PollOptionTable.TABLE_NAME} (${PollOptionTable.ID}) ON DELETE CASCADE,
|
||||
$VOTER_ID INTEGER NOT NULL REFERENCES ${RecipientTable.TABLE_NAME} (${RecipientTable.ID}) ON DELETE CASCADE,
|
||||
$VOTE_COUNT INTEGER,
|
||||
$DATE_RECEIVED INTEGER DEFAULT 0,
|
||||
$VOTE_STATE INTEGER DEFAULT 0,
|
||||
UNIQUE($POLL_ID, $VOTER_ID, $POLL_OPTION_ID) ON CONFLICT REPLACE
|
||||
)
|
||||
"""
|
||||
|
||||
val CREATE_INDEXES = arrayOf(
|
||||
"CREATE INDEX poll_vote_poll_id_index ON $TABLE_NAME ($POLL_ID)",
|
||||
"CREATE INDEX poll_vote_poll_option_id_index ON $TABLE_NAME ($POLL_OPTION_ID)",
|
||||
"CREATE INDEX poll_vote_voter_id_index ON $TABLE_NAME ($VOTER_ID)"
|
||||
)
|
||||
}
|
||||
|
||||
override fun remapRecipient(fromId: RecipientId, toId: RecipientId) {
|
||||
val countFromPoll = writableDatabase
|
||||
.update(PollTable.TABLE_NAME)
|
||||
.values(PollTable.AUTHOR_ID to toId.serialize())
|
||||
.where("${PollTable.AUTHOR_ID} = ?", fromId)
|
||||
.run()
|
||||
|
||||
val countFromVotes = writableDatabase
|
||||
.update(PollVoteTable.TABLE_NAME)
|
||||
.values(PollVoteTable.VOTER_ID to toId.serialize())
|
||||
.where("${PollVoteTable.VOTER_ID} = ?", fromId)
|
||||
.run()
|
||||
|
||||
Log.d(TAG, "Remapped $fromId to $toId. count from polls: $countFromPoll from poll votes: $countFromVotes")
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts a newly created poll with its options
|
||||
*/
|
||||
fun insertPoll(question: String, allowMultipleVotes: Boolean, options: List<String>, authorId: Long, messageId: Long) {
|
||||
writableDatabase.withinTransaction { db ->
|
||||
val pollId = db.insertInto(PollTable.TABLE_NAME)
|
||||
.values(
|
||||
contentValuesOf(
|
||||
PollTable.QUESTION to question,
|
||||
PollTable.ALLOW_MULTIPLE_VOTES to allowMultipleVotes,
|
||||
PollTable.AUTHOR_ID to authorId,
|
||||
PollTable.MESSAGE_ID to messageId
|
||||
)
|
||||
)
|
||||
.run()
|
||||
|
||||
SqlUtil.buildBulkInsert(
|
||||
PollOptionTable.TABLE_NAME,
|
||||
arrayOf(PollOptionTable.POLL_ID, PollOptionTable.OPTION_TEXT, PollOptionTable.OPTION_ORDER),
|
||||
options.toPollContentValues(pollId)
|
||||
).forEach {
|
||||
db.execSQL(it.where, it.whereArgs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts a vote in a poll and increases the vote count by 1.
|
||||
* Status is marked as [VoteState.PENDING_ADD] here and then once it successfully sends, it will get updated to [VoteState.ADDED] in [markPendingAsAdded]
|
||||
*/
|
||||
fun insertVote(poll: PollRecord, pollOption: PollOption): Int {
|
||||
val self = Recipient.self().id.toLong()
|
||||
var voteCount = 0
|
||||
|
||||
writableDatabase.withinTransaction { db ->
|
||||
voteCount = getCurrentPollVoteCount(poll.id, self) + 1
|
||||
val contentValues = ContentValues().apply {
|
||||
put(PollVoteTable.POLL_ID, poll.id)
|
||||
put(PollVoteTable.POLL_OPTION_ID, pollOption.id)
|
||||
put(PollVoteTable.VOTER_ID, self)
|
||||
put(PollVoteTable.VOTE_COUNT, voteCount)
|
||||
put(PollVoteTable.VOTE_STATE, VoteState.PENDING_ADD.value)
|
||||
}
|
||||
|
||||
db.insertInto(PollVoteTable.TABLE_NAME)
|
||||
.values(contentValues)
|
||||
.run(SQLiteDatabase.CONFLICT_REPLACE)
|
||||
}
|
||||
|
||||
AppDependencies.databaseObserver.notifyMessageUpdateObservers(MessageId(poll.messageId))
|
||||
return voteCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Once a vote is sent to at least one person, we can update the [VoteState.PENDING_ADD] state to [VoteState.ADDED].
|
||||
* If the poll only allows one vote, it also clears out any old votes.
|
||||
*/
|
||||
fun markPendingAsAdded(pollId: Long, voterId: Long, voteCount: Int, messageId: Long) {
|
||||
val poll = SignalDatabase.polls.getPollFromId(pollId)
|
||||
if (poll == null) {
|
||||
Log.w(TAG, "Cannot find poll anymore $pollId")
|
||||
return
|
||||
}
|
||||
|
||||
writableDatabase.updateWithOnConflict(
|
||||
PollVoteTable.TABLE_NAME,
|
||||
contentValuesOf(PollVoteTable.VOTE_STATE to VoteState.ADDED.value),
|
||||
"${PollVoteTable.POLL_ID} = ? AND ${PollVoteTable.VOTER_ID} = ? AND ${PollVoteTable.VOTE_COUNT} = ? AND ${PollVoteTable.VOTE_STATE} = ${VoteState.PENDING_ADD.value}",
|
||||
SqlUtil.buildArgs(pollId, voterId, voteCount),
|
||||
SQLiteDatabase.CONFLICT_REPLACE
|
||||
)
|
||||
|
||||
if (!poll.allowMultipleVotes) {
|
||||
writableDatabase.delete(PollVoteTable.TABLE_NAME)
|
||||
.where("${PollVoteTable.POLL_ID} = ? AND ${PollVoteTable.VOTER_ID} = ? AND ${PollVoteTable.VOTE_COUNT} < ?", poll.id, Recipient.self().id, voteCount)
|
||||
.run()
|
||||
}
|
||||
AppDependencies.databaseObserver.notifyMessageUpdateObservers(MessageId(messageId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes vote from a poll. This also increases the vote count because removal of a vote, is technically a type of vote.
|
||||
* Status is marked as [VoteState.PENDING_REMOVE] here and then once it successfully sends, it will get updated to [VoteState.REMOVED] in [markPendingAsRemoved]
|
||||
*/
|
||||
fun removeVote(poll: PollRecord, pollOption: PollOption): Int {
|
||||
val self = Recipient.self().id.toLong()
|
||||
var voteCount = 0
|
||||
|
||||
writableDatabase.withinTransaction { db ->
|
||||
voteCount = getCurrentPollVoteCount(poll.id, self) + 1
|
||||
db.insertInto(PollVoteTable.TABLE_NAME)
|
||||
.values(
|
||||
PollVoteTable.POLL_ID to poll.id,
|
||||
PollVoteTable.POLL_OPTION_ID to pollOption.id,
|
||||
PollVoteTable.VOTER_ID to self,
|
||||
PollVoteTable.VOTE_COUNT to voteCount,
|
||||
PollVoteTable.VOTE_STATE to VoteState.PENDING_REMOVE.value
|
||||
)
|
||||
.run(SQLiteDatabase.CONFLICT_REPLACE)
|
||||
}
|
||||
|
||||
AppDependencies.databaseObserver.notifyMessageUpdateObservers(MessageId(poll.messageId))
|
||||
return voteCount
|
||||
}
|
||||
|
||||
/**
|
||||
* Once a vote is sent to at least one person, we can update the [VoteState.PENDING_REMOVE] state to [VoteState.REMOVED].
|
||||
*/
|
||||
fun markPendingAsRemoved(pollId: Long, voterId: Long, voteCount: Int, messageId: Long) {
|
||||
writableDatabase.withinTransaction { db ->
|
||||
db.updateWithOnConflict(
|
||||
PollVoteTable.TABLE_NAME,
|
||||
contentValuesOf(PollVoteTable.VOTE_STATE to VoteState.REMOVED.value),
|
||||
"${PollVoteTable.POLL_ID} = ? AND ${PollVoteTable.VOTER_ID} = ? AND ${PollVoteTable.VOTE_COUNT} = ? AND ${PollVoteTable.VOTE_STATE} = ${VoteState.PENDING_REMOVE.value}",
|
||||
SqlUtil.buildArgs(pollId, voterId, voteCount),
|
||||
SQLiteDatabase.CONFLICT_REPLACE
|
||||
)
|
||||
}
|
||||
AppDependencies.databaseObserver.notifyMessageUpdateObservers(MessageId(messageId))
|
||||
}
|
||||
|
||||
/**
|
||||
* For a given poll, returns the option indexes that the person has voted for
|
||||
*/
|
||||
fun getVotes(pollId: Long, allowMultipleVotes: Boolean): List<Int> {
|
||||
val voteQuery = if (allowMultipleVotes) {
|
||||
"(${PollVoteTable.VOTE_STATE} = ${VoteState.ADDED.value} OR ${PollVoteTable.VOTE_STATE} = ${VoteState.PENDING_ADD.value})"
|
||||
} else {
|
||||
"${PollVoteTable.VOTE_STATE} = ${VoteState.PENDING_ADD.value}"
|
||||
}
|
||||
|
||||
return readableDatabase
|
||||
.select(PollOptionTable.OPTION_ORDER)
|
||||
.from("${PollVoteTable.TABLE_NAME} LEFT JOIN ${PollOptionTable.TABLE_NAME} ON ${PollVoteTable.TABLE_NAME}.${PollVoteTable.POLL_OPTION_ID} = ${PollOptionTable.TABLE_NAME}.${PollOptionTable.ID}")
|
||||
.where(
|
||||
"""
|
||||
${PollVoteTable.TABLE_NAME}.${PollVoteTable.POLL_ID} = ? AND
|
||||
${PollVoteTable.VOTER_ID} = ? AND
|
||||
${PollVoteTable.POLL_OPTION_ID} IS NOT NULL AND
|
||||
$voteQuery
|
||||
""",
|
||||
pollId,
|
||||
Recipient.self().id.toLong()
|
||||
)
|
||||
.run()
|
||||
.readToList { cursor -> cursor.requireInt(PollOptionTable.OPTION_ORDER) }
|
||||
}
|
||||
|
||||
/**
|
||||
* For a given poll, returns who has voted in the poll. If a person has voted for multiple options, only count their most recent vote.
|
||||
*/
|
||||
fun getAllVotes(messageId: Long): List<PollVote> {
|
||||
return readableDatabase
|
||||
.select()
|
||||
.from("${PollTable.TABLE_NAME} INNER JOIN ${PollVoteTable.TABLE_NAME} ON ${PollTable.TABLE_NAME}.${PollTable.ID} = ${PollVoteTable.TABLE_NAME}.${PollVoteTable.POLL_ID}")
|
||||
.where("${PollTable.MESSAGE_ID} = ?", messageId)
|
||||
.orderBy("${PollVoteTable.DATE_RECEIVED} DESC")
|
||||
.run()
|
||||
.readToList { cursor ->
|
||||
PollVote(
|
||||
pollId = cursor.requireLong(PollVoteTable.POLL_ID),
|
||||
question = cursor.requireNonNullString(PollTable.QUESTION),
|
||||
voterId = RecipientId.from(cursor.requireLong(PollVoteTable.VOTER_ID)),
|
||||
dateReceived = cursor.requireLong(PollVoteTable.DATE_RECEIVED)
|
||||
)
|
||||
}
|
||||
.distinctBy { it.pollId to it.voterId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the [VoteState] for a given voting session (as indicated by voteCount)
|
||||
*/
|
||||
fun getPollVoteStateForGivenVote(pollId: Long, voteCount: Int): VoteState {
|
||||
val value = readableDatabase
|
||||
.select(PollVoteTable.VOTE_STATE)
|
||||
.from(PollVoteTable.TABLE_NAME)
|
||||
.where("${PollVoteTable.POLL_ID} = ? AND ${PollVoteTable.VOTER_ID} = ? AND ${PollVoteTable.VOTE_COUNT} = ?", pollId, Recipient.self().id.toLong(), voteCount)
|
||||
.run()
|
||||
.readToSingleInt()
|
||||
return VoteState.fromValue(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the [VoteState] for a given voting session (as indicated by voteCount)
|
||||
*/
|
||||
fun setPollVoteStateForGivenVote(pollId: Long, voterId: Long, voteCount: Int, messageId: Long, undoRemoval: Boolean) {
|
||||
val state = if (undoRemoval) VoteState.ADDED.value else VoteState.REMOVED.value
|
||||
writableDatabase.withinTransaction { db ->
|
||||
db.updateWithOnConflict(
|
||||
PollVoteTable.TABLE_NAME,
|
||||
contentValuesOf(
|
||||
PollVoteTable.VOTE_STATE to state
|
||||
),
|
||||
"${PollVoteTable.POLL_ID} = ? AND ${PollVoteTable.VOTER_ID} = ? AND ${PollVoteTable.VOTE_COUNT} = ?",
|
||||
SqlUtil.buildArgs(pollId, voterId, voteCount),
|
||||
SQLiteDatabase.CONFLICT_REPLACE
|
||||
)
|
||||
}
|
||||
AppDependencies.databaseObserver.notifyMessageUpdateObservers(MessageId(messageId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts all of the votes a person has made on a poll. Clears out any old data if they voted previously.
|
||||
*/
|
||||
fun insertVotes(pollId: Long, pollOptionIds: List<Long>, voterId: Long, voteCount: Long, messageId: MessageId) {
|
||||
writableDatabase.withinTransaction { db ->
|
||||
// Delete any previous votes they had on the poll
|
||||
db.delete(PollVoteTable.TABLE_NAME)
|
||||
.where("${PollVoteTable.VOTER_ID} = ? AND ${PollVoteTable.POLL_ID} = ?", voterId, pollId)
|
||||
.run()
|
||||
|
||||
SqlUtil.buildBulkInsert(
|
||||
PollVoteTable.TABLE_NAME,
|
||||
arrayOf(PollVoteTable.POLL_ID, PollVoteTable.POLL_OPTION_ID, PollVoteTable.VOTER_ID, PollVoteTable.VOTE_COUNT, PollVoteTable.DATE_RECEIVED, PollVoteTable.VOTE_STATE),
|
||||
pollOptionIds.toPollVoteContentValues(pollId, voterId, voteCount)
|
||||
).forEach {
|
||||
db.execSQL(it.where, it.whereArgs)
|
||||
}
|
||||
}
|
||||
|
||||
AppDependencies.databaseObserver.notifyMessageUpdateObservers(messageId)
|
||||
SignalDatabase.messages.updateVotesUnread(writableDatabase, messageId.id, hasVotes(pollId), pollOptionIds.isEmpty())
|
||||
}
|
||||
|
||||
private fun hasVotes(pollId: Long): Boolean {
|
||||
return readableDatabase
|
||||
.exists(PollVoteTable.TABLE_NAME)
|
||||
.where("${PollVoteTable.POLL_ID} = ?", pollId)
|
||||
.run()
|
||||
}
|
||||
|
||||
/**
|
||||
* If a poll has ended, returns the message id of the poll end message. Otherwise, return -1.
|
||||
*/
|
||||
fun getPollTerminateMessageId(messageId: Long): Long {
|
||||
return readableDatabase
|
||||
.select(PollTable.END_MESSAGE_ID)
|
||||
.from(PollTable.TABLE_NAME)
|
||||
.where("${PollTable.MESSAGE_ID} = ?", messageId)
|
||||
.run()
|
||||
.readToSingleLong(-1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends a poll
|
||||
*/
|
||||
fun endPoll(pollId: Long, endingMessageId: Long) {
|
||||
val messageId = getMessageId(pollId)
|
||||
if (messageId == null) {
|
||||
Log.w(TAG, "Unable to find the poll to end.")
|
||||
return
|
||||
}
|
||||
writableDatabase.withinTransaction { db ->
|
||||
db.update(PollTable.TABLE_NAME)
|
||||
.values(PollTable.END_MESSAGE_ID to endingMessageId)
|
||||
.where("${PollTable.ID} = ?", pollId)
|
||||
.run()
|
||||
}
|
||||
AppDependencies.databaseObserver.notifyMessageUpdateObservers(MessageId(messageId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the poll id if associated with a given message id
|
||||
*/
|
||||
fun getPollId(messageId: Long): Long? {
|
||||
return readableDatabase
|
||||
.select(PollTable.ID)
|
||||
.from(PollTable.TABLE_NAME)
|
||||
.where("${PollTable.MESSAGE_ID} = ?", messageId)
|
||||
.run()
|
||||
.readToSingleLongOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the message id for a poll id
|
||||
*/
|
||||
fun getMessageId(pollId: Long): Long? {
|
||||
return readableDatabase
|
||||
.select(PollTable.MESSAGE_ID)
|
||||
.from(PollTable.TABLE_NAME)
|
||||
.where("${PollTable.ID} = ?", pollId)
|
||||
.run()
|
||||
.readToSingleLongOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a poll record for a given poll id
|
||||
*/
|
||||
fun getPollFromId(pollId: Long): PollRecord? {
|
||||
return getPoll(getMessageId(pollId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the minimum amount necessary to create a poll for a message id
|
||||
*/
|
||||
fun getPollForOutgoingMessage(messageId: Long): Poll? {
|
||||
return readableDatabase.withinTransaction { db ->
|
||||
db.select(PollTable.ID, PollTable.QUESTION, PollTable.ALLOW_MULTIPLE_VOTES)
|
||||
.from(PollTable.TABLE_NAME)
|
||||
.where("${PollTable.MESSAGE_ID} = ?", messageId)
|
||||
.run()
|
||||
.readToSingleObject { cursor ->
|
||||
val pollId = cursor.requireLong(PollTable.ID)
|
||||
Poll(
|
||||
question = cursor.requireString(PollTable.QUESTION) ?: "",
|
||||
allowMultipleVotes = cursor.requireBoolean(PollTable.ALLOW_MULTIPLE_VOTES),
|
||||
pollOptions = getPollOptionText(pollId),
|
||||
authorId = Recipient.self().id.toLong()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the poll if associated with a given message id
|
||||
*/
|
||||
fun getPoll(messageId: Long?): PollRecord? {
|
||||
return if (messageId != null) {
|
||||
getPollsForMessages(listOf(messageId))[messageId]
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps message ids to its associated poll (if it exists)
|
||||
*/
|
||||
fun getPollsForMessages(messageIds: Collection<Long>): Map<Long, PollRecord> {
|
||||
if (messageIds.isEmpty()) {
|
||||
return emptyMap()
|
||||
}
|
||||
|
||||
val self = Recipient.self().id.toLong()
|
||||
val query = SqlUtil.buildFastCollectionQuery(PollTable.MESSAGE_ID, messageIds)
|
||||
return readableDatabase.withinTransaction { db ->
|
||||
db.select(PollTable.ID, PollTable.MESSAGE_ID, PollTable.QUESTION, PollTable.ALLOW_MULTIPLE_VOTES, PollTable.END_MESSAGE_ID, PollTable.AUTHOR_ID, PollTable.MESSAGE_ID)
|
||||
.from(PollTable.TABLE_NAME)
|
||||
.where(query.where, query.whereArgs)
|
||||
.run()
|
||||
.readToMap { cursor ->
|
||||
val pollId = cursor.requireLong(PollTable.ID)
|
||||
val pollVotes = getPollVotes(pollId)
|
||||
val pendingVotes = getPendingVotes(pollId)
|
||||
val pollOptions = getPollOptions(pollId).map { option ->
|
||||
val voterIds = pollVotes[option.key] ?: emptyList()
|
||||
PollOption(id = option.key, text = option.value, voterIds = voterIds, isSelected = voterIds.contains(self), isPending = pendingVotes.contains(option.key))
|
||||
}
|
||||
val poll = PollRecord(
|
||||
id = pollId,
|
||||
question = cursor.requireNonNullString(PollTable.QUESTION),
|
||||
pollOptions = pollOptions,
|
||||
allowMultipleVotes = cursor.requireBoolean(PollTable.ALLOW_MULTIPLE_VOTES),
|
||||
hasEnded = cursor.requireBoolean(PollTable.END_MESSAGE_ID),
|
||||
authorId = cursor.requireLong(PollTable.AUTHOR_ID),
|
||||
messageId = cursor.requireLong(PollTable.MESSAGE_ID)
|
||||
)
|
||||
cursor.requireLong(PollTable.MESSAGE_ID) to poll
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a poll id, returns a list of all of the ids of its options
|
||||
*/
|
||||
fun getPollOptionIds(pollId: Long): List<Long> {
|
||||
return readableDatabase
|
||||
.select(PollOptionTable.ID)
|
||||
.from(PollOptionTable.TABLE_NAME)
|
||||
.where("${PollVoteTable.POLL_ID} = ?", pollId)
|
||||
.orderBy(PollOptionTable.OPTION_ORDER)
|
||||
.run()
|
||||
.readToList { cursor ->
|
||||
cursor.requireLong(PollOptionTable.ID)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a poll id and a voter id, return their vote count (how many times they have voted)
|
||||
*/
|
||||
fun getCurrentPollVoteCount(pollId: Long, voterId: Long): Int {
|
||||
return readableDatabase
|
||||
.select("MAX(${PollVoteTable.VOTE_COUNT})")
|
||||
.from(PollVoteTable.TABLE_NAME)
|
||||
.where("${PollVoteTable.POLL_ID} = ? AND ${PollVoteTable.VOTER_ID} = ?", pollId, voterId)
|
||||
.run()
|
||||
.readToSingleInt(-1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return if the poll supports multiple votes for options
|
||||
*/
|
||||
fun canAllowMultipleVotes(pollId: Long): Boolean {
|
||||
return readableDatabase
|
||||
.select(PollTable.ALLOW_MULTIPLE_VOTES)
|
||||
.from(PollTable.TABLE_NAME)
|
||||
.where("${PollTable.ID} = ? ", pollId)
|
||||
.run()
|
||||
.readToSingleBoolean()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the poll has ended
|
||||
*/
|
||||
fun hasEnded(pollId: Long): Boolean {
|
||||
return readableDatabase
|
||||
.select(PollTable.END_MESSAGE_ID)
|
||||
.from(PollTable.TABLE_NAME)
|
||||
.where("${PollTable.ID} = ? ", pollId)
|
||||
.run()
|
||||
.readToSingleBoolean()
|
||||
}
|
||||
|
||||
private fun getPollOptions(pollId: Long): Map<Long, String> {
|
||||
return readableDatabase
|
||||
.select(PollOptionTable.ID, PollOptionTable.OPTION_TEXT)
|
||||
.from(PollOptionTable.TABLE_NAME)
|
||||
.where("${PollVoteTable.POLL_ID} = ?", pollId)
|
||||
.run()
|
||||
.readToMap { cursor ->
|
||||
cursor.requireLong(PollOptionTable.ID) to cursor.requireNonNullString(PollOptionTable.OPTION_TEXT)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getPollVotes(pollId: Long): Map<Long, List<Long>> {
|
||||
return readableDatabase
|
||||
.select(PollVoteTable.POLL_OPTION_ID, PollVoteTable.VOTER_ID)
|
||||
.from(PollVoteTable.TABLE_NAME)
|
||||
.where("${PollVoteTable.POLL_ID} = ? AND (${PollVoteTable.VOTE_STATE} = ${VoteState.ADDED.value} OR ${PollVoteTable.VOTE_STATE} = ${VoteState.PENDING_REMOVE.value})", pollId)
|
||||
.run()
|
||||
.groupBy { cursor ->
|
||||
cursor.requireLong(PollVoteTable.POLL_OPTION_ID) to cursor.requireLong(PollVoteTable.VOTER_ID)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getPendingVotes(pollId: Long): List<Long> {
|
||||
return readableDatabase
|
||||
.select(PollVoteTable.POLL_OPTION_ID)
|
||||
.from(PollVoteTable.TABLE_NAME)
|
||||
.where("${PollVoteTable.POLL_ID} = ? AND ${PollVoteTable.VOTER_ID} = ? AND (${PollVoteTable.VOTE_STATE} = ${VoteState.PENDING_ADD.value} OR ${PollVoteTable.VOTE_STATE} = ${VoteState.PENDING_REMOVE.value})", pollId, Recipient.self().id)
|
||||
.run()
|
||||
.readToList { cursor ->
|
||||
cursor.requireLong(PollVoteTable.POLL_OPTION_ID)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getPollOptionText(pollId: Long): List<String> {
|
||||
return readableDatabase
|
||||
.select(PollOptionTable.OPTION_TEXT)
|
||||
.from(PollOptionTable.TABLE_NAME)
|
||||
.where("${PollOptionTable.POLL_ID} = ?", pollId)
|
||||
.run()
|
||||
.readToList { it.requireString(PollOptionTable.OPTION_TEXT)!! }
|
||||
}
|
||||
|
||||
private fun <E> Collection<E>.toPollContentValues(pollId: Long): List<ContentValues> {
|
||||
return this.mapIndexed { index, option ->
|
||||
contentValuesOf(
|
||||
PollOptionTable.POLL_ID to pollId,
|
||||
PollOptionTable.OPTION_TEXT to option,
|
||||
PollOptionTable.OPTION_ORDER to index
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <E> Collection<E>.toPollVoteContentValues(pollId: Long, voterId: Long, voteCount: Long): List<ContentValues> {
|
||||
return this.map {
|
||||
contentValuesOf(
|
||||
PollVoteTable.POLL_ID to pollId,
|
||||
PollVoteTable.POLL_OPTION_ID to it,
|
||||
PollVoteTable.VOTER_ID to voterId,
|
||||
PollVoteTable.VOTE_COUNT to voteCount,
|
||||
PollVoteTable.DATE_RECEIVED to System.currentTimeMillis(),
|
||||
PollVoteTable.VOTE_STATE to VoteState.ADDED.value
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum class VoteState(val value: Int) {
|
||||
/** We have no information on the vote state */
|
||||
NONE(0),
|
||||
|
||||
/** Vote is in the process of being removed */
|
||||
PENDING_REMOVE(1),
|
||||
|
||||
/** Vote is in the process of being added */
|
||||
PENDING_ADD(2),
|
||||
|
||||
/** Vote was removed */
|
||||
REMOVED(3),
|
||||
|
||||
/** Vote was added */
|
||||
ADDED(4);
|
||||
|
||||
companion object {
|
||||
fun fromValue(value: Int) = VoteState.entries.first { it.value == value }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,7 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
|
||||
val inAppPaymentSubscriberTable: InAppPaymentSubscriberTable = InAppPaymentSubscriberTable(context, this)
|
||||
val chatFoldersTable: ChatFolderTables = ChatFolderTables(context, this)
|
||||
val backupMediaSnapshotTable: BackupMediaSnapshotTable = BackupMediaSnapshotTable(context, this)
|
||||
val pollTable: PollTables = PollTables(context, this)
|
||||
|
||||
override fun onOpen(db: net.zetetic.database.sqlcipher.SQLiteDatabase) {
|
||||
db.setForeignKeyConstraintsEnabled(true)
|
||||
@@ -147,6 +148,7 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
|
||||
executeStatements(db, NotificationProfileTables.CREATE_TABLE)
|
||||
executeStatements(db, DistributionListTables.CREATE_TABLE)
|
||||
executeStatements(db, ChatFolderTables.CREATE_TABLE)
|
||||
executeStatements(db, PollTables.CREATE_TABLE)
|
||||
db.execSQL(BackupMediaSnapshotTable.CREATE_TABLE)
|
||||
|
||||
executeStatements(db, RecipientTable.CREATE_INDEXS)
|
||||
@@ -172,6 +174,7 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
|
||||
executeStatements(db, ChatFolderTables.CREATE_INDEXES)
|
||||
executeStatements(db, NameCollisionTables.CREATE_INDEXES)
|
||||
executeStatements(db, BackupMediaSnapshotTable.CREATE_INDEXES)
|
||||
executeStatements(db, PollTables.CREATE_INDEXES)
|
||||
|
||||
executeStatements(db, MessageSendLogTables.CREATE_TRIGGERS)
|
||||
|
||||
@@ -582,5 +585,10 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
|
||||
@get:JvmName("backupMediaSnapshots")
|
||||
val backupMediaSnapshots: BackupMediaSnapshotTable
|
||||
get() = instance!!.backupMediaSnapshotTable
|
||||
|
||||
@get:JvmStatic
|
||||
@get:JvmName("polls")
|
||||
val polls: PollTables
|
||||
get() = instance!!.pollTable
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,11 @@ public final class ThreadBodyUtil {
|
||||
return new ThreadBody(getCallLogSummary(context, record));
|
||||
} else if (MessageRecordUtil.isScheduled(record)) {
|
||||
return new ThreadBody(context.getString(R.string.ThreadRecord_scheduled_message));
|
||||
} else if (MessageRecordUtil.hasPoll(record)) {
|
||||
return new ThreadBody(context.getString(R.string.Poll__poll_question, record.getPoll().getQuestion()));
|
||||
} else if (MessageRecordUtil.hasPollTerminate(record)) {
|
||||
String creator = record.isOutgoing() ? context.getResources().getString(R.string.MessageRecord_you) : record.getFromRecipient().getDisplayName(context);
|
||||
return new ThreadBody(context.getString(R.string.Poll__poll_end, creator, record.getMessageExtras().pollTerminate.question));
|
||||
}
|
||||
|
||||
boolean hasImage = false;
|
||||
@@ -96,6 +101,14 @@ public final class ThreadBodyUtil {
|
||||
}
|
||||
}
|
||||
|
||||
public static CharSequence getFormattedBodyForPollNotification(@NonNull Context context, @NonNull MmsMessageRecord record) {
|
||||
return format(EmojiStrings.POLL, context.getString(R.string.Poll__poll_question, record.getPoll().getQuestion()), null).body;
|
||||
}
|
||||
|
||||
public static CharSequence getFormattedBodyForPollEndNotification(@NonNull Context context, @NonNull MmsMessageRecord record) {
|
||||
return format(EmojiStrings.POLL, context.getString(R.string.Poll__poll_end, record.getFromRecipient().getDisplayName(context), record.getMessageExtras().pollTerminate.question), null).body;
|
||||
}
|
||||
|
||||
private static @NonNull String getGiftSummary(@NonNull Context context, @NonNull MessageRecord messageRecord) {
|
||||
if (messageRecord.isOutgoing()) {
|
||||
return context.getString(R.string.ThreadRecord__you_donated_for_s, messageRecord.getToRecipient().getShortDisplayName(context));
|
||||
|
||||
@@ -70,6 +70,7 @@ import org.thoughtcrime.securesms.util.JsonUtils
|
||||
import org.thoughtcrime.securesms.util.JsonUtils.SaneJSONObject
|
||||
import org.thoughtcrime.securesms.util.LRUCache
|
||||
import org.thoughtcrime.securesms.util.TextSecurePreferences
|
||||
import org.thoughtcrime.securesms.util.isPoll
|
||||
import org.thoughtcrime.securesms.util.isScheduled
|
||||
import org.whispersystems.signalservice.api.push.ServiceId
|
||||
import org.whispersystems.signalservice.api.storage.SignalAccountRecord
|
||||
@@ -496,6 +497,7 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
|
||||
}
|
||||
|
||||
messages.setAllReactionsSeen()
|
||||
messages.setAllVotesSeen()
|
||||
notifyConversationListListeners()
|
||||
|
||||
return messageRecords
|
||||
@@ -557,6 +559,7 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
|
||||
messageRecords += messages.setMessagesReadSince(threadId, sinceTimestamp)
|
||||
|
||||
messages.setReactionsSeen(threadId, sinceTimestamp)
|
||||
messages.setVoteSeen(threadId, sinceTimestamp)
|
||||
|
||||
val unreadCount = messages.getUnreadCount(threadId)
|
||||
val unreadMentionsCount = messages.getUnreadMentionCount(threadId)
|
||||
@@ -2097,6 +2100,8 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
|
||||
Extra.forSticker(slide.emoji, authorId)
|
||||
} else if (record.isMms && (record as MmsMessageRecord).slideDeck.slides.size > 1) {
|
||||
Extra.forAlbum(authorId)
|
||||
} else if (record.isPoll()) {
|
||||
Extra.forPoll(authorId)
|
||||
} else if (threadRecipient != null && threadRecipient.isGroup) {
|
||||
Extra.forDefault(authorId)
|
||||
} else {
|
||||
@@ -2280,7 +2285,8 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
|
||||
individualRecipientId = jsonObject.getString("individualRecipientId")!!,
|
||||
bodyRanges = jsonObject.getString("bodyRanges"),
|
||||
isScheduled = jsonObject.getBoolean("isScheduled"),
|
||||
isRecipientHidden = jsonObject.getBoolean("isRecipientHidden")
|
||||
isRecipientHidden = jsonObject.getBoolean("isRecipientHidden"),
|
||||
isPoll = jsonObject.getBoolean("isPoll")
|
||||
)
|
||||
} catch (exception: Exception) {
|
||||
null
|
||||
@@ -2291,7 +2297,7 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
|
||||
|
||||
return ThreadRecord.Builder(cursor.requireLong(ID))
|
||||
.setRecipient(recipient)
|
||||
.setType(cursor.requireInt(SNIPPET_TYPE).toLong())
|
||||
.setType(cursor.requireLong(SNIPPET_TYPE))
|
||||
.setDistributionType(cursor.requireInt(TYPE))
|
||||
.setBody(cursor.requireString(SNIPPET) ?: "")
|
||||
.setDate(cursor.requireLong(DATE))
|
||||
@@ -2367,7 +2373,10 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
|
||||
val isScheduled: Boolean = false,
|
||||
@field:JsonProperty
|
||||
@param:JsonProperty("isRecipientHidden")
|
||||
val isRecipientHidden: Boolean = false
|
||||
val isRecipientHidden: Boolean = false,
|
||||
@field:JsonProperty
|
||||
@param:JsonProperty("isPoll")
|
||||
val isPoll: Boolean = false
|
||||
) {
|
||||
|
||||
fun getIndividualRecipientId(): String {
|
||||
@@ -2414,6 +2423,10 @@ class ThreadTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
|
||||
fun forScheduledMessage(individualRecipient: RecipientId): Extra {
|
||||
return Extra(individualRecipientId = individualRecipient.serialize(), isScheduled = true)
|
||||
}
|
||||
|
||||
fun forPoll(individualRecipient: RecipientId): Extra {
|
||||
return Extra(individualRecipientId = individualRecipient.serialize(), isPoll = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -146,6 +146,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V288_CopyStickerDat
|
||||
import org.thoughtcrime.securesms.database.helpers.migration.V289_AddQuoteTargetContentTypeColumn
|
||||
import org.thoughtcrime.securesms.database.helpers.migration.V290_AddArchiveThumbnailTransferStateColumn
|
||||
import org.thoughtcrime.securesms.database.helpers.migration.V291_NullOutRemoteKeyIfEmpty
|
||||
import org.thoughtcrime.securesms.database.helpers.migration.V292_AddPollTables
|
||||
import org.thoughtcrime.securesms.database.SQLiteDatabase as SignalSqliteDatabase
|
||||
|
||||
/**
|
||||
@@ -297,10 +298,11 @@ object SignalDatabaseMigrations {
|
||||
288 to V288_CopyStickerDataHashStartToEnd,
|
||||
289 to V289_AddQuoteTargetContentTypeColumn,
|
||||
290 to V290_AddArchiveThumbnailTransferStateColumn,
|
||||
291 to V291_NullOutRemoteKeyIfEmpty
|
||||
291 to V291_NullOutRemoteKeyIfEmpty,
|
||||
292 to V292_AddPollTables
|
||||
)
|
||||
|
||||
const val DATABASE_VERSION = 291
|
||||
const val DATABASE_VERSION = 292
|
||||
|
||||
@JvmStatic
|
||||
fun migrate(context: Application, db: SignalSqliteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.database.helpers.migration
|
||||
|
||||
import android.app.Application
|
||||
import org.thoughtcrime.securesms.database.SQLiteDatabase
|
||||
|
||||
/**
|
||||
* Adds the tables and indexes necessary for polls
|
||||
*/
|
||||
@Suppress("ClassName")
|
||||
object V292_AddPollTables : SignalDatabaseMigration {
|
||||
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE poll (
|
||||
_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
author_id INTEGER NOT NULL REFERENCES recipient (_id) ON DELETE CASCADE,
|
||||
message_id INTEGER NOT NULL REFERENCES message (_id) ON DELETE CASCADE,
|
||||
question TEXT,
|
||||
allow_multiple_votes INTEGER DEFAULT 0,
|
||||
end_message_id INTEGER DEFAULT 0
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE poll_option (
|
||||
_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
poll_id INTEGER NOT NULL REFERENCES poll (_id) ON DELETE CASCADE,
|
||||
option_text TEXT,
|
||||
option_order INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
db.execSQL(
|
||||
"""
|
||||
CREATE TABLE poll_vote (
|
||||
_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
poll_id INTEGER NOT NULL REFERENCES poll (_id) ON DELETE CASCADE,
|
||||
poll_option_id INTEGER DEFAULT NULL REFERENCES poll_option (_id) ON DELETE CASCADE,
|
||||
voter_id INTEGER NOT NULL REFERENCES recipient (_id) ON DELETE CASCADE,
|
||||
vote_count INTEGER,
|
||||
date_received INTEGER DEFAULT 0,
|
||||
vote_state INTEGER DEFAULT 0,
|
||||
UNIQUE(poll_id, voter_id, poll_option_id) ON CONFLICT REPLACE
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
db.execSQL("CREATE INDEX poll_author_id_index ON poll (author_id)")
|
||||
db.execSQL("CREATE INDEX poll_message_id_index ON poll (message_id)")
|
||||
|
||||
db.execSQL("CREATE INDEX poll_option_poll_id_index ON poll_option (poll_id)")
|
||||
|
||||
db.execSQL("CREATE INDEX poll_vote_poll_id_index ON poll_vote (poll_id)")
|
||||
db.execSQL("CREATE INDEX poll_vote_poll_option_id_index ON poll_vote (poll_option_id)")
|
||||
db.execSQL("CREATE INDEX poll_vote_voter_id_index ON poll_vote (voter_id)")
|
||||
|
||||
db.execSQL("ALTER TABLE message ADD COLUMN votes_unread INTEGER DEFAULT 0")
|
||||
db.execSQL("ALTER TABLE message ADD COLUMN votes_last_seen INTEGER DEFAULT 0")
|
||||
db.execSQL("CREATE INDEX message_votes_unread_index ON message (votes_unread)")
|
||||
}
|
||||
}
|
||||
@@ -260,4 +260,8 @@ public abstract class DisplayRecord {
|
||||
public boolean isUnsupported() {
|
||||
return MessageTypes.isUnsupportedMessageType(type);
|
||||
}
|
||||
|
||||
public boolean isPollTerminate() {
|
||||
return MessageTypes.isPollTerminate(type);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.thoughtcrime.securesms.util.DateUtils;
|
||||
import org.thoughtcrime.securesms.util.ExpirationUtil;
|
||||
import org.thoughtcrime.securesms.util.GroupUtil;
|
||||
import org.thoughtcrime.securesms.util.MessageRecordUtil;
|
||||
import org.thoughtcrime.securesms.util.SignalE164Util;
|
||||
import org.thoughtcrime.securesms.util.Util;
|
||||
import org.whispersystems.signalservice.api.groupsv2.DecryptedGroupUtil;
|
||||
@@ -293,6 +294,9 @@ public abstract class MessageRecord extends DisplayRecord {
|
||||
return staticUpdateDescription(context.getString(isGroupV2() ? R.string.MessageRecord_you_unblocked_this_group : R.string.MessageRecord_you_unblocked_this_person) , Glyph.THREAD);
|
||||
} else if (isUnsupported()) {
|
||||
return staticUpdateDescription(context.getString(R.string.MessageRecord_unsupported_feature, getFromRecipient().getDisplayName(context)), Glyph.ERROR);
|
||||
} else if (MessageRecordUtil.hasPollTerminate(this)) {
|
||||
String creator = isOutgoing() ? context.getString(R.string.MessageRecord_you) : getFromRecipient().getDisplayName(context);
|
||||
return staticUpdateDescriptionWithExpiration(context.getString(R.string.MessageRecord_ended_the_poll, creator, messageExtras.pollTerminate.question), Glyph.POLL);
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -476,6 +480,10 @@ public abstract class MessageRecord extends DisplayRecord {
|
||||
return UpdateDescription.staticDescription(string, glyph);
|
||||
}
|
||||
|
||||
protected static @NonNull UpdateDescription staticUpdateDescriptionWithExpiration(@NonNull String string, Glyph glyph) {
|
||||
return UpdateDescription.staticDescriptionWithExpiration(string, glyph);
|
||||
}
|
||||
|
||||
protected static @NonNull UpdateDescription staticUpdateDescription(@NonNull String string,
|
||||
Glyph glyph,
|
||||
@ColorInt int lightTint,
|
||||
@@ -732,7 +740,7 @@ public abstract class MessageRecord extends DisplayRecord {
|
||||
isProfileChange() || isGroupV1MigrationEvent() || isChatSessionRefresh() || isBadDecryptType() ||
|
||||
isChangeNumber() || isReleaseChannelDonationRequest() || isThreadMergeEventType() || isSmsExportType() || isSessionSwitchoverEventType() ||
|
||||
isPaymentsRequestToActivate() || isPaymentsActivated() || isReportedSpam() || isMessageRequestAccepted() ||
|
||||
isBlocked() || isUnblocked() || isUnsupported();
|
||||
isBlocked() || isUnblocked() || isUnsupported() || isPollTerminate();
|
||||
}
|
||||
|
||||
public boolean isMediaPending() {
|
||||
|
||||
@@ -8,6 +8,7 @@ package org.thoughtcrime.securesms.database.model
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
|
||||
import org.thoughtcrime.securesms.database.CallTable
|
||||
import org.thoughtcrime.securesms.payments.Payment
|
||||
import org.thoughtcrime.securesms.polls.PollRecord
|
||||
|
||||
fun MessageRecord.withReactions(reactions: List<ReactionRecord>): MessageRecord {
|
||||
return if (this is MmsMessageRecord) {
|
||||
@@ -39,3 +40,11 @@ fun MessageRecord.withCall(call: CallTable.Call): MessageRecord {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
fun MessageRecord.withPoll(poll: PollRecord): MessageRecord {
|
||||
return if (this is MmsMessageRecord) {
|
||||
this.withPoll(poll)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,22 +25,20 @@ import org.thoughtcrime.securesms.database.MessageTypes;
|
||||
import org.thoughtcrime.securesms.database.documents.IdentityKeyMismatch;
|
||||
import org.thoughtcrime.securesms.database.documents.NetworkFailure;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.CryptoValue;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge;
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras;
|
||||
import org.thoughtcrime.securesms.fonts.SignalSymbols;
|
||||
import org.thoughtcrime.securesms.fonts.SignalSymbols.Glyph;
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview;
|
||||
import org.thoughtcrime.securesms.mms.Slide;
|
||||
import org.thoughtcrime.securesms.mms.SlideDeck;
|
||||
import org.thoughtcrime.securesms.payments.CryptoValueUtil;
|
||||
import org.thoughtcrime.securesms.payments.Payment;
|
||||
import org.thoughtcrime.securesms.polls.PollRecord;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
import org.whispersystems.signalservice.api.payments.FormatterOptions;
|
||||
import org.whispersystems.signalservice.api.payments.Money;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
@@ -75,6 +73,7 @@ public class MmsMessageRecord extends MessageRecord {
|
||||
private final BodyRangeList messageRanges;
|
||||
private final Payment payment;
|
||||
private final CallTable.Call call;
|
||||
private final PollRecord poll;
|
||||
private final long scheduledDate;
|
||||
private final MessageId latestRevisionId;
|
||||
private final boolean isRead;
|
||||
@@ -115,6 +114,7 @@ public class MmsMessageRecord extends MessageRecord {
|
||||
@Nullable GiftBadge giftBadge,
|
||||
@Nullable Payment payment,
|
||||
@Nullable CallTable.Call call,
|
||||
@Nullable PollRecord poll,
|
||||
long scheduledDate,
|
||||
@Nullable MessageId latestRevisionId,
|
||||
@Nullable MessageId originalMessageId,
|
||||
@@ -137,6 +137,7 @@ public class MmsMessageRecord extends MessageRecord {
|
||||
this.messageRanges = messageRanges;
|
||||
this.payment = payment;
|
||||
this.call = call;
|
||||
this.poll = poll;
|
||||
this.scheduledDate = scheduledDate;
|
||||
this.latestRevisionId = latestRevisionId;
|
||||
this.isRead = isRead;
|
||||
@@ -199,6 +200,10 @@ public class MmsMessageRecord extends MessageRecord {
|
||||
return giftBadge;
|
||||
}
|
||||
|
||||
public @Nullable PollRecord getPoll() {
|
||||
return poll;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean hasSelfMention() {
|
||||
return mentionsSelf;
|
||||
@@ -332,7 +337,7 @@ public class MmsMessageRecord extends MessageRecord {
|
||||
return new MmsMessageRecord(getId(), getFromRecipient(), getFromDeviceId(), getToRecipient(), getDateSent(), getDateReceived(), getServerTimestamp(), hasDeliveryReceipt(), getThreadId(), getBody(), getSlideDeck(),
|
||||
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(),
|
||||
hasReadReceipt(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), reactions, isRemoteDelete(), mentionsSelf,
|
||||
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getScheduledDate(), getLatestRevisionId(),
|
||||
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getPoll(), getScheduledDate(), getLatestRevisionId(),
|
||||
getOriginalMessageId(), getRevisionNumber(), isRead(), getMessageExtras());
|
||||
}
|
||||
|
||||
@@ -340,7 +345,7 @@ public class MmsMessageRecord extends MessageRecord {
|
||||
return new MmsMessageRecord(getId(), getFromRecipient(), getFromDeviceId(), getToRecipient(), getDateSent(), getDateReceived(), getServerTimestamp(), hasDeliveryReceipt(), getThreadId(), getBody(), getSlideDeck(),
|
||||
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(),
|
||||
hasReadReceipt(), null, getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf,
|
||||
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getScheduledDate(), getLatestRevisionId(),
|
||||
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getPoll(), getScheduledDate(), getLatestRevisionId(),
|
||||
getOriginalMessageId(), getRevisionNumber(), isRead(), getMessageExtras());
|
||||
}
|
||||
|
||||
@@ -362,7 +367,7 @@ public class MmsMessageRecord extends MessageRecord {
|
||||
return new MmsMessageRecord(getId(), getFromRecipient(), getFromDeviceId(), getToRecipient(), getDateSent(), getDateReceived(), getServerTimestamp(), hasDeliveryReceipt(), getThreadId(), getBody(), slideDeck,
|
||||
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(),
|
||||
hasReadReceipt(), quote, contacts, linkPreviews, isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf,
|
||||
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getScheduledDate(), getLatestRevisionId(),
|
||||
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getPoll(), getScheduledDate(), getLatestRevisionId(),
|
||||
getOriginalMessageId(), getRevisionNumber(), isRead(), getMessageExtras());
|
||||
}
|
||||
|
||||
@@ -370,7 +375,7 @@ public class MmsMessageRecord extends MessageRecord {
|
||||
return new MmsMessageRecord(getId(), getFromRecipient(), getFromDeviceId(), getToRecipient(), getDateSent(), getDateReceived(), getServerTimestamp(), hasDeliveryReceipt(), getThreadId(), getBody(), getSlideDeck(),
|
||||
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(),
|
||||
hasReadReceipt(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf,
|
||||
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), payment, getCall(), getScheduledDate(), getLatestRevisionId(),
|
||||
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), payment, getCall(), getPoll(), getScheduledDate(), getLatestRevisionId(),
|
||||
getOriginalMessageId(), getRevisionNumber(), isRead(), getMessageExtras());
|
||||
}
|
||||
|
||||
@@ -379,7 +384,15 @@ public class MmsMessageRecord extends MessageRecord {
|
||||
return new MmsMessageRecord(getId(), getFromRecipient(), getFromDeviceId(), getToRecipient(), getDateSent(), getDateReceived(), getServerTimestamp(), hasDeliveryReceipt(), getThreadId(), getBody(), getSlideDeck(),
|
||||
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(),
|
||||
hasReadReceipt(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf,
|
||||
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), call, getScheduledDate(), getLatestRevisionId(),
|
||||
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), call, getPoll(), getScheduledDate(), getLatestRevisionId(),
|
||||
getOriginalMessageId(), getRevisionNumber(), isRead(), getMessageExtras());
|
||||
}
|
||||
|
||||
public @NonNull MmsMessageRecord withPoll(@Nullable PollRecord poll) {
|
||||
return new MmsMessageRecord(getId(), getFromRecipient(), getFromDeviceId(), getToRecipient(), getDateSent(), getDateReceived(), getServerTimestamp(), hasDeliveryReceipt(), getThreadId(), getBody(), getSlideDeck(),
|
||||
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), getExpireTimerVersion(), isViewOnce(),
|
||||
hasReadReceipt(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf,
|
||||
getNotifiedTimestamp(), isViewed(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), poll, getScheduledDate(), getLatestRevisionId(),
|
||||
getOriginalMessageId(), getRevisionNumber(), isRead(), getMessageExtras());
|
||||
}
|
||||
|
||||
|
||||
@@ -230,6 +230,11 @@ public final class ThreadRecord {
|
||||
else return true;
|
||||
}
|
||||
|
||||
public boolean isPoll() {
|
||||
if (extra != null) return extra.isPoll();
|
||||
else return false;
|
||||
}
|
||||
|
||||
public boolean isPinned() {
|
||||
return isPinned;
|
||||
}
|
||||
|
||||
@@ -6,12 +6,10 @@ import android.text.SpannableStringBuilder;
|
||||
|
||||
import androidx.annotation.AnyThread;
|
||||
import androidx.annotation.ColorInt;
|
||||
import androidx.annotation.DrawableRes;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import org.thoughtcrime.securesms.fonts.SignalSymbols;
|
||||
import org.thoughtcrime.securesms.fonts.SignalSymbols.Glyph;
|
||||
import org.whispersystems.signalservice.api.push.ServiceId;
|
||||
|
||||
@@ -35,6 +33,7 @@ public final class UpdateDescription {
|
||||
private final SpannableFactory stringFactory;
|
||||
private final Spannable staticString;
|
||||
private final Glyph glyph;
|
||||
private final boolean canExpire;
|
||||
private final int lightTint;
|
||||
private final int darkTint;
|
||||
|
||||
@@ -43,6 +42,16 @@ public final class UpdateDescription {
|
||||
@Nullable Spannable staticString,
|
||||
@NonNull Glyph glyph,
|
||||
@ColorInt int lightTint,
|
||||
@ColorInt int darkTint) {
|
||||
this(mentioned, stringFactory, staticString, glyph, false, lightTint, darkTint);
|
||||
}
|
||||
|
||||
private UpdateDescription(@NonNull Collection<ServiceId> mentioned,
|
||||
@Nullable SpannableFactory stringFactory,
|
||||
@Nullable Spannable staticString,
|
||||
@NonNull Glyph glyph,
|
||||
boolean canExpire,
|
||||
@ColorInt int lightTint,
|
||||
@ColorInt int darkTint)
|
||||
{
|
||||
if (staticString == null && stringFactory == null) {
|
||||
@@ -52,6 +61,7 @@ public final class UpdateDescription {
|
||||
this.stringFactory = stringFactory;
|
||||
this.staticString = staticString;
|
||||
this.glyph = glyph;
|
||||
this.canExpire = canExpire;
|
||||
this.lightTint = lightTint;
|
||||
this.darkTint = darkTint;
|
||||
}
|
||||
@@ -84,6 +94,13 @@ public final class UpdateDescription {
|
||||
return new UpdateDescription(Collections.emptyList(), null, new SpannableString(staticString), glyph, 0, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an update description that's string value is fixed with a start glyph and has the ability to expire when a disappearing timer is set.
|
||||
*/
|
||||
public static UpdateDescription staticDescriptionWithExpiration(@NonNull String staticString, Glyph glyph) {
|
||||
return new UpdateDescription(Collections.emptyList(), null, new SpannableString(staticString), glyph, true,0, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an update description that's string value is fixed.
|
||||
*/
|
||||
@@ -144,6 +161,10 @@ public final class UpdateDescription {
|
||||
return darkTint;
|
||||
}
|
||||
|
||||
public boolean hasExpiration() {
|
||||
return canExpire;
|
||||
}
|
||||
|
||||
public static UpdateDescription concatWithNewLines(@NonNull List<UpdateDescription> updateDescriptions) {
|
||||
if (updateDescriptions.size() == 0) {
|
||||
throw new AssertionError();
|
||||
|
||||
@@ -167,6 +167,7 @@ object SignalSymbols {
|
||||
PLUS('\u002B'),
|
||||
PLUS_CIRCLE('\u2295'),
|
||||
PLUS_SQUARE('\uE06C'),
|
||||
POLL('\uE082'),
|
||||
RAISE_HAND('\uE07E'),
|
||||
RAISE_HAND_FILL('\uE084'),
|
||||
REPLY('\uE06D'),
|
||||
|
||||
@@ -226,6 +226,7 @@ public final class JobManagerFactories {
|
||||
put(PaymentNotificationSendJobV2.KEY, new PaymentNotificationSendJobV2.Factory());
|
||||
put(PaymentSendJob.KEY, new PaymentSendJob.Factory());
|
||||
put(PaymentTransactionCheckJob.KEY, new PaymentTransactionCheckJob.Factory());
|
||||
put(PollVoteJob.KEY, new PollVoteJob.Factory());
|
||||
put(PreKeysSyncJob.KEY, new PreKeysSyncJob.Factory());
|
||||
put(ProfileKeySendJob.KEY, new ProfileKeySendJob.Factory());
|
||||
put(ProfileUploadJob.KEY, new ProfileUploadJob.Factory());
|
||||
|
||||
242
app/src/main/java/org/thoughtcrime/securesms/jobs/PollVoteJob.kt
Normal file
242
app/src/main/java/org/thoughtcrime/securesms/jobs/PollVoteJob.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ import org.thoughtcrime.securesms.messages.StorySendUtil;
|
||||
import org.thoughtcrime.securesms.mms.MessageGroupContext;
|
||||
import org.thoughtcrime.securesms.mms.MmsException;
|
||||
import org.thoughtcrime.securesms.mms.OutgoingMessage;
|
||||
import org.thoughtcrime.securesms.polls.Poll;
|
||||
import org.thoughtcrime.securesms.ratelimit.ProofRequiredExceptionHandler;
|
||||
import org.thoughtcrime.securesms.recipients.Recipient;
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId;
|
||||
@@ -275,15 +276,17 @@ public final class PushGroupSendJob extends PushSendJob {
|
||||
try {
|
||||
rotateSenderCertificateIfNecessary();
|
||||
|
||||
GroupId.Push groupId = groupRecipient.requireGroupId().requirePush();
|
||||
Optional<byte[]> profileKey = getProfileKey(groupRecipient);
|
||||
Optional<SignalServiceDataMessage.Sticker> sticker = getStickerFor(message);
|
||||
List<SharedContact> sharedContacts = getSharedContactsFor(message);
|
||||
List<SignalServicePreview> previews = getPreviewsFor(message);
|
||||
List<SignalServiceDataMessage.Mention> mentions = getMentionsFor(message.getMentions());
|
||||
List<BodyRange> bodyRanges = getBodyRanges(message);
|
||||
List<Attachment> attachments = Stream.of(message.getAttachments()).filterNot(Attachment::isSticker).toList();
|
||||
List<SignalServiceAttachment> attachmentPointers = getAttachmentPointersFor(attachments);
|
||||
GroupId.Push groupId = groupRecipient.requireGroupId().requirePush();
|
||||
Optional<byte[]> profileKey = getProfileKey(groupRecipient);
|
||||
Optional<SignalServiceDataMessage.Sticker> sticker = getStickerFor(message);
|
||||
List<SharedContact> sharedContacts = getSharedContactsFor(message);
|
||||
List<SignalServicePreview> previews = getPreviewsFor(message);
|
||||
List<SignalServiceDataMessage.Mention> mentions = getMentionsFor(message.getMentions());
|
||||
List<BodyRange> bodyRanges = getBodyRanges(message);
|
||||
Optional<SignalServiceDataMessage.PollCreate> pollCreate = getPollCreate(message);
|
||||
Optional<SignalServiceDataMessage.PollTerminate> pollTerminate = getPollTerminate(message);
|
||||
List<Attachment> attachments = Stream.of(message.getAttachments()).filterNot(Attachment::isSticker).toList();
|
||||
List<SignalServiceAttachment> attachmentPointers = getAttachmentPointersFor(attachments);
|
||||
boolean isRecipientUpdate = Stream.of(SignalDatabase.groupReceipts().getGroupReceiptInfo(messageId))
|
||||
.anyMatch(info -> info.getStatus() > GroupReceiptTable.STATUS_UNDELIVERED);
|
||||
|
||||
@@ -362,7 +365,9 @@ public final class PushGroupSendJob extends PushSendJob {
|
||||
.withSharedContacts(sharedContacts)
|
||||
.withPreviews(previews)
|
||||
.withMentions(mentions)
|
||||
.withBodyRanges(bodyRanges);
|
||||
.withBodyRanges(bodyRanges)
|
||||
.withPollCreate(pollCreate.orElse(null))
|
||||
.withPollTerminate(pollTerminate.orElse(null));
|
||||
|
||||
if (message.getParentStoryId() != null) {
|
||||
try {
|
||||
@@ -407,6 +412,23 @@ public final class PushGroupSendJob extends PushSendJob {
|
||||
}
|
||||
}
|
||||
|
||||
private Optional<SignalServiceDataMessage.PollCreate> getPollCreate(OutgoingMessage message) {
|
||||
Poll poll = message.getPoll();
|
||||
if (poll == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
return Optional.of(new SignalServiceDataMessage.PollCreate(poll.getQuestion(), poll.getAllowMultipleVotes(), poll.getPollOptions()));
|
||||
}
|
||||
|
||||
private Optional<SignalServiceDataMessage.PollTerminate> getPollTerminate(OutgoingMessage message) {
|
||||
if (message.getMessageExtras() == null || message.getMessageExtras().pollTerminate == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
return Optional.of(new SignalServiceDataMessage.PollTerminate(message.getMessageExtras().pollTerminate.targetTimestamp));
|
||||
}
|
||||
|
||||
public static long getMessageId(@Nullable byte[] serializedData) {
|
||||
JsonJobData data = JsonJobData.deserialize(serializedData);
|
||||
return data.getLong(KEY_MESSAGE_ID);
|
||||
|
||||
@@ -38,6 +38,8 @@ import org.thoughtcrime.securesms.linkpreview.LinkPreview
|
||||
import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory.MediaPreviewArgs
|
||||
import org.thoughtcrime.securesms.messagedetails.InternalMessageDetailsFragment.Companion.create
|
||||
import org.thoughtcrime.securesms.messagedetails.MessageDetailsAdapter.MessageDetailsViewState
|
||||
import org.thoughtcrime.securesms.polls.PollOption
|
||||
import org.thoughtcrime.securesms.polls.PollRecord
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet.forMessageRecord
|
||||
@@ -396,6 +398,18 @@ class MessageDetailsFragment : FullScreenDialogFragment(), MessageDetailsAdapter
|
||||
Toast.makeText(requireContext(), "Can't touch this.", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
override fun onViewResultsClicked(pollId: Long) {
|
||||
Log.w(TAG, "Not yet implemented!", Exception())
|
||||
}
|
||||
|
||||
override fun onViewPollClicked(messageId: Long) {
|
||||
Log.w(TAG, "Not yet implemented!", Exception())
|
||||
}
|
||||
|
||||
override fun onToggleVote(poll: PollRecord, pollOption: PollOption, isChecked: Boolean) {
|
||||
Log.w(TAG, "Not yet implemented!", Exception())
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
fun onMessageDetailsFragmentDismissed()
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@ import org.thoughtcrime.securesms.database.model.ReactionRecord
|
||||
import org.thoughtcrime.securesms.database.model.StickerRecord
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.PollTerminate
|
||||
import org.thoughtcrime.securesms.database.model.toBodyRangeList
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.groups.BadGroupIdException
|
||||
@@ -83,6 +85,7 @@ import org.thoughtcrime.securesms.mms.IncomingMessage
|
||||
import org.thoughtcrime.securesms.mms.MmsException
|
||||
import org.thoughtcrime.securesms.mms.QuoteModel
|
||||
import org.thoughtcrime.securesms.notifications.v2.ConversationId
|
||||
import org.thoughtcrime.securesms.polls.Poll
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.Recipient.HiddenState
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
@@ -168,6 +171,9 @@ object DataMessageProcessor {
|
||||
message.isMediaMessage -> insertResult = handleMediaMessage(context, envelope, metadata, message, senderRecipient, threadRecipient, groupId, receivedTime, localMetrics)
|
||||
message.body != null -> insertResult = handleTextMessage(context, envelope, metadata, message, senderRecipient, threadRecipient, groupId, receivedTime, localMetrics)
|
||||
message.groupCallUpdate != null -> handleGroupCallUpdateMessage(envelope, message, senderRecipient.id, groupId)
|
||||
message.pollCreate != null -> insertResult = handlePollCreate(context, envelope, metadata, message, senderRecipient, threadRecipient, groupId, receivedTime)
|
||||
message.pollTerminate != null -> insertResult = handlePollTerminate(context, envelope, metadata, message, senderRecipient, earlyMessageCacheEntry, threadRecipient, groupId, receivedTime)
|
||||
message.pollVote != null -> messageId = handlePollVote(context, envelope, message, senderRecipient, earlyMessageCacheEntry)
|
||||
}
|
||||
|
||||
messageId = messageId ?: insertResult?.messageId?.let { MessageId(it) }
|
||||
@@ -1040,6 +1046,178 @@ object DataMessageProcessor {
|
||||
)
|
||||
}
|
||||
|
||||
fun handlePollCreate(
|
||||
context: Context,
|
||||
envelope: Envelope,
|
||||
metadata: EnvelopeMetadata,
|
||||
message: DataMessage,
|
||||
senderRecipient: Recipient,
|
||||
threadRecipient: Recipient,
|
||||
groupId: GroupId.V2?,
|
||||
receivedTime: Long
|
||||
): InsertResult? {
|
||||
log(envelope.timestamp!!, "Handle poll creation")
|
||||
val poll: DataMessage.PollCreate = message.pollCreate!!
|
||||
|
||||
handlePossibleExpirationUpdate(envelope, metadata, senderRecipient, threadRecipient, groupId, message.expireTimerDuration, message.expireTimerVersion, receivedTime)
|
||||
|
||||
if (groupId == null) {
|
||||
warn(envelope.timestamp!!, "[handlePollCreate] Polls can only be sent to groups. author: $senderRecipient")
|
||||
return null
|
||||
}
|
||||
|
||||
val groupRecord = SignalDatabase.groups.getGroup(groupId).orNull()
|
||||
if (groupRecord == null || !groupRecord.members.contains(senderRecipient.id)) {
|
||||
warn(envelope.timestamp!!, "[handlePollCreate] Poll author is not in the group. author $senderRecipient")
|
||||
return null
|
||||
}
|
||||
|
||||
val pollMessage = IncomingMessage(
|
||||
type = MessageType.NORMAL,
|
||||
from = senderRecipient.id,
|
||||
sentTimeMillis = envelope.timestamp!!,
|
||||
serverTimeMillis = envelope.serverTimestamp!!,
|
||||
receivedTimeMillis = receivedTime,
|
||||
groupId = groupId,
|
||||
expiresIn = message.expireTimerDuration.inWholeMilliseconds,
|
||||
isUnidentified = metadata.sealedSender,
|
||||
serverGuid = envelope.serverGuid,
|
||||
poll = Poll(
|
||||
question = poll.question!!,
|
||||
allowMultipleVotes = poll.allowMultiple!!,
|
||||
pollOptions = poll.options,
|
||||
authorId = senderRecipient.id.toLong()
|
||||
),
|
||||
body = poll.question!!
|
||||
)
|
||||
|
||||
val insertResult: InsertResult? = SignalDatabase.messages.insertMessageInbox(pollMessage).orNull()
|
||||
return if (insertResult != null) {
|
||||
AppDependencies.messageNotifier.updateNotification(context, ConversationId.forConversation(insertResult.threadId))
|
||||
insertResult
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun handlePollTerminate(
|
||||
context: Context,
|
||||
envelope: Envelope,
|
||||
metadata: EnvelopeMetadata,
|
||||
message: DataMessage,
|
||||
senderRecipient: Recipient,
|
||||
earlyMessageCacheEntry: EarlyMessageCacheEntry? = null,
|
||||
threadRecipient: Recipient,
|
||||
groupId: GroupId.V2?,
|
||||
receivedTime: Long
|
||||
): InsertResult? {
|
||||
val pollTerminate: DataMessage.PollTerminate = message.pollTerminate!!
|
||||
val targetSentTimestamp = pollTerminate.targetSentTimestamp!!
|
||||
|
||||
log(envelope.timestamp!!, "Handle poll termination for poll $targetSentTimestamp")
|
||||
|
||||
handlePossibleExpirationUpdate(envelope, metadata, senderRecipient, threadRecipient, groupId, message.expireTimerDuration, message.expireTimerVersion, receivedTime)
|
||||
|
||||
val messageId = handlePollValidation(envelope = envelope, targetSentTimestamp = targetSentTimestamp, senderRecipient = senderRecipient, earlyMessageCacheEntry = earlyMessageCacheEntry, targetAuthor = senderRecipient)
|
||||
if (messageId == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
val poll = SignalDatabase.polls.getPoll(messageId.id)
|
||||
if (poll == null) {
|
||||
warn(envelope.timestamp!!, "[handlePollTerminate] Poll was not found. timestamp: $targetSentTimestamp author: ${senderRecipient.id}")
|
||||
return null
|
||||
}
|
||||
|
||||
val pollMessage = IncomingMessage(
|
||||
type = MessageType.POLL_TERMINATE,
|
||||
from = senderRecipient.id,
|
||||
sentTimeMillis = envelope.timestamp!!,
|
||||
serverTimeMillis = envelope.serverTimestamp!!,
|
||||
receivedTimeMillis = receivedTime,
|
||||
groupId = groupId,
|
||||
expiresIn = message.expireTimerDuration.inWholeMilliseconds,
|
||||
isUnidentified = metadata.sealedSender,
|
||||
serverGuid = envelope.serverGuid,
|
||||
messageExtras = MessageExtras(pollTerminate = PollTerminate(poll.question, poll.messageId, targetSentTimestamp))
|
||||
)
|
||||
|
||||
val insertResult: InsertResult? = SignalDatabase.messages.insertMessageInbox(pollMessage).orNull()
|
||||
|
||||
return if (insertResult != null) {
|
||||
AppDependencies.messageNotifier.updateNotification(context, ConversationId.forConversation(insertResult.threadId))
|
||||
insertResult
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun handlePollVote(
|
||||
context: Context,
|
||||
envelope: Envelope,
|
||||
message: DataMessage,
|
||||
senderRecipient: Recipient,
|
||||
earlyMessageCacheEntry: EarlyMessageCacheEntry?
|
||||
): MessageId? {
|
||||
val pollVote: DataMessage.PollVote = message.pollVote!!
|
||||
val targetSentTimestamp = pollVote.targetSentTimestamp!!
|
||||
|
||||
log(envelope.timestamp!!, "Handle poll vote for poll $targetSentTimestamp")
|
||||
|
||||
val targetAuthorServiceId: ServiceId = ServiceId.parseOrThrow(pollVote.targetAuthorAciBinary!!)
|
||||
if (targetAuthorServiceId.isUnknown) {
|
||||
warn(envelope.timestamp!!, "[handlePollVote] Vote was to an unknown UUID! Ignoring the message.")
|
||||
return null
|
||||
}
|
||||
|
||||
val messageId = handlePollValidation(envelope, targetSentTimestamp, senderRecipient, earlyMessageCacheEntry, Recipient.externalPush(targetAuthorServiceId))
|
||||
if (messageId == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
val targetMessage = SignalDatabase.messages.getMessageRecord(messageId.id)
|
||||
val pollId = SignalDatabase.polls.getPollId(messageId.id)
|
||||
if (pollId == null) {
|
||||
warn(envelope.timestamp!!, "[handlePollVote] Poll was not found. timestamp: $targetSentTimestamp author: ${senderRecipient.id}")
|
||||
return null
|
||||
}
|
||||
|
||||
val existingVoteCount = SignalDatabase.polls.getCurrentPollVoteCount(pollId, senderRecipient.id.toLong())
|
||||
val currentVoteCount = pollVote.voteCount?.toLong() ?: 0
|
||||
if (currentVoteCount <= existingVoteCount) {
|
||||
warn(envelope.timestamp!!, "[handlePollVote] Incoming vote count was not higher. timestamp: $targetSentTimestamp author: ${senderRecipient.id}")
|
||||
return null
|
||||
}
|
||||
|
||||
val allOptionIds = SignalDatabase.polls.getPollOptionIds(pollId)
|
||||
if (pollVote.optionIndexes.any { it < 0 || it >= allOptionIds.size }) {
|
||||
warn(envelope.timestamp!!, "[handlePollVote] Invalid option indexes. timestamp: $targetSentTimestamp author: ${senderRecipient.id}")
|
||||
return null
|
||||
}
|
||||
|
||||
if (!SignalDatabase.polls.canAllowMultipleVotes(pollId) && pollVote.optionIndexes.size > 1) {
|
||||
warn(envelope.timestamp!!, "[handlePollVote] Can not vote multiple times. timestamp: $targetSentTimestamp author: ${senderRecipient.id}")
|
||||
return null
|
||||
}
|
||||
|
||||
if (SignalDatabase.polls.hasEnded(pollId)) {
|
||||
warn(envelope.timestamp!!, "[handlePollVote] Poll has already ended. timestamp: $targetSentTimestamp author: ${senderRecipient.id}")
|
||||
return null
|
||||
}
|
||||
|
||||
SignalDatabase.polls.insertVotes(
|
||||
pollId = pollId,
|
||||
pollOptionIds = pollVote.optionIndexes.map { index -> allOptionIds[index] },
|
||||
voterId = senderRecipient.id.toLong(),
|
||||
voteCount = pollVote.voteCount?.toLong() ?: 0,
|
||||
messageId = messageId
|
||||
)
|
||||
|
||||
AppDependencies.messageNotifier.updateNotification(context, ConversationId.fromMessageRecord(targetMessage))
|
||||
|
||||
return messageId
|
||||
}
|
||||
|
||||
fun notifyTypingStoppedFromIncomingMessage(context: Context, senderRecipient: Recipient, threadRecipientId: RecipientId, device: Int) {
|
||||
val threadId = SignalDatabase.threads.getThreadIdIfExistsFor(threadRecipientId)
|
||||
|
||||
@@ -1167,6 +1345,53 @@ object DataMessageProcessor {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* When ending or voting on a poll, checks validity of the message. Specifically
|
||||
* that the message exists, was only sent to a group, and the sender
|
||||
* is a member of the group. Returns the messageId of the poll if valid, null otherwise.
|
||||
*/
|
||||
private fun handlePollValidation(
|
||||
envelope: Envelope,
|
||||
targetSentTimestamp: Long,
|
||||
senderRecipient: Recipient,
|
||||
earlyMessageCacheEntry: EarlyMessageCacheEntry?,
|
||||
targetAuthor: Recipient
|
||||
): MessageId? {
|
||||
val targetMessage = SignalDatabase.messages.getMessageFor(targetSentTimestamp, targetAuthor.id)
|
||||
if (targetMessage == null) {
|
||||
warn(envelope.timestamp!!, "[handlePollValidation] Could not find matching message! Putting it in the early message cache. timestamp: $targetSentTimestamp author: ${targetAuthor.id}")
|
||||
if (earlyMessageCacheEntry != null) {
|
||||
AppDependencies.earlyMessageCache.store(senderRecipient.id, targetSentTimestamp, earlyMessageCacheEntry)
|
||||
PushProcessEarlyMessagesJob.enqueue()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (targetMessage.isRemoteDelete) {
|
||||
warn(envelope.timestamp!!, "[handlePollValidation] Found a matching message, but it's flagged as remotely deleted. timestamp: $targetSentTimestamp author: ${targetAuthor.id}")
|
||||
return null
|
||||
}
|
||||
|
||||
val targetThread = SignalDatabase.threads.getThreadRecord(targetMessage.threadId)
|
||||
if (targetThread == null) {
|
||||
warn(envelope.timestamp!!, "[handlePollValidation] Could not find a thread for the message. timestamp: $targetSentTimestamp author: ${targetAuthor.id}")
|
||||
return null
|
||||
}
|
||||
|
||||
val groupRecord = SignalDatabase.groups.getGroup(targetThread.recipient.id).orNull()
|
||||
if (groupRecord == null) {
|
||||
warn(envelope.timestamp!!, "[handlePollValidation] Target thread needs to be a group. timestamp: $targetSentTimestamp author: ${targetAuthor.id}")
|
||||
return null
|
||||
}
|
||||
|
||||
if (!groupRecord.members.contains(senderRecipient.id)) {
|
||||
warn(envelope.timestamp!!, "[handlePollValidation] Sender is not in the group. timestamp: $targetSentTimestamp author: ${targetAuthor.id}")
|
||||
return null
|
||||
}
|
||||
|
||||
return MessageId(targetMessage.id)
|
||||
}
|
||||
|
||||
fun getContacts(message: DataMessage): List<Contact> {
|
||||
return message.contact.map { ContactModelMapper.remoteToLocal(it) }
|
||||
}
|
||||
|
||||
@@ -51,7 +51,10 @@ object SignalServiceProtoUtil {
|
||||
bodyRanges.isNotEmpty() ||
|
||||
sticker != null ||
|
||||
reaction != null ||
|
||||
hasRemoteDelete
|
||||
hasRemoteDelete ||
|
||||
pollCreate != null ||
|
||||
pollVote != null ||
|
||||
pollTerminate != null
|
||||
}
|
||||
|
||||
val DataMessage.hasDisallowedAnnouncementOnlyContent: Boolean
|
||||
|
||||
@@ -41,6 +41,8 @@ import org.thoughtcrime.securesms.database.model.StickerPackId
|
||||
import org.thoughtcrime.securesms.database.model.StoryType
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.PollTerminate
|
||||
import org.thoughtcrime.securesms.database.model.toBodyRangeList
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.groups.BadGroupIdException
|
||||
@@ -87,6 +89,7 @@ import org.thoughtcrime.securesms.mms.OutgoingMessage
|
||||
import org.thoughtcrime.securesms.mms.QuoteModel
|
||||
import org.thoughtcrime.securesms.notifications.MarkReadReceiver
|
||||
import org.thoughtcrime.securesms.payments.MobileCoinPublicAddress
|
||||
import org.thoughtcrime.securesms.polls.Poll
|
||||
import org.thoughtcrime.securesms.ratelimit.RateLimitUtil
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
@@ -241,6 +244,12 @@ object SyncMessageProcessor {
|
||||
}
|
||||
dataMessage.hasRemoteDelete -> DataMessageProcessor.handleRemoteDelete(context, envelope, dataMessage, senderRecipient.id, earlyMessageCacheEntry)
|
||||
dataMessage.isMediaMessage -> threadId = handleSynchronizeSentMediaMessage(context, sent, envelope.timestamp!!, senderRecipient, threadRecipient)
|
||||
dataMessage.pollCreate != null -> threadId = handleSynchronizedPollCreate(envelope, dataMessage, sent, senderRecipient)
|
||||
dataMessage.pollVote != null -> {
|
||||
DataMessageProcessor.handlePollVote(context, envelope, dataMessage, senderRecipient, earlyMessageCacheEntry)
|
||||
threadId = SignalDatabase.threads.getOrCreateThreadIdFor(getSyncMessageDestination(sent))
|
||||
}
|
||||
dataMessage.pollTerminate != null -> threadId = handleSynchronizedPollEnd(envelope, dataMessage, sent, senderRecipient, earlyMessageCacheEntry)
|
||||
else -> threadId = handleSynchronizeSentTextMessage(sent, envelope.timestamp!!)
|
||||
}
|
||||
|
||||
@@ -1725,6 +1734,120 @@ object SyncMessageProcessor {
|
||||
MultiDeviceAttachmentBackfillUpdateJob.enqueue(request.targetMessage!!, request.targetConversation!!, messageId)
|
||||
}
|
||||
|
||||
private fun handleSynchronizedPollCreate(
|
||||
envelope: Envelope,
|
||||
message: DataMessage,
|
||||
sent: Sent,
|
||||
senderRecipient: Recipient
|
||||
): Long {
|
||||
log(envelope.timestamp!!, "Synchronize sent poll creation message.")
|
||||
|
||||
val recipient = getSyncMessageDestination(sent)
|
||||
if (!recipient.isGroup) {
|
||||
warn(envelope.timestamp!!, "Poll creation messages should only be synced in groups. Dropping.")
|
||||
return -1
|
||||
}
|
||||
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(recipient)
|
||||
|
||||
val expiresInMillis = message.expireTimerDuration.inWholeMilliseconds
|
||||
if (recipient.expiresInSeconds != message.expireTimerDuration.inWholeSeconds.toInt() || ((message.expireTimerVersion ?: -1) > recipient.expireTimerVersion)) {
|
||||
handleSynchronizeSentExpirationUpdate(sent, sideEffect = true)
|
||||
}
|
||||
|
||||
val poll: DataMessage.PollCreate = message.pollCreate!!
|
||||
val outgoingMessage = OutgoingMessage.pollMessage(
|
||||
threadRecipient = recipient,
|
||||
sentTimeMillis = sent.timestamp!!,
|
||||
expiresIn = recipient.expiresInSeconds.seconds.inWholeMilliseconds,
|
||||
poll = Poll(
|
||||
question = poll.question!!,
|
||||
allowMultipleVotes = poll.allowMultiple!!,
|
||||
pollOptions = poll.options,
|
||||
authorId = senderRecipient.id.toLong()
|
||||
),
|
||||
question = poll.question!!
|
||||
)
|
||||
|
||||
val messageId = SignalDatabase.messages.insertMessageOutbox(outgoingMessage, threadId, false, GroupReceiptTable.STATUS_UNKNOWN, null).messageId
|
||||
updateGroupReceiptStatus(sent, messageId, recipient.requireGroupId())
|
||||
|
||||
log(envelope.timestamp!!, "Inserted sync poll create message as messageId $messageId")
|
||||
|
||||
SignalDatabase.messages.markAsSent(messageId, true)
|
||||
|
||||
if (expiresInMillis > 0) {
|
||||
SignalDatabase.messages.markExpireStarted(messageId, sent.expirationStartTimestamp ?: 0)
|
||||
AppDependencies.expiringMessageManager.scheduleDeletion(messageId, recipient.isGroup, sent.expirationStartTimestamp ?: 0, expiresInMillis)
|
||||
}
|
||||
|
||||
return threadId
|
||||
}
|
||||
|
||||
private fun handleSynchronizedPollEnd(
|
||||
envelope: Envelope,
|
||||
message: DataMessage,
|
||||
sent: Sent,
|
||||
senderRecipient: Recipient,
|
||||
earlyMessageCacheEntry: EarlyMessageCacheEntry?
|
||||
): Long {
|
||||
log(envelope.timestamp!!, "Synchronize sent poll terminate message")
|
||||
|
||||
val recipient = getSyncMessageDestination(sent)
|
||||
if (!recipient.isGroup) {
|
||||
warn(envelope.timestamp!!, "Poll termination messages should only be synced in groups. Dropping.")
|
||||
return -1
|
||||
}
|
||||
|
||||
val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(recipient)
|
||||
|
||||
val expiresInMillis = message.expireTimerDuration.inWholeMilliseconds
|
||||
if (recipient.expiresInSeconds != message.expireTimerDuration.inWholeSeconds.toInt() || ((message.expireTimerVersion ?: -1) > recipient.expireTimerVersion)) {
|
||||
handleSynchronizeSentExpirationUpdate(sent, sideEffect = true)
|
||||
}
|
||||
|
||||
val pollTerminate = message.pollTerminate!!
|
||||
val targetMessage = SignalDatabase.messages.getMessageFor(pollTerminate.targetSentTimestamp!!, Recipient.self().id)
|
||||
if (targetMessage == null) {
|
||||
warn(envelope.timestamp!!, "Unable to find target message for poll termination. Putting in early message cache.")
|
||||
if (earlyMessageCacheEntry != null) {
|
||||
AppDependencies.earlyMessageCache.store(senderRecipient.id, pollTerminate.targetSentTimestamp!!, earlyMessageCacheEntry)
|
||||
PushProcessEarlyMessagesJob.enqueue()
|
||||
}
|
||||
return -1
|
||||
}
|
||||
val poll = SignalDatabase.polls.getPoll(targetMessage.id)
|
||||
if (poll == null) {
|
||||
warn(envelope.timestamp!!, "Unable to find poll for poll termination. Dropping.")
|
||||
return -1
|
||||
}
|
||||
|
||||
val outgoingMessage = OutgoingMessage.pollTerminateMessage(
|
||||
threadRecipient = recipient,
|
||||
sentTimeMillis = sent.timestamp!!,
|
||||
expiresIn = recipient.expiresInSeconds.seconds.inWholeMilliseconds,
|
||||
messageExtras = MessageExtras(
|
||||
pollTerminate = PollTerminate(
|
||||
question = poll.question,
|
||||
messageId = poll.messageId,
|
||||
targetTimestamp = pollTerminate.targetSentTimestamp!!
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val messageId = SignalDatabase.messages.insertMessageOutbox(outgoingMessage, threadId, false, GroupReceiptTable.STATUS_UNKNOWN, null).messageId
|
||||
SignalDatabase.messages.markAsSent(messageId, true)
|
||||
|
||||
log(envelope.timestamp!!, "Inserted sync poll end message as messageId $messageId")
|
||||
|
||||
if (expiresInMillis > 0) {
|
||||
SignalDatabase.messages.markExpireStarted(messageId, sent.expirationStartTimestamp ?: 0)
|
||||
AppDependencies.expiringMessageManager.scheduleDeletion(messageId, recipient.isGroup, sent.expirationStartTimestamp ?: 0, expiresInMillis)
|
||||
}
|
||||
|
||||
return threadId
|
||||
}
|
||||
|
||||
private fun ConversationIdentifier.toRecipientId(): RecipientId? {
|
||||
return when {
|
||||
threadGroupId != null -> {
|
||||
|
||||
@@ -12,6 +12,7 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview
|
||||
import org.thoughtcrime.securesms.polls.Poll
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
||||
class IncomingMessage(
|
||||
@@ -39,7 +40,8 @@ class IncomingMessage(
|
||||
mentions: List<Mention> = emptyList(),
|
||||
val giftBadge: GiftBadge? = null,
|
||||
val messageExtras: MessageExtras? = null,
|
||||
val isGroupAdd: Boolean = false
|
||||
val isGroupAdd: Boolean = false,
|
||||
val poll: Poll? = null
|
||||
) {
|
||||
|
||||
val attachments: List<Attachment> = ArrayList(attachments)
|
||||
|
||||
@@ -13,6 +13,7 @@ import org.thoughtcrime.securesms.database.model.databaseprotos.GV2UpdateDescrip
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.MessageExtras
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview
|
||||
import org.thoughtcrime.securesms.polls.Poll
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.sms.GroupV2UpdateMessageUtil
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
@@ -58,6 +59,7 @@ data class OutgoingMessage(
|
||||
val isMessageRequestAccept: Boolean = false,
|
||||
val isBlocked: Boolean = false,
|
||||
val isUnblocked: Boolean = false,
|
||||
val poll: Poll? = null,
|
||||
val messageExtras: MessageExtras? = null
|
||||
) {
|
||||
|
||||
@@ -470,6 +472,31 @@ data class OutgoingMessage(
|
||||
)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun pollMessage(threadRecipient: Recipient, sentTimeMillis: Long, expiresIn: Long, poll: Poll, question: String = ""): OutgoingMessage {
|
||||
return OutgoingMessage(
|
||||
threadRecipient = threadRecipient,
|
||||
sentTimeMillis = sentTimeMillis,
|
||||
expiresIn = expiresIn,
|
||||
poll = poll,
|
||||
body = question,
|
||||
isUrgent = true,
|
||||
isSecure = true
|
||||
)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun pollTerminateMessage(threadRecipient: Recipient, sentTimeMillis: Long, expiresIn: Long, messageExtras: MessageExtras): OutgoingMessage {
|
||||
return OutgoingMessage(
|
||||
threadRecipient = threadRecipient,
|
||||
sentTimeMillis = sentTimeMillis,
|
||||
expiresIn = expiresIn,
|
||||
messageExtras = messageExtras,
|
||||
isUrgent = true,
|
||||
isSecure = true
|
||||
)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun quickReply(
|
||||
threadRecipient: Recipient,
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.annotation.StringRes
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiStrings
|
||||
import org.thoughtcrime.securesms.contactshare.Contact
|
||||
import org.thoughtcrime.securesms.contactshare.ContactUtil
|
||||
import org.thoughtcrime.securesms.database.MentionUtil
|
||||
@@ -24,6 +25,7 @@ import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.mms.Slide
|
||||
import org.thoughtcrime.securesms.mms.SlideDeck
|
||||
import org.thoughtcrime.securesms.permissions.Permissions
|
||||
import org.thoughtcrime.securesms.polls.PollVote
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientUtil
|
||||
import org.thoughtcrime.securesms.service.KeyCachingService
|
||||
@@ -32,6 +34,8 @@ import org.thoughtcrime.securesms.util.MediaUtil
|
||||
import org.thoughtcrime.securesms.util.SpanUtil
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.thoughtcrime.securesms.util.hasGiftBadge
|
||||
import org.thoughtcrime.securesms.util.hasPoll
|
||||
import org.thoughtcrime.securesms.util.hasPollTerminate
|
||||
import org.thoughtcrime.securesms.util.hasSharedContact
|
||||
import org.thoughtcrime.securesms.util.hasSticker
|
||||
import org.thoughtcrime.securesms.util.isMediaMessage
|
||||
@@ -240,6 +244,10 @@ class MessageNotification(threadRecipient: Recipient, record: MessageRecord) : N
|
||||
ThreadBodyUtil.getFormattedBodyForNotification(context, record, null)
|
||||
} else if (record.isPaymentNotification || record.isPaymentTombstone) {
|
||||
ThreadBodyUtil.getFormattedBodyForNotification(context, record, null)
|
||||
} else if (record.hasPoll()) {
|
||||
ThreadBodyUtil.getFormattedBodyForPollNotification(context, record as MmsMessageRecord)
|
||||
} else if (record.hasPollTerminate()) {
|
||||
ThreadBodyUtil.getFormattedBodyForPollEndNotification(context, record as MmsMessageRecord)
|
||||
} else {
|
||||
getBodyWithMentionsAndStyles(context, record)
|
||||
}
|
||||
@@ -380,3 +388,33 @@ class ReactionNotification(threadRecipient: Recipient, record: MessageRecord, va
|
||||
return "ReactionNotification(timestamp=$timestamp, isNewNotification=$isNewNotification)"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a notification associated with a new vote.
|
||||
*/
|
||||
class VoteNotification(threadRecipient: Recipient, record: MessageRecord, val vote: PollVote) : NotificationItem(threadRecipient, record) {
|
||||
override val timestamp: Long = vote.dateReceived
|
||||
override val authorRecipient: Recipient = Recipient.resolved(vote.voterId)
|
||||
override val isNewNotification: Boolean = timestamp > notifiedTimestamp
|
||||
|
||||
override fun getPrimaryTextActual(context: Context): CharSequence {
|
||||
return if (KeyCachingService.isLocked(context)) {
|
||||
SpanUtil.italic(context.getString(R.string.MessageNotifier_locked_message))
|
||||
} else {
|
||||
context.getString(R.string.MessageNotifier_s_voted_in_poll, EmojiStrings.POLL, authorRecipient.getDisplayName(context), vote.question)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getStartingPosition(context: Context): Int {
|
||||
return SignalDatabase.messages.getMessagePositionInConversation(threadId = thread.threadId, groupStoryId = 0L, receivedTimestamp = record.dateReceived)
|
||||
}
|
||||
|
||||
override fun getLargeIconUri(): Uri? = null
|
||||
override fun getBigPictureUri(): Uri? = null
|
||||
override fun getThumbnailInfo(context: Context): ThumbnailInfo = ThumbnailInfo()
|
||||
override fun canReply(context: Context): Boolean = false
|
||||
|
||||
override fun toString(): String {
|
||||
return "VoteNotification(timestamp=$timestamp, isNewNotification=$isNewNotification)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.ReactionRecord
|
||||
import org.thoughtcrime.securesms.notifications.profiles.NotificationProfile
|
||||
import org.thoughtcrime.securesms.polls.PollVote
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.util.isStoryReaction
|
||||
|
||||
@@ -37,6 +38,7 @@ object NotificationStateProvider {
|
||||
val threadRecipient: Recipient? = SignalDatabase.threads.getRecipientForThreadId(record.threadId)
|
||||
if (threadRecipient != null) {
|
||||
val hasUnreadReactions = CursorUtil.requireInt(unreadMessages, MessageTable.REACTIONS_UNREAD) == 1
|
||||
val hasUnreadVotes = CursorUtil.requireInt(unreadMessages, MessageTable.VOTES_UNREAD) == 1
|
||||
val conversationId = ConversationId.fromMessageRecord(record)
|
||||
|
||||
val parentRecord = conversationId.groupStoryId?.let {
|
||||
@@ -56,17 +58,24 @@ object NotificationStateProvider {
|
||||
if (attachments.isNotEmpty()) {
|
||||
record = record.withAttachments(attachments)
|
||||
}
|
||||
val poll = SignalDatabase.polls.getPoll(record.id)
|
||||
if (poll != null) {
|
||||
record = record.withPoll(poll)
|
||||
}
|
||||
}
|
||||
|
||||
messages += NotificationMessage(
|
||||
messageRecord = record,
|
||||
reactions = if (hasUnreadReactions) SignalDatabase.reactions.getReactions(MessageId(record.id)) else emptyList(),
|
||||
pollVotes = if (hasUnreadVotes) SignalDatabase.polls.getAllVotes(record.id) else emptyList(),
|
||||
threadRecipient = threadRecipient,
|
||||
thread = conversationId,
|
||||
stickyThread = stickyThreads.containsKey(conversationId),
|
||||
isUnreadMessage = CursorUtil.requireInt(unreadMessages, MessageTable.READ) == 0,
|
||||
hasUnreadReactions = hasUnreadReactions,
|
||||
hasUnreadVotes = hasUnreadVotes,
|
||||
lastReactionRead = CursorUtil.requireLong(unreadMessages, MessageTable.REACTIONS_LAST_SEEN),
|
||||
lastVoteRead = CursorUtil.requireLong(unreadMessages, MessageTable.VOTES_LAST_SEEN),
|
||||
isParentStorySentBySelf = parentRecord?.isOutgoing ?: false,
|
||||
hasSelfRepliedToStory = hasSelfRepliedToGroupStory ?: false
|
||||
)
|
||||
@@ -108,6 +117,17 @@ object NotificationStateProvider {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (notification.hasUnreadVotes) {
|
||||
notification.pollVotes.forEach {
|
||||
when (notification.shouldIncludeVote(it, notificationProfile)) {
|
||||
MessageInclusion.INCLUDE -> notificationItems.add(VoteNotification(notification.threadRecipient, notification.messageRecord, it))
|
||||
MessageInclusion.EXCLUDE -> Unit
|
||||
MessageInclusion.MUTE_FILTERED -> muteFilteredMessages += NotificationState.FilteredMessage(notification.messageRecord.id, notification.messageRecord.isMms)
|
||||
MessageInclusion.PROFILE_FILTERED -> profileFilteredMessages += NotificationState.FilteredMessage(notification.messageRecord.id, notification.messageRecord.isMms)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
notificationItems.sort()
|
||||
@@ -127,12 +147,15 @@ object NotificationStateProvider {
|
||||
private data class NotificationMessage(
|
||||
val messageRecord: MessageRecord,
|
||||
val reactions: List<ReactionRecord>,
|
||||
val pollVotes: List<PollVote>,
|
||||
val threadRecipient: Recipient,
|
||||
val thread: ConversationId,
|
||||
val stickyThread: Boolean,
|
||||
val isUnreadMessage: Boolean,
|
||||
val hasUnreadReactions: Boolean,
|
||||
val hasUnreadVotes: Boolean,
|
||||
val lastReactionRead: Long,
|
||||
val lastVoteRead: Long,
|
||||
val isParentStorySentBySelf: Boolean,
|
||||
val hasSelfRepliedToStory: Boolean
|
||||
) {
|
||||
@@ -172,6 +195,18 @@ object NotificationStateProvider {
|
||||
}
|
||||
}
|
||||
|
||||
fun shouldIncludeVote(vote: PollVote, notificationProfile: NotificationProfile?): MessageInclusion {
|
||||
return if (threadRecipient.isMuted) {
|
||||
MessageInclusion.MUTE_FILTERED
|
||||
} else if (notificationProfile != null && !notificationProfile.isRecipientAllowed(threadRecipient.id)) {
|
||||
MessageInclusion.PROFILE_FILTERED
|
||||
} else if (vote.voterId != Recipient.self().id && messageRecord.isOutgoing && vote.dateReceived > lastVoteRead) {
|
||||
MessageInclusion.INCLUDE
|
||||
} else {
|
||||
MessageInclusion.EXCLUDE
|
||||
}
|
||||
}
|
||||
|
||||
private val Recipient.isDoNotNotifyMentions: Boolean
|
||||
get() = mentionSetting == RecipientTable.MentionSetting.DO_NOT_NOTIFY
|
||||
}
|
||||
|
||||
41
app/src/main/java/org/thoughtcrime/securesms/polls/Poll.kt
Normal file
41
app/src/main/java/org/thoughtcrime/securesms/polls/Poll.kt
Normal 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())
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.thoughtcrime.securesms.polls
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
/**
|
||||
* Represents a poll option and a list of recipients who have voted for that option
|
||||
*/
|
||||
@Parcelize
|
||||
data class PollOption(
|
||||
val id: Long,
|
||||
val text: String,
|
||||
val voterIds: List<Long>,
|
||||
val isSelected: Boolean = false,
|
||||
val isPending: Boolean = false
|
||||
) : Parcelable
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.thoughtcrime.securesms.polls
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
/**
|
||||
* Data class representing a poll entry in the db, its options, and any voting
|
||||
*/
|
||||
@Parcelize
|
||||
data class PollRecord(
|
||||
val id: Long,
|
||||
val question: String,
|
||||
val pollOptions: List<PollOption>,
|
||||
val allowMultipleVotes: Boolean,
|
||||
val hasEnded: Boolean,
|
||||
val authorId: Long,
|
||||
val messageId: Long
|
||||
) : Parcelable
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.polls
|
||||
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
||||
/**
|
||||
* Tracks general information of a poll vote including who they are and what poll they voted in. Primarily used in notifications.
|
||||
*/
|
||||
data class PollVote(
|
||||
val pollId: Long,
|
||||
val voterId: RecipientId,
|
||||
val question: String,
|
||||
val dateReceived: Long = 0
|
||||
)
|
||||
@@ -33,12 +33,11 @@ import org.thoughtcrime.securesms.attachments.AttachmentId;
|
||||
import org.thoughtcrime.securesms.attachments.DatabaseAttachment;
|
||||
import org.thoughtcrime.securesms.backup.v2.BackupRepository;
|
||||
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
|
||||
import org.thoughtcrime.securesms.contactshare.Contact;
|
||||
import org.thoughtcrime.securesms.database.AttachmentTable;
|
||||
import org.thoughtcrime.securesms.database.MessageTable;
|
||||
import org.thoughtcrime.securesms.database.MessageTable.InsertResult;
|
||||
import org.thoughtcrime.securesms.database.MessageTable.SyncMessageId;
|
||||
import org.thoughtcrime.securesms.database.NoSuchMessageException;
|
||||
import org.thoughtcrime.securesms.database.PollTables;
|
||||
import org.thoughtcrime.securesms.database.RecipientTable;
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase;
|
||||
import org.thoughtcrime.securesms.database.ThreadTable;
|
||||
@@ -253,6 +252,37 @@ public class MessageSender {
|
||||
}
|
||||
}
|
||||
|
||||
public static long sendPollAction(final Context context,
|
||||
final OutgoingMessage message,
|
||||
final long threadId,
|
||||
@NonNull SendType sendType,
|
||||
@Nullable final String metricId,
|
||||
@Nullable final MessageTable.InsertListener insertListener)
|
||||
{
|
||||
try {
|
||||
Recipient recipient = message.getThreadRecipient();
|
||||
long allocatedThreadId = SignalDatabase.threads().getOrCreateValidThreadId(recipient, threadId, message.getDistributionType());
|
||||
InsertResult insertResult = SignalDatabase.messages().insertMessageOutbox(applyUniversalExpireTimerIfNecessary(context, recipient, message, allocatedThreadId), allocatedThreadId, sendType != SendType.SIGNAL, insertListener);
|
||||
long messageId = insertResult.getMessageId();
|
||||
|
||||
if (!recipient.isPushV2Group()) {
|
||||
Log.w(TAG, "Can only send polls to groups.");
|
||||
return threadId;
|
||||
}
|
||||
|
||||
SignalLocalMetrics.GroupMessageSend.onInsertedIntoDatabase(messageId, metricId);
|
||||
|
||||
sendMessageInternal(context, recipient, sendType, messageId, insertResult.getQuoteAttachmentId(), Collections.emptyList());
|
||||
onMessageSent();
|
||||
SignalDatabase.threads().update(allocatedThreadId, true, true);
|
||||
|
||||
return allocatedThreadId;
|
||||
} catch (MmsException e) {
|
||||
Log.w(TAG, e);
|
||||
return threadId;
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean sendPushWithPreUploadedMedia(final Context context,
|
||||
final OutgoingMessage message,
|
||||
final Collection<PreUploadResult> preUploadResults,
|
||||
|
||||
@@ -170,7 +170,7 @@ fun InstalledStickerPackRow(
|
||||
RoundCheckbox(
|
||||
checked = selected,
|
||||
onCheckedChange = { onSelectionToggle(pack) },
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
modifier = Modifier.padding(start = 12.dp, end = 20.dp, top = 12.dp, bottom = 12.dp)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -70,7 +70,8 @@ object MessageConstraintsUtil {
|
||||
!targetMessage.isViewOnceMessage() &&
|
||||
!targetMessage.hasAudio() &&
|
||||
!targetMessage.hasSharedContact() &&
|
||||
!targetMessage.hasSticker()
|
||||
!targetMessage.hasSticker() &&
|
||||
!targetMessage.hasPoll()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,6 +11,7 @@ import org.thoughtcrime.securesms.database.model.Quote
|
||||
import org.thoughtcrime.securesms.database.model.databaseprotos.GiftBadge
|
||||
import org.thoughtcrime.securesms.mms.QuoteModel
|
||||
import org.thoughtcrime.securesms.mms.TextSlide
|
||||
import org.thoughtcrime.securesms.polls.PollRecord
|
||||
import org.thoughtcrime.securesms.stickers.StickerUrl
|
||||
|
||||
const val MAX_BODY_DISPLAY_LENGTH = 1000
|
||||
@@ -100,6 +101,12 @@ fun MessageRecord.hasTextSlide(): Boolean =
|
||||
fun MessageRecord.requireTextSlide(): TextSlide =
|
||||
requireNotNull((this as MmsMessageRecord).slideDeck.textSlide)
|
||||
|
||||
fun MessageRecord.hasPoll(): Boolean = isMms && (this as MmsMessageRecord).poll != null
|
||||
|
||||
fun MessageRecord.getPoll(): PollRecord? = if (isMms) (this as MmsMessageRecord).poll else null
|
||||
|
||||
fun MessageRecord.hasPollTerminate(): Boolean = this.isPollTerminate && this.messageExtras != null && this.messageExtras!!.pollTerminate != null
|
||||
|
||||
fun MessageRecord.hasBigImageLinkPreview(context: Context): Boolean {
|
||||
if (!hasLinkPreview()) {
|
||||
return false
|
||||
@@ -124,6 +131,10 @@ fun MessageRecord.requireGiftBadge(): GiftBadge {
|
||||
return (this as MmsMessageRecord).giftBadge!!
|
||||
}
|
||||
|
||||
fun MessageRecord.isPoll(): Boolean {
|
||||
return (this as? MmsMessageRecord)?.poll != null
|
||||
}
|
||||
|
||||
fun MessageRecord.isTextOnly(context: Context): Boolean {
|
||||
return !isMms ||
|
||||
(
|
||||
@@ -140,7 +151,8 @@ fun MessageRecord.isTextOnly(context: Context): Boolean {
|
||||
!isCaptionlessMms(context) &&
|
||||
!hasGiftBadge() &&
|
||||
!isPaymentNotification &&
|
||||
!isPaymentTombstone
|
||||
!isPaymentTombstone &&
|
||||
!isPoll()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1184,5 +1184,13 @@ object RemoteConfig {
|
||||
hotSwappable = true
|
||||
)
|
||||
|
||||
@JvmStatic
|
||||
@get:JvmName("polls")
|
||||
val polls: Boolean by remoteBoolean(
|
||||
key = "android.polls",
|
||||
defaultValue = false,
|
||||
hotSwappable = true
|
||||
)
|
||||
|
||||
// endregion
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user