Add message editing feature.

This commit is contained in:
Clark
2023-04-14 16:29:26 -04:00
committed by Cody Henthorne
parent 4f06a0d27c
commit 07f6baf7c1
73 changed files with 2051 additions and 304 deletions
@@ -109,13 +109,11 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
void onActivatePaymentsClicked();
void onSendPaymentClicked(@NonNull RecipientId recipientId);
void onScheduledIndicatorClicked(@NonNull View view, @NonNull MessageRecord messageRecord);
/** @return true if handled, false if you want to let the normal url handling continue */
boolean onUrlClicked(@NonNull String url);
void onViewGiftBadgeClicked(@NonNull MessageRecord messageRecord);
void onGiftBadgeRevealed(@NonNull MessageRecord messageRecord);
void goToMediaPreview(ConversationItem parent, View sharedElement, MediaIntentFactory.MediaPreviewArgs args);
void onEditedIndicatorClicked(@NonNull MessageRecord messageRecord);
}
}
@@ -18,7 +18,7 @@ import org.thoughtcrime.securesms.util.ViewUtil;
public class AnimatingToggle extends FrameLayout {
private View current;
private View previous;
private final Animation inAnimation;
private final Animation outAnimation;
@@ -55,9 +55,17 @@ public class AnimatingToggle extends FrameLayout {
public void display(@Nullable View view) {
if (view == current && current.getVisibility() == View.VISIBLE) return;
if (current != null) ViewUtil.animateOut(current, outAnimation, View.GONE);
if (view != null) ViewUtil.animateIn(view, inAnimation);
if (previous != null && previous.getAnimation() == outAnimation) {
previous.clearAnimation();
previous.setVisibility(View.GONE);
}
if (current != null) {
ViewUtil.animateOut(current, outAnimation, View.GONE);
}
if (view != null) {
ViewUtil.animateIn(view, inAnimation);
}
previous = current;
current = view;
}
@@ -28,6 +28,7 @@ import com.airbnb.lottie.model.KeyPath;
import org.signal.core.util.concurrent.SignalExecutors;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
import org.thoughtcrime.securesms.database.model.MessageRecord;
@@ -35,7 +36,6 @@ import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.permissions.Permissions;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.service.ExpiringMessageManager;
import org.thoughtcrime.securesms.util.DateUtils;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.Projection;
@@ -143,8 +143,8 @@ public class ConversationItemFooter extends ConstraintLayout {
timerView.stopAnimation();
}
public void setMessageRecord(@NonNull MessageRecord messageRecord, @NonNull Locale locale) {
presentDate(messageRecord, locale);
public void setMessageRecord(@NonNull MessageRecord messageRecord, @NonNull Locale locale, @NonNull ConversationItemDisplayMode displayMode) {
presentDate(messageRecord, locale, displayMode);
presentSimInfo(messageRecord);
presentTimer(messageRecord);
presentInsecureIndicator(messageRecord);
@@ -218,7 +218,7 @@ public class ConversationItemFooter extends ConstraintLayout {
}
}
public TextView getDateView() {
public View getDateView() {
return dateView;
}
@@ -300,7 +300,7 @@ public class ConversationItemFooter extends ConstraintLayout {
return speedToggleHitRect;
}
private void presentDate(@NonNull MessageRecord messageRecord, @NonNull Locale locale) {
private void presentDate(@NonNull MessageRecord messageRecord, @NonNull Locale locale, @NonNull ConversationItemDisplayMode displayMode) {
dateView.forceLayout();
if (messageRecord.isFailed()) {
int errorMsg;
@@ -320,7 +320,11 @@ public class ConversationItemFooter extends ConstraintLayout {
} else if (MessageRecordUtil.isScheduled(messageRecord)) {
dateView.setText(DateUtils.getOnlyTimeString(getContext(), locale, ((MediaMmsMessageRecord) messageRecord).getScheduledDate()));
} else {
dateView.setText(DateUtils.getSimpleRelativeTimeSpanString(getContext(), locale, messageRecord.getTimestamp()));
String date = DateUtils.getSimpleRelativeTimeSpanString(getContext(), locale, messageRecord.getTimestamp());
if (displayMode != ConversationItemDisplayMode.DETAILED && messageRecord instanceof MediaMmsMessageRecord && ((MediaMmsMessageRecord) messageRecord).isEditMessage()) {
date = getContext().getString(R.string.ConversationItem_edited_timestamp_footer, date);
}
dateView.setText(date);
}
}
@@ -360,17 +364,12 @@ public class ConversationItemFooter extends ConstraintLayout {
}
} else if (!messageRecord.isOutgoing() && !messageRecord.isMediaPending()) {
SignalExecutors.BOUNDED.execute(() -> {
ExpiringMessageManager expirationManager = ApplicationDependencies.getExpiringMessageManager();
long id = messageRecord.getId();
boolean mms = messageRecord.isMms();
long id = messageRecord.getId();
boolean mms = messageRecord.isMms();
long now = System.currentTimeMillis();
if (mms) {
SignalDatabase.messages().markExpireStarted(id);
} else {
SignalDatabase.messages().markExpireStarted(id);
}
expirationManager.scheduleDeletion(id, mms, messageRecord.getExpiresIn());
SignalDatabase.messages().markExpireStarted(id, now);
ApplicationDependencies.getExpiringMessageManager().scheduleDeletion(id, mms, now, messageRecord.getExpiresIn());
});
}
} else {
@@ -5,6 +5,7 @@ import android.animation.ValueAnimator;
import android.content.Context;
import android.graphics.drawable.ColorDrawable;
import android.net.Uri;
import android.text.SpannableString;
import android.text.format.DateUtils;
import android.util.AttributeSet;
import android.view.KeyEvent;
@@ -39,9 +40,13 @@ import org.thoughtcrime.securesms.components.emoji.EmojiEventListener;
import org.thoughtcrime.securesms.components.emoji.EmojiToggle;
import org.thoughtcrime.securesms.components.emoji.MediaKeyboard;
import org.thoughtcrime.securesms.components.voice.VoiceNotePlaybackState;
import org.thoughtcrime.securesms.conversation.ConversationMessage;
import org.thoughtcrime.securesms.conversation.ConversationStickerSuggestionAdapter;
import org.thoughtcrime.securesms.conversation.VoiceNoteDraftView;
import org.thoughtcrime.securesms.database.DraftTable;
import org.thoughtcrime.securesms.database.model.MessageId;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.Quote;
import org.thoughtcrime.securesms.database.model.StickerRecord;
import org.thoughtcrime.securesms.keyboard.KeyboardPage;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
@@ -53,6 +58,7 @@ import org.thoughtcrime.securesms.mms.QuoteModel;
import org.thoughtcrime.securesms.mms.SlideDeck;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.ViewUtil;
import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener;
import org.thoughtcrime.securesms.util.concurrent.ListenableFuture;
@@ -88,6 +94,8 @@ public class InputPanel extends LinearLayout
private View recordingContainer;
private View recordLockCancel;
private ViewGroup composeContainer;
private View editMessageLabel;
private View editMessageCancel;
private MicrophoneRecorderView microphoneRecorderView;
private SlideToCancel slideToCancel;
@@ -105,6 +113,7 @@ public class InputPanel extends LinearLayout
private boolean hideForSelection;
private ConversationStickerSuggestionAdapter stickerSuggestionAdapter;
private MessageRecord messageToEdit;
public InputPanel(Context context) {
super(context);
@@ -144,6 +153,8 @@ public class InputPanel extends LinearLayout
findViewById(R.id.microphone),
TimeUnit.HOURS.toSeconds(1),
() -> microphoneRecorderView.cancelAction(false));
this.editMessageLabel = findViewById(R.id.edit_message);
this.editMessageCancel = findViewById(R.id.input_panel_exit_edit_mode);
this.recordLockCancel.setOnClickListener(v -> microphoneRecorderView.cancelAction(true));
@@ -167,6 +178,8 @@ public class InputPanel extends LinearLayout
stickerSuggestion.setLayoutManager(new LinearLayoutManager(getContext(), LinearLayoutManager.HORIZONTAL, false));
stickerSuggestion.setAdapter(stickerSuggestionAdapter);
editMessageCancel.setOnClickListener(v -> exitEditMessageMode());
}
public void setListener(final @NonNull Listener listener) {
@@ -183,7 +196,7 @@ public class InputPanel extends LinearLayout
public void setQuote(@NonNull GlideRequests glideRequests,
long id,
@NonNull Recipient author,
@NonNull CharSequence body,
@Nullable CharSequence body,
@NonNull SlideDeck attachments,
@NonNull QuoteModel.Type quoteType)
{
@@ -372,6 +385,52 @@ public class InputPanel extends LinearLayout
quoteView.setWallpaperEnabled(enabled);
}
public void enterEditMessageMode(@NonNull GlideRequests glideRequests, @NonNull ConversationMessage messageToEdit, boolean fromDraft) {
SpannableString textToEdit = messageToEdit.getDisplayBody(getContext());
if (!fromDraft) {
composeText.setText(textToEdit);
composeText.setSelection(textToEdit.length());
}
Quote quote = MessageRecordUtil.getQuote(messageToEdit.getMessageRecord());
if (quote == null) {
clearQuote();
} else {
setQuote(glideRequests, quote.getId(), Recipient.resolved(quote.getAuthor()), quote.getDisplayText(), quote.getAttachment(), quote.getQuoteType());
}
this.messageToEdit = messageToEdit.getMessageRecord();
updateEditModeUi();
}
public void exitEditMessageMode() {
if (messageToEdit != null) {
composeText.setText("");
messageToEdit = null;
quoteView.setMessageType(QuoteView.MessageType.PREVIEW);
}
updateEditModeUi();
}
private void updateEditModeUi() {
if (inEditMessageMode()) {
ViewUtil.focusAndShowKeyboard(composeText);
editMessageLabel.setVisibility(View.VISIBLE);
editMessageCancel.setVisibility(View.VISIBLE);
if (listener != null) {
listener.onEnterEditMode();
}
} else {
editMessageLabel.setVisibility(View.GONE);
editMessageCancel.setVisibility(View.GONE);
if (listener != null) {
listener.onExitEditMode();
}
}
}
public boolean inEditMessageMode() {
return messageToEdit != null;
}
public void setHideForMessageRequestState(boolean hideForMessageRequestState) {
this.hideForMessageRequestState = hideForMessageRequestState;
updateVisibility();
@@ -617,6 +676,16 @@ public class InputPanel extends LinearLayout
}
}
public @Nullable MessageRecord getEditMessage() {
return messageToEdit;
}
public @Nullable MessageId getEditMessageId() {
if (messageToEdit == null) {
return null;
}
return new MessageId(messageToEdit.getId());
}
public interface Listener extends VoiceNoteDraftView.Listener {
void onRecorderStarted();
void onRecorderLocked();
@@ -628,6 +697,8 @@ public class InputPanel extends LinearLayout
void onStickerSuggestionSelected(@NonNull StickerRecord sticker);
void onQuoteChanged(long id, @NonNull RecipientId author);
void onQuoteCleared();
void onEnterEditMode();
void onExitEditMode();
}
private static class SlideToCancel {
@@ -202,6 +202,7 @@ public class QuoteView extends FrameLayout implements RecipientForeverObserver {
params.width = thumbWidth;
thumbnailView.setLayoutParams(params);
dismissView.setVisibility(messageType == MessageType.PREVIEW ? View.VISIBLE : View.GONE);
}
public void setQuote(GlideRequests glideRequests,
@@ -110,19 +110,19 @@ public class ConversationAdapter
private final Set<MultiselectPart> selected;
private final Calendar calendar;
private String searchQuery;
private ConversationMessage recordToPulse;
private View typingView;
private View footerView;
private PagingController pagingController;
private boolean hasWallpaper;
private boolean isMessageRequestAccepted;
private ConversationMessage inlineContent;
private Colorizer colorizer;
private boolean isTypingViewEnabled;
private boolean condensedMode;
private boolean scheduledMessagesMode;
private PulseRequest pulseRequest;
private String searchQuery;
private ConversationMessage recordToPulse;
private View typingView;
private View footerView;
private PagingController pagingController;
private boolean hasWallpaper;
private boolean isMessageRequestAccepted;
private ConversationMessage inlineContent;
private Colorizer colorizer;
private boolean isTypingViewEnabled;
private ConversationItemDisplayMode condensedMode;
private boolean scheduledMessagesMode;
private PulseRequest pulseRequest;
public ConversationAdapter(@NonNull Context context,
@NonNull LifecycleOwner lifecycleOwner,
@@ -258,7 +258,7 @@ public class ConversationAdapter
}
}
public void setCondensedMode(boolean condensedMode) {
public void setCondensedMode(ConversationItemDisplayMode condensedMode) {
this.condensedMode = condensedMode;
notifyDataSetChanged();
}
@@ -283,7 +283,7 @@ public class ConversationAdapter
ConversationMessage previousMessage = adapterPosition < getItemCount() - 1 && !isFooterPosition(adapterPosition + 1) ? getItem(adapterPosition + 1) : null;
ConversationMessage nextMessage = adapterPosition > 0 && !isHeaderPosition(adapterPosition - 1) ? getItem(adapterPosition - 1) : null;
ConversationItemDisplayMode displayMode = condensedMode ? ConversationItemDisplayMode.CONDENSED : ConversationItemDisplayMode.STANDARD;
ConversationItemDisplayMode displayMode = condensedMode != null ? condensedMode : ConversationItemDisplayMode.STANDARD;
conversationViewHolder.getBindable().bind(lifecycleOwner,
conversationMessage,
@@ -295,7 +295,7 @@ public class ConversationAdapter
recipient,
searchQuery,
conversationMessage == recordToPulse,
hasWallpaper && !condensedMode,
hasWallpaper && displayMode.displayWallpaper(),
isMessageRequestAccepted,
conversationMessage == inlineContent,
colorizer,
@@ -112,6 +112,7 @@ import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectFor
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment;
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs;
import org.thoughtcrime.securesms.conversation.quotes.MessageQuotesBottomSheet;
import org.thoughtcrime.securesms.conversation.ui.edit.EditMessageHistoryDialog;
import org.thoughtcrime.securesms.conversation.ui.error.EnableCallNotificationSettingsDialog;
import org.thoughtcrime.securesms.conversation.v2.AddToContactsContract;
import org.thoughtcrime.securesms.database.DatabaseObserver;
@@ -180,8 +181,8 @@ import org.thoughtcrime.securesms.util.CommunicationActions;
import org.thoughtcrime.securesms.util.HtmlUtil;
import org.signal.core.util.concurrent.LifecycleDisposable;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.MessageConstraintsUtil;
import org.thoughtcrime.securesms.util.Projection;
import org.thoughtcrime.securesms.util.RemoteDeleteUtil;
import org.thoughtcrime.securesms.util.SaveAttachmentTask;
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
import org.thoughtcrime.securesms.util.SignalProxyUtil;
@@ -836,6 +837,15 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
}));
}
if (menuState.shouldShowEditAction()) {
items.add(new ActionItem(R.drawable.symbol_edit_24, getResources().getString(R.string.conversation_selection__menu_edit), () -> {
handleEditMessage(getSelectedConversationMessage());
if (actionMode != null) {
actionMode.finish();
}
}));
}
if (menuState.shouldShowForwardAction()) {
items.add(new ActionItem(R.drawable.symbol_forward_24, getResources().getString(R.string.conversation_selection__menu_forward), () -> handleForwardMessageParts(selectedParts)));
}
@@ -1078,7 +1088,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
int deleteForEveryoneResId = isNoteToSelfDelete ? R.string.ConversationFragment_delete_everywhere : R.string.ConversationFragment_delete_for_everyone;
if (RemoteDeleteUtil.isValidSend(messageRecords, System.currentTimeMillis()) && (!isNoteToSelfDelete || TextSecurePreferences.isMultiDevice(requireContext()))) {
if (MessageConstraintsUtil.isValidRemoteDeleteSend(messageRecords, System.currentTimeMillis()) && (!isNoteToSelfDelete || TextSecurePreferences.isMultiDevice(requireContext()))) {
builder.setNeutralButton(deleteForEveryoneResId, (dialog, which) -> handleDeleteForEveryone(messageRecords));
}
@@ -1148,6 +1158,10 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
listener.handleReplyMessage(message);
}
private void handleEditMessage(@NonNull ConversationMessage selectedConversationMessage) {
listener.handleEditMessage(selectedConversationMessage);
}
private void handleSaveAttachment(final MediaMmsMessageRecord message) {
if (message.isViewOnce()) {
throw new AssertionError("Cannot save a view-once message.");
@@ -1455,6 +1469,7 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
void openAttachmentKeyboard();
void setThreadId(long threadId);
void handleReplyMessage(ConversationMessage conversationMessage);
void handleEditMessage(@NonNull ConversationMessage conversationMessage);
void onMessageActionToolbarOpened();
void onMessageActionToolbarClosed();
void onBottomActionBarVisibilityChanged(int visibility);
@@ -2110,6 +2125,15 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
requireActivity().startActivity(MediaIntentFactory.create(requireActivity(), args), options.toBundle());
}
@Override
public void onEditedIndicatorClicked(@NonNull MessageRecord messageRecord) {
if (messageRecord.isOutgoing()) {
EditMessageHistoryDialog.show(getChildFragmentManager(), messageRecord.getToRecipient().getId(), messageRecord.getId());
} else {
EditMessageHistoryDialog.show(getChildFragmentManager(), messageRecord.getFromRecipient().getId(), messageRecord.getId());
}
}
@Override
public void onActivatePaymentsClicked() {
Intent intent = new Intent(requireContext(), PaymentsActivity.class);
@@ -2268,6 +2292,9 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
case REPLY:
handleReplyMessage(conversationMessage);
break;
case EDIT:
handleEditMessage(conversationMessage);
break;
case FORWARD:
handleForwardMessageParts(conversationMessage.getMultiselectCollection().toSet());
break;
@@ -419,7 +419,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
@Override
public void updateTimestamps() {
getActiveFooter(messageRecord).setMessageRecord(messageRecord, locale);
getActiveFooter(messageRecord).setMessageRecord(messageRecord, locale, displayMode);
}
@Override
@@ -526,10 +526,10 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
!messageRecord.isRemoteDelete() &&
bodyText.getLastLineWidth() > 0)
{
TextView dateView = footer.getDateView();
int footerWidth = footer.getMeasuredWidth();
int availableWidth = getAvailableMessageBubbleWidth(bodyText);
int collapsedTopMargin = -1 * (dateView.getMeasuredHeight() + ViewUtil.dpToPx(4));
View dateView = footer.getDateView();
int footerWidth = footer.getMeasuredWidth();
int availableWidth = getAvailableMessageBubbleWidth(bodyText);
int collapsedTopMargin = -1 * (dateView.getMeasuredHeight() + ViewUtil.dpToPx(4));
if (bodyText.isSingleLine() && !messageRecord.isFailed()) {
int maxBubbleWidth = hasBigImageLinkPreview(messageRecord) || hasThumbnail(messageRecord) ? readDimen(R.dimen.media_bubble_max_width) : getMaxBubbleWidth();
@@ -1666,7 +1666,18 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (isFooterVisible(current, next, isGroupThread)) {
ConversationItemFooter activeFooter = getActiveFooter(current);
activeFooter.setVisibility(VISIBLE);
activeFooter.setMessageRecord(current, locale);
activeFooter.setMessageRecord(current, locale, displayMode);
if (MessageRecordUtil.isEditMessage(current)) {
activeFooter.getDateView().setOnClickListener(v -> {
if (eventListener != null) {
eventListener.onEditedIndicatorClicked(current);
}
});
} else {
activeFooter.getDateView().setOnClickListener(null);
activeFooter.getDateView().setClickable(false);
}
if (hasWallpaper && hasNoBubble((messageRecord))) {
if (messageRecord.isOutgoing()) {
@@ -1714,7 +1725,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
private void setHasBeenQuoted(@NonNull ConversationMessage message) {
if (message.hasBeenQuoted() && !isCondensedMode() && quotedIndicator != null && batchSelected.isEmpty()) {
if (message.hasBeenQuoted() && !isCondensedMode() && quotedIndicator != null && batchSelected.isEmpty() && displayMode != ConversationItemDisplayMode.EXTRA_CONDENSED) {
quotedIndicator.setVisibility(VISIBLE);
quotedIndicator.setOnClickListener(quotedIndicatorClickListener);
} else if (quotedIndicator != null) {
@@ -1737,7 +1748,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
private boolean forceFooter(@NonNull MessageRecord messageRecord) {
return hasAudio(messageRecord);
return hasAudio(messageRecord) || MessageRecordUtil.isEditMessage(messageRecord);
}
private ConversationItemFooter getActiveFooter(@NonNull MessageRecord messageRecord) {
@@ -1841,7 +1852,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
int background;
if (isSingularMessage(current, previous, next, isGroupThread)) {
if (isSingularMessage(current, previous, next, isGroupThread) || displayMode == ConversationItemDisplayMode.EXTRA_CONDENSED) {
if (current.isOutgoing()) {
background = R.drawable.message_bubble_background_sent_alone;
outliner.setRadius(bigRadius);
@@ -1922,6 +1933,10 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
private boolean isFooterVisible(@NonNull MessageRecord current, @NonNull Optional<MessageRecord> next, boolean isGroupThread) {
if (displayMode == ConversationItemDisplayMode.EXTRA_CONDENSED) {
return false;
}
boolean differentTimestamps = next.isPresent() && !DateUtils.isSameExtendedRelativeTimestamp(next.get().getTimestamp(), current.getTimestamp());
return forceFooter(messageRecord) || current.getExpiresIn() > 0 || !current.isSecure() || current.isPending() || current.isPendingInsecureSmsFallback() ||
@@ -1937,11 +1952,11 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
int spacingTop = readDimen(context, R.dimen.conversation_vertical_message_spacing_collapse);
int spacingBottom = spacingTop;
if (isStartOfMessageCluster(current, previous, isGroupThread)) {
if (isStartOfMessageCluster(current, previous, isGroupThread) && (displayMode != ConversationItemDisplayMode.EXTRA_CONDENSED || next.isEmpty())) {
spacingTop = readDimen(context, R.dimen.conversation_vertical_message_spacing_default);
}
if (isEndOfMessageCluster(current, next, isGroupThread)) {
if (isEndOfMessageCluster(current, next, isGroupThread) || displayMode == ConversationItemDisplayMode.EXTRA_CONDENSED) {
spacingBottom = readDimen(context, R.dimen.conversation_vertical_message_spacing_default);
}
@@ -7,6 +7,13 @@ enum class ConversationItemDisplayMode {
/** Smaller bubbles, often trimming text and shrinking images. Used for quote threads. */
CONDENSED,
/** Smaller bubbles, no footers */
EXTRA_CONDENSED,
/** Less length restrictions. Used to show more info in message details. */
DETAILED
DETAILED;
fun displayWallpaper(): Boolean {
return this == STANDARD || this == DETAILED
}
}
@@ -282,12 +282,14 @@ import org.thoughtcrime.securesms.util.ConversationUtil;
import org.thoughtcrime.securesms.util.Debouncer;
import org.thoughtcrime.securesms.util.Dialogs;
import org.thoughtcrime.securesms.util.DrawableUtil;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.FullscreenHelper;
import org.thoughtcrime.securesms.util.IdentityUtil;
import org.signal.core.util.concurrent.LifecycleDisposable;
import org.thoughtcrime.securesms.util.Material3OnScrollHelper;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.MessageConstraintsUtil;
import org.thoughtcrime.securesms.util.MessageUtil;
import org.thoughtcrime.securesms.util.PlayStoreUtil;
import org.thoughtcrime.securesms.util.ServiceUtil;
@@ -399,6 +401,7 @@ public class ConversationParentFragment extends Fragment
private AnimatingToggle buttonToggle;
private SendButton sendButton;
private ImageButton attachButton;
private ImageButton sendEditButton;
protected ConversationTitleView titleView;
private TextView charactersLeft;
private ConversationFragment fragment;
@@ -794,7 +797,8 @@ public class ConversationParentFragment extends Fragment
initiating,
true,
null,
result.getScheduledTime()).addListener(new AssertedSuccessListener<Void>() {
result.getScheduledTime(),
null).addListener(new AssertedSuccessListener<Void>() {
@Override
public void onSuccess(Void result) {
AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> {
@@ -1591,6 +1595,7 @@ public class ConversationParentFragment extends Fragment
inputPanel.setEnabled(canSendMessages);
sendButton.setEnabled(canSendMessages);
attachButton.setEnabled(canSendMessages);
sendEditButton.setEnabled(canSendMessages);
});
}
@@ -1670,6 +1675,24 @@ public class ConversationParentFragment extends Fragment
quoteResult.addListener(listener);
break;
case Draft.MESSAGE_EDIT:
SettableFuture<Boolean> messageEditResult = new SettableFuture<>();
disposables.add(draftViewModel.loadDraftEditMessage(draft.getValue()).subscribe(
conversationMessage -> {
inputPanel.enterEditMessageMode(glideRequests, conversationMessage, true);
messageEditResult.set(true);
},
err -> {
Log.e(TAG, "Failed to restore message edit from a draft.", err);
messageEditResult.set(false);
},
() -> {
Log.e(TAG, "Failed to load message edit. No matching message record.");
messageEditResult.set(false);
}
));
messageEditResult.addListener(listener);
break;
case Draft.VOICE_NOTE:
case Draft.BODY_RANGES:
listener.onSuccess(true);
@@ -1846,6 +1869,7 @@ public class ConversationParentFragment extends Fragment
buttonToggle = view.findViewById(R.id.button_toggle);
sendButton = view.findViewById(R.id.send_button);
attachButton = view.findViewById(R.id.attach_button);
sendEditButton = view.findViewById(R.id.send_edit_button);
composeText = view.findViewById(R.id.embedded_text_editor);
charactersLeft = view.findViewById(R.id.space_left);
emojiDrawerStub = ViewUtil.findStubById(view, R.id.emoji_drawer_stub);
@@ -1902,6 +1926,7 @@ public class ConversationParentFragment extends Fragment
attachButton.setOnClickListener(new AttachButtonListener());
attachButton.setOnLongClickListener(new AttachButtonLongClickListener());
sendButton.setOnClickListener(sendButtonListener);
sendEditButton.setOnClickListener(v -> handleSendEditMessage());
sendButton.setScheduledSendListener(new SendButton.ScheduledSendListener() {
@Override
public void onSendScheduled() {
@@ -2759,6 +2784,8 @@ public class ConversationParentFragment extends Fragment
callback.onSendComplete(threadId);
draftViewModel.onSendComplete(threadId);
inputPanel.exitEditMessageMode();
}
private void sendMessage(@Nullable String metricId) {
@@ -2794,6 +2821,7 @@ public class ConversationParentFragment extends Fragment
MessageSendType sendType = sendButton.getSelectedSendType();
long expiresIn = TimeUnit.SECONDS.toMillis(recipient.getExpiresInSeconds());
boolean initiating = threadId == -1;
boolean isEditMessage = inputPanel.inEditMessageMode();
boolean needsSplit = !sendType.usesSmsTransport() && message.length() > sendType.calculateCharacters(message).maxPrimaryMessageSize;
boolean isMediaMessage = attachmentManager.isAttachmentPresent() ||
recipient.isGroup() ||
@@ -2806,14 +2834,19 @@ public class ConversationParentFragment extends Fragment
Log.i(TAG, "[sendMessage] recipient: " + recipient.getId() + ", threadId: " + threadId + ", sendType: " + (sendType.usesSignalTransport() ? "signal" : "sms") + ", isManual: " + sendButton.isManualSelection());
if ((recipient.isMmsGroup() || recipient.getEmail().isPresent()) && !viewModel.getConversationStateSnapshot().isMmsEnabled()) {
if (!sendType.usesSignalTransport() && isEditMessage) {
Toast.makeText(requireContext(),
R.string.ConversationActivity_edit_sms_message_error,
Toast.LENGTH_LONG)
.show();
} else if ((recipient.isMmsGroup() || recipient.getEmail().isPresent()) && !viewModel.getConversationStateSnapshot().isMmsEnabled()) {
handleManualMmsRequired();
} else if (sendType.usesSignalTransport() && (identityRecords.isUnverified(true) || identityRecords.isUntrusted(true))) {
handleRecentSafetyNumberChange();
} else if (isMediaMessage) {
sendMediaMessage(sendType, expiresIn, false, initiating, metricId, scheduledDate);
sendMediaMessage(sendType, expiresIn, false, initiating, metricId, scheduledDate, inputPanel.getEditMessageId());
} else {
sendTextMessage(sendType, expiresIn, initiating, metricId, scheduledDate);
sendTextMessage(sendType, expiresIn, initiating, metricId, scheduledDate, inputPanel.getEditMessageId());
}
} catch (RecipientFormattingException ex) {
Toast.makeText(requireContext(),
@@ -2862,7 +2895,8 @@ public class ConversationParentFragment extends Fragment
null,
true,
result.getBodyRanges(),
-1);
-1,
0);
final Context context = requireContext().getApplicationContext();
@@ -2884,7 +2918,7 @@ public class ConversationParentFragment extends Fragment
}, this::sendComplete);
}
private void sendMediaMessage(@NonNull MessageSendType sendType, final long expiresIn, final boolean viewOnce, final boolean initiating, @Nullable String metricId, long scheduledDate)
private void sendMediaMessage(@NonNull MessageSendType sendType, final long expiresIn, final boolean viewOnce, final boolean initiating, @Nullable String metricId, long scheduledDate, @Nullable MessageId editMessageId)
throws InvalidMessageException
{
Log.i(TAG, "Sending media message...");
@@ -2903,7 +2937,8 @@ public class ConversationParentFragment extends Fragment
initiating,
true,
metricId,
scheduledDate);
scheduledDate,
editMessageId);
}
private ListenableFuture<Void> sendMediaMessage(@NonNull RecipientId recipientId,
@@ -2921,7 +2956,7 @@ public class ConversationParentFragment extends Fragment
final boolean clearComposeBox,
final @Nullable String metricId)
{
return sendMediaMessage(recipientId, sendType, body, slideDeck, quote, contacts, previews, mentions, styling, expiresIn, viewOnce, initiating, clearComposeBox, metricId, -1);
return sendMediaMessage(recipientId, sendType, body, slideDeck, quote, contacts, previews, mentions, styling, expiresIn, viewOnce, initiating, clearComposeBox, metricId, -1, null);
}
private ListenableFuture<Void> sendMediaMessage(@NonNull RecipientId recipientId,
@@ -2938,7 +2973,8 @@ public class ConversationParentFragment extends Fragment
final boolean initiating,
final boolean clearComposeBox,
final @Nullable String metricId,
final long scheduledDate)
final long scheduledDate,
@Nullable MessageId editMessageId)
{
if (ExpiredBuildReminder.isEligible()) {
showExpiredDialog();
@@ -2952,7 +2988,7 @@ public class ConversationParentFragment extends Fragment
if (SignalStore.uiHints().hasNotSeenTextFormattingAlert() && styling != null && styling.getRangesCount() > 0) {
final String finalBody = body;
Dialogs.showFormattedTextDialog(requireContext(), () -> sendMediaMessage(recipientId, sendType, finalBody, slideDeck, quote, contacts, previews, mentions, styling, expiresIn, viewOnce, initiating, clearComposeBox, metricId, scheduledDate));
Dialogs.showFormattedTextDialog(requireContext(), () -> sendMediaMessage(recipientId, sendType, finalBody, slideDeck, quote, contacts, previews, mentions, styling, expiresIn, viewOnce, initiating, clearComposeBox, metricId, scheduledDate, editMessageId));
return new SettableFuture<>(null);
}
@@ -2988,7 +3024,8 @@ public class ConversationParentFragment extends Fragment
null,
false,
styling,
scheduledDate);
scheduledDate,
editMessageId != null ? editMessageId.getId() : 0);
final SettableFuture<Void> future = new SettableFuture<>();
final Context context = requireContext().getApplicationContext();
@@ -3028,7 +3065,12 @@ public class ConversationParentFragment extends Fragment
return future;
}
private void sendTextMessage(@NonNull MessageSendType sendType, final long expiresIn, final boolean initiating, final @Nullable String metricId, long scheduledDate)
private void sendTextMessage(@NonNull MessageSendType sendType,
final long expiresIn,
final boolean initiating,
final @Nullable String metricId,
long scheduledDate,
@Nullable MessageId messageToEdit)
throws InvalidMessageException
{
if (ExpiredBuildReminder.isEligible()) {
@@ -3049,8 +3091,11 @@ public class ConversationParentFragment extends Fragment
final OutgoingMessage message;
if (sendPush) {
if (scheduledDate > 0) {
message = OutgoingMessage.text(recipient.get(), messageBody, expiresIn, System.currentTimeMillis(), null).sendAt(scheduledDate);
if (messageToEdit != null) {
message = OutgoingMessage.editText(recipient.get(), messageBody, System.currentTimeMillis(), null, messageToEdit.getId());
} else if (scheduledDate > 0) {
message = OutgoingMessage.text(recipient.get(), messageBody, expiresIn, System.currentTimeMillis(), null)
.sendAt(scheduledDate);
} else {
message = OutgoingMessage.text(recipient.get(), messageBody, expiresIn, System.currentTimeMillis(), null);
}
@@ -3111,6 +3156,13 @@ public class ConversationParentFragment extends Fragment
return;
}
if (inputPanel.inEditMessageMode()) {
buttonToggle.display(sendEditButton);
quickAttachmentToggle.hide();
inlineAttachmentToggle.hide();
return;
}
if (draftViewModel.getVoiceNoteDraft() != null) {
buttonToggle.display(sendButton);
quickAttachmentToggle.hide();
@@ -3336,7 +3388,8 @@ public class ConversationParentFragment extends Fragment
initiating,
true,
null,
scheduledDate);
scheduledDate,
null);
}
private void sendSticker(@NonNull StickerRecord stickerRecord, boolean clearCompose) {
@@ -3653,7 +3706,11 @@ public class ConversationParentFragment extends Fragment
@Override
public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {
if (actionId == EditorInfo.IME_ACTION_SEND) {
sendButton.performClick();
if (inputPanel.isInEditMode()) {
sendEditButton.performClick();
} else {
sendButton.performClick();
}
return true;
}
return false;
@@ -3737,7 +3794,13 @@ public class ConversationParentFragment extends Fragment
}
private void handleSaveDraftOnTextChange(@NonNull CharSequence text) {
textDraftSaveDebouncer.publish(() -> draftViewModel.setTextDraft(StringUtil.trimSequence(text).toString(), MentionAnnotation.getMentionsFromAnnotations(text), MessageStyler.getStyling(text)));
textDraftSaveDebouncer.publish(() -> {
if (inputPanel.inEditMessageMode()) {
draftViewModel.setMessageEditDraft(inputPanel.getEditMessageId(), StringUtil.trimSequence(text).toString(), MentionAnnotation.getMentionsFromAnnotations(text), MessageStyler.getStyling(text));
} else {
draftViewModel.setTextDraft(StringUtil.trimSequence(text).toString(), MentionAnnotation.getMentionsFromAnnotations(text), MessageStyler.getStyling(text));
}
});
}
private void handleTypingIndicatorOnTextChange(@NonNull String text) {
@@ -4050,6 +4113,62 @@ public class ConversationParentFragment extends Fragment
inputPanel.clickOnComposeInput();
}
@Override
public void handleEditMessage(@NonNull ConversationMessage conversationMessage) {
if (!FeatureFlags.editMessageSending()) {
return;
}
if (isSearchRequested) {
searchViewItem.collapseActionView();
}
disposables.add(viewModel.resolveMessageToEdit(conversationMessage).subscribe(updatedMessage -> {
inputPanel.enterEditMessageMode(glideRequests, updatedMessage, false);
}));
}
private void handleSendEditMessage() {
if (!FeatureFlags.editMessageSending()) {
Log.w(TAG, "Edit message sending disabled, forcing exit of edit mode");
inputPanel.exitEditMessageMode();
return;
}
if (!inputPanel.inEditMessageMode()) {
Log.w(TAG, "Not in edit message mode, unknown state, forcing re-exit");
inputPanel.exitEditMessageMode();
return;
}
MessageRecord editMessage = inputPanel.getEditMessage();
if (editMessage == null) {
Log.w(TAG, "No edit message found, forcing exit");
inputPanel.exitEditMessageMode();
return;
}
if (!MessageConstraintsUtil.isValidEditMessageSend(editMessage, System.currentTimeMillis())) {
Log.i(TAG, "Edit message no longer valid");
final int editDurationHours = MessageConstraintsUtil.getEditMessageThresholdHours();
Dialogs.showAlertDialog(requireContext(), null, getResources().getQuantityString(R.plurals.ConversationActivity_edit_message_too_old, editDurationHours, editDurationHours));
return;
}
String metricId = recipient.get().isGroup() ? SignalLocalMetrics.GroupMessageSend.start()
: SignalLocalMetrics.IndividualMessageSend.start();
sendMessage(metricId);
}
@Override
public void onEnterEditMode() {
updateToggleButtonState();
}
@Override
public void onExitEditMode() {
updateToggleButtonState();
}
@Override
public void onMessageActionToolbarOpened() {
searchViewItem.collapseActionView();
@@ -42,6 +42,7 @@ import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.ReactionRecord;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.ThemeUtil;
import org.thoughtcrime.securesms.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
@@ -738,6 +739,10 @@ public final class ConversationReactionOverlay extends FrameLayout {
items.add(new ActionItem(R.drawable.symbol_reply_24, getResources().getString(R.string.conversation_selection__menu_reply), () -> handleActionItemClicked(Action.REPLY)));
}
if (FeatureFlags.editMessageSending() && menuState.shouldShowEditAction()) {
items.add(new ActionItem(R.drawable.symbol_edit_24, getResources().getString(R.string.conversation_selection__menu_edit), () -> handleActionItemClicked(Action.EDIT)));
}
if (menuState.shouldShowForwardAction()) {
items.add(new ActionItem(R.drawable.symbol_forward_24, getResources().getString(R.string.conversation_selection__menu_forward), () -> handleActionItemClicked(Action.FORWARD)));
}
@@ -968,6 +973,7 @@ public final class ConversationReactionOverlay extends FrameLayout {
public enum Action {
REPLY,
EDIT,
FORWARD,
RESEND,
DOWNLOAD,
@@ -6,6 +6,7 @@ import android.os.Build;
import androidx.annotation.NonNull;
import androidx.annotation.WorkerThread;
import org.signal.core.util.StreamUtil;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
@@ -15,22 +16,28 @@ import org.thoughtcrime.securesms.database.RecipientTable;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.ThreadTable;
import org.thoughtcrime.securesms.database.model.GroupRecord;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobs.MultiDeviceViewedUpdateJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.mms.PartAuthority;
import org.thoughtcrime.securesms.mms.TextSlide;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.recipients.RecipientUtil;
import org.thoughtcrime.securesms.util.BubbleUtil;
import org.thoughtcrime.securesms.util.ConversationUtil;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.Util;
import java.io.IOException;
import java.io.InputStream;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Observable;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.schedulers.Schedulers;
@@ -184,6 +191,28 @@ public class ConversationRepository {
}).subscribeOn(Schedulers.io());
}
@NonNull
public Single<ConversationMessage> resolveMessageToEdit(@NonNull ConversationMessage message) {
return Single.fromCallable(() -> {
MessageRecord messageRecord = message.getMessageRecord();
if (MessageRecordUtil.hasTextSlide(messageRecord)) {
TextSlide textSlide = MessageRecordUtil.requireTextSlide(messageRecord);
if (textSlide.getUri() == null) {
return message;
}
try (InputStream stream = PartAuthority.getAttachmentStream(context, textSlide.getUri())) {
String body = StreamUtil.readFullyAsString(stream);
return ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(context, messageRecord, body);
} catch (IOException e) {
Log.w(TAG, "Failed to read text slide data.");
}
}
return message;
}).subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
}
Observable<Integer> getUnreadCount(long threadId, long afterTime) {
if (threadId <= -1L || afterTime <= 0L) {
return Observable.just(0);
@@ -58,6 +58,7 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.BackpressureStrategy;
import io.reactivex.rxjava3.core.Flowable;
import io.reactivex.rxjava3.core.Observable;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.processors.PublishProcessor;
@@ -441,6 +442,11 @@ public class ConversationViewModel extends ViewModel {
return LiveDataReactiveStreams.fromPublisher(activeProfile.toFlowable(BackpressureStrategy.LATEST));
}
@NonNull
public Single<ConversationMessage> resolveMessageToEdit(@NonNull ConversationMessage message) {
return conversationRepository.resolveMessageToEdit(message);
}
void setArgs(@NonNull ConversationIntents.Args args) {
this.args = args;
}
@@ -8,6 +8,7 @@ import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.MessageConstraintsUtil;
import java.util.Set;
import java.util.stream.Collectors;
@@ -25,6 +26,7 @@ final class MenuState {
private final boolean delete;
private final boolean reactions;
private final boolean paymentDetails;
private final boolean edit;
private MenuState(@NonNull Builder builder) {
forward = builder.forward;
@@ -36,6 +38,7 @@ final class MenuState {
delete = builder.delete;
reactions = builder.reactions;
paymentDetails = builder.paymentDetails;
edit = builder.edit;
}
boolean shouldShowForwardAction() {
@@ -74,6 +77,10 @@ final class MenuState {
return paymentDetails;
}
boolean shouldShowEditAction() {
return edit;
}
static MenuState getMenuState(@NonNull Recipient conversationRecipient,
@NonNull Set<MultiselectPart> selectedParts,
boolean shouldShowMessageRequest,
@@ -152,7 +159,8 @@ final class MenuState {
.shouldShowReplyAction(false)
.shouldShowDetailsAction(false)
.shouldShowSaveAttachmentAction(false)
.shouldShowResendAction(false);
.shouldShowResendAction(false)
.shouldShowEdit(false);
} else {
MessageRecord messageRecord = selectedParts.iterator().next().getMessageRecord();
@@ -169,6 +177,10 @@ final class MenuState {
.shouldShowForwardAction(shouldShowForwardAction)
.shouldShowDetailsAction(!actionMessage && !conversationRecipient.isReleaseNotes())
.shouldShowReplyAction(canReplyToMessage(conversationRecipient, actionMessage, messageRecord, shouldShowMessageRequest, isNonAdminInAnnouncementGroup));
builder.shouldShowEdit(!actionMessage &&
hasText &&
MessageConstraintsUtil.isValidEditMessageSend(messageRecord, System.currentTimeMillis()));
}
return builder.shouldShowCopyAction(!actionMessage && !remoteDelete && hasText && !hasGift && !hasPayment)
@@ -204,23 +216,7 @@ final class MenuState {
}
static boolean isActionMessage(@NonNull MessageRecord messageRecord) {
return messageRecord.isGroupAction() ||
messageRecord.isCallLog() ||
messageRecord.isJoined() ||
messageRecord.isExpirationTimerUpdate() ||
messageRecord.isEndSession() ||
messageRecord.isIdentityUpdate() ||
messageRecord.isIdentityVerified() ||
messageRecord.isIdentityDefault() ||
messageRecord.isProfileChange() ||
messageRecord.isGroupV1MigrationEvent() ||
messageRecord.isChatSessionRefresh() ||
messageRecord.isInMemoryMessageRecord() ||
messageRecord.isChangeNumber() ||
messageRecord.isBoostRequest() ||
messageRecord.isPaymentsRequestToActivate() ||
messageRecord.isPaymentsActivated() ||
messageRecord.isSmsExportType();
return messageRecord.isInMemoryMessageRecord() || messageRecord.isUpdate();
}
private final static class Builder {
@@ -234,6 +230,7 @@ final class MenuState {
private boolean delete;
private boolean reactions;
private boolean paymentDetails;
private boolean edit;
@NonNull Builder shouldShowForwardAction(boolean forward) {
this.forward = forward;
@@ -280,6 +277,11 @@ final class MenuState {
return this;
}
@NonNull Builder shouldShowEdit(boolean edit) {
this.edit = edit;
return this;
}
@NonNull
MenuState build() {
return new MenuState(this);
@@ -93,7 +93,7 @@ class ScheduledMessagesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment
val colorizer = Colorizer()
messageAdapter = ConversationAdapter(requireContext(), viewLifecycleOwner, GlideApp.with(this), Locale.getDefault(), ConversationAdapterListener(), conversationRecipient, colorizer).apply {
setCondensedMode(true)
setCondensedMode(ConversationItemDisplayMode.CONDENSED)
setScheduledMessagesMode(true)
}
@@ -276,6 +276,7 @@ class ScheduledMessagesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment
override fun onViewGiftBadgeClicked(messageRecord: MessageRecord) = Unit
override fun onActivatePaymentsClicked() = Unit
override fun onSendPaymentClicked(recipientId: RecipientId) = Unit
override fun onEditedIndicatorClicked(messageRecord: MessageRecord) = Unit
}
companion object {
@@ -7,7 +7,9 @@ import android.text.SpannableString
import io.reactivex.rxjava3.core.Maybe
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.StreamUtil
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.components.mention.MentionAnnotation
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory
@@ -21,14 +23,19 @@ import org.thoughtcrime.securesms.database.ThreadTable
import org.thoughtcrime.securesms.database.adjustBodyRanges
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
import org.thoughtcrime.securesms.database.model.Mention
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.mms.PartAuthority
import org.thoughtcrime.securesms.mms.QuoteId
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.Base64
import org.thoughtcrime.securesms.util.concurrent.SerialMonoLifoExecutor
import org.thoughtcrime.securesms.util.hasTextSlide
import org.thoughtcrime.securesms.util.requireTextSlide
import java.io.IOException
import java.util.concurrent.Executor
class DraftRepository(
@@ -38,6 +45,10 @@ class DraftRepository(
private val saveDraftsExecutor: Executor = SerialMonoLifoExecutor(SignalExecutors.BOUNDED)
) {
companion object {
val TAG = Log.tag(DraftRepository::class.java)
}
fun deleteVoiceNoteDraftData(draft: DraftTable.Draft?) {
if (draft != null) {
SignalExecutors.BOUNDED.execute {
@@ -56,7 +67,11 @@ class DraftRepository(
}
draftTable.replaceDrafts(actualThreadId, drafts)
threadTable.updateSnippet(actualThreadId, drafts.getSnippet(context), drafts.getUriSnippet(), System.currentTimeMillis(), MessageTypes.BASE_DRAFT_TYPE, true)
if (drafts.shouldUpdateSnippet()) {
threadTable.updateSnippet(actualThreadId, drafts.getSnippet(context), drafts.getUriSnippet(), System.currentTimeMillis(), MessageTypes.BASE_DRAFT_TYPE, true)
} else {
threadTable.update(actualThreadId, unarchive = false, allowDeletion = false)
}
} else if (threadId > 0) {
draftTable.clearDrafts(threadId)
threadTable.update(threadId, unarchive = false, allowDeletion = false)
@@ -101,5 +116,26 @@ class DraftRepository(
}
}
fun loadDraftMessageEdit(serialized: String): Maybe<ConversationMessage> {
return Maybe.fromCallable {
val messageId = MessageId.deserialize(serialized)
val messageRecord: MessageRecord = SignalDatabase.messages.getMessageRecordOrNull(messageId.id) ?: return@fromCallable null
if (messageRecord.hasTextSlide()) {
val textSlide = messageRecord.requireTextSlide()
if (textSlide.uri != null) {
try {
PartAuthority.getAttachmentStream(context, textSlide.uri!!).use { stream ->
val body = StreamUtil.readFullyAsString(stream)
return@fromCallable ConversationMessageFactory.createWithUnresolvedData(context, messageRecord, body)
}
} catch (e: IOException) {
Log.e(TAG, "Failed to load text slide", e)
}
}
}
ConversationMessageFactory.createWithUnresolvedData(context, messageRecord)
}
}
data class DatabaseDraft(val drafts: Drafts, val updatedText: CharSequence?)
}
@@ -17,7 +17,8 @@ data class DraftState(
val bodyRangesDraft: DraftTable.Draft? = null,
val quoteDraft: DraftTable.Draft? = null,
val locationDraft: DraftTable.Draft? = null,
val voiceNoteDraft: DraftTable.Draft? = null
val voiceNoteDraft: DraftTable.Draft? = null,
val messageEditDraft: DraftTable.Draft? = null
) {
fun copyAndClearDrafts(threadId: Long = this.threadId): DraftState {
@@ -26,6 +27,7 @@ data class DraftState(
fun toDrafts(): Drafts {
return Drafts().apply {
addIfNotNull(messageEditDraft)
addIfNotNull(textDraft)
addIfNotNull(bodyRangesDraft)
addIfNotNull(quoteDraft)
@@ -41,7 +43,8 @@ data class DraftState(
bodyRangesDraft = drafts.getDraftOfType(DraftTable.Draft.BODY_RANGES),
quoteDraft = drafts.getDraftOfType(DraftTable.Draft.QUOTE),
locationDraft = drafts.getDraftOfType(DraftTable.Draft.LOCATION),
voiceNoteDraft = drafts.getDraftOfType(DraftTable.Draft.VOICE_NOTE)
voiceNoteDraft = drafts.getDraftOfType(DraftTable.Draft.VOICE_NOTE),
messageEditDraft = drafts.getDraftOfType(DraftTable.Draft.MESSAGE_EDIT)
)
}
}
@@ -11,6 +11,7 @@ import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.database.DraftTable.Draft
import org.thoughtcrime.securesms.database.MentionUtil
import org.thoughtcrime.securesms.database.model.Mention
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.mms.QuoteId
import org.thoughtcrime.securesms.recipients.Recipient
@@ -67,6 +68,22 @@ class DraftViewModel @JvmOverloads constructor(
store.update { it.copy(recipientId = recipient.id) }
}
fun setMessageEditDraft(messageId: MessageId, text: String, mentions: List<Mention>, styleBodyRanges: BodyRangeList?) {
store.update {
val mentionRanges: BodyRangeList? = MentionUtil.mentionsToBodyRangeList(mentions)
val bodyRanges: BodyRangeList? = if (styleBodyRanges == null) {
mentionRanges
} else if (mentionRanges == null) {
styleBodyRanges
} else {
styleBodyRanges.toBuilder().addAllRanges(mentionRanges.rangesList).build()
}
saveDrafts(it.copy(textDraft = text.toTextDraft(), bodyRangesDraft = bodyRanges?.toDraft(), messageEditDraft = Draft(Draft.MESSAGE_EDIT, messageId.serialize())))
}
}
fun setTextDraft(text: String, mentions: List<Mention>, styleBodyRanges: BodyRangeList?) {
store.update {
val mentionRanges: BodyRangeList? = MentionUtil.mentionsToBodyRangeList(mentions)
@@ -131,6 +148,12 @@ class DraftViewModel @JvmOverloads constructor(
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
}
fun loadDraftEditMessage(serialized: String): Maybe<ConversationMessage> {
return repository.loadDraftMessageEdit(serialized)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
}
}
private fun String.toTextDraft(): Draft? {
@@ -18,6 +18,7 @@ import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialog
import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager
import org.thoughtcrime.securesms.conversation.ConversationAdapter
import org.thoughtcrime.securesms.conversation.ConversationBottomSheetCallback
import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode
import org.thoughtcrime.securesms.conversation.colors.Colorizer
import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
@@ -72,7 +73,7 @@ class MessageQuotesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment() {
val colorizer = Colorizer()
messageAdapter = ConversationAdapter(requireContext(), viewLifecycleOwner, GlideApp.with(this), Locale.getDefault(), ConversationAdapterListener(), conversationRecipient, colorizer).apply {
setCondensedMode(true)
setCondensedMode(ConversationItemDisplayMode.CONDENSED)
}
val list: RecyclerView = view.findViewById<RecyclerView>(R.id.quotes_list).apply {
@@ -250,6 +251,11 @@ class MessageQuotesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment() {
dismiss()
getAdapterListener().onSendPaymentClicked(recipientId)
}
override fun onEditedIndicatorClicked(messageRecord: MessageRecord) {
dismiss()
getAdapterListener().onEditedIndicatorClicked(messageRecord)
}
}
companion object {
@@ -0,0 +1,161 @@
package org.thoughtcrime.securesms.conversation.ui.edit
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.os.bundleOf
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.LinearLayoutManager
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.util.concurrent.LifecycleDisposable
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.conversation.ConversationAdapter
import org.thoughtcrime.securesms.conversation.ConversationBottomSheetCallback
import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode
import org.thoughtcrime.securesms.conversation.colors.Colorizer
import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.databinding.MessageEditHistoryBottomSheetBinding
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ItemDecoration
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackController
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicy
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionPlayerHolder
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionRecycler
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.ViewModelFactory
import org.thoughtcrime.securesms.util.fragments.requireListener
import java.util.Locale
/**
* Show history of edits for a specific message.
*/
class EditMessageHistoryDialog : FixedRoundedCornerBottomSheetDialogFragment() {
override val peekHeightPercentage: Float = 0.4f
private val binding: MessageEditHistoryBottomSheetBinding by ViewBinderDelegate(MessageEditHistoryBottomSheetBinding::bind)
private val messageId: Long by lazy { requireArguments().getLong(ARGUMENT_MESSAGE_ID) }
private val conversationRecipient: Recipient by lazy { Recipient.resolved(requireArguments().getParcelable(ARGUMENT_CONVERSATION_RECIPIENT_ID)!!) }
private val viewModel: EditMessageHistoryViewModel by viewModels(factoryProducer = ViewModelFactory.factoryProducer { EditMessageHistoryViewModel(messageId, conversationRecipient) })
private val disposables: LifecycleDisposable = LifecycleDisposable()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = MessageEditHistoryBottomSheetBinding.inflate(inflater, container, false).root
view.minimumHeight = (resources.displayMetrics.heightPixels * peekHeightPercentage).toInt()
return view
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
disposables.bindTo(viewLifecycleOwner)
val colorizer = Colorizer()
val messageAdapter = ConversationAdapter(
requireContext(),
viewLifecycleOwner,
GlideApp.with(this),
Locale.getDefault(),
ConversationAdapterListener(),
conversationRecipient,
colorizer
).apply {
setCondensedMode(ConversationItemDisplayMode.EXTRA_CONDENSED)
}
binding.editHistoryList.apply {
layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false)
adapter = messageAdapter
itemAnimator = null
}
val recyclerViewColorizer = RecyclerViewColorizer(binding.editHistoryList)
disposables += viewModel
.getEditHistory()
.subscribeBy { messages ->
if (messages.isEmpty()) {
dismiss()
}
messageAdapter.submitList(messages)
recyclerViewColorizer.setChatColors(conversationRecipient.chatColors)
}
disposables += viewModel.getNameColorsMap().subscribe { map ->
colorizer.onNameColorsChanged(map)
messageAdapter.notifyItemRangeChanged(0, messageAdapter.itemCount, ConversationAdapter.PAYLOAD_NAME_COLORS)
}
initializeGiphyMp4()
}
private fun initializeGiphyMp4(): GiphyMp4ProjectionRecycler {
val maxPlayback = GiphyMp4PlaybackPolicy.maxSimultaneousPlaybackInConversation()
val holders = GiphyMp4ProjectionPlayerHolder.injectVideoViews(
requireContext(),
viewLifecycleOwner.lifecycle,
binding.videoContainer,
maxPlayback
)
val callback = GiphyMp4ProjectionRecycler(holders)
GiphyMp4PlaybackController.attach(binding.editHistoryList, callback, maxPlayback)
binding.editHistoryList.addItemDecoration(GiphyMp4ItemDecoration(callback) {}, 0)
return callback
}
private inner class ConversationAdapterListener : ConversationAdapter.ItemClickListener by requireListener<ConversationBottomSheetCallback>().getConversationAdapterListener() {
override fun onQuoteClicked(messageRecord: MmsMessageRecord) = Unit
override fun onScheduledIndicatorClicked(view: View, messageRecord: MessageRecord) = Unit
override fun onGroupMemberClicked(recipientId: RecipientId, groupId: GroupId) = Unit
override fun onItemClick(item: MultiselectPart) = Unit
override fun onItemLongClick(itemView: View, item: MultiselectPart) = Unit
override fun onQuotedIndicatorClicked(messageRecord: MessageRecord) = Unit
override fun onReactionClicked(multiselectPart: MultiselectPart, messageId: Long, isMms: Boolean) = Unit
override fun onMessageWithRecaptchaNeededClicked(messageRecord: MessageRecord) = Unit
override fun onGroupMigrationLearnMoreClicked(membershipChange: GroupMigrationMembershipChange) = Unit
override fun onChatSessionRefreshLearnMoreClicked() = Unit
override fun onBadDecryptLearnMoreClicked(author: RecipientId) = Unit
override fun onSafetyNumberLearnMoreClicked(recipient: Recipient) = Unit
override fun onJoinGroupCallClicked() = Unit
override fun onInviteFriendsToGroupClicked(groupId: GroupId.V2) = Unit
override fun onEnableCallNotificationsClicked() = Unit
override fun onCallToAction(action: String) = Unit
override fun onDonateClicked() = Unit
override fun onRecipientNameClicked(target: RecipientId) = Unit
override fun onViewGiftBadgeClicked(messageRecord: MessageRecord) = Unit
override fun onActivatePaymentsClicked() = Unit
override fun onSendPaymentClicked(recipientId: RecipientId) = Unit
override fun onEditedIndicatorClicked(messageRecord: MessageRecord) = Unit
}
companion object {
private const val ARGUMENT_MESSAGE_ID = "message_id"
private const val ARGUMENT_CONVERSATION_RECIPIENT_ID = "recipient_id"
@JvmStatic
fun show(fragmentManager: FragmentManager, threadRecipient: RecipientId, messageId: Long) {
EditMessageHistoryDialog()
.apply {
arguments = bundleOf(
ARGUMENT_MESSAGE_ID to messageId,
ARGUMENT_CONVERSATION_RECIPIENT_ID to threadRecipient
)
}
.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
}
@@ -0,0 +1,48 @@
package org.thoughtcrime.securesms.conversation.ui.edit
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.conversation.ConversationDataSource
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.database.DatabaseObserver
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
object EditMessageHistoryRepository {
fun getEditHistory(messageId: Long): Observable<List<ConversationMessage>> {
return Observable.create { emitter ->
val threadId: Long = SignalDatabase.messages.getThreadIdForMessage(messageId)
if (threadId < 0) {
emitter.onNext(emptyList())
return@create
}
val databaseObserver: DatabaseObserver = ApplicationDependencies.getDatabaseObserver()
val observer = DatabaseObserver.Observer { emitter.onNext(getEditHistorySync(messageId)) }
databaseObserver.registerConversationObserver(threadId, observer)
emitter.setCancellable { databaseObserver.unregisterObserver(observer) }
emitter.onNext(getEditHistorySync(messageId))
}.subscribeOn(Schedulers.io())
}
private fun getEditHistorySync(messageId: Long): List<ConversationMessage> {
val context = ApplicationDependencies.getApplication()
val records = SignalDatabase
.messages
.getMessageEditHistory(messageId)
.toList()
val attachmentHelper = ConversationDataSource.AttachmentHelper()
.apply {
addAll(records)
fetchAttachments()
}
return attachmentHelper
.buildUpdatedModels(context, records)
.map { ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(context, it) }
}
}
@@ -0,0 +1,39 @@
package org.thoughtcrime.securesms.conversation.ui.edit
import androidx.lifecycle.ViewModel
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.conversation.colors.GroupAuthorNameColorHelper
import org.thoughtcrime.securesms.conversation.colors.NameColor
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
/**
* View model to show history of edits for a specific message.
*/
class EditMessageHistoryViewModel(private val messageId: Long, private val conversationRecipient: Recipient) : ViewModel() {
private val groupAuthorNameColorHelper = GroupAuthorNameColorHelper()
fun getEditHistory(): Observable<List<ConversationMessage>> {
return EditMessageHistoryRepository
.getEditHistory(messageId)
.observeOn(AndroidSchedulers.mainThread())
}
fun getNameColorsMap(): Observable<Map<RecipientId, NameColor>> {
return conversationRecipient
.live()
.observable()
.map { recipient ->
if (recipient.groupId.isPresent) {
groupAuthorNameColorHelper.getColorMap(recipient.groupId.get())
} else {
emptyMap()
}
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
}
}
@@ -603,6 +603,10 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment)
requireActivity().startActivity(create(requireActivity(), args), options.toBundle())
}
override fun onEditedIndicatorClicked(messageRecord: MessageRecord) {
// TODO [alex] -- ("Not yet implemented")
}
override fun onItemClick(item: MultiselectPart?) {
// TODO [alex] -- ("Not yet implemented")
}
@@ -37,6 +37,7 @@ import com.fasterxml.jackson.annotation.JsonProperty;
import org.json.JSONArray;
import org.json.JSONException;
import org.signal.core.util.CursorUtil;
import org.signal.core.util.SQLiteDatabaseExtensionsKt;
import org.signal.core.util.SetUtil;
import org.signal.core.util.SqlUtil;
import org.signal.core.util.StreamUtil;
@@ -1484,6 +1485,16 @@ public class AttachmentTable extends DatabaseTable {
return EncryptedMediaDataSource.createFor(attachmentSecret, dataInfo.file, dataInfo.random, dataInfo.length);
}
public void duplicateAttachmentsForMessage(long destinationMessageId, long sourceMessageId) {
SQLiteDatabaseExtensionsKt.withinTransaction(getWritableDatabase(), db -> {
db.execSQL("CREATE TEMPORARY TABLE tmp_part AS SELECT * FROM " + TABLE_NAME + " WHERE " + MMS_ID + " = ?", SqlUtil.buildArgs(sourceMessageId));
db.execSQL("UPDATE tmp_part SET " + ROW_ID + " = NULL, " + MMS_ID + " = ?", SqlUtil.buildArgs(destinationMessageId));
db.execSQL("INSERT INTO " + TABLE_NAME + " SELECT * FROM tmp_part");
db.execSQL("DROP TABLE tmp_part");
return 0;
});
}
@VisibleForTesting
static class DataInfo {
private final File file;
@@ -141,6 +141,7 @@ class DraftTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
const val QUOTE = "quote"
const val BODY_RANGES = "mention"
const val VOICE_NOTE = "voice_note"
const val MESSAGE_EDIT = "message_edit"
}
}
@@ -159,6 +160,10 @@ class DraftTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
return firstOrNull { it.type == type }
}
fun shouldUpdateSnippet(): Boolean {
return none { it.type == Draft.MESSAGE_EDIT }
}
fun getSnippet(context: Context): String {
val textDraft = getDraftOfType(Draft.TEXT)
return if (textDraft != null) {
@@ -134,6 +134,7 @@ import org.thoughtcrime.securesms.util.Base64
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.JsonUtils
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.MessageConstraintsUtil
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.isStory
@@ -203,6 +204,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
const val STORY_TYPE = "story_type"
const val PARENT_STORY_ID = "parent_story_id"
const val SCHEDULED_DATE = "scheduled_date"
const val LATEST_REVISION_ID = "latest_revision_id"
const val ORIGINAL_MESSAGE_ID = "original_message_id"
const val REVISION_NUMBER = "revision_number"
const val CREATE_TABLE = """
CREATE TABLE $TABLE_NAME (
@@ -254,12 +258,15 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
$PARENT_STORY_ID INTEGER DEFAULT 0,
$EXPORT_STATE BLOB DEFAULT NULL,
$EXPORTED INTEGER DEFAULT 0,
$SCHEDULED_DATE INTEGER DEFAULT -1
$SCHEDULED_DATE INTEGER DEFAULT -1,
$LATEST_REVISION_ID INTEGER DEFAULT NULL REFERENCES $TABLE_NAME ($ID) ON DELETE CASCADE,
$ORIGINAL_MESSAGE_ID INTEGER DEFAULT NULL REFERENCES $TABLE_NAME ($ID) ON DELETE CASCADE,
$REVISION_NUMBER INTEGER DEFAULT 0
)
"""
private const val INDEX_THREAD_DATE = "message_thread_date_index"
private const val INDEX_THREAD_STORY_SCHEDULED_DATE = "message_thread_story_parent_story_scheduled_date_index"
private const val INDEX_THREAD_STORY_SCHEDULED_DATE_LATEST_REVISION_ID = "message_thread_story_parent_story_scheduled_date_latest_revision_id_index"
@JvmField
val CREATE_INDEXS = arrayOf(
@@ -271,8 +278,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
"CREATE INDEX IF NOT EXISTS message_reactions_unread_index ON $TABLE_NAME ($REACTIONS_UNREAD);",
"CREATE INDEX IF NOT EXISTS message_story_type_index ON $TABLE_NAME ($STORY_TYPE);",
"CREATE INDEX IF NOT EXISTS message_parent_story_id_index ON $TABLE_NAME ($PARENT_STORY_ID);",
"CREATE INDEX IF NOT EXISTS $INDEX_THREAD_STORY_SCHEDULED_DATE ON $TABLE_NAME ($THREAD_ID, $DATE_RECEIVED, $STORY_TYPE, $PARENT_STORY_ID, $SCHEDULED_DATE);",
"CREATE INDEX IF NOT EXISTS message_quote_id_quote_author_scheduled_date_index ON $TABLE_NAME ($QUOTE_ID, $QUOTE_AUTHOR, $SCHEDULED_DATE);",
"CREATE INDEX IF NOT EXISTS $INDEX_THREAD_STORY_SCHEDULED_DATE_LATEST_REVISION_ID ON $TABLE_NAME ($THREAD_ID, $DATE_RECEIVED, $STORY_TYPE, $PARENT_STORY_ID, $SCHEDULED_DATE, $LATEST_REVISION_ID);",
"CREATE INDEX IF NOT EXISTS message_quote_id_quote_author_scheduled_date_latest_revision_id_index ON $TABLE_NAME ($QUOTE_ID, $QUOTE_AUTHOR, $SCHEDULED_DATE, $LATEST_REVISION_ID);",
"CREATE INDEX IF NOT EXISTS message_exported_index ON $TABLE_NAME ($EXPORTED);",
"CREATE INDEX IF NOT EXISTS message_id_type_payment_transactions_index ON $TABLE_NAME ($ID,$TYPE) WHERE $TYPE & ${MessageTypes.SPECIAL_TYPE_PAYMENTS_NOTIFICATION} != 0;"
)
@@ -323,7 +330,10 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
MESSAGE_RANGES,
STORY_TYPE,
PARENT_STORY_ID,
SCHEDULED_DATE
SCHEDULED_DATE,
LATEST_REVISION_ID,
ORIGINAL_MESSAGE_ID,
REVISION_NUMBER
)
private val MMS_PROJECTION: Array<String> = MMS_PROJECTION_BASE + "NULL AS ${AttachmentTable.ATTACHMENT_JSON_ALIAS}"
@@ -380,7 +390,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
$TYPE & ${MessageTypes.GROUP_V2_LEAVE_BITS} != ${MessageTypes.GROUP_V2_LEAVE_BITS} AND
$STORY_TYPE = 0 AND
$PARENT_STORY_ID <= 0 AND
$SCHEDULED_DATE = -1 AND
$SCHEDULED_DATE = -1 AND
$LATEST_REVISION_ID IS NULL AND
$TYPE NOT IN (
${MessageTypes.PROFILE_CHANGE_TYPE},
${MessageTypes.GV1_MIGRATION_TYPE},
@@ -965,8 +976,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
}
@JvmOverloads
fun insertMessageInbox(message: IncomingTextMessage, type: Long = MessageTypes.BASE_INBOX_TYPE): Optional<InsertResult> {
var type = type
fun insertMessageInbox(message: IncomingTextMessage, editedMessage: MediaMmsMessageRecord? = null): Optional<InsertResult> {
var type = MessageTypes.BASE_INBOX_TYPE
var tryToCollapseJoinRequestEvents = false
if (message.isJoined) {
@@ -1059,13 +1070,19 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
values.put(THREAD_ID, threadId)
values.put(SERVER_GUID, message.serverGuid)
if (editedMessage != null) {
values.put(ORIGINAL_MESSAGE_ID, editedMessage.getOriginalOrOwnMessageId().id)
} else {
values.putNull(ORIGINAL_MESSAGE_ID)
}
return if (message.isPush && isDuplicate(message, threadId)) {
Log.w(TAG, "Duplicate message (" + message.sentTimestampMillis + "), ignoring...")
Optional.empty()
} else {
val messageId = writableDatabase.insert(TABLE_NAME, null, values)
if (unread) {
if (unread && editedMessage == null) {
threads.incrementUnread(threadId, 1, 0)
}
@@ -1084,6 +1101,48 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
}
}
fun insertEditMessageInbox(threadId: Long, mediaMessage: IncomingMediaMessage, targetMessage: MediaMmsMessageRecord): Optional<InsertResult> {
val insertResult = insertSecureDecryptedMessageInbox(mediaMessage, threadId, targetMessage)
if (insertResult.isPresent) {
val (messageId) = insertResult.get()
if (targetMessage.expireStarted > 0) {
markExpireStarted(messageId, targetMessage.expireStarted)
}
writableDatabase.update(TABLE_NAME)
.values(LATEST_REVISION_ID to messageId)
.where("$ID = ? OR $LATEST_REVISION_ID = ?", targetMessage.id, targetMessage.id)
.run()
reactions.moveReactionsToNewMessage(newMessageId = messageId, previousId = targetMessage.id)
}
return insertResult
}
fun insertEditMessageInbox(textMessage: IncomingTextMessage, targetMessage: MediaMmsMessageRecord): Optional<InsertResult> {
val insertResult = insertMessageInbox(textMessage, targetMessage)
if (insertResult.isPresent) {
val (messageId) = insertResult.get()
if (targetMessage.expireStarted > 0) {
markExpireStarted(messageId, targetMessage.expireStarted)
}
writableDatabase.update(TABLE_NAME)
.values(LATEST_REVISION_ID to messageId)
.where("$ID_WHERE OR $LATEST_REVISION_ID = ?", targetMessage.id, targetMessage.id)
.run()
reactions.moveReactionsToNewMessage(newMessageId = messageId, previousId = targetMessage.id)
}
return insertResult
}
fun insertProfileNameChangeMessages(recipient: Recipient, newProfileName: String, previousProfileName: String) {
writableDatabase.withinTransaction { db ->
val groupRecords = groups.getGroupsContainingMember(recipient.id, false)
@@ -1676,7 +1735,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
fun getScheduledMessageCountForThread(threadId: Long): Int {
return readableDatabase
.select("COUNT(*)")
.from("$TABLE_NAME INDEXED BY $INDEX_THREAD_STORY_SCHEDULED_DATE")
.from("$TABLE_NAME INDEXED BY $INDEX_THREAD_STORY_SCHEDULED_DATE_LATEST_REVISION_ID")
.where("$THREAD_ID = ? AND $STORY_TYPE = ? AND $PARENT_STORY_ID <= ? AND $SCHEDULED_DATE != ?", threadId, 0, 0, -1)
.run()
.readToSingleInt()
@@ -1685,8 +1744,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
fun getMessageCountForThread(threadId: Long): Int {
return readableDatabase
.select("COUNT(*)")
.from("$TABLE_NAME INDEXED BY $INDEX_THREAD_STORY_SCHEDULED_DATE")
.where("$THREAD_ID = ? AND $STORY_TYPE = ? AND $PARENT_STORY_ID <= ? AND $SCHEDULED_DATE = ?", threadId, 0, 0, -1)
.from("$TABLE_NAME INDEXED BY $INDEX_THREAD_STORY_SCHEDULED_DATE_LATEST_REVISION_ID")
.where("$THREAD_ID = ? AND $STORY_TYPE = ? AND $PARENT_STORY_ID <= ? AND $SCHEDULED_DATE = ? AND $LATEST_REVISION_ID IS NULL", threadId, 0, 0, -1)
.run()
.readToSingleInt()
}
@@ -1694,8 +1753,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
fun getMessageCountForThread(threadId: Long, beforeTime: Long): Int {
return readableDatabase
.select("COUNT(*)")
.from("$TABLE_NAME INDEXED BY $INDEX_THREAD_STORY_SCHEDULED_DATE")
.where("$THREAD_ID = ? AND $DATE_RECEIVED < ? AND $STORY_TYPE = ? AND $PARENT_STORY_ID <= ? AND $SCHEDULED_DATE = ?", threadId, beforeTime, 0, 0, -1)
.from("$TABLE_NAME INDEXED BY $INDEX_THREAD_STORY_SCHEDULED_DATE_LATEST_REVISION_ID")
.where("$THREAD_ID = ? AND $DATE_RECEIVED < ? AND $STORY_TYPE = ? AND $PARENT_STORY_ID <= ? AND $SCHEDULED_DATE = ? AND $LATEST_REVISION_ID IS NULL", threadId, beforeTime, 0, 0, -1)
.run()
.readToSingleInt()
}
@@ -1746,6 +1805,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
val query = """
$THREAD_ID = ? AND
$STORY_TYPE = 0 AND
$LATEST_REVISION_ID IS NULL AND
$PARENT_STORY_ID <= 0 AND
(
NOT $TYPE & ${MessageTypes.IGNORABLE_TYPESMASK_WHEN_COUNTING} AND
@@ -1844,11 +1904,31 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
}
}
private fun getOriginalEditedMessageRecord(messageId: Long): Long {
return readableDatabase.select(ID)
.from(TABLE_NAME)
.where("$TABLE_NAME.$LATEST_REVISION_ID = ?", messageId)
.orderBy("$ID DESC")
.limit(1)
.run()
.readToSingleLong(0)
}
fun getMessages(messageIds: Collection<Long?>): MmsReader {
val ids = TextUtils.join(",", messageIds)
return mmsReaderFor(rawQueryWithAttachments("$TABLE_NAME.$ID IN ($ids)", null))
}
fun getMessageEditHistory(id: Long): MmsReader {
val cursor = readableDatabase.select(*MMS_PROJECTION)
.from(TABLE_NAME)
.where("$TABLE_NAME.$ID = ? OR $TABLE_NAME.$LATEST_REVISION_ID = ?", id, id)
.orderBy("$TABLE_NAME.$ID DESC")
.run()
return mmsReaderFor(cursor)
}
private fun updateMailboxBitmask(id: Long, maskOff: Long, maskOn: Long, threadId: Optional<Long>) {
writableDatabase.withinTransaction { db ->
db.execSQL(
@@ -2378,6 +2458,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
null
}
val editedMessage = getOriginalEditedMessageRecord(messageId)
OutgoingMessage(
recipient = threadRecipient,
body = body,
@@ -2399,7 +2481,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
giftBadge = giftBadge,
isSecure = MessageTypes.isSecureType(outboxType),
bodyRanges = messageRanges,
scheduledDate = scheduledDate
scheduledDate = scheduledDate,
messageToEdit = editedMessage
)
}
} ?: throw NoSuchMessageException("No record found for id: $messageId")
@@ -2410,7 +2493,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
retrieved: IncomingMediaMessage,
contentLocation: String,
candidateThreadId: Long,
mailbox: Long
mailbox: Long,
editedMessage: MediaMmsMessageRecord?
): Optional<InsertResult> {
val threadId = if (candidateThreadId == -1L || retrieved.isGroupMessage) {
getThreadIdFor(retrieved)
@@ -2443,7 +2527,10 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
PARENT_STORY_ID to if (retrieved.parentStoryId != null) retrieved.parentStoryId.serialize() else 0,
READ to if (silentUpdate || retrieved.isExpirationUpdate) 1 else 0,
UNIDENTIFIED to retrieved.isUnidentified,
SERVER_GUID to retrieved.serverGuid
SERVER_GUID to retrieved.serverGuid,
LATEST_REVISION_ID to null,
ORIGINAL_MESSAGE_ID to editedMessage?.getOriginalOrOwnMessageId()?.id,
REVISION_NUMBER to (editedMessage?.revisionNumber?.inc() ?: 0)
)
val quoteAttachments: MutableList<Attachment> = mutableListOf()
@@ -2483,6 +2570,24 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
unarchive = true
)
if (editedMessage != null) {
if (retrieved.quote != null && editedMessage.quote != null) {
writableDatabase.execSQL(
"""
WITH o as (SELECT $QUOTE_ID, $QUOTE_AUTHOR, $QUOTE_BODY, $QUOTE_TYPE, $QUOTE_MISSING, $QUOTE_BODY_RANGES FROM $TABLE_NAME WHERE $ID = ${editedMessage.id})
UPDATE $TABLE_NAME
SET $QUOTE_ID = old.$QUOTE_ID, $QUOTE_AUTHOR = old.$QUOTE_AUTHOR, $QUOTE_BODY = old.$QUOTE_BODY, $QUOTE_TYPE = old.$QUOTE_TYPE, $QUOTE_MISSING = old.$QUOTE_MISSING, $QUOTE_BODY_RANGES = old.$QUOTE_BODY_RANGES
FROM o old
WHERE $TABLE_NAME.$ID = $messageId
"""
)
}
}
if (retrieved.attachments.isEmpty() && editedMessage?.id != null && attachments.getAttachmentsForMessage(editedMessage.id).isNotEmpty()) {
attachments.duplicateAttachmentsForMessage(messageId, editedMessage.id)
}
val isNotStoryGroupReply = retrieved.parentStoryId == null || !retrieved.parentStoryId.isGroupReply()
if (!MessageTypes.isPaymentsActivated(mailbox) && !MessageTypes.isPaymentsRequestToActivate(mailbox) && !MessageTypes.isExpirationTimerUpdate(mailbox) && !retrieved.storyType.isStory && isNotStoryGroupReply) {
@@ -2528,11 +2633,12 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
type = type or MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATED
}
return insertMessageInbox(retrieved, contentLocation, threadId, type)
return insertMessageInbox(retrieved, contentLocation, threadId, type, editedMessage = null)
}
@JvmOverloads
@Throws(MmsException::class)
fun insertSecureDecryptedMessageInbox(retrieved: IncomingMediaMessage, threadId: Long): Optional<InsertResult> {
fun insertSecureDecryptedMessageInbox(retrieved: IncomingMediaMessage, threadId: Long, edittedMediaMessage: MediaMmsMessageRecord? = null): Optional<InsertResult> {
var type = MessageTypes.BASE_INBOX_TYPE or MessageTypes.SECURE_MESSAGE_BIT
var hasSpecialType = false
@@ -2581,7 +2687,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
hasSpecialType = true
}
return insertMessageInbox(retrieved, "", threadId, type)
return insertMessageInbox(retrieved, "", threadId, type, edittedMediaMessage)
}
fun insertMessageInbox(notification: NotificationInd, subscriptionId: Int): Pair<Long, Long> {
@@ -2836,15 +2942,27 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
Log.w(TAG, "Found early delivery receipts for " + message.sentTimeMillis + ". Applying them.")
}
var editedMessage: MessageRecord? = null
if (message.isMessageEdit) {
try {
editedMessage = getMessageRecord(message.messageToEdit)
if (!MessageConstraintsUtil.isValidEditMessageSend(editedMessage)) {
throw MmsException("Message is not valid to edit")
}
} catch (e: NoSuchMessageException) {
throw MmsException("Unable to locate edited message", e)
}
}
val contentValues = ContentValues()
contentValues.put(DATE_SENT, message.sentTimeMillis)
contentValues.put(MMS_MESSAGE_TYPE, PduHeaders.MESSAGE_TYPE_SEND_REQ)
contentValues.put(TYPE, type)
contentValues.put(THREAD_ID, threadId)
contentValues.put(READ, 1)
contentValues.put(DATE_RECEIVED, System.currentTimeMillis())
contentValues.put(DATE_RECEIVED, editedMessage?.dateReceived ?: System.currentTimeMillis())
contentValues.put(SMS_SUBSCRIPTION_ID, message.subscriptionId)
contentValues.put(EXPIRES_IN, message.expiresIn)
contentValues.put(EXPIRES_IN, editedMessage?.expiresIn ?: message.expiresIn)
contentValues.put(VIEW_ONCE, message.isViewOnce)
contentValues.put(FROM_RECIPIENT_ID, Recipient.self().id.serialize())
contentValues.put(FROM_DEVICE_ID, SignalStore.account().deviceId)
@@ -2854,6 +2972,14 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
contentValues.put(STORY_TYPE, message.storyType.code)
contentValues.put(PARENT_STORY_ID, if (message.parentStoryId != null) message.parentStoryId.serialize() else 0)
contentValues.put(SCHEDULED_DATE, message.scheduledDate)
contentValues.putNull(LATEST_REVISION_ID)
if (editedMessage != null) {
contentValues.put(ORIGINAL_MESSAGE_ID, editedMessage.getOriginalOrOwnMessageId().id)
contentValues.put(REVISION_NUMBER, editedMessage.revisionNumber + 1)
} else {
contentValues.putNull(ORIGINAL_MESSAGE_ID)
}
if (message.threadRecipient.isSelf && hasAudioAttachment(message.attachments)) {
contentValues.put(VIEWED_RECEIPT_COUNT, 1L)
@@ -2935,10 +3061,19 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
}
}
if (message.messageToEdit > 0) {
writableDatabase.update(TABLE_NAME)
.values(LATEST_REVISION_ID to messageId)
.where("$ID_WHERE OR $LATEST_REVISION_ID = ?", message.messageToEdit, message.messageToEdit)
.run()
reactions.moveReactionsToNewMessage(messageId, message.messageToEdit)
}
threads.updateLastSeenAndMarkSentAndLastScrolledSilenty(threadId)
if (!message.storyType.isStory) {
if (message.outgoingQuote == null) {
if (message.outgoingQuote == null && editedMessage == null) {
ApplicationDependencies.getDatabaseObserver().notifyMessageInsertObservers(threadId, MessageId(messageId))
} else {
ApplicationDependencies.getDatabaseObserver().notifyConversationListeners(threadId)
@@ -3273,6 +3408,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
reader.filterNotNull()
}
}
fun getAllRateLimitedMessageIds(): Set<Long> {
val db = databaseHelper.signalReadableDatabase
val where = "(" + TYPE + " & " + MessageTypes.TOTAL_MASK + " & " + MessageTypes.MESSAGE_RATE_LIMITED_BIT + ") > 0"
@@ -3389,7 +3525,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
}
private fun getMessagesInThreadAfterInclusive(threadId: Long, timestamp: Long, limit: Long): List<MessageRecord> {
val where = "$TABLE_NAME.$THREAD_ID = ? AND $TABLE_NAME.$DATE_RECEIVED >= ? AND $TABLE_NAME.$SCHEDULED_DATE = -1"
val where = "$TABLE_NAME.$THREAD_ID = ? AND $TABLE_NAME.$DATE_RECEIVED >= ? AND $TABLE_NAME.$SCHEDULED_DATE = -1 AND $TABLE_NAME.$LATEST_REVISION_ID IS NULL"
val args = buildArgs(threadId, timestamp)
return mmsReaderFor(rawQueryWithAttachments(where, args, false, limit)).use { reader ->
@@ -3858,7 +3994,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
fun getAllMessagesThatQuote(id: MessageId): List<MessageRecord> {
val targetMessage: MessageRecord = getMessageRecord(id.id)
val query = "$QUOTE_ID = ${targetMessage.dateSent} AND $QUOTE_AUTHOR = ${targetMessage.fromRecipient.id.serialize()} AND $SCHEDULED_DATE = -1"
val query = "$QUOTE_ID = ${targetMessage.dateSent} AND $QUOTE_AUTHOR = ${targetMessage.fromRecipient.id.serialize()} AND $SCHEDULED_DATE = -1 AND $LATEST_REVISION_ID IS NULL"
val order = "$DATE_RECEIVED DESC"
val records: MutableList<MessageRecord> = ArrayList()
@@ -3873,8 +4009,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
}
fun getQuotedMessagePosition(threadId: Long, quoteId: Long, authorId: RecipientId): Int {
var position = 0
readableDatabase
.select(DATE_SENT, FROM_RECIPIENT_ID, REMOTE_DELETED)
.select(DATE_SENT, FROM_RECIPIENT_ID, REMOTE_DELETED, LATEST_REVISION_ID)
.from(TABLE_NAME)
.where("$THREAD_ID = $threadId AND $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1")
.orderBy("$DATE_RECEIVED DESC")
@@ -3887,8 +4024,10 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
return if (cursor.requireBoolean(REMOTE_DELETED)) {
-1
} else {
cursor.position
position
}
} else if (cursor.requireLong(LATEST_REVISION_ID) == 0L) {
position++
}
}
@@ -3899,7 +4038,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
readableDatabase
.select(DATE_RECEIVED, FROM_RECIPIENT_ID, REMOTE_DELETED)
.from(TABLE_NAME)
.where("$THREAD_ID = $threadId AND $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1")
.where("$THREAD_ID = $threadId AND $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1 AND $LATEST_REVISION_ID IS NULL")
.orderBy("$DATE_RECEIVED DESC")
.run()
.forEach { cursor ->
@@ -3938,10 +4077,10 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
if (groupStoryId > 0) {
order = "$DATE_RECEIVED ASC"
selection = "$THREAD_ID = $threadId AND $DATE_RECEIVED < $receivedTimestamp AND $STORY_TYPE = 0 AND $PARENT_STORY_ID = $groupStoryId AND $SCHEDULED_DATE = -1"
selection = "$THREAD_ID = $threadId AND $DATE_RECEIVED < $receivedTimestamp AND $STORY_TYPE = 0 AND $PARENT_STORY_ID = $groupStoryId AND $SCHEDULED_DATE = -1 AND $LATEST_REVISION_ID IS NULL"
} else {
order = "$DATE_RECEIVED DESC"
selection = "$THREAD_ID = $threadId AND $DATE_RECEIVED > $receivedTimestamp AND $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1"
selection = "$THREAD_ID = $threadId AND $DATE_RECEIVED > $receivedTimestamp AND $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1 AND $LATEST_REVISION_ID IS NULL"
}
return readableDatabase
@@ -3957,7 +4096,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
return readableDatabase
.select(DATE_RECEIVED)
.from(TABLE_NAME)
.where("$DATE_RECEIVED > $date AND $SCHEDULED_DATE = -1")
.where("$DATE_RECEIVED > $date AND $SCHEDULED_DATE = -1 AND $LATEST_REVISION_ID IS NULL")
.orderBy("$DATE_RECEIVED ASC")
.limit(1)
.run()
@@ -3968,7 +4107,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
return readableDatabase
.select("COUNT(*)")
.from(TABLE_NAME)
.where("$DATE_RECEIVED < $date AND $SCHEDULED_DATE = -1")
.where("$DATE_RECEIVED < $date AND $SCHEDULED_DATE = -1 AND $LATEST_REVISION_ID IS NULL")
.run()
.readToSingleInt()
}
@@ -3986,7 +4125,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
return readableDatabase
.select("COUNT(*)")
.from(TABLE_NAME)
.where("$THREAD_ID = $threadId AND $DATE_RECEIVED >= $timestamp AND $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1")
.where("$THREAD_ID = $threadId AND $DATE_RECEIVED >= $timestamp AND $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1 AND $LATEST_REVISION_ID IS NULL")
.run()
.readToSingleInt()
}
@@ -4017,7 +4156,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
return readableDatabase
.select("COUNT(*)")
.from("$TABLE_NAME INDEXED BY $INDEX_THREAD_DATE")
.where("$READ = 0 AND $STORY_TYPE = 0 AND $THREAD_ID = $threadId AND $PARENT_STORY_ID <= 0")
.where("$READ = 0 AND $STORY_TYPE = 0 AND $THREAD_ID = $threadId AND $PARENT_STORY_ID <= 0 AND $LATEST_REVISION_ID IS NULL")
.run()
.readToSingleInt()
}
@@ -4410,7 +4549,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
return readableDatabase
.select(*MMS_PROJECTION)
.from(TABLE_NAME)
.where("$THREAD_ID = ? AND $STORY_TYPE = ? AND $PARENT_STORY_ID <= ? AND $SCHEDULED_DATE = ?", threadId, 0, 0, -1)
.where("$THREAD_ID = ? AND $STORY_TYPE = ? AND $PARENT_STORY_ID <= ? AND $SCHEDULED_DATE = ? AND $LATEST_REVISION_ID IS NULL", threadId, 0, 0, -1)
.orderBy("$DATE_RECEIVED DESC")
.limit(limitStr)
.run()
@@ -4422,7 +4561,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
fun getScheduledMessagesInThread(threadId: Long): List<MessageRecord> {
val cursor = readableDatabase
.select(*MMS_PROJECTION)
.from("$TABLE_NAME INDEXED BY $INDEX_THREAD_STORY_SCHEDULED_DATE")
.from("$TABLE_NAME INDEXED BY $INDEX_THREAD_STORY_SCHEDULED_DATE_LATEST_REVISION_ID")
.where("$THREAD_ID = ? AND $STORY_TYPE = ? AND $PARENT_STORY_ID <= ? AND $SCHEDULED_DATE != ?", threadId, 0, 0, -1)
.orderBy("$SCHEDULED_DATE DESC, $ID DESC")
.run()
@@ -4690,6 +4829,10 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
}
}
private fun MessageRecord.getOriginalOrOwnMessageId(): MessageId {
return this.originalMessageId ?: MessageId(this.id)
}
protected enum class ReceiptType(val columnName: String, val groupStatus: Int) {
READ(READ_RECEIPT_COUNT, GroupReceiptTable.STATUS_READ),
DELIVERY(DELIVERY_RECEIPT_COUNT, GroupReceiptTable.STATUS_DELIVERED),
@@ -4974,6 +5117,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
val storyType = StoryType.fromCode(cursor.requireInt(STORY_TYPE))
val parentStoryId = ParentStoryId.deserialize(cursor.requireLong(PARENT_STORY_ID))
val scheduledDate = cursor.requireLong(SCHEDULED_DATE)
val latestRevisionId: MessageId? = cursor.requireLong(LATEST_REVISION_ID).let { if (it == 0L) null else MessageId(it) }
val originalMessageId: MessageId? = cursor.requireLong(ORIGINAL_MESSAGE_ID).let { if (it == 0L) null else MessageId(it) }
val editCount = cursor.requireInt(REVISION_NUMBER)
if (!TextSecurePreferences.isReadReceiptsEnabled(context)) {
readReceiptCount = 0
@@ -5057,7 +5203,10 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
giftBadge,
null,
null,
scheduledDate
scheduledDate,
latestRevisionId,
originalMessageId,
editCount
)
}
@@ -6,6 +6,7 @@ import android.database.Cursor
import org.signal.core.util.CursorUtil
import org.signal.core.util.SqlUtil
import org.signal.core.util.delete
import org.signal.core.util.update
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.database.model.ReactionRecord
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
@@ -177,4 +178,12 @@ class ReactionTable(context: Context, databaseHelper: SignalDatabase) : Database
.where("$MESSAGE_ID NOT IN (SELECT ${MessageTable.ID} FROM ${MessageTable.TABLE_NAME})")
.run()
}
fun moveReactionsToNewMessage(newMessageId: Long, previousId: Long) {
writableDatabase
.update(TABLE_NAME)
.values(MESSAGE_ID to newMessageId)
.where("$MESSAGE_ID = ?", previousId)
.run()
}
}
@@ -77,7 +77,9 @@ class SearchTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
WHERE
$FTS_TABLE_NAME MATCH ? AND
${MessageTable.TABLE_NAME}.${MessageTable.TYPE} & ${MessageTypes.GROUP_V2_BIT} = 0 AND
${MessageTable.TABLE_NAME}.${MessageTable.TYPE} & ${MessageTypes.SPECIAL_TYPE_PAYMENTS_NOTIFICATION} = 0
${MessageTable.TABLE_NAME}.${MessageTable.TYPE} & ${MessageTypes.SPECIAL_TYPE_PAYMENTS_NOTIFICATION} = 0 AND
${MessageTable.TABLE_NAME}.${MessageTable.SCHEDULED_DATE} < 0 AND
${MessageTable.TABLE_NAME}.${MessageTable.LATEST_REVISION_ID} IS NULL
ORDER BY ${MessageTable.DATE_RECEIVED} DESC
LIMIT 500
"""
@@ -99,7 +101,11 @@ class SearchTable(context: Context, databaseHelper: SignalDatabase) : DatabaseTa
INNER JOIN ${ThreadTable.TABLE_NAME} ON $FTS_TABLE_NAME.$THREAD_ID = ${ThreadTable.TABLE_NAME}.${ThreadTable.ID}
WHERE
$FTS_TABLE_NAME MATCH ? AND
${MessageTable.TABLE_NAME}.${MessageTable.THREAD_ID} = ?
${MessageTable.TABLE_NAME}.${MessageTable.THREAD_ID} = ? AND
${MessageTable.TABLE_NAME}.${MessageTable.TYPE} & ${MessageTypes.GROUP_V2_BIT} = 0 AND
${MessageTable.TABLE_NAME}.${MessageTable.TYPE} & ${MessageTypes.SPECIAL_TYPE_PAYMENTS_NOTIFICATION} = 0 AND
${MessageTable.TABLE_NAME}.${MessageTable.SCHEDULED_DATE} < 0 AND
${MessageTable.TABLE_NAME}.${MessageTable.LATEST_REVISION_ID} IS NULL
ORDER BY ${MessageTable.DATE_RECEIVED} DESC
LIMIT 500
"""
@@ -161,7 +161,6 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
Log.i(TAG, "Upgrading database: $oldVersion, $newVersion")
val startTime = System.currentTimeMillis()
db.setForeignKeyConstraintsEnabled(false)
db.beginTransaction()
try {
migrate(context, db, oldVersion, newVersion)
@@ -169,7 +168,6 @@ open class SignalDatabase(private val context: Application, databaseSecret: Data
db.setTransactionSuccessful()
} finally {
db.endTransaction()
db.setForeignKeyConstraintsEnabled(true)
// We have to re-begin the transaction for the calling code (see comment at start of method)
db.beginTransaction()
@@ -41,6 +41,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V182_CallTableMigra
import org.thoughtcrime.securesms.database.helpers.migration.V183_CallLinkTableMigration
import org.thoughtcrime.securesms.database.helpers.migration.V184_CallLinkReplaceIndexMigration
import org.thoughtcrime.securesms.database.helpers.migration.V185_MessageRecipientsMigration
import org.thoughtcrime.securesms.database.helpers.migration.V186_AddEditMessageColumnsMigration
/**
* Contains all of the database migrations for [SignalDatabase]. Broken into a separate file for cleanliness.
@@ -49,7 +50,7 @@ object SignalDatabaseMigrations {
val TAG: String = Log.tag(SignalDatabaseMigrations.javaClass)
const val DATABASE_VERSION = 185
const val DATABASE_VERSION = 186
@JvmStatic
fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
@@ -200,6 +201,10 @@ object SignalDatabaseMigrations {
if (oldVersion < 185) {
V185_MessageRecipientsMigration.migrate(context, db, oldVersion, newVersion)
}
if (oldVersion < 186) {
V186_AddEditMessageColumnsMigration.migrate(context, db, oldVersion, newVersion)
}
}
@JvmStatic
@@ -0,0 +1,189 @@
package org.thoughtcrime.securesms.database.helpers.migration
import android.app.Application
import net.zetetic.database.sqlcipher.SQLiteDatabase
import org.signal.core.util.Stopwatch
import org.signal.core.util.logging.Log
import org.signal.core.util.readToList
import org.signal.core.util.requireNonNullString
/**
* Changes needed for edit message. New foreign keys require recreating the table.
*/
@Suppress("ClassName")
object V186_AddEditMessageColumnsMigration : SignalDatabaseMigration {
private val TAG = Log.tag(V186_AddEditMessageColumnsMigration::class.java)
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
val stopwatch = Stopwatch("migration")
val dependentItems: List<SqlItem> = getAllDependentItems(db, "message")
dependentItems.forEach { item ->
val sql = "DROP ${item.type} IF EXISTS ${item.name}"
Log.d(TAG, "Executing: $sql")
db.execSQL(sql)
}
stopwatch.split("drop-dependents")
db.execSQL(
"""
CREATE TABLE message_tmp (
_id INTEGER PRIMARY KEY AUTOINCREMENT,
date_sent INTEGER NOT NULL,
date_received INTEGER NOT NULL,
date_server INTEGER DEFAULT -1,
thread_id INTEGER NOT NULL REFERENCES thread (_id) ON DELETE CASCADE,
from_recipient_id INTEGER NOT NULL REFERENCES recipient (_id) ON DELETE CASCADE,
from_device_id INTEGER,
to_recipient_id INTEGER NOT NULL REFERENCES recipient (_id) ON DELETE CASCADE,
type INTEGER NOT NULL,
body TEXT,
read INTEGER DEFAULT 0,
ct_l TEXT,
exp INTEGER,
m_type INTEGER,
m_size INTEGER,
st INTEGER,
tr_id TEXT,
subscription_id INTEGER DEFAULT -1,
receipt_timestamp INTEGER DEFAULT -1,
delivery_receipt_count INTEGER DEFAULT 0,
read_receipt_count INTEGER DEFAULT 0,
viewed_receipt_count INTEGER DEFAULT 0,
mismatched_identities TEXT DEFAULT NULL,
network_failures TEXT DEFAULT NULL,
expires_in INTEGER DEFAULT 0,
expire_started INTEGER DEFAULT 0,
notified INTEGER DEFAULT 0,
quote_id INTEGER DEFAULT 0,
quote_author INTEGER DEFAULT 0,
quote_body TEXT DEFAULT NULL,
quote_missing INTEGER DEFAULT 0,
quote_mentions BLOB DEFAULT NULL,
quote_type INTEGER DEFAULT 0,
shared_contacts TEXT DEFAULT NULL,
unidentified INTEGER DEFAULT 0,
link_previews TEXT DEFAULT NULL,
view_once INTEGER DEFAULT 0,
reactions_unread INTEGER DEFAULT 0,
reactions_last_seen INTEGER DEFAULT -1,
remote_deleted INTEGER DEFAULT 0,
mentions_self INTEGER DEFAULT 0,
notified_timestamp INTEGER DEFAULT 0,
server_guid TEXT DEFAULT NULL,
message_ranges BLOB DEFAULT NULL,
story_type INTEGER DEFAULT 0,
parent_story_id INTEGER DEFAULT 0,
export_state BLOB DEFAULT NULL,
exported INTEGER DEFAULT 0,
scheduled_date INTEGER DEFAULT -1,
latest_revision_id INTEGER DEFAULT NULL REFERENCES message (_id) ON DELETE CASCADE,
original_message_id INTEGER DEFAULT NULL REFERENCES message (_id) ON DELETE CASCADE,
revision_number INTEGER DEFAULT 0
)
"""
)
stopwatch.split("create-table")
db.execSQL(
"""
INSERT INTO message_tmp
SELECT
_id,
date_sent,
date_received,
date_server,
thread_id,
from_recipient_id,
from_device_id,
to_recipient_id,
type,
body,
read,
ct_l,
exp,
m_type,
m_size,
st,
tr_id,
subscription_id,
receipt_timestamp,
delivery_receipt_count,
read_receipt_count,
viewed_receipt_count,
mismatched_identities,
network_failures,
expires_in,
expire_started,
notified,
quote_id,
quote_author,
quote_body,
quote_missing,
quote_mentions,
quote_type,
shared_contacts,
unidentified,
link_previews,
view_once,
reactions_unread,
reactions_last_seen,
remote_deleted,
mentions_self,
notified_timestamp,
server_guid,
message_ranges,
story_type,
parent_story_id,
export_state,
exported,
scheduled_date,
NULL AS latest_revision_id,
NULL AS original_message_id,
0 as revision_number
FROM message
"""
)
stopwatch.split("copy-data")
db.execSQL("DROP TABLE message")
stopwatch.split("drop-old")
db.execSQL("ALTER TABLE message_tmp RENAME TO message")
stopwatch.split("rename-table")
dependentItems.forEach { item ->
val sql = when (item.name) {
"message_thread_story_parent_story_scheduled_date_index" -> "CREATE INDEX message_thread_story_parent_story_scheduled_date_latest_revision_id_index ON message (thread_id, date_received, story_type, parent_story_id, scheduled_date, latest_revision_id)"
"message_quote_id_quote_author_scheduled_date_index" -> "CREATE INDEX message_quote_id_quote_author_scheduled_date_latest_revision_id_index ON message (quote_id, quote_author, scheduled_date, latest_revision_id)"
else -> item.createStatement
}
Log.d(TAG, "Executing: $sql")
db.execSQL(sql)
}
stopwatch.split("recreate-dependents")
db.execSQL("PRAGMA foreign_key_check")
stopwatch.split("fk-check")
stopwatch.stop(TAG)
}
private fun getAllDependentItems(db: SQLiteDatabase, tableName: String): List<SqlItem> {
return db.rawQuery("SELECT type, name, sql FROM sqlite_schema WHERE tbl_name='$tableName' AND type != 'table'").readToList { cursor ->
SqlItem(
type = cursor.requireNonNullString("type"),
name = cursor.requireNonNullString("name"),
createStatement = cursor.requireNonNullString("sql")
)
}
}
data class SqlItem(
val type: String,
val name: String,
val createStatement: String
)
}
@@ -54,7 +54,9 @@ public class InMemoryMessageRecord extends MessageRecord {
false,
0,
0,
-1);
-1,
null,
0);
}
@Override
@@ -70,6 +70,7 @@ public class MediaMmsMessageRecord extends MmsMessageRecord {
private final Payment payment;
private final CallTable.Call call;
private final long scheduledDate;
private final MessageId latestRevisionId;
public MediaMmsMessageRecord(long id,
Recipient fromRecipient,
@@ -106,18 +107,22 @@ public class MediaMmsMessageRecord extends MmsMessageRecord {
@Nullable GiftBadge giftBadge,
@Nullable Payment payment,
@Nullable CallTable.Call call,
long scheduledDate)
long scheduledDate,
@Nullable MessageId latestRevisionId,
@Nullable MessageId originalMessageId,
int revisionNumber)
{
super(id, body, fromRecipient, fromDeviceId, toRecipient, dateSent,
dateReceived, dateServer, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox, mismatches, failures,
subscriptionId, expiresIn, expireStarted, viewOnce, slideDeck,
readReceiptCount, quote, contacts, linkPreviews, unidentified, reactions, remoteDelete, notifiedTimestamp, viewedReceiptCount, receiptTimestamp,
storyType, parentStoryId, giftBadge);
this.mentionsSelf = mentionsSelf;
this.messageRanges = messageRanges;
this.payment = payment;
this.call = call;
this.scheduledDate = scheduledDate;
storyType, parentStoryId, giftBadge, originalMessageId, revisionNumber);
this.mentionsSelf = mentionsSelf;
this.messageRanges = messageRanges;
this.payment = payment;
this.call = call;
this.scheduledDate = scheduledDate;
this.latestRevisionId = latestRevisionId;
}
@Override
@@ -204,18 +209,24 @@ public class MediaMmsMessageRecord extends MmsMessageRecord {
return scheduledDate;
}
public @Nullable MessageId getLatestRevisionId() {
return latestRevisionId;
}
public @NonNull MediaMmsMessageRecord withReactions(@NonNull List<ReactionRecord> reactions) {
return new MediaMmsMessageRecord(getId(), getFromRecipient(), getFromDeviceId(), getToRecipient(), getDateSent(), getDateReceived(), getServerTimestamp(), getDeliveryReceiptCount(), getThreadId(), getBody(), getSlideDeck(),
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), isViewOnce(),
getReadReceiptCount(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), reactions, isRemoteDelete(), mentionsSelf,
getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getScheduledDate());
getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getScheduledDate(), getLatestRevisionId(),
getOriginalMessageId(), getRevisionNumber());
}
public @NonNull MediaMmsMessageRecord withoutQuote() {
return new MediaMmsMessageRecord(getId(), getFromRecipient(), getFromDeviceId(), getToRecipient(), getDateSent(), getDateReceived(), getServerTimestamp(), getDeliveryReceiptCount(), getThreadId(), getBody(), getSlideDeck(),
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), isViewOnce(),
getReadReceiptCount(), null, getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf,
getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getScheduledDate());
getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getScheduledDate(), getLatestRevisionId(),
getOriginalMessageId(), getRevisionNumber());
}
public @NonNull MediaMmsMessageRecord withAttachments(@NonNull Context context, @NonNull List<DatabaseAttachment> attachments) {
@@ -236,14 +247,16 @@ public class MediaMmsMessageRecord extends MmsMessageRecord {
return new MediaMmsMessageRecord(getId(), getFromRecipient(), getFromDeviceId(), getToRecipient(), getDateSent(), getDateReceived(), getServerTimestamp(), getDeliveryReceiptCount(), getThreadId(), getBody(), slideDeck,
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), isViewOnce(),
getReadReceiptCount(), quote, contacts, linkPreviews, isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf,
getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getScheduledDate());
getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getScheduledDate(), getLatestRevisionId(),
getOriginalMessageId(), getRevisionNumber());
}
public @NonNull MediaMmsMessageRecord withPayment(@NonNull Payment payment) {
return new MediaMmsMessageRecord(getId(), getFromRecipient(), getFromDeviceId(), getToRecipient(), getDateSent(), getDateReceived(), getServerTimestamp(), getDeliveryReceiptCount(), getThreadId(), getBody(), getSlideDeck(),
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), isViewOnce(),
getReadReceiptCount(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf,
getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), payment, getCall(), getScheduledDate());
getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), payment, getCall(), getScheduledDate(), getLatestRevisionId(),
getOriginalMessageId(), getRevisionNumber());
}
@@ -251,7 +264,8 @@ public class MediaMmsMessageRecord extends MmsMessageRecord {
return new MediaMmsMessageRecord(getId(), getFromRecipient(), getFromDeviceId(), getToRecipient(), getDateSent(), getDateReceived(), getServerTimestamp(), getDeliveryReceiptCount(), getThreadId(), getBody(), getSlideDeck(),
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), isViewOnce(),
getReadReceiptCount(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf,
getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), call, getScheduledDate());
getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), call, getScheduledDate(), getLatestRevisionId(),
getOriginalMessageId(), getRevisionNumber());
}
private static @NonNull List<Contact> updateContacts(@NonNull List<Contact> contacts, @NonNull Map<AttachmentId, DatabaseAttachment> attachmentIdMap) {
@@ -102,6 +102,8 @@ public abstract class MessageRecord extends DisplayRecord {
private final boolean remoteDelete;
private final long notifiedTimestamp;
private final long receiptTimestamp;
private final MessageId originalMessageId;
private final int revisionNumber;
protected Boolean isJumboji = null;
@@ -110,10 +112,18 @@ public abstract class MessageRecord extends DisplayRecord {
int deliveryStatus, int deliveryReceiptCount, long type,
Set<IdentityKeyMismatch> mismatches,
Set<NetworkFailure> networkFailures,
int subscriptionId, long expiresIn, long expireStarted,
int readReceiptCount, boolean unidentified,
@NonNull List<ReactionRecord> reactions, boolean remoteDelete, long notifiedTimestamp,
int viewedReceiptCount, long receiptTimestamp)
int subscriptionId,
long expiresIn,
long expireStarted,
int readReceiptCount,
boolean unidentified,
@NonNull List<ReactionRecord> reactions,
boolean remoteDelete,
long notifiedTimestamp,
int viewedReceiptCount,
long receiptTimestamp,
@Nullable MessageId originalMessageId,
int revisionNumber)
{
super(body, fromRecipient, toRecipient, dateSent, dateReceived,
threadId, deliveryStatus, deliveryReceiptCount, type,
@@ -131,6 +141,8 @@ public abstract class MessageRecord extends DisplayRecord {
this.remoteDelete = remoteDelete;
this.notifiedTimestamp = notifiedTimestamp;
this.receiptTimestamp = receiptTimestamp;
this.originalMessageId = originalMessageId;
this.revisionNumber = revisionNumber;
}
public abstract boolean isMms();
@@ -721,6 +733,18 @@ public abstract class MessageRecord extends DisplayRecord {
throw new NullPointerException();
}
public boolean isEditMessage() {
return originalMessageId != null;
}
public @Nullable MessageId getOriginalMessageId() {
return originalMessageId;
}
public int getRevisionNumber() {
return revisionNumber;
}
public static final class InviteAddState {
private final boolean invited;
@@ -39,12 +39,13 @@ public abstract class MmsMessageRecord extends MessageRecord {
@NonNull List<LinkPreview> linkPreviews, boolean unidentified,
@NonNull List<ReactionRecord> reactions, boolean remoteDelete, long notifiedTimestamp,
int viewedReceiptCount, long receiptTimestamp, @NonNull StoryType storyType,
@Nullable ParentStoryId parentStoryId, @Nullable GiftBadge giftBadge)
@Nullable ParentStoryId parentStoryId, @Nullable GiftBadge giftBadge, @Nullable MessageId originalMessageId,
int revisionNumber)
{
super(id, body, fromRecipient, fromDeviceId, toRecipient,
dateSent, dateReceived, dateServer, threadId, deliveryStatus, deliveryReceiptCount,
type, mismatches, networkFailures, subscriptionId, expiresIn, expireStarted, readReceiptCount,
unidentified, reactions, remoteDelete, notifiedTimestamp, viewedReceiptCount, receiptTimestamp);
unidentified, reactions, remoteDelete, notifiedTimestamp, viewedReceiptCount, receiptTimestamp, originalMessageId, revisionNumber);
this.slideDeck = slideDeck;
this.quote = quote;
@@ -60,7 +60,7 @@ public class NotificationMmsMessageRecord extends MmsMessageRecord {
dateSent, dateReceived, -1, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox,
new HashSet<>(), new HashSet<>(), subscriptionId,
0, 0, false, slideDeck, readReceiptCount, null, Collections.emptyList(), Collections.emptyList(), false,
Collections.emptyList(), false, 0, viewedReceiptCount, receiptTimestamp, storyType, parentStoryId, giftBadge);
Collections.emptyList(), false, 0, viewedReceiptCount, receiptTimestamp, storyType, parentStoryId, giftBadge, null, 0);
this.contentLocation = contentLocation;
this.messageSize = messageSize;
@@ -140,9 +140,10 @@ public class IndividualSendJob extends PushSendJob {
throws IOException, MmsException, NoSuchMessageException, UndeliverableMessageException, RetryLaterException
{
ExpiringMessageManager expirationManager = ApplicationDependencies.getExpiringMessageManager();
MessageTable database = SignalDatabase.messages();
OutgoingMessage message = database.getOutgoingMessage(messageId);
long threadId = database.getMessageRecord(messageId).getThreadId();
MessageTable database = SignalDatabase.messages();
OutgoingMessage message = database.getOutgoingMessage(messageId);
long threadId = database.getMessageRecord(messageId).getThreadId();
MessageRecord originalEditedMessage = message.getMessageToEdit() > 0 ? SignalDatabase.messages().getMessageRecordOrNull(message.getMessageToEdit()) : null;
if (database.isSent(messageId)) {
warn(TAG, String.valueOf(message.getSentTimeMillis()), "Message " + messageId + " was already sent. Ignoring.");
@@ -158,7 +159,7 @@ public class IndividualSendJob extends PushSendJob {
byte[] profileKey = recipient.getProfileKey();
UnidentifiedAccessMode accessMode = recipient.getUnidentifiedAccessMode();
boolean unidentified = deliver(message);
boolean unidentified = deliver(message, originalEditedMessage);
database.markAsSent(messageId, true);
markAttachmentsUploaded(messageId, message);
@@ -181,7 +182,10 @@ public class IndividualSendJob extends PushSendJob {
SignalDatabase.recipients().setUnidentifiedAccessMode(recipient.getId(), UnidentifiedAccessMode.DISABLED);
}
if (message.getExpiresIn() > 0 && !message.isExpirationUpdate()) {
if (originalEditedMessage != null && originalEditedMessage.getExpireStarted() > 0) {
database.markExpireStarted(messageId, originalEditedMessage.getExpireStarted());
expirationManager.scheduleDeletion(messageId, true, originalEditedMessage.getExpireStarted(), originalEditedMessage.getExpiresIn());
} else if (message.getExpiresIn() > 0 && !message.isExpirationUpdate()) {
database.markExpireStarted(messageId);
expirationManager.scheduleDeletion(messageId, true, message.getExpiresIn());
}
@@ -214,7 +218,7 @@ public class IndividualSendJob extends PushSendJob {
notifyMediaMessageDeliveryFailed(context, messageId);
}
private boolean deliver(OutgoingMessage message)
private boolean deliver(OutgoingMessage message, MessageRecord originalEditedMessage)
throws IOException, InsecureFallbackApprovalException, UntrustedIdentityException, UndeliverableMessageException
{
if (message.getThreadRecipient() == null) {
@@ -283,7 +287,12 @@ public class IndividualSendJob extends PushSendJob {
SignalServiceDataMessage mediaMessage = mediaMessageBuilder.build();
if (Util.equals(SignalStore.account().getAci(), address.getServiceId())) {
if (originalEditedMessage != null) {
SendMessageResult result = messageSender.sendEditMessage(address, UnidentifiedAccessUtil.getAccessFor(context, messageRecipient), ContentHint.RESENDABLE, mediaMessage, IndividualSendEvents.EMPTY, message.isUrgent(), originalEditedMessage.getDateSent());
SignalDatabase.messageLog().insertIfPossible(messageRecipient.getId(), message.getSentTimeMillis(), result, ContentHint.RESENDABLE, new MessageId(messageId), false);
return result.getSuccess().isUnidentified();
} else if (Util.equals(SignalStore.account().getAci(), address.getServiceId())) {
Optional<UnidentifiedAccessPair> syncAccess = UnidentifiedAccessUtil.getAccessForSync(context);
SendMessageResult result = messageSender.sendSyncMessage(mediaMessage);
SignalDatabase.messageLog().insertIfPossible(messageRecipient.getId(), message.getSentTimeMillis(), result, ContentHint.RESENDABLE, new MessageId(messageId), false);
@@ -52,6 +52,7 @@ import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException;
import org.whispersystems.signalservice.api.messages.SendMessageResult;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceEditMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2;
import org.whispersystems.signalservice.api.messages.SignalServicePreview;
import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessage;
@@ -177,9 +178,10 @@ public final class PushGroupSendJob extends PushSendJob {
{
SignalLocalMetrics.GroupMessageSend.onJobStarted(messageId);
MessageTable database = SignalDatabase.messages();
OutgoingMessage message = database.getOutgoingMessage(messageId);
long threadId = database.getMessageRecord(messageId).getThreadId();
MessageTable database = SignalDatabase.messages();
OutgoingMessage message = database.getOutgoingMessage(messageId);
long threadId = database.getMessageRecord(messageId).getThreadId();
MessageRecord originalEditedMessage = message.getMessageToEdit() > 0 ? SignalDatabase.messages().getMessageRecordOrNull(message.getMessageToEdit()) : null;
Set<NetworkFailure> existingNetworkFailures = new HashSet<>(message.getNetworkFailures());
Set<IdentityKeyMismatch> existingIdentityMismatches = new HashSet<>(message.getIdentityKeyMismatches());
@@ -227,7 +229,7 @@ public final class PushGroupSendJob extends PushSendJob {
skipped = result.skipped;
}
List<SendMessageResult> results = deliver(message, groupRecipient, target);
List<SendMessageResult> results = deliver(message, originalEditedMessage, groupRecipient, target);
processGroupMessageResults(context, messageId, threadId, groupRecipient, message, results, target, skipped, existingNetworkFailures, existingIdentityMismatches);
Log.i(TAG, JobLogger.format(this, "Finished send."));
@@ -251,7 +253,7 @@ public final class PushGroupSendJob extends PushSendJob {
SignalDatabase.messages().markAsSentFailed(messageId);
}
private List<SendMessageResult> deliver(OutgoingMessage message, @NonNull Recipient groupRecipient, @NonNull List<Recipient> destinations)
private List<SendMessageResult> deliver(OutgoingMessage message, @Nullable MessageRecord originalEditedMessage, @NonNull Recipient groupRecipient, @NonNull List<Recipient> destinations)
throws IOException, UntrustedIdentityException, UndeliverableMessageException
{
try {
@@ -317,7 +319,7 @@ public final class PushGroupSendJob extends PushSendJob {
.withExpiration(groupRecipient.getExpiresInSeconds())
.asGroupMessage(group)
.build();
return GroupSendUtil.sendResendableDataMessage(context, groupRecipient.requireGroupId().requireV2(), null, destinations, isRecipientUpdate, ContentHint.IMPLICIT, new MessageId(messageId), groupDataMessage, message.isUrgent(), false);
return GroupSendUtil.sendResendableDataMessage(context, groupRecipient.requireGroupId().requireV2(), null, destinations, isRecipientUpdate, ContentHint.IMPLICIT, new MessageId(messageId), groupDataMessage, message.isUrgent(), false, null);
} else {
throw new UndeliverableMessageException("Messages can no longer be sent to V1 groups!");
}
@@ -369,6 +371,10 @@ public final class PushGroupSendJob extends PushSendJob {
groupMessageBuilder.withQuote(getQuoteFor(message).orElse(null));
}
SignalServiceDataMessage groupMessage = groupMessageBuilder.build();
SignalServiceEditMessage editMessage = originalEditedMessage != null ? new SignalServiceEditMessage(originalEditedMessage.getDateSent(), groupMessage)
: null;
Log.i(TAG, JobLogger.format(this, "Beginning message send."));
return GroupSendUtil.sendResendableDataMessage(context,
@@ -378,9 +384,10 @@ public final class PushGroupSendJob extends PushSendJob {
isRecipientUpdate,
ContentHint.RESENDABLE,
new MessageId(messageId),
groupMessageBuilder.build(),
groupMessage,
message.isUrgent(),
message.getStoryType().isStory() || message.getParentStoryId() != null);
message.getStoryType().isStory() || message.getParentStoryId() != null,
editMessage);
}
} catch (ServerRejectedException e) {
throw new UndeliverableMessageException(e);
@@ -67,6 +67,7 @@ import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemo
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
import org.whispersystems.signalservice.api.messages.SignalServicePreview;
import org.whispersystems.signalservice.api.messages.shared.SharedContact;
import org.whispersystems.signalservice.api.push.ServiceId;
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException;
import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException;
@@ -313,6 +314,9 @@ public abstract class PushSendJob extends SendJob {
protected Optional<SignalServiceDataMessage.Quote> getQuoteFor(OutgoingMessage message) throws IOException {
if (message.getOutgoingQuote() == null) return Optional.empty();
if (message.isMessageEdit()) {
return Optional.of(new SignalServiceDataMessage.Quote(0, ServiceId.UNKNOWN, "", null, null, SignalServiceDataMessage.Quote.Type.NORMAL, null));
}
long quoteId = message.getOutgoingQuote().getId();
String quoteBody = message.getOutgoingQuote().getText();
@@ -236,7 +236,8 @@ public class ReactionSendJob extends BaseJob {
messageId,
dataMessage,
true,
false);
false,
null);
if (includesSelf) {
results.add(ApplicationDependencies.getSignalServiceMessageSender().sendSyncMessage(dataMessage));
@@ -217,7 +217,8 @@ public class RemoteDeleteSendJob extends BaseJob {
new MessageId(messageId),
dataMessage,
true,
isForStory);
isForStory,
null);
return GroupSendJobHelper.getCompletedSends(destinations, results);
}
@@ -24,6 +24,7 @@ import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.ConversationItemFooter;
import org.thoughtcrime.securesms.components.FullScreenDialogFragment;
import org.thoughtcrime.securesms.components.emoji.EmojiTextView;
import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode;
import org.thoughtcrime.securesms.conversation.colors.ColorizerView;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.recipients.Recipient;
@@ -138,7 +139,7 @@ public class LongMessageFragment extends FullScreenDialogFragment {
} else {
text.setMentionBackgroundTint(ContextCompat.getColor(requireContext(), R.color.transparent_black_40));
}
footer.setMessageRecord(message.get().getMessageRecord(), Locale.getDefault());
footer.setMessageRecord(message.get().getMessageRecord(), Locale.getDefault(), ConversationItemDisplayMode.STANDARD);
});
}
@@ -65,7 +65,7 @@ import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.Debouncer
import org.thoughtcrime.securesms.util.FullscreenHelper
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.RemoteDeleteUtil
import org.thoughtcrime.securesms.util.MessageConstraintsUtil
import org.thoughtcrime.securesms.util.SaveAttachmentTask
import org.thoughtcrime.securesms.util.SpanUtil
import org.thoughtcrime.securesms.util.StorageUtil
@@ -599,7 +599,7 @@ class MediaPreviewV2Fragment : LoggingFragment(R.layout.fragment_media_preview_v
fun canRemotelyDelete(attachment: DatabaseAttachment): Boolean {
val mmsId = attachment.mmsId
val attachmentCount = SignalDatabase.attachments.getAttachmentsForMessage(mmsId).size
return attachmentCount <= 1 && RemoteDeleteUtil.isValidSend(listOf(SignalDatabase.messages.getMessageRecord(mmsId)), System.currentTimeMillis())
return attachmentCount <= 1 && MessageConstraintsUtil.isValidRemoteDeleteSend(listOf(SignalDatabase.messages.getMessageRecord(mmsId)), System.currentTimeMillis())
}
private fun editMediaItem(currentItem: MediaTable.MediaRecord) {
@@ -14,6 +14,7 @@ import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.conversation.ConversationMessage;
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.databinding.MessageDetailsViewEditHistoryBinding;
import org.thoughtcrime.securesms.mms.GlideRequests;
final class MessageDetailsAdapter extends ListAdapter<MessageDetailsAdapter.MessageDetailsViewState<?>, RecyclerView.ViewHolder> {
@@ -40,6 +41,8 @@ final class MessageDetailsAdapter extends ListAdapter<MessageDetailsAdapter.Mess
return new RecipientHeaderViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.message_details_recipient_header, parent, false));
case MessageDetailsViewState.RECIPIENT:
return new RecipientViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.message_details_recipient, parent, false), callbacks);
case MessageDetailsViewState.EDIT_HISTORY:
return new ViewEditHistoryViewHolder(MessageDetailsViewEditHistoryBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false), callbacks);
default:
throw new AssertionError("unknown view type");
}
@@ -53,6 +56,8 @@ final class MessageDetailsAdapter extends ListAdapter<MessageDetailsAdapter.Mess
((RecipientHeaderViewHolder) holder).bind((RecipientHeader) getItem(position).data);
} else if (holder instanceof RecipientViewHolder) {
((RecipientViewHolder) holder).bind((RecipientDeliveryStatus) getItem(position).data);
} else if (holder instanceof ViewEditHistoryViewHolder) {
((ViewEditHistoryViewHolder) holder).bind((MessageRecord) getItem(position).data);
} else {
throw new AssertionError("unknown view holder");
}
@@ -72,6 +77,7 @@ final class MessageDetailsAdapter extends ListAdapter<MessageDetailsAdapter.Mess
if (oldData.getClass() == newData.getClass() && oldItem.itemType == newItem.itemType) {
switch (oldItem.itemType) {
case MessageDetailsViewState.MESSAGE_HEADER:
case MessageDetailsViewState.EDIT_HISTORY:
return true;
case MessageDetailsViewState.RECIPIENT_HEADER:
return oldData == newData;
@@ -94,6 +100,7 @@ final class MessageDetailsAdapter extends ListAdapter<MessageDetailsAdapter.Mess
case MessageDetailsViewState.MESSAGE_HEADER:
return false;
case MessageDetailsViewState.RECIPIENT_HEADER:
case MessageDetailsViewState.EDIT_HISTORY:
return true;
case MessageDetailsViewState.RECIPIENT:
return ((RecipientDeliveryStatus) oldData).getDeliveryStatus() == ((RecipientDeliveryStatus) newData).getDeliveryStatus();
@@ -108,6 +115,7 @@ final class MessageDetailsAdapter extends ListAdapter<MessageDetailsAdapter.Mess
public static final int MESSAGE_HEADER = 0;
public static final int RECIPIENT_HEADER = 1;
public static final int RECIPIENT = 2;
public static final int EDIT_HISTORY = 3;
private final T data;
private int itemType;
@@ -120,5 +128,6 @@ final class MessageDetailsAdapter extends ListAdapter<MessageDetailsAdapter.Mess
interface Callbacks {
void onErrorClicked(@NonNull MessageRecord messageRecord);
void onViewEditHistoryClicked(MessageRecord record);
}
}
@@ -15,6 +15,7 @@ import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.FullScreenDialogFragment;
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer;
import org.thoughtcrime.securesms.conversation.ui.edit.EditMessageHistoryDialog;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackController;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionPlayerHolder;
@@ -26,12 +27,13 @@ import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet;
import org.thoughtcrime.securesms.util.Material3OnScrollHelper;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
public final class MessageDetailsFragment extends FullScreenDialogFragment {
public final class MessageDetailsFragment extends FullScreenDialogFragment implements MessageDetailsAdapter.Callbacks {
private static final String MESSAGE_ID_EXTRA = "message_id";
private static final String RECIPIENT_EXTRA = "recipient_id";
@@ -89,7 +91,7 @@ public final class MessageDetailsFragment extends FullScreenDialogFragment {
View toolbarShadow = view.findViewById(R.id.toolbar_shadow);
colorizer = new Colorizer();
adapter = new MessageDetailsAdapter(getViewLifecycleOwner(), glideRequests, colorizer, this::onErrorClicked);
adapter = new MessageDetailsAdapter(getViewLifecycleOwner(), glideRequests, colorizer, this);
recyclerViewColorizer = new RecyclerViewColorizer(list);
list.setAdapter(adapter);
@@ -127,6 +129,10 @@ public final class MessageDetailsFragment extends FullScreenDialogFragment {
list.add(new MessageDetailsViewState<>(details.getConversationMessage(), MessageDetailsViewState.MESSAGE_HEADER));
if (MessageRecordUtil.isEditMessage(details.getConversationMessage().getMessageRecord())) {
list.add(new MessageDetailsViewState<>(details.getConversationMessage().getMessageRecord(), MessageDetailsViewState.EDIT_HISTORY));
}
if (details.getConversationMessage().getMessageRecord().isOutgoing()) {
addRecipients(list, RecipientHeader.NOT_SENT, details.getNotSent());
addRecipients(list, RecipientHeader.VIEWED, details.getViewed());
@@ -154,12 +160,22 @@ public final class MessageDetailsFragment extends FullScreenDialogFragment {
return true;
}
private void onErrorClicked(@NonNull MessageRecord messageRecord) {
@Override
public void onErrorClicked(@NonNull MessageRecord messageRecord) {
SafetyNumberBottomSheet
.forMessageRecord(requireContext(), messageRecord)
.show(getChildFragmentManager());
}
@Override
public void onViewEditHistoryClicked(MessageRecord record) {
if (record.isOutgoing()) {
EditMessageHistoryDialog.show(requireParentFragment().getChildFragmentManager(), record.getToRecipient().getId(), record.getId());
} else {
EditMessageHistoryDialog.show(requireParentFragment().getChildFragmentManager(), record.getFromRecipient().getId(), record.getId());
}
}
public interface Callback {
void onMessageDetailsFragmentDismissed();
}
@@ -0,0 +1,23 @@
package org.thoughtcrime.securesms.messagedetails;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.databinding.MessageDetailsViewEditHistoryBinding;
public class ViewEditHistoryViewHolder extends RecyclerView.ViewHolder {
private final org.thoughtcrime.securesms.databinding.MessageDetailsViewEditHistoryBinding binding;
private final MessageDetailsAdapter.Callbacks callbacks;
public ViewEditHistoryViewHolder(@NonNull MessageDetailsViewEditHistoryBinding binding, @NonNull MessageDetailsAdapter.Callbacks callbacks) {
super(binding.getRoot());
this.binding = binding;
this.callbacks = callbacks;
}
public void bind(@NonNull MessageRecord record) {
binding.viewEditHistory.setOnClickListener(v -> callbacks.onViewEditHistoryClicked(record));
}
}
@@ -88,7 +88,7 @@ import org.thoughtcrime.securesms.util.Base64
import org.thoughtcrime.securesms.util.EarlyMessageCacheEntry
import org.thoughtcrime.securesms.util.LinkUtil
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.RemoteDeleteUtil
import org.thoughtcrime.securesms.util.MessageConstraintsUtil
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.isStory
import org.whispersystems.signalservice.api.crypto.EnvelopeMetadata
@@ -317,7 +317,7 @@ object DataMessageProcessor {
* Inserts an expiration update if the message timer doesn't match the thread timer.
*/
@Throws(StorageFailedException::class)
private fun handlePossibleExpirationUpdate(
fun handlePossibleExpirationUpdate(
envelope: Envelope,
metadata: EnvelopeMetadata,
senderRecipientId: RecipientId,
@@ -482,7 +482,7 @@ object DataMessageProcessor {
return null
}
val targetMessageId = MessageId(targetMessage.id)
val targetMessageId = (targetMessage as? MediaMmsMessageRecord)?.latestRevisionId ?: MessageId(targetMessage.id)
if (isRemove) {
SignalDatabase.reactions.deleteReaction(targetMessageId, senderRecipientId)
@@ -502,7 +502,7 @@ object DataMessageProcessor {
val targetSentTimestamp: Long = message.delete.targetSentTimestamp
val targetMessage: MessageRecord? = SignalDatabase.messages.getMessageFor(targetSentTimestamp, senderRecipientId)
return if (targetMessage != null && RemoteDeleteUtil.isValidReceive(targetMessage, senderRecipientId, envelope.serverTimestamp)) {
return if (targetMessage != null && MessageConstraintsUtil.isValidRemoteDeleteReceive(targetMessage, senderRecipientId, envelope.serverTimestamp)) {
SignalDatabase.messages.markAsRemoteDelete(targetMessage.id)
if (targetMessage.isStory()) {
SignalDatabase.messages.deleteRemotelyDeletedStory(targetMessage.id)
@@ -944,7 +944,7 @@ object DataMessageProcessor {
GroupCallPeekJob.enqueue(groupRecipientId)
}
private fun notifyTypingStoppedFromIncomingMessage(context: Context, senderRecipient: Recipient, threadRecipientId: RecipientId, device: Int) {
fun notifyTypingStoppedFromIncomingMessage(context: Context, senderRecipient: Recipient, threadRecipientId: RecipientId, device: Int) {
val threadId = SignalDatabase.threads.getThreadIdIfExistsFor(threadRecipientId)
if (threadId > 0 && TextSecurePreferences.isTypingIndicatorsEnabled(context)) {
@@ -0,0 +1,191 @@
package org.thoughtcrime.securesms.messages
import android.content.Context
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.orNull
import org.thoughtcrime.securesms.database.MessageTable.InsertResult
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.database.model.toBodyRangeList
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.jobs.PushProcessEarlyMessagesJob
import org.thoughtcrime.securesms.jobs.SendDeliveryReceiptJob
import org.thoughtcrime.securesms.messages.MessageContentProcessorV2.Companion.log
import org.thoughtcrime.securesms.messages.MessageContentProcessorV2.Companion.warn
import org.thoughtcrime.securesms.messages.SignalServiceProtoUtil.groupId
import org.thoughtcrime.securesms.messages.SignalServiceProtoUtil.isMediaMessage
import org.thoughtcrime.securesms.messages.SignalServiceProtoUtil.toPointers
import org.thoughtcrime.securesms.mms.IncomingMediaMessage
import org.thoughtcrime.securesms.mms.QuoteModel
import org.thoughtcrime.securesms.notifications.v2.ConversationId.Companion.forConversation
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sms.IncomingEncryptedMessage
import org.thoughtcrime.securesms.sms.IncomingTextMessage
import org.thoughtcrime.securesms.util.EarlyMessageCacheEntry
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.MessageConstraintsUtil
import org.thoughtcrime.securesms.util.hasAudio
import org.thoughtcrime.securesms.util.hasSharedContact
import org.whispersystems.signalservice.api.crypto.EnvelopeMetadata
import org.whispersystems.signalservice.internal.push.SignalServiceProtos
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.DataMessage
import java.util.Optional
object EditMessageProcessor {
fun process(
context: Context,
senderRecipient: Recipient,
threadRecipient: Recipient,
envelope: SignalServiceProtos.Envelope,
content: SignalServiceProtos.Content,
metadata: EnvelopeMetadata,
earlyMessageCacheEntry: EarlyMessageCacheEntry?
) {
val editMessage = content.editMessage
log(envelope.timestamp, "[handleEditMessage] Edit message for " + editMessage.targetSentTimestamp)
var targetMessage: MediaMmsMessageRecord? = SignalDatabase.messages.getMessageFor(editMessage.targetSentTimestamp, senderRecipient.id) as MediaMmsMessageRecord
val targetThreadRecipient: Recipient? = if (targetMessage != null) SignalDatabase.threads.getRecipientForThreadId(targetMessage.threadId) else null
if (targetMessage == null || targetThreadRecipient == null) {
warn(envelope.timestamp, "[handleEditMessage] Could not find matching message! timestamp: ${editMessage.targetSentTimestamp} author: ${senderRecipient.id}")
if (earlyMessageCacheEntry != null) {
ApplicationDependencies.getEarlyMessageCache().store(senderRecipient.id, editMessage.targetSentTimestamp, earlyMessageCacheEntry)
PushProcessEarlyMessagesJob.enqueue()
}
return
}
val message = editMessage.dataMessage
val isMediaMessage = message.isMediaMessage
val groupId: GroupId.V2? = message.groupV2.groupId
val originalMessage = targetMessage.originalMessageId?.let { SignalDatabase.messages.getMessageRecord(it.id) } ?: targetMessage
val validTiming = MessageConstraintsUtil.isValidEditMessageReceive(originalMessage, senderRecipient, envelope.serverTimestamp)
val validAuthor = senderRecipient.id == originalMessage.fromRecipient.id
val validGroup = groupId == targetThreadRecipient.groupId.orNull()
val validTarget = !originalMessage.isViewOnce && !originalMessage.hasAudio() && !originalMessage.hasSharedContact()
if (!validTiming || !validAuthor || !validGroup || !validTarget) {
warn(envelope.timestamp, "[handleEditMessage] Invalid message edit! editTime: ${envelope.serverTimestamp}, targetTime: ${originalMessage.serverTimestamp}, editAuthor: ${senderRecipient.id}, targetAuthor: ${originalMessage.fromRecipient.id}, editThread: ${threadRecipient.id}, targetThread: ${targetThreadRecipient.id}, validity: (timing: $validTiming, author: $validAuthor, group: $validGroup, target: $validTarget)")
return
}
if (groupId != null && MessageContentProcessorV2.handleGv2PreProcessing(context, envelope.timestamp, content, metadata, groupId, message.groupV2, senderRecipient)) {
warn(envelope.timestamp, "[handleEditMessage] Group processor indicated we should ignore this.")
return
}
DataMessageProcessor.notifyTypingStoppedFromIncomingMessage(context, senderRecipient, threadRecipient.id, metadata.sourceDeviceId)
targetMessage = targetMessage.withAttachments(context, SignalDatabase.attachments.getAttachmentsForMessage(targetMessage.id))
val insertResult: InsertResult? = if (isMediaMessage || targetMessage.quote != null || targetMessage.slideDeck.slides.isNotEmpty()) {
handleEditMediaMessage(senderRecipient.id, groupId, envelope, metadata, message, targetMessage)
} else {
handleEditTextMessage(senderRecipient.id, groupId, envelope, metadata, message, targetMessage)
}
if (insertResult != null) {
SignalExecutors.BOUNDED.execute {
ApplicationDependencies.getJobManager().add(SendDeliveryReceiptJob(senderRecipient.id, message.timestamp, MessageId(insertResult.messageId)))
}
if (targetMessage.expireStarted > 0) {
ApplicationDependencies.getExpiringMessageManager()
.scheduleDeletion(
insertResult.messageId,
true,
targetMessage.expireStarted,
targetMessage.expiresIn
)
}
ApplicationDependencies.getMessageNotifier().updateNotification(context, forConversation(insertResult.threadId))
}
}
private fun handleEditMediaMessage(
senderRecipientId: RecipientId,
groupId: GroupId.V2?,
envelope: SignalServiceProtos.Envelope,
metadata: EnvelopeMetadata,
message: DataMessage,
targetMessage: MediaMmsMessageRecord
): InsertResult? {
val messageRanges: BodyRangeList? = message.bodyRangesList.filter { it.hasStyle() }.toList().toBodyRangeList()
val targetQuote = targetMessage.quote
val quote: QuoteModel? = if (targetQuote != null && message.hasQuote()) {
QuoteModel(
targetQuote.id,
targetQuote.author,
targetQuote.displayText.toString(),
targetQuote.isOriginalMissing,
emptyList(),
null,
targetQuote.quoteType,
null
)
} else {
null
}
val attachments = message.attachmentsList.toPointers()
attachments.filter {
MediaUtil.SlideType.LONG_TEXT == MediaUtil.getSlideTypeFromContentType(it.contentType)
}
val mediaMessage = IncomingMediaMessage(
from = senderRecipientId,
sentTimeMillis = message.timestamp,
serverTimeMillis = envelope.serverTimestamp,
receivedTimeMillis = targetMessage.receiptTimestamp,
expiresIn = targetMessage.expiresIn,
isViewOnce = message.isViewOnce,
isUnidentified = metadata.sealedSender,
body = message.body,
groupId = groupId,
attachments = attachments,
quote = quote,
sharedContacts = emptyList(),
linkPreviews = DataMessageProcessor.getLinkPreviews(message.previewList, message.body ?: "", false),
mentions = DataMessageProcessor.getMentions(message.bodyRangesList),
serverGuid = envelope.serverGuid,
messageRanges = messageRanges,
isPushMessage = true
)
return SignalDatabase.messages.insertEditMessageInbox(-1, mediaMessage, targetMessage).orNull()
}
private fun handleEditTextMessage(
senderRecipientId: RecipientId,
groupId: GroupId.V2?,
envelope: SignalServiceProtos.Envelope,
metadata: EnvelopeMetadata,
message: DataMessage,
targetMessage: MediaMmsMessageRecord
): InsertResult? {
var textMessage = IncomingTextMessage(
senderRecipientId,
metadata.sourceDeviceId,
envelope.timestamp,
envelope.timestamp,
targetMessage.receiptTimestamp,
message.body,
Optional.ofNullable(groupId),
targetMessage.expiresIn,
metadata.sealedSender,
envelope.serverGuid
)
textMessage = IncomingEncryptedMessage(textMessage, message.body)
return SignalDatabase.messages.insertEditMessageInbox(textMessage, targetMessage).orNull()
}
}
@@ -37,6 +37,7 @@ import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair;
import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException;
import org.whispersystems.signalservice.api.messages.SendMessageResult;
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceEditMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessageRecipient;
import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage;
@@ -94,29 +95,15 @@ public final class GroupSendUtil {
@NonNull MessageId messageId,
@NonNull SignalServiceDataMessage message,
boolean urgent,
boolean isForStory)
boolean isForStory,
@Nullable SignalServiceEditMessage editMessage)
throws IOException, UntrustedIdentityException
{
Preconditions.checkArgument(groupId == null || distributionListId == null, "Cannot supply both a groupId and a distributionListId!");
DistributionId distributionId = groupId != null ? getDistributionId(groupId) : getDistributionId(distributionListId);
return sendMessage(context, groupId, distributionId, messageId, allTargets, isRecipientUpdate, isForStory, DataSendOperation.resendable(message, contentHint, messageId, urgent, isForStory), null);
}
@WorkerThread
public static List<SendMessageResult> sendResendableStoryRelatedMessage(@NonNull Context context,
@Nullable GroupId.V2 groupId,
@NonNull DistributionListId distributionListId,
@NonNull List<Recipient> allTargets,
boolean isRecipientUpdate,
ContentHint contentHint,
@NonNull MessageId messageId,
@NonNull SignalServiceDataMessage message,
boolean urgent)
throws IOException, UntrustedIdentityException
{
return sendMessage(context, groupId, getDistributionId(distributionListId), messageId, allTargets, isRecipientUpdate, true, DataSendOperation.resendable(message, contentHint, messageId, urgent, true), null);
return sendMessage(context, groupId, distributionId, messageId, allTargets, isRecipientUpdate, isForStory, DataSendOperation.resendable(message, contentHint, messageId, urgent, isForStory, editMessage), null);
}
/**
@@ -480,22 +467,24 @@ public final class GroupSendUtil {
private final boolean resendable;
private final boolean urgent;
private final boolean isForStory;
private final SignalServiceEditMessage editMessage;
public static DataSendOperation resendable(@NonNull SignalServiceDataMessage message, @NonNull ContentHint contentHint, @NonNull MessageId relatedMessageId, boolean urgent, boolean isForStory) {
return new DataSendOperation(message, contentHint, true, relatedMessageId, urgent, isForStory);
public static DataSendOperation resendable(@NonNull SignalServiceDataMessage message, @NonNull ContentHint contentHint, @NonNull MessageId relatedMessageId, boolean urgent, boolean isForStory, @Nullable SignalServiceEditMessage editMessage) {
return new DataSendOperation(editMessage != null ? editMessage.getDataMessage() : message, contentHint, true, relatedMessageId, urgent, isForStory, editMessage);
}
public static DataSendOperation unresendable(@NonNull SignalServiceDataMessage message, @NonNull ContentHint contentHint, boolean urgent) {
return new DataSendOperation(message, contentHint, false, null, urgent, false);
return new DataSendOperation(message, contentHint, false, null, urgent, false, null);
}
private DataSendOperation(@NonNull SignalServiceDataMessage message, @NonNull ContentHint contentHint, boolean resendable, @Nullable MessageId relatedMessageId, boolean urgent, boolean isForStory) {
private DataSendOperation(@NonNull SignalServiceDataMessage message, @NonNull ContentHint contentHint, boolean resendable, @Nullable MessageId relatedMessageId, boolean urgent, boolean isForStory, @Nullable SignalServiceEditMessage editMessage) {
this.message = message;
this.contentHint = contentHint;
this.resendable = resendable;
this.relatedMessageId = relatedMessageId;
this.urgent = urgent;
this.isForStory = isForStory;
this.editMessage = editMessage;
if (resendable && relatedMessageId == null) {
throw new IllegalArgumentException("If a message is resendable, it must have a related message ID!");
@@ -512,7 +501,7 @@ public final class GroupSendUtil {
throws NoSessionException, UntrustedIdentityException, InvalidKeyException, IOException, InvalidRegistrationIdException
{
SenderKeyGroupEvents listener = relatedMessageId != null ? new SenderKeyMetricEventListener(relatedMessageId.getId()) : SenderKeyGroupEvents.EMPTY;
return messageSender.sendGroupDataMessage(distributionId, targets, access, isRecipientUpdate, contentHint, message, listener, urgent, isForStory, partialListener);
return messageSender.sendGroupDataMessage(distributionId, targets, access, isRecipientUpdate, contentHint, message, listener, urgent, isForStory, editMessage, partialListener);
}
@Override
@@ -527,8 +516,14 @@ public final class GroupSendUtil {
{
// PniSignatures are only needed for 1:1 messages, but some message jobs use the GroupSendUtil methods to send 1:1
if (targets.size() == 1 && relatedMessageId == null) {
Recipient targetRecipient = targetRecipients.get(0);
SendMessageResult result = messageSender.sendDataMessage(targets.get(0), access.get(0), contentHint, message, SignalServiceMessageSender.IndividualSendEvents.EMPTY, urgent, targetRecipient.needsPniSignature());
Recipient targetRecipient = targetRecipients.get(0);
SendMessageResult result;
if (editMessage != null) {
result = messageSender.sendEditMessage(targets.get(0), access.get(0), contentHint, message, SignalServiceMessageSender.IndividualSendEvents.EMPTY, urgent, editMessage.getTargetSentTimestamp());
} else {
result = messageSender.sendDataMessage(targets.get(0), access.get(0), contentHint, message, SignalServiceMessageSender.IndividualSendEvents.EMPTY, urgent, targetRecipient.needsPniSignature());
}
if (targetRecipient.needsPniSignature()) {
SignalDatabase.pendingPniSignatureMessages().insertIfNecessary(targetRecipients.get(0).getId(), getSentTimestamp(), result);
@@ -134,7 +134,7 @@ import org.thoughtcrime.securesms.util.IdentityUtil;
import org.thoughtcrime.securesms.util.LinkUtil;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.RemoteDeleteUtil;
import org.thoughtcrime.securesms.util.MessageConstraintsUtil;
import org.thoughtcrime.securesms.util.TextSecurePreferences;
import org.thoughtcrime.securesms.util.Util;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
@@ -1064,7 +1064,7 @@ public class MessageContentProcessor {
MessageRecord targetMessage = SignalDatabase.messages().getMessageFor(delete.getTargetSentTimestamp(), senderRecipient.getId());
if (targetMessage != null && RemoteDeleteUtil.isValidReceive(targetMessage, senderRecipient, content.getServerReceivedTimestamp())) {
if (targetMessage != null && MessageConstraintsUtil.isValidRemoteDeleteReceive(targetMessage, senderRecipient.getId(), content.getServerReceivedTimestamp())) {
MessageTable db = targetMessage.isMms() ? SignalDatabase.messages() : SignalDatabase.messages();
db.markAsRemoteDelete(targetMessage.getId());
if (MessageRecordUtil.isStory(targetMessage)) {
@@ -2220,7 +2220,8 @@ public class MessageContentProcessor {
null,
true,
bodyRanges,
-1);
-1,
0);
if (recipient.getExpiresInSeconds() != message.getDataMessage().get().getExpiresInSeconds()) {
handleSynchronizeSentExpirationUpdate(message);
@@ -2342,7 +2343,8 @@ public class MessageContentProcessor {
null,
true,
bodyRanges,
-1);
-1,
0);
MessageTable messageTable = SignalDatabase.messages();
long threadId = SignalDatabase.threads().getOrCreateThreadIdFor(recipient);
@@ -2441,7 +2443,8 @@ public class MessageContentProcessor {
giftBadge.orElse(null),
true,
bodyRanges,
-1);
-1,
0);
if (recipient.getExpiresInSeconds() != message.getDataMessage().get().getExpiresInSeconds()) {
handleSynchronizeSentExpirationUpdate(message);
@@ -111,6 +111,8 @@ open class MessageContentProcessorV2(private val context: Context) {
getGroupRecipient(content.storyMessage.group, sender)
} else if (content.dataMessage.hasGroupContext) {
getGroupRecipient(content.dataMessage.groupV2, sender)
} else if (content.editMessage.dataMessage.hasGroupContext) {
getGroupRecipient(content.editMessage.dataMessage.groupV2, sender)
} else {
sender
}
@@ -379,6 +381,21 @@ open class MessageContentProcessorV2(private val context: Context) {
content.hasDecryptionErrorMessage() -> {
handleRetryReceipt(envelope, metadata, content.decryptionErrorMessage!!.toDecryptionErrorMessage(metadata), senderRecipient)
}
content.hasEditMessage() -> {
if (FeatureFlags.editMessageReceiving()) {
EditMessageProcessor.process(
context,
senderRecipient,
threadRecipient,
envelope,
content,
metadata,
if (processingEarlyContent) null else EarlyMessageCacheEntry(envelope, content, metadata, serverDeliveredTimestamp)
)
} else {
warn(envelope.timestamp, "Got message edit, but processing is disabled")
}
}
content.hasSenderKeyDistributionMessage() || content.hasPniSignatureMessage() -> {
// Already handled, here in order to prevent unrecognized message log
}
@@ -125,9 +125,10 @@ public class ApplicationMigrations {
static final int DECRYPTIONS_DRAINED = 80;
static final int REBUILD_MESSAGE_FTS_INDEX_3 = 81;
static final int TO_FROM_RECIPIENTS = 82;
static final int REBUILD_MESSAGE_FTS_INDEX_4 = 83;
}
public static final int CURRENT_VERSION = 82;
public static final int CURRENT_VERSION = 83;
/**
* This *must* be called after the {@link JobManager} has been instantiated, but *before* the call
@@ -557,6 +558,10 @@ public class ApplicationMigrations {
jobs.put(Version.TO_FROM_RECIPIENTS, new DatabaseMigrationJob());
}
if (lastSeenVersion < Version.REBUILD_MESSAGE_FTS_INDEX_4) {
jobs.put(Version.REBUILD_MESSAGE_FTS_INDEX_4, new RebuildMessageSearchIndexMigrationJob());
}
return jobs;
}
@@ -50,11 +50,13 @@ data class OutgoingMessage(
val isEndSession: Boolean = false,
val isIdentityVerified: Boolean = false,
val isIdentityDefault: Boolean = false,
val scheduledDate: Long = -1
val scheduledDate: Long = -1,
val messageToEdit: Long = 0
) {
val isV2Group: Boolean = messageGroupContext != null && GroupV2UpdateMessageUtil.isGroupV2(messageGroupContext)
val isJustAGroupLeave: Boolean = messageGroupContext != null && GroupV2UpdateMessageUtil.isJustAGroupLeave(messageGroupContext)
val isMessageEdit: Boolean = messageToEdit != 0L
/**
* Smaller constructor for calling from Java and legacy code using the original interface.
@@ -80,7 +82,8 @@ data class OutgoingMessage(
giftBadge: GiftBadge? = null,
isSecure: Boolean = false,
bodyRanges: BodyRangeList? = null,
scheduledDate: Long = -1
scheduledDate: Long = -1,
messageToEdit: Long = 0
) : this(
threadRecipient = recipient,
body = body ?: "",
@@ -102,7 +105,8 @@ data class OutgoingMessage(
giftBadge = giftBadge,
isSecure = isSecure,
bodyRanges = bodyRanges,
scheduledDate = scheduledDate
scheduledDate = scheduledDate,
messageToEdit = messageToEdit
)
/**
@@ -200,6 +204,28 @@ data class OutgoingMessage(
)
}
/**
* Edit a secure message that only contains text.
*/
@JvmStatic
fun editText(
recipient: Recipient,
body: String,
sentTimeMillis: Long,
bodyRanges: BodyRangeList?,
messageToEdit: Long
): OutgoingMessage {
return OutgoingMessage(
threadRecipient = recipient,
sentTimeMillis = sentTimeMillis,
body = body,
isUrgent = true,
isSecure = true,
bodyRanges = bodyRanges,
messageToEdit = messageToEdit
)
}
/**
* Helper for creating a group update message when a state change occurs and needs to be sent to others.
*/
@@ -102,7 +102,8 @@ public class RemoteReplyReceiver extends BroadcastReceiver {
null,
recipient.isPushGroup(),
null,
-1);
-1,
0);
threadId = MessageSender.send(context, reply, -1, MessageSender.SendType.SIGNAL, null, null);
break;
}
@@ -45,7 +45,7 @@ object DeleteDialog {
DeleteProgressDialogAsyncTask(context, messageRecords, emitter::onSuccess).executeOnExecutor(SignalExecutors.BOUNDED)
}
if (RemoteDeleteUtil.isValidSend(messageRecords, System.currentTimeMillis())) {
if (MessageConstraintsUtil.isValidRemoteDeleteSend(messageRecords, System.currentTimeMillis())) {
builder.setNeutralButton(R.string.ConversationFragment_delete_for_everyone) { _, _ -> handleDeleteForEveryone(context, messageRecords, emitter) }
}
}
@@ -109,6 +109,8 @@ public final class FeatureFlags {
private static final String CALLS_TAB = "android.calls.tab";
private static final String TEXT_FORMATTING_SPOILER_SEND = "android.textFormatting.spoilerSend";
private static final String AD_HOC_CALLING = "android.calling.ad.hoc";
private static final String EDIT_MESSAGE_RECEIVE = "android.editMessage.receive";
private static final String EDIT_MESSAGE_SEND = "android.editMessage.send";
/**
* We will only store remote values for flags in this set. If you want a flag to be controllable
@@ -167,7 +169,9 @@ public final class FeatureFlags {
TEXT_FORMATTING,
ANY_ADDRESS_PORTS_KILL_SWITCH,
CALLS_TAB,
TEXT_FORMATTING_SPOILER_SEND
TEXT_FORMATTING_SPOILER_SEND,
EDIT_MESSAGE_RECEIVE,
EDIT_MESSAGE_SEND
);
@VisibleForTesting
@@ -232,7 +236,9 @@ public final class FeatureFlags {
PAYMENTS_REQUEST_ACTIVATE_FLOW,
CDS_HARD_LIMIT,
TEXT_FORMATTING,
TEXT_FORMATTING_SPOILER_SEND
TEXT_FORMATTING_SPOILER_SEND,
EDIT_MESSAGE_RECEIVE,
EDIT_MESSAGE_SEND
);
/**
@@ -598,6 +604,14 @@ public final class FeatureFlags {
return getBoolean(ANY_ADDRESS_PORTS_KILL_SWITCH, false);
}
public static boolean editMessageReceiving() {
return getBoolean(EDIT_MESSAGE_RECEIVE, false);
}
public static boolean editMessageSending() {
return getBoolean(EDIT_MESSAGE_SEND, false);
}
/**
* Whether or not the calls tab is enabled
*/
@@ -0,0 +1,83 @@
package org.thoughtcrime.securesms.util
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import java.util.concurrent.TimeUnit
import kotlin.time.Duration.Companion.hours
/**
* Helpers for determining if a message send/receive is valid for those that
* have strict time limits.
*/
object MessageConstraintsUtil {
private val RECEIVE_THRESHOLD = TimeUnit.DAYS.toMillis(1)
private val SEND_THRESHOLD = TimeUnit.HOURS.toMillis(3)
private val MAX_EDIT_COUNT = 10
@JvmStatic
fun isValidRemoteDeleteReceive(targetMessage: MessageRecord, deleteSenderId: RecipientId, deleteServerTimestamp: Long): Boolean {
val selfIsDeleteSender = isSelf(deleteSenderId)
val isValidIncomingOutgoing = selfIsDeleteSender && targetMessage.isOutgoing || !selfIsDeleteSender && !targetMessage.isOutgoing
val isValidSender = targetMessage.fromRecipient.id == deleteSenderId || selfIsDeleteSender && targetMessage.isOutgoing
val messageTimestamp = if (selfIsDeleteSender && targetMessage.isOutgoing) targetMessage.dateSent else targetMessage.serverTimestamp
return isValidIncomingOutgoing &&
isValidSender &&
((deleteServerTimestamp - messageTimestamp < RECEIVE_THRESHOLD) || (selfIsDeleteSender && targetMessage.isOutgoing))
}
@JvmStatic
fun isValidEditMessageReceive(targetMessage: MessageRecord, editSender: Recipient, editServerTimestamp: Long): Boolean {
return isValidRemoteDeleteReceive(targetMessage, editSender.id, editServerTimestamp)
}
@JvmStatic
fun isValidRemoteDeleteSend(targetMessages: Collection<MessageRecord>, currentTime: Long): Boolean {
// TODO [greyson] [remote-delete] Update with server timestamp when available for outgoing messages
return targetMessages.all { isValidRemoteDeleteSend(it, currentTime) }
}
@JvmStatic
fun getEditMessageThresholdHours(): Int {
return SEND_THRESHOLD.hours.inWholeHours.toInt()
}
/**
* Check if at the current time a target message can be edited
*/
@JvmStatic
fun isValidEditMessageSend(targetMessage: MessageRecord, currentTime: Long): Boolean {
return isValidRemoteDeleteSend(targetMessage, currentTime) &&
targetMessage.revisionNumber < 10 &&
!targetMessage.isViewOnceMessage() &&
!targetMessage.hasAudio() &&
!targetMessage.hasSharedContact()
}
/**
* Check regardless of timing, whether a target message can be edited
*/
@JvmStatic
fun isValidEditMessageSend(targetMessage: MessageRecord): Boolean {
return isValidEditMessageSend(targetMessage, targetMessage.dateSent)
}
private fun isValidRemoteDeleteSend(message: MessageRecord, currentTime: Long): Boolean {
return !message.isUpdate &&
message.isOutgoing &&
message.isPush &&
(!message.toRecipient.isGroup || message.toRecipient.isActiveGroup) &&
!message.isRemoteDelete &&
!message.hasGiftBadge() &&
!message.isPaymentNotification &&
(currentTime - message.dateSent < SEND_THRESHOLD || message.toRecipient.isSelf)
}
private fun isSelf(recipientId: RecipientId): Boolean {
return Recipient.isSelfSet() && Recipient.self().id == recipientId
}
}
@@ -151,3 +151,7 @@ fun MessageRecord.isScheduled(): Boolean {
fun MessageRecord.getRecordQuoteType(): QuoteModel.Type {
return if (hasGiftBadge()) QuoteModel.Type.GIFT_BADGE else QuoteModel.Type.NORMAL
}
fun MessageRecord.isEditMessage(): Boolean {
return this is MediaMmsMessageRecord && isEditMessage
}
@@ -1,60 +0,0 @@
package org.thoughtcrime.securesms.util;
import androidx.annotation.NonNull;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import java.util.Collection;
import java.util.concurrent.TimeUnit;
public final class RemoteDeleteUtil {
private static final long RECEIVE_THRESHOLD = TimeUnit.DAYS.toMillis(1);
private static final long SEND_THRESHOLD = TimeUnit.HOURS.toMillis(3);
private RemoteDeleteUtil() {}
public static boolean isValidReceive(@NonNull MessageRecord targetMessage, @NonNull Recipient deleteSender, long deleteServerTimestamp) {
return isValidReceive(targetMessage, deleteSender.getId(), deleteServerTimestamp);
}
public static boolean isValidReceive(@NonNull MessageRecord targetMessage, @NonNull RecipientId deleteSenderId, long deleteServerTimestamp) {
boolean selfIsDeleteSender = isSelf(deleteSenderId);
boolean isValidIncomingOutgoing = (selfIsDeleteSender && targetMessage.isOutgoing()) ||
(!selfIsDeleteSender && !targetMessage.isOutgoing());
boolean isValidSender = targetMessage.getFromRecipient().getId().equals(deleteSenderId) || selfIsDeleteSender && targetMessage.isOutgoing();
long messageTimestamp = selfIsDeleteSender && targetMessage.isOutgoing() ? targetMessage.getDateSent()
: targetMessage.getServerTimestamp();
return isValidIncomingOutgoing &&
isValidSender &&
(((deleteServerTimestamp - messageTimestamp) < RECEIVE_THRESHOLD) || (selfIsDeleteSender && targetMessage.isOutgoing()));
}
public static boolean isValidSend(@NonNull Collection<MessageRecord> targetMessages, long currentTime) {
// TODO [greyson] [remote-delete] Update with server timestamp when available for outgoing messages
return Stream.of(targetMessages).allMatch(message -> isValidSend(message, currentTime));
}
private static boolean isValidSend(MessageRecord message, long currentTime) {
return !message.isUpdate() &&
message.isOutgoing() &&
message.isPush() &&
(!message.getToRecipient().isGroup() || message.getToRecipient().isActiveGroup()) &&
!message.isRemoteDelete() &&
!MessageRecordUtil.hasGiftBadge(message) &&
!message.isPaymentNotification() &&
(((currentTime - message.getDateSent()) < SEND_THRESHOLD) || message.getToRecipient().isSelf());
}
private static boolean isSelf(@NonNull RecipientId recipientId) {
return Recipient.isSelfSet() && Recipient.self().getId().equals(recipientId);
}
}
@@ -0,0 +1,25 @@
package org.thoughtcrime.securesms.util
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
/**
* Simplifies [ViewModel] creation by providing default implementations of [ViewModelProvider.Factory]
* and a factory producer that call through to a lambda to create the view model instance.
*
* Example use:
*
* private val viewModel: MyViewModel by viewModels(factoryProducer = ViewModelFactory.factoryProducer { MyViewModel(inputParams) })
*/
class ViewModelFactory<MODEL : ViewModel>(private val create: () -> MODEL) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return create() as T
}
companion object {
fun <MODEL : ViewModel> factoryProducer(create: () -> MODEL): () -> ViewModelProvider.Factory {
return { ViewModelFactory(create) }
}
}
}
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M18.972,5.263c0.407,0.26 0.525,0.802 0.265,1.209l-8.32,13a0.875,0.875 0,0 1,-1.426 0.067l-4.68,-5.98a0.875,0.875 0,0 1,1.378 -1.078l3.92,5.008 7.654,-11.96a0.875,0.875 0,0 1,1.209 -0.266Z"
android:fillColor="#000"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="16"
android:viewportHeight="16">
<path
android:pathData="M13.71,2.29a2.418,2.418 0,0 0,-3.42 0L3.04,9.54a0.65,0.65 0,0 0,-0.149 0.232l-1.114,2.97c-0.346,0.924 0.557,1.827 1.481,1.48l2.97,-1.113a0.65,0.65 0,0 0,0.231 -0.15l7.25,-7.25a2.418,2.418 0,0 0,0 -3.419ZM11.21,3.21a1.118,1.118 0,1 1,1.58 1.58l-0.79,0.79L10.42,4l0.79,-0.79ZM9.5,4.92l-5.441,5.44 -0.948,2.53 2.529,-0.95 5.44,-5.44L9.5,4.92Z"
android:fillColor="#000"
android:fillType="evenOdd"/>
</vector>
@@ -8,7 +8,8 @@
android:background="@color/signal_background_primary"
android:clipChildren="false"
android:clipToPadding="false"
android:orientation="vertical">
android:orientation="vertical"
tools:viewBindingIgnore="true">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/input_panel_sticker_suggestion"
@@ -25,17 +26,33 @@
android:clipToPadding="false"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="16dp"
android:paddingStart="14dp"
android:paddingTop="8dp"
android:paddingEnd="16dp"
android:paddingBottom="8dp">
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/input_panel_exit_edit_mode"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_gravity="bottom"
android:layout_marginEnd="12dp"
android:layout_marginBottom="6dp"
android:background="@drawable/circle_tintable"
android:backgroundTint="@color/signal_colorOnSurfaceVariant"
android:foreground="?attr/selectableItemBackgroundBorderless"
android:scaleType="center"
android:visibility="gone"
app:srcCompat="@drawable/symbol_x_24"
app:tint="@color/signal_colorOnPrimary" />
<FrameLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:clipChildren="false"
android:clipToPadding="false">
android:clipToPadding="false"
android:paddingStart="2dp">
<LinearLayout
android:id="@+id/compose_bubble"
@@ -46,6 +63,21 @@
android:clipToPadding="false"
android:orientation="vertical">
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/edit_message"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="16dp"
android:drawablePadding="6dp"
android:text="@string/InputPanel_edit_message"
android:textAppearance="@style/Signal.Text.LabelLarge"
android:textColor="@color/signal_colorOnSurface"
android:visibility="gone"
app:drawableStartCompat="@drawable/symbol_edit_compact_16"
app:drawableTint="@color/signal_colorOnSurface" />
<org.thoughtcrime.securesms.components.QuoteView
android:id="@+id/quote_view"
android:layout_width="match_parent"
@@ -213,7 +245,20 @@
android:padding="9dp"
android:scaleType="fitCenter"
app:tint="@color/conversation_send_button_tint"
app:srcCompat="@drawable/ic_plus_24" />
app:srcCompat="@drawable/symbol_plus_24" />
<ImageButton
android:id="@+id/send_edit_button"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center"
android:background="@drawable/circle_touch_highlight_background"
android:contentDescription="@string/ConversationActivity_send_edit"
android:nextFocusLeft="@+id/embedded_text_editor"
android:padding="9dp"
android:scaleType="fitCenter"
app:tint="@color/conversation_send_button_tint"
app:srcCompat="@drawable/symbol_check_24" />
<org.thoughtcrime.securesms.components.SendButton
android:id="@+id/send_button"
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.appcompat.widget.LinearLayoutCompat xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/view_edit_history"
style="@style/Signal.Text.BodyLarge"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?selectableItemBackground"
android:drawablePadding="24dp"
android:gravity="center_vertical"
android:minHeight="56dp"
android:paddingHorizontal="24dp"
android:text="@string/MessageDetails__view_edit_history"
app:drawableTint="@color/signal_colorOnSurface"
app:drawableStartCompat="@drawable/symbol_edit_24" />
<include
layout="@layout/dsl_divider_item"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom" />
</androidx.appcompat.widget.LinearLayoutCompat>
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.appcompat.widget.LinearLayoutCompat xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_horizontal"
android:orientation="vertical">
<View
android:id="@+id/anchor"
android:layout_width="48dp"
android:layout_height="2dp"
android:layout_gravity="center_horizontal"
android:layout_marginTop="16dp"
android:background="@color/signal_icon_tint_tab_unselected" />
<TextView
android:id="@+id/edit_history_title"
style="@style/Signal.Text.TitleMedium"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:text="@string/EditMessageHistoryDialog_title" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="8dp"
android:layout_weight="1">
<FrameLayout
android:id="@+id/video_container"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/edit_history_list"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingTop="16dp"
android:paddingBottom="24dp" />
</FrameLayout>
</androidx.appcompat.widget.LinearLayoutCompat>
+20
View File
@@ -301,9 +301,13 @@
<string name="ConversationItem_cant_download_image_you_will_need_to_send_it_again">Can\'t download image. You will need to send it again.</string>
<!-- Dialog error message shown when user can\'t download a their own video message via a linked device due to a permanent failure (e.g., unable to decrypt) -->
<string name="ConversationItem_cant_download_video_you_will_need_to_send_it_again">Can\'t download video. You will need to send it again.</string>
<!-- Display as the timestamp footer in a message bubble in a conversation when a message has been edited. The timestamp will go from '11m' to 'edited 11m' -->
<string name="ConversationItem_edited_timestamp_footer">edited %1$s</string>
<!-- ConversationActivity -->
<string name="ConversationActivity_add_attachment">Add attachment</string>
<!-- Accessibility text associated with image button to send an edited message. -->
<string name="ConversationActivity_send_edit">Send edit</string>
<string name="ConversationActivity_select_contact_info">Select contact info</string>
<string name="ConversationActivity_compose_message">Compose message</string>
<string name="ConversationActivity_sorry_there_was_an_error_setting_your_attachment">Sorry, there was an error setting your attachment.</string>
@@ -311,6 +315,13 @@
<string name="ConversationActivity_message_is_empty_exclamation">Message is empty!</string>
<string name="ConversationActivity_group_members">Group members</string>
<string name="ConversationActivity__tap_here_to_start_a_group_call">Tap here to start a group call</string>
<!-- Warning toast shown to user if they somehow try to edit an sms/mms message -->
<string name="ConversationActivity_edit_sms_message_error">Unable to edit SMS messages</string>
<!-- Warning dialog text shown to user if they try to send a message edit that is too old where %1$d is replaced with the amount of hours, e.g. 3 -->
<plurals name="ConversationActivity_edit_message_too_old">
<item quantity="one">Edits can only be applied within %1$d hour from the time you sent this message.</item>
<item quantity="other">Edits can only be applied within %1$d hours from the time you sent this message.</item>
</plurals>
<string name="ConversationActivity_invalid_recipient">Invalid recipient!</string>
<string name="ConversationActivity_added_to_home_screen">Added to home screen</string>
@@ -1075,6 +1086,8 @@
<string name="InputPanel_tap_and_hold_to_record_a_voice_message_release_to_send">Tap and hold to record a voice message, release to send</string>
<!-- Message shown if the user tries to switch a conversation from Signal to SMS -->
<string name="InputPanel__sms_messaging_is_no_longer_supported_in_signal">SMS messaging is no longer supported in Signal.</string>
<!-- When editing a message, label shown above the text input field in the composer -->
<string name="InputPanel_edit_message">Edit message</string>
<!-- InviteActivity -->
<string name="InviteActivity_share">Share</string>
@@ -2653,6 +2666,8 @@
<!-- message_Details_recipient -->
<string name="message_details_recipient__failed_to_send">Failed to send</string>
<string name="message_details_recipient__new_safety_number">New safety number</string>
<!-- Button text shown in message details when the message has an edit history and this will let them view the history -->
<string name="MessageDetails__view_edit_history">View edit history</string>
<!-- AndroidManifest.xml -->
<string name="AndroidManifest__create_passphrase">Create passphrase</string>
@@ -3188,6 +3203,8 @@
<string name="conversation_selection__menu_forward">Forward</string>
<!-- Button to reply to a message; Action item with hyphenation. Translation can use soft hyphen - Unicode U+00AD -->
<string name="conversation_selection__menu_reply">Reply</string>
<!-- Button to edit a message; Action item with hyphenation. Translation can use soft hyphen - Unicode U+00AD -->
<string name="conversation_selection__menu_edit">Edit</string>
<!-- Button to save a message attachment (image, file etc.); Action item with hyphenation. Translation can use soft hyphen - Unicode U+00AD -->
<string name="conversation_selection__menu_save">Save</string>
<!-- Button to retry sending a message; Action item with hyphenation. Translation can use soft hyphen - Unicode U+00AD -->
@@ -5928,5 +5945,8 @@
<!-- Toggle button label for compact size -->
<string name="ChooseNavigationBarStyleFragment__compact">Compact</string>
<!-- Title shown at top of bottom sheet dialog for displaying a message's edit history -->
<string name="EditMessageHistoryDialog_title">Edit history</string>
<!-- EOF -->
</resources>
@@ -176,7 +176,10 @@ object FakeMessageRecords {
giftBadge,
payment,
call,
-1
-1,
null,
null,
0
)
}
}
@@ -341,6 +341,11 @@ object SqlUtil {
}.toTypedArray()
}
@JvmStatic
fun appendArgs(args: Array<String>, vararg objects: Any?): Array<String> {
return args + buildArgs(objects)
}
@JvmStatic
fun buildBulkInsert(tableName: String, columns: Array<String>, contentValues: List<ContentValues>): List<Query> {
return buildBulkInsert(tableName, columns, contentValues, MAX_QUERY_ARGS)
@@ -37,6 +37,7 @@ import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentPoin
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemoteId;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream;
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceEditMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2;
import org.whispersystems.signalservice.api.messages.SignalServicePreview;
import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage;
@@ -105,6 +106,7 @@ import org.whispersystems.signalservice.internal.push.SignalServiceProtos.BodyRa
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.CallMessage;
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Content;
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.DataMessage;
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.EditMessage;
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.GroupContextV2;
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.NullMessage;
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Preview;
@@ -421,6 +423,41 @@ public class SignalServiceMessageSender {
Content content = createMessageContent(message);
return sendContent(recipient, unidentifiedAccess, contentHint, message, sendEvents, urgent, includePniSignature, content);
}
/**
* Send an edit message to a single recipient.
*/
public SendMessageResult sendEditMessage(SignalServiceAddress recipient,
Optional<UnidentifiedAccessPair> unidentifiedAccess,
ContentHint contentHint,
SignalServiceDataMessage message,
IndividualSendEvents sendEvents,
boolean urgent,
long targetSentTimestamp)
throws UntrustedIdentityException, IOException
{
Log.d(TAG, "[" + message.getTimestamp() + "] Sending an edit message.");
Content content = createEditMessageContent(new SignalServiceEditMessage(targetSentTimestamp, message));
return sendContent(recipient, unidentifiedAccess, contentHint, message, sendEvents, urgent, false, content);
}
/**
* Sends content to a single recipient.
*/
private SendMessageResult sendContent(SignalServiceAddress recipient,
Optional<UnidentifiedAccessPair> unidentifiedAccess,
ContentHint contentHint,
SignalServiceDataMessage message,
IndividualSendEvents sendEvents,
boolean urgent,
boolean includePniSignature,
Content content)
throws UntrustedIdentityException, IOException
{
if (includePniSignature) {
Log.d(TAG, "[" + message.getTimestamp() + "] Including PNI signature.");
content = content.toBuilder()
@@ -506,21 +543,29 @@ public class SignalServiceMessageSender {
/**
* Sends a {@link SignalServiceDataMessage} to a group using sender keys.
*/
public List<SendMessageResult> sendGroupDataMessage(DistributionId distributionId,
List<SignalServiceAddress> recipients,
List<UnidentifiedAccess> unidentifiedAccess,
boolean isRecipientUpdate,
ContentHint contentHint,
SignalServiceDataMessage message,
SenderKeyGroupEvents sendEvents,
boolean urgent,
boolean isForStory,
public List<SendMessageResult> sendGroupDataMessage(DistributionId distributionId,
List<SignalServiceAddress> recipients,
List<UnidentifiedAccess> unidentifiedAccess,
boolean isRecipientUpdate,
ContentHint contentHint,
SignalServiceDataMessage message,
SenderKeyGroupEvents sendEvents,
boolean urgent,
boolean isForStory,
SignalServiceEditMessage editMessage,
PartialSendBatchCompleteListener partialListener)
throws IOException, UntrustedIdentityException, NoSessionException, InvalidKeyException, InvalidRegistrationIdException
{
Log.d(TAG, "[" + message.getTimestamp() + "] Sending a group data message to " + recipients.size() + " recipients using DistributionId " + distributionId);
Log.d(TAG, "[" + message.getTimestamp() + "] Sending a group " + (editMessage != null ? "edit data message" : "data message") + " to " + recipients.size() + " recipients using DistributionId " + distributionId);
Content content;
if (editMessage != null) {
content = createEditMessageContent(editMessage);
} else {
content = createMessageContent(message);
}
Content content = createMessageContent(message);
Optional<byte[]> groupId = message.getGroupId();
List<SendMessageResult> results = sendGroupMessage(distributionId, recipients, unidentifiedAccess, message.getTimestamp(), content, contentHint, groupId, false, sendEvents, urgent, isForStory);
@@ -919,7 +964,23 @@ public class SignalServiceMessageSender {
}
private Content createMessageContent(SignalServiceDataMessage message) throws IOException {
Content.Builder container = Content.newBuilder();
Content.Builder container = Content.newBuilder();
DataMessage.Builder dataMessage = createDataMessage(message);
return enforceMaxContentSize(container.setDataMessage(dataMessage).build());
}
private Content createEditMessageContent(SignalServiceEditMessage editMessage) throws IOException {
Content.Builder container = Content.newBuilder();
DataMessage.Builder dataMessage = createDataMessage(editMessage.getDataMessage());
EditMessage.Builder editMessageProto = EditMessage.newBuilder()
.setDataMessage(dataMessage)
.setTargetSentTimestamp(editMessage.getTargetSentTimestamp());
return enforceMaxContentSize(container.setEditMessage(editMessageProto).build());
}
private DataMessage.Builder createDataMessage(SignalServiceDataMessage message) throws IOException {
DataMessage.Builder builder = DataMessage.newBuilder();
List<AttachmentPointer> pointers = createAttachmentPointers(message.getAttachments());
@@ -1119,7 +1180,7 @@ public class SignalServiceMessageSender {
builder.setTimestamp(message.getTimestamp());
return enforceMaxContentSize(container.setDataMessage(builder).build());
return builder;
}
private Preview createPreview(SignalServicePreview preview) throws IOException {
@@ -6,6 +6,7 @@ import org.signal.libsignal.zkgroup.groups.GroupMasterKey
import org.signal.libsignal.zkgroup.receipts.ReceiptCredentialPresentation
import org.whispersystems.signalservice.api.InvalidMessageStructureException
import org.whispersystems.signalservice.api.util.UuidUtil
import org.whispersystems.signalservice.internal.push.SignalServiceProtos
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.AttachmentPointer
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Content
import org.whispersystems.signalservice.internal.push.SignalServiceProtos.DataMessage
@@ -36,6 +37,7 @@ object EnvelopeContentValidator {
content.hasStoryMessage() -> validateStoryMessage(content.storyMessage)
content.hasPniSignatureMessage() -> Result.Valid
content.hasSenderKeyDistributionMessage() -> Result.Valid
content.hasEditMessage() -> validateEditMessage(content.editMessage)
else -> Result.Invalid("Content is empty!")
}
}
@@ -215,6 +217,43 @@ object EnvelopeContentValidator {
return Result.Valid
}
private fun validateEditMessage(editMessage: SignalServiceProtos.EditMessage): Result {
if (!editMessage.hasDataMessage()) {
return Result.Invalid("[EditMessage] No data message present")
}
if (!editMessage.hasTargetSentTimestamp()) {
return Result.Invalid("[EditMessage] No targetSentTimestamp specified")
}
val dataMessage: DataMessage = editMessage.dataMessage
if (dataMessage.requiredProtocolVersion > DataMessage.ProtocolVersion.CURRENT_VALUE) {
return Result.UnsupportedDataMessage(
ourVersion = DataMessage.ProtocolVersion.CURRENT_VALUE,
theirVersion = dataMessage.requiredProtocolVersion
)
}
if (dataMessage.previewList.any { it.hasImage() && it.image.isPresentAndInvalid() }) {
return Result.Invalid("[EditMessage] Invalid AttachmentPointer on DataMessage.previewList.image!")
}
if (dataMessage.bodyRangesList.any { it.hasMentionUuid() && it.mentionUuid.isNullOrInvalidUuid() }) {
return Result.Invalid("[EditMessage] Invalid UUID on body range!")
}
if (dataMessage.attachmentsList.any { it.isNullOrInvalid() }) {
return Result.Invalid("[EditMessage] Invalid attachments!")
}
if (dataMessage.hasGroupV2()) {
validateGroupContextV2(dataMessage.groupV2, "[EditMessage]")?.let { return it }
}
return Result.Valid
}
private fun AttachmentPointer?.isNullOrInvalid(): Boolean {
return this == null || this.attachmentIdentifierCase == AttachmentPointer.AttachmentIdentifierCase.ATTACHMENTIDENTIFIER_NOT_SET
}
@@ -71,7 +71,8 @@ import java.util.stream.Collectors;
import javax.annotation.Nullable;
@SuppressWarnings("OptionalIsPresent") public final class SignalServiceContent {
@SuppressWarnings("OptionalIsPresent")
public final class SignalServiceContent {
private static final String TAG = SignalServiceContent.class.getSimpleName();
@@ -95,6 +96,7 @@ import javax.annotation.Nullable;
private final Optional<DecryptionErrorMessage> decryptionErrorMessage;
private final Optional<SignalServiceStoryMessage> storyMessage;
private final Optional<SignalServicePniSignatureMessage> pniSignatureMessage;
private final Optional<SignalServiceEditMessage> editMessage;
private SignalServiceContent(SignalServiceDataMessage message,
Optional<SenderKeyDistributionMessage> senderKeyDistributionMessage,
@@ -130,6 +132,7 @@ import javax.annotation.Nullable;
this.decryptionErrorMessage = Optional.empty();
this.storyMessage = Optional.empty();
this.pniSignatureMessage = pniSignatureMessage;
this.editMessage = Optional.empty();
}
private SignalServiceContent(SignalServiceSyncMessage synchronizeMessage,
@@ -166,6 +169,7 @@ import javax.annotation.Nullable;
this.decryptionErrorMessage = Optional.empty();
this.storyMessage = Optional.empty();
this.pniSignatureMessage = pniSignatureMessage;
this.editMessage = Optional.empty();
}
private SignalServiceContent(SignalServiceCallMessage callMessage,
@@ -202,6 +206,7 @@ import javax.annotation.Nullable;
this.decryptionErrorMessage = Optional.empty();
this.storyMessage = Optional.empty();
this.pniSignatureMessage = pniSignatureMessage;
this.editMessage = Optional.empty();
}
private SignalServiceContent(SignalServiceReceiptMessage receiptMessage,
@@ -238,6 +243,7 @@ import javax.annotation.Nullable;
this.decryptionErrorMessage = Optional.empty();
this.storyMessage = Optional.empty();
this.pniSignatureMessage = pniSignatureMessage;
this.editMessage = Optional.empty();
}
private SignalServiceContent(DecryptionErrorMessage errorMessage,
@@ -274,6 +280,7 @@ import javax.annotation.Nullable;
this.decryptionErrorMessage = Optional.of(errorMessage);
this.storyMessage = Optional.empty();
this.pniSignatureMessage = pniSignatureMessage;
this.editMessage = Optional.empty();
}
private SignalServiceContent(SignalServiceTypingMessage typingMessage,
@@ -310,6 +317,7 @@ import javax.annotation.Nullable;
this.decryptionErrorMessage = Optional.empty();
this.storyMessage = Optional.empty();
this.pniSignatureMessage = pniSignatureMessage;
this.editMessage = Optional.empty();
}
private SignalServiceContent(SenderKeyDistributionMessage senderKeyDistributionMessage,
@@ -345,6 +353,7 @@ import javax.annotation.Nullable;
this.decryptionErrorMessage = Optional.empty();
this.storyMessage = Optional.empty();
this.pniSignatureMessage = pniSignatureMessage;
this.editMessage = Optional.empty();
}
private SignalServiceContent(SignalServicePniSignatureMessage pniSignatureMessage,
@@ -380,6 +389,7 @@ import javax.annotation.Nullable;
this.decryptionErrorMessage = Optional.empty();
this.storyMessage = Optional.empty();
this.pniSignatureMessage = Optional.of(pniSignatureMessage);
this.editMessage = Optional.empty();
}
private SignalServiceContent(SignalServiceStoryMessage storyMessage,
@@ -416,6 +426,44 @@ import javax.annotation.Nullable;
this.decryptionErrorMessage = Optional.empty();
this.storyMessage = Optional.of(storyMessage);
this.pniSignatureMessage = pniSignatureMessage;
this.editMessage = Optional.empty();
}
private SignalServiceContent(SignalServiceEditMessage editMessage,
Optional<SenderKeyDistributionMessage> senderKeyDistributionMessage,
Optional<SignalServicePniSignatureMessage> pniSignatureMessage,
SignalServiceAddress sender,
int senderDevice,
long timestamp,
long serverReceivedTimestamp,
long serverDeliveredTimestamp,
boolean needsReceipt,
String serverUuid,
Optional<byte[]> groupId,
String destinationUuid,
SignalServiceContentProto serializedState)
{
this.sender = sender;
this.senderDevice = senderDevice;
this.timestamp = timestamp;
this.serverReceivedTimestamp = serverReceivedTimestamp;
this.serverDeliveredTimestamp = serverDeliveredTimestamp;
this.needsReceipt = needsReceipt;
this.serverUuid = serverUuid;
this.groupId = groupId;
this.destinationUuid = destinationUuid;
this.serializedState = serializedState;
this.message = Optional.empty();
this.synchronizeMessage = Optional.empty();
this.callMessage = Optional.empty();
this.readMessage = Optional.empty();
this.typingMessage = Optional.empty();
this.senderKeyDistributionMessage = senderKeyDistributionMessage;
this.decryptionErrorMessage = Optional.empty();
this.storyMessage = Optional.empty();
this.pniSignatureMessage = pniSignatureMessage;
this.editMessage = Optional.of(editMessage);
}
public Optional<SignalServiceDataMessage> getDataMessage() {
@@ -454,6 +502,10 @@ import javax.annotation.Nullable;
return pniSignatureMessage;
}
public Optional<SignalServiceEditMessage> getEditMessage() {
return editMessage;
}
public SignalServiceAddress getSender() {
return sender;
}
@@ -542,7 +594,7 @@ import javax.annotation.Nullable;
}
if (message.hasDataMessage()) {
return new SignalServiceContent(createSignalServiceMessage(metadata, message.getDataMessage()),
return new SignalServiceContent(createSignalServiceDataMessage(metadata, message.getDataMessage()),
senderKeyDistributionMessage,
pniSignatureMessage,
metadata.getSender(),
@@ -652,6 +704,20 @@ import javax.annotation.Nullable;
metadata.getGroupId(),
metadata.getDestinationUuid(),
serviceContentProto);
} else if (message.hasEditMessage()) {
return new SignalServiceContent(createEditMessage(metadata, message.getEditMessage()),
senderKeyDistributionMessage,
pniSignatureMessage,
metadata.getSender(),
metadata.getSenderDevice(),
metadata.getTimestamp(),
metadata.getServerReceivedTimestamp(),
metadata.getServerDeliveredTimestamp(),
false,
metadata.getServerGuid(),
metadata.getGroupId(),
metadata.getDestinationUuid(),
serviceContentProto);
} else if (senderKeyDistributionMessage.isPresent()) {
// IMPORTANT: This block should always be last, since you can pair SKDM's with other content
return new SignalServiceContent(senderKeyDistributionMessage.get(),
@@ -672,8 +738,8 @@ import javax.annotation.Nullable;
return null;
}
private static SignalServiceDataMessage createSignalServiceMessage(SignalServiceMetadata metadata,
SignalServiceProtos.DataMessage content)
private static SignalServiceDataMessage createSignalServiceDataMessage(SignalServiceMetadata metadata,
SignalServiceProtos.DataMessage content)
throws UnsupportedDataMessageException, InvalidMessageStructureException
{
SignalServiceGroupV2 groupInfoV2 = createGroupV2Info(content);
@@ -757,7 +823,7 @@ import javax.annotation.Nullable;
if (content.hasSent()) {
Map<ServiceId, Boolean> unidentifiedStatuses = new HashMap<>();
SignalServiceProtos.SyncMessage.Sent sentContent = content.getSent();
Optional<SignalServiceDataMessage> dataMessage = sentContent.hasMessage() ? Optional.of(createSignalServiceMessage(metadata, sentContent.getMessage())) : Optional.empty();
Optional<SignalServiceDataMessage> dataMessage = sentContent.hasMessage() ? Optional.of(createSignalServiceDataMessage(metadata, sentContent.getMessage())) : Optional.empty();
Optional<SignalServiceStoryMessage> storyMessage = sentContent.hasStoryMessage() ? Optional.of(createStoryMessage(sentContent.getStoryMessage())) : Optional.empty();
Optional<SignalServiceAddress> address = SignalServiceAddress.isValidAddress(sentContent.getDestinationUuid())
? Optional.of(new SignalServiceAddress(ServiceId.parseOrThrow(sentContent.getDestinationUuid()), sentContent.getDestinationE164()))
@@ -1105,6 +1171,14 @@ import javax.annotation.Nullable;
}
}
private static SignalServiceEditMessage createEditMessage(SignalServiceMetadata metadata, SignalServiceProtos.EditMessage content) throws InvalidMessageStructureException, UnsupportedDataMessageException {
if (content.hasDataMessage() && content.getTargetSentTimestamp() != 0) {
return new SignalServiceEditMessage(content.getTargetSentTimestamp(), createSignalServiceDataMessage(metadata, content.getDataMessage()));
} else {
throw new InvalidMessageStructureException("Missing data message or timestamp from edit message.");
}
}
private static @Nullable SignalServiceDataMessage.Quote createQuote(SignalServiceProtos.DataMessage content, boolean isGroupV2)
throws InvalidMessageStructureException
{
@@ -0,0 +1,6 @@
package org.whispersystems.signalservice.api.messages
data class SignalServiceEditMessage(
val targetSentTimestamp: Long,
val dataMessage: SignalServiceDataMessage
)
@@ -51,6 +51,7 @@ message Content {
optional bytes decryptionErrorMessage = 8;
optional StoryMessage storyMessage = 9;
optional PniSignatureMessage pniSignatureMessage = 10;
optional EditMessage editMessage = 11;
}
message CallMessage {
@@ -772,4 +773,9 @@ message DecryptionErrorMessage {
message PniSignatureMessage {
optional bytes pni = 1;
optional bytes signature = 2;
}
message EditMessage {
optional uint64 targetSentTimestamp = 1;
optional DataMessage dataMessage = 2;
}