diff --git a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java index a74f4fd0d9..4bc690f879 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java @@ -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); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/AnimatingToggle.java b/app/src/main/java/org/thoughtcrime/securesms/components/AnimatingToggle.java index 58947258c3..a3a3489c1d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/AnimatingToggle.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/AnimatingToggle.java @@ -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; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java index d9ab71908b..e644cf1dd7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java @@ -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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java b/app/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java index 78e438b60c..b88fb40e55 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java @@ -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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/QuoteView.java b/app/src/main/java/org/thoughtcrime/securesms/components/QuoteView.java index e64274785f..a7d4e16360 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/QuoteView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/QuoteView.java @@ -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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java index a42fe61eaf..bf6bb0ccd3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java @@ -110,19 +110,19 @@ public class ConversationAdapter private final Set 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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java index 2517021201..06fd887496 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -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; diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java index 39f05474a9..4266b89e3d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -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 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); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemDisplayMode.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemDisplayMode.kt index 641f3f70e3..9a300a8ff6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemDisplayMode.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItemDisplayMode.kt @@ -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 + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java index 3aeb541826..fa9f569db4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java @@ -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() { + result.getScheduledTime(), + null).addListener(new AssertedSuccessListener() { @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 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 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 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 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(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java index d41966889b..522feb420c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationReactionOverlay.java @@ -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, diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationRepository.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationRepository.java index a922c4b604..87b0429bc0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationRepository.java @@ -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 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 getUnreadCount(long threadId, long afterTime) { if (threadId <= -1L || afterTime <= 0L) { return Observable.just(0); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationViewModel.java index 93d654cac6..058162da0a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationViewModel.java @@ -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 resolveMessageToEdit(@NonNull ConversationMessage message) { + return conversationRepository.resolveMessageToEdit(message); + } + void setArgs(@NonNull ConversationIntents.Args args) { this.args = args; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java index cf2ff82d9c..5123060dee 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/MenuState.java @@ -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 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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ScheduledMessagesBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ScheduledMessagesBottomSheet.kt index 9120be46ac..ee69928f12 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ScheduledMessagesBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ScheduledMessagesBottomSheet.kt @@ -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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftRepository.kt index 8f734a6ad9..32e3a9b978 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftRepository.kt @@ -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 { + 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?) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftState.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftState.kt index 911839b0a5..b82213a5b1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftState.kt @@ -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) ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftViewModel.kt index 8ad5e37476..a33a56cd41 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftViewModel.kt @@ -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, 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, 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 { + return repository.loadDraftMessageEdit(serialized) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + } } private fun String.toTextDraft(): Draft? { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/quotes/MessageQuotesBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/quotes/MessageQuotesBottomSheet.kt index 424565a2d8..2a2709dfda 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/quotes/MessageQuotesBottomSheet.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/quotes/MessageQuotesBottomSheet.kt @@ -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(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 { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/edit/EditMessageHistoryDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/edit/EditMessageHistoryDialog.kt new file mode 100644 index 0000000000..3d0ffff11c --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/edit/EditMessageHistoryDialog.kt @@ -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().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) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/edit/EditMessageHistoryRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/edit/EditMessageHistoryRepository.kt new file mode 100644 index 0000000000..6cd20f11cb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/edit/EditMessageHistoryRepository.kt @@ -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> { + 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 { + 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) } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/edit/EditMessageHistoryViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/edit/EditMessageHistoryViewModel.kt new file mode 100644 index 0000000000..7412cc0e16 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ui/edit/EditMessageHistoryViewModel.kt @@ -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> { + return EditMessageHistoryRepository + .getEditHistory(messageId) + .observeOn(AndroidSchedulers.mainThread()) + } + + fun getNameColorsMap(): Observable> { + return conversationRecipient + .live() + .observable() + .map { recipient -> + if (recipient.groupId.isPresent) { + groupAuthorNameColorHelper.getColorMap(recipient.groupId.get()) + } else { + emptyMap() + } + } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index 8aa0747182..b50a66f2e7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -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") } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.java b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.java index 52a43fe56d..235e967c48 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/AttachmentTable.java @@ -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; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DraftTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/DraftTable.kt index 90c08e52d5..803df48d4b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/DraftTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DraftTable.kt @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt index 58331533fd..6ee49ad7b9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.kt @@ -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 = 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 { - var type = type + fun insertMessageInbox(message: IncomingTextMessage, editedMessage: MediaMmsMessageRecord? = null): Optional { + 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 { + 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 { + 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): 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) { 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 { 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 = 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 { + fun insertSecureDecryptedMessageInbox(retrieved: IncomingMediaMessage, threadId: Long, edittedMediaMessage: MediaMmsMessageRecord? = null): Optional { 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 { @@ -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 { 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 { - 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 { 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 = 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 { 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 ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ReactionTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ReactionTable.kt index 678d719bd8..df36e091b8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ReactionTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ReactionTable.kt @@ -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() + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SearchTable.kt b/app/src/main/java/org/thoughtcrime/securesms/database/SearchTable.kt index 9ab53750b0..a2837fdaad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SearchTable.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SearchTable.kt @@ -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 """ diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt index 51deaaa9f5..ae9e4a9a51 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SignalDatabase.kt @@ -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() diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt index 92caa2edca..b5b29379f3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V186_AddEditMessageColumnsMigration.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V186_AddEditMessageColumnsMigration.kt new file mode 100644 index 0000000000..16f84c5a0d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V186_AddEditMessageColumnsMigration.kt @@ -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 = 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 { + 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 + ) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/InMemoryMessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/InMemoryMessageRecord.java index 21898f782d..5f0978a918 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/InMemoryMessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/InMemoryMessageRecord.java @@ -54,7 +54,9 @@ public class InMemoryMessageRecord extends MessageRecord { false, 0, 0, - -1); + -1, + null, + 0); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java index 653201db94..f9ce7047c9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java @@ -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 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 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 updateContacts(@NonNull List contacts, @NonNull Map attachmentIdMap) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java index 4cec607d43..41ef07d5b7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MessageRecord.java @@ -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 mismatches, Set networkFailures, - int subscriptionId, long expiresIn, long expireStarted, - int readReceiptCount, boolean unidentified, - @NonNull List reactions, boolean remoteDelete, long notifiedTimestamp, - int viewedReceiptCount, long receiptTimestamp) + int subscriptionId, + long expiresIn, + long expireStarted, + int readReceiptCount, + boolean unidentified, + @NonNull List 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; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java index 921e31216c..e77a67dc6a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MmsMessageRecord.java @@ -39,12 +39,13 @@ public abstract class MmsMessageRecord extends MessageRecord { @NonNull List linkPreviews, boolean unidentified, @NonNull List 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; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.java index 533e2d9bb6..36c6018142 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/NotificationMmsMessageRecord.java @@ -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; diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/IndividualSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/IndividualSendJob.java index 5f2aca9237..5bc5878ec6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/IndividualSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/IndividualSendJob.java @@ -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 syncAccess = UnidentifiedAccessUtil.getAccessForSync(context); SendMessageResult result = messageSender.sendSyncMessage(mediaMessage); SignalDatabase.messageLog().insertIfPossible(messageRecipient.getId(), message.getSentTimeMillis(), result, ContentHint.RESENDABLE, new MessageId(messageId), false); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java index edf17acd4d..773f411962 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java @@ -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 existingNetworkFailures = new HashSet<>(message.getNetworkFailures()); Set existingIdentityMismatches = new HashSet<>(message.getIdentityKeyMismatches()); @@ -227,7 +229,7 @@ public final class PushGroupSendJob extends PushSendJob { skipped = result.skipped; } - List results = deliver(message, groupRecipient, target); + List 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 deliver(OutgoingMessage message, @NonNull Recipient groupRecipient, @NonNull List destinations) + private List deliver(OutgoingMessage message, @Nullable MessageRecord originalEditedMessage, @NonNull Recipient groupRecipient, @NonNull List 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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java index e3ddd33154..25da3c80f5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushSendJob.java @@ -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 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(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/ReactionSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/ReactionSendJob.java index 474e7eb9df..6655238046 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/ReactionSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/ReactionSendJob.java @@ -236,7 +236,8 @@ public class ReactionSendJob extends BaseJob { messageId, dataMessage, true, - false); + false, + null); if (includesSelf) { results.add(ApplicationDependencies.getSignalServiceMessageSender().sendSyncMessage(dataMessage)); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/RemoteDeleteSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/RemoteDeleteSendJob.java index 6f81b7e10b..c84aee7b46 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/RemoteDeleteSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/RemoteDeleteSendJob.java @@ -217,7 +217,8 @@ public class RemoteDeleteSendJob extends BaseJob { new MessageId(messageId), dataMessage, true, - isForStory); + isForStory, + null); return GroupSendJobHelper.getCompletedSends(destinations, results); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageFragment.java b/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageFragment.java index 80186ad006..8a45c36b60 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageFragment.java @@ -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); }); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewV2Fragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewV2Fragment.kt index 7292f930ad..f421125a91 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewV2Fragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewV2Fragment.kt @@ -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) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsAdapter.java index dc1d7b01f0..074eaf898d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/MessageDetailsAdapter.java @@ -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, RecyclerView.ViewHolder> { @@ -40,6 +41,8 @@ final class MessageDetailsAdapter extends ListAdapter(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(); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/messagedetails/ViewEditHistoryViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/ViewEditHistoryViewHolder.java new file mode 100644 index 0000000000..fb07a8c976 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/messagedetails/ViewEditHistoryViewHolder.java @@ -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)); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/DataMessageProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/messages/DataMessageProcessor.kt index 626fb46bf3..e2990b603c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/DataMessageProcessor.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/DataMessageProcessor.kt @@ -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)) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/EditMessageProcessor.kt b/app/src/main/java/org/thoughtcrime/securesms/messages/EditMessageProcessor.kt new file mode 100644 index 0000000000..2c261c5ffa --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/EditMessageProcessor.kt @@ -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() + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/GroupSendUtil.java b/app/src/main/java/org/thoughtcrime/securesms/messages/GroupSendUtil.java index 459a76a5ea..34af38b7b9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/GroupSendUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/GroupSendUtil.java @@ -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 sendResendableStoryRelatedMessage(@NonNull Context context, - @Nullable GroupId.V2 groupId, - @NonNull DistributionListId distributionListId, - @NonNull List 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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java index d9cf9589f5..1731a6353b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java @@ -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); diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessorV2.kt b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessorV2.kt index 57b70fbe3c..b17d4ab7d1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessorV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessorV2.kt @@ -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 } diff --git a/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java b/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java index 35a5d176ec..2e8edc1a68 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java +++ b/app/src/main/java/org/thoughtcrime/securesms/migrations/ApplicationMigrations.java @@ -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; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMessage.kt index 114e49098a..838af0a159 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMessage.kt @@ -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. */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java index bc8daf0d2b..02ef6c5e5a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java @@ -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; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DeleteDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/util/DeleteDialog.kt index 1b37817534..e4bcdf3bf2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/DeleteDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DeleteDialog.kt @@ -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) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java index fa29d1755a..2bda34168a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -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 */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MessageConstraintsUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/util/MessageConstraintsUtil.kt new file mode 100644 index 0000000000..56f094adf6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MessageConstraintsUtil.kt @@ -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, 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 + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MessageRecordUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/util/MessageRecordUtil.kt index 569a7221a4..3428903e71 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/MessageRecordUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MessageRecordUtil.kt @@ -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 +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteDeleteUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteDeleteUtil.java deleted file mode 100644 index f006fc3466..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteDeleteUtil.java +++ /dev/null @@ -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 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); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ViewModelFactory.kt b/app/src/main/java/org/thoughtcrime/securesms/util/ViewModelFactory.kt new file mode 100644 index 0000000000..5b65d8a53d --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ViewModelFactory.kt @@ -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(private val create: () -> MODEL) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return create() as T + } + + companion object { + fun factoryProducer(create: () -> MODEL): () -> ViewModelProvider.Factory { + return { ViewModelFactory(create) } + } + } +} diff --git a/app/src/main/res/drawable/symbol_check_24.xml b/app/src/main/res/drawable/symbol_check_24.xml new file mode 100644 index 0000000000..1ebb78249b --- /dev/null +++ b/app/src/main/res/drawable/symbol_check_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/symbol_edit_compact_16.xml b/app/src/main/res/drawable/symbol_edit_compact_16.xml new file mode 100644 index 0000000000..7b543eea4c --- /dev/null +++ b/app/src/main/res/drawable/symbol_edit_compact_16.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/conversation_input_panel.xml b/app/src/main/res/layout/conversation_input_panel.xml index bc1f00353f..3579221241 100644 --- a/app/src/main/res/layout/conversation_input_panel.xml +++ b/app/src/main/res/layout/conversation_input_panel.xml @@ -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"> + + + android:clipToPadding="false" + android:paddingStart="2dp"> + + + app:srcCompat="@drawable/symbol_plus_24" /> + + + + + + + + + diff --git a/app/src/main/res/layout/message_edit_history_bottom_sheet.xml b/app/src/main/res/layout/message_edit_history_bottom_sheet.xml new file mode 100644 index 0000000000..e1637bd7ce --- /dev/null +++ b/app/src/main/res/layout/message_edit_history_bottom_sheet.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ac9d34a1d5..b75dc4d59f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -301,9 +301,13 @@ Can\'t download image. You will need to send it again. Can\'t download video. You will need to send it again. + + edited %1$s Add attachment + + Send edit Select contact info Compose message Sorry, there was an error setting your attachment. @@ -311,6 +315,13 @@ Message is empty! Group members Tap here to start a group call + + Unable to edit SMS messages + + + Edits can only be applied within %1$d hour from the time you sent this message. + Edits can only be applied within %1$d hours from the time you sent this message. + Invalid recipient! Added to home screen @@ -1075,6 +1086,8 @@ Tap and hold to record a voice message, release to send SMS messaging is no longer supported in Signal. + + Edit message Share @@ -2653,6 +2666,8 @@ Failed to send New safety number + + View edit history Create passphrase @@ -3188,6 +3203,8 @@ Forward Reply + + Edit Save @@ -5928,5 +5945,8 @@ Compact + + Edit history + diff --git a/app/src/test/java/org/thoughtcrime/securesms/database/FakeMessageRecords.kt b/app/src/test/java/org/thoughtcrime/securesms/database/FakeMessageRecords.kt index 255a77dec2..5ae719ed2d 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/database/FakeMessageRecords.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/database/FakeMessageRecords.kt @@ -176,7 +176,10 @@ object FakeMessageRecords { giftBadge, payment, call, - -1 + -1, + null, + null, + 0 ) } } diff --git a/core-util/src/main/java/org/signal/core/util/SqlUtil.kt b/core-util/src/main/java/org/signal/core/util/SqlUtil.kt index 87d1c876b5..5946f9a93e 100644 --- a/core-util/src/main/java/org/signal/core/util/SqlUtil.kt +++ b/core-util/src/main/java/org/signal/core/util/SqlUtil.kt @@ -341,6 +341,11 @@ object SqlUtil { }.toTypedArray() } + @JvmStatic + fun appendArgs(args: Array, vararg objects: Any?): Array { + return args + buildArgs(objects) + } + @JvmStatic fun buildBulkInsert(tableName: String, columns: Array, contentValues: List): List { return buildBulkInsert(tableName, columns, contentValues, MAX_QUERY_ARGS) diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java index 5d71c576fb..a418e4ad78 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/SignalServiceMessageSender.java @@ -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 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 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 sendGroupDataMessage(DistributionId distributionId, - List recipients, - List unidentifiedAccess, - boolean isRecipientUpdate, - ContentHint contentHint, - SignalServiceDataMessage message, - SenderKeyGroupEvents sendEvents, - boolean urgent, - boolean isForStory, + public List sendGroupDataMessage(DistributionId distributionId, + List recipients, + List 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 groupId = message.getGroupId(); List 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 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 { diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/EnvelopeContentValidator.kt b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/EnvelopeContentValidator.kt index cd361c7bf1..e88ff658a3 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/EnvelopeContentValidator.kt +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/EnvelopeContentValidator.kt @@ -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 } diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceContent.java b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceContent.java index 27cf12b368..b8ff4ede8a 100644 --- a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceContent.java +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceContent.java @@ -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; private final Optional storyMessage; private final Optional pniSignatureMessage; + private final Optional editMessage; private SignalServiceContent(SignalServiceDataMessage message, Optional 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, + Optional pniSignatureMessage, + SignalServiceAddress sender, + int senderDevice, + long timestamp, + long serverReceivedTimestamp, + long serverDeliveredTimestamp, + boolean needsReceipt, + String serverUuid, + Optional 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 getDataMessage() { @@ -454,6 +502,10 @@ import javax.annotation.Nullable; return pniSignatureMessage; } + public Optional 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 unidentifiedStatuses = new HashMap<>(); SignalServiceProtos.SyncMessage.Sent sentContent = content.getSent(); - Optional dataMessage = sentContent.hasMessage() ? Optional.of(createSignalServiceMessage(metadata, sentContent.getMessage())) : Optional.empty(); + Optional dataMessage = sentContent.hasMessage() ? Optional.of(createSignalServiceDataMessage(metadata, sentContent.getMessage())) : Optional.empty(); Optional storyMessage = sentContent.hasStoryMessage() ? Optional.of(createStoryMessage(sentContent.getStoryMessage())) : Optional.empty(); Optional 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 { diff --git a/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceEditMessage.kt b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceEditMessage.kt new file mode 100644 index 0000000000..fc1715650f --- /dev/null +++ b/libsignal/service/src/main/java/org/whispersystems/signalservice/api/messages/SignalServiceEditMessage.kt @@ -0,0 +1,6 @@ +package org.whispersystems.signalservice.api.messages + +data class SignalServiceEditMessage( + val targetSentTimestamp: Long, + val dataMessage: SignalServiceDataMessage +) diff --git a/libsignal/service/src/main/proto/SignalService.proto b/libsignal/service/src/main/proto/SignalService.proto index 76b2503642..3c533319dd 100644 --- a/libsignal/service/src/main/proto/SignalService.proto +++ b/libsignal/service/src/main/proto/SignalService.proto @@ -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; } \ No newline at end of file