Add message editing feature.

This commit is contained in:
Clark
2023-04-14 16:29:26 -04:00
committed by Cody Henthorne
parent 4f06a0d27c
commit 07f6baf7c1
73 changed files with 2051 additions and 304 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -110,19 +110,19 @@ public class ConversationAdapter
private final Set<MultiselectPart> selected;
private final Calendar calendar;
private String searchQuery;
private ConversationMessage recordToPulse;
private View typingView;
private View footerView;
private PagingController pagingController;
private boolean hasWallpaper;
private boolean isMessageRequestAccepted;
private ConversationMessage inlineContent;
private Colorizer colorizer;
private boolean isTypingViewEnabled;
private boolean condensedMode;
private boolean scheduledMessagesMode;
private PulseRequest pulseRequest;
private String searchQuery;
private ConversationMessage recordToPulse;
private View typingView;
private View footerView;
private PagingController pagingController;
private boolean hasWallpaper;
private boolean isMessageRequestAccepted;
private ConversationMessage inlineContent;
private Colorizer colorizer;
private boolean isTypingViewEnabled;
private ConversationItemDisplayMode condensedMode;
private boolean scheduledMessagesMode;
private PulseRequest pulseRequest;
public ConversationAdapter(@NonNull Context context,
@NonNull LifecycleOwner lifecycleOwner,
@@ -258,7 +258,7 @@ public class ConversationAdapter
}
}
public void setCondensedMode(boolean condensedMode) {
public void setCondensedMode(ConversationItemDisplayMode condensedMode) {
this.condensedMode = condensedMode;
notifyDataSetChanged();
}
@@ -283,7 +283,7 @@ public class ConversationAdapter
ConversationMessage previousMessage = adapterPosition < getItemCount() - 1 && !isFooterPosition(adapterPosition + 1) ? getItem(adapterPosition + 1) : null;
ConversationMessage nextMessage = adapterPosition > 0 && !isHeaderPosition(adapterPosition - 1) ? getItem(adapterPosition - 1) : null;
ConversationItemDisplayMode displayMode = condensedMode ? ConversationItemDisplayMode.CONDENSED : ConversationItemDisplayMode.STANDARD;
ConversationItemDisplayMode displayMode = condensedMode != null ? condensedMode : ConversationItemDisplayMode.STANDARD;
conversationViewHolder.getBindable().bind(lifecycleOwner,
conversationMessage,
@@ -295,7 +295,7 @@ public class ConversationAdapter
recipient,
searchQuery,
conversationMessage == recordToPulse,
hasWallpaper && !condensedMode,
hasWallpaper && displayMode.displayWallpaper(),
isMessageRequestAccepted,
conversationMessage == inlineContent,
colorizer,

View File

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

View File

@@ -419,7 +419,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
@Override
public void updateTimestamps() {
getActiveFooter(messageRecord).setMessageRecord(messageRecord, locale);
getActiveFooter(messageRecord).setMessageRecord(messageRecord, locale, displayMode);
}
@Override
@@ -526,10 +526,10 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
!messageRecord.isRemoteDelete() &&
bodyText.getLastLineWidth() > 0)
{
TextView dateView = footer.getDateView();
int footerWidth = footer.getMeasuredWidth();
int availableWidth = getAvailableMessageBubbleWidth(bodyText);
int collapsedTopMargin = -1 * (dateView.getMeasuredHeight() + ViewUtil.dpToPx(4));
View dateView = footer.getDateView();
int footerWidth = footer.getMeasuredWidth();
int availableWidth = getAvailableMessageBubbleWidth(bodyText);
int collapsedTopMargin = -1 * (dateView.getMeasuredHeight() + ViewUtil.dpToPx(4));
if (bodyText.isSingleLine() && !messageRecord.isFailed()) {
int maxBubbleWidth = hasBigImageLinkPreview(messageRecord) || hasThumbnail(messageRecord) ? readDimen(R.dimen.media_bubble_max_width) : getMaxBubbleWidth();
@@ -1666,7 +1666,18 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
if (isFooterVisible(current, next, isGroupThread)) {
ConversationItemFooter activeFooter = getActiveFooter(current);
activeFooter.setVisibility(VISIBLE);
activeFooter.setMessageRecord(current, locale);
activeFooter.setMessageRecord(current, locale, displayMode);
if (MessageRecordUtil.isEditMessage(current)) {
activeFooter.getDateView().setOnClickListener(v -> {
if (eventListener != null) {
eventListener.onEditedIndicatorClicked(current);
}
});
} else {
activeFooter.getDateView().setOnClickListener(null);
activeFooter.getDateView().setClickable(false);
}
if (hasWallpaper && hasNoBubble((messageRecord))) {
if (messageRecord.isOutgoing()) {
@@ -1714,7 +1725,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
private void setHasBeenQuoted(@NonNull ConversationMessage message) {
if (message.hasBeenQuoted() && !isCondensedMode() && quotedIndicator != null && batchSelected.isEmpty()) {
if (message.hasBeenQuoted() && !isCondensedMode() && quotedIndicator != null && batchSelected.isEmpty() && displayMode != ConversationItemDisplayMode.EXTRA_CONDENSED) {
quotedIndicator.setVisibility(VISIBLE);
quotedIndicator.setOnClickListener(quotedIndicatorClickListener);
} else if (quotedIndicator != null) {
@@ -1737,7 +1748,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
private boolean forceFooter(@NonNull MessageRecord messageRecord) {
return hasAudio(messageRecord);
return hasAudio(messageRecord) || MessageRecordUtil.isEditMessage(messageRecord);
}
private ConversationItemFooter getActiveFooter(@NonNull MessageRecord messageRecord) {
@@ -1841,7 +1852,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
int background;
if (isSingularMessage(current, previous, next, isGroupThread)) {
if (isSingularMessage(current, previous, next, isGroupThread) || displayMode == ConversationItemDisplayMode.EXTRA_CONDENSED) {
if (current.isOutgoing()) {
background = R.drawable.message_bubble_background_sent_alone;
outliner.setRadius(bigRadius);
@@ -1922,6 +1933,10 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
private boolean isFooterVisible(@NonNull MessageRecord current, @NonNull Optional<MessageRecord> next, boolean isGroupThread) {
if (displayMode == ConversationItemDisplayMode.EXTRA_CONDENSED) {
return false;
}
boolean differentTimestamps = next.isPresent() && !DateUtils.isSameExtendedRelativeTimestamp(next.get().getTimestamp(), current.getTimestamp());
return forceFooter(messageRecord) || current.getExpiresIn() > 0 || !current.isSecure() || current.isPending() || current.isPendingInsecureSmsFallback() ||
@@ -1937,11 +1952,11 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
int spacingTop = readDimen(context, R.dimen.conversation_vertical_message_spacing_collapse);
int spacingBottom = spacingTop;
if (isStartOfMessageCluster(current, previous, isGroupThread)) {
if (isStartOfMessageCluster(current, previous, isGroupThread) && (displayMode != ConversationItemDisplayMode.EXTRA_CONDENSED || next.isEmpty())) {
spacingTop = readDimen(context, R.dimen.conversation_vertical_message_spacing_default);
}
if (isEndOfMessageCluster(current, next, isGroupThread)) {
if (isEndOfMessageCluster(current, next, isGroupThread) || displayMode == ConversationItemDisplayMode.EXTRA_CONDENSED) {
spacingBottom = readDimen(context, R.dimen.conversation_vertical_message_spacing_default);
}

View File

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

View File

@@ -282,12 +282,14 @@ import org.thoughtcrime.securesms.util.ConversationUtil;
import org.thoughtcrime.securesms.util.Debouncer;
import org.thoughtcrime.securesms.util.Dialogs;
import org.thoughtcrime.securesms.util.DrawableUtil;
import org.thoughtcrime.securesms.util.FeatureFlags;
import org.thoughtcrime.securesms.util.FullscreenHelper;
import org.thoughtcrime.securesms.util.IdentityUtil;
import org.signal.core.util.concurrent.LifecycleDisposable;
import org.thoughtcrime.securesms.util.Material3OnScrollHelper;
import org.thoughtcrime.securesms.util.MediaUtil;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.MessageConstraintsUtil;
import org.thoughtcrime.securesms.util.MessageUtil;
import org.thoughtcrime.securesms.util.PlayStoreUtil;
import org.thoughtcrime.securesms.util.ServiceUtil;
@@ -399,6 +401,7 @@ public class ConversationParentFragment extends Fragment
private AnimatingToggle buttonToggle;
private SendButton sendButton;
private ImageButton attachButton;
private ImageButton sendEditButton;
protected ConversationTitleView titleView;
private TextView charactersLeft;
private ConversationFragment fragment;
@@ -794,7 +797,8 @@ public class ConversationParentFragment extends Fragment
initiating,
true,
null,
result.getScheduledTime()).addListener(new AssertedSuccessListener<Void>() {
result.getScheduledTime(),
null).addListener(new AssertedSuccessListener<Void>() {
@Override
public void onSuccess(Void result) {
AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> {
@@ -1591,6 +1595,7 @@ public class ConversationParentFragment extends Fragment
inputPanel.setEnabled(canSendMessages);
sendButton.setEnabled(canSendMessages);
attachButton.setEnabled(canSendMessages);
sendEditButton.setEnabled(canSendMessages);
});
}
@@ -1670,6 +1675,24 @@ public class ConversationParentFragment extends Fragment
quoteResult.addListener(listener);
break;
case Draft.MESSAGE_EDIT:
SettableFuture<Boolean> messageEditResult = new SettableFuture<>();
disposables.add(draftViewModel.loadDraftEditMessage(draft.getValue()).subscribe(
conversationMessage -> {
inputPanel.enterEditMessageMode(glideRequests, conversationMessage, true);
messageEditResult.set(true);
},
err -> {
Log.e(TAG, "Failed to restore message edit from a draft.", err);
messageEditResult.set(false);
},
() -> {
Log.e(TAG, "Failed to load message edit. No matching message record.");
messageEditResult.set(false);
}
));
messageEditResult.addListener(listener);
break;
case Draft.VOICE_NOTE:
case Draft.BODY_RANGES:
listener.onSuccess(true);
@@ -1846,6 +1869,7 @@ public class ConversationParentFragment extends Fragment
buttonToggle = view.findViewById(R.id.button_toggle);
sendButton = view.findViewById(R.id.send_button);
attachButton = view.findViewById(R.id.attach_button);
sendEditButton = view.findViewById(R.id.send_edit_button);
composeText = view.findViewById(R.id.embedded_text_editor);
charactersLeft = view.findViewById(R.id.space_left);
emojiDrawerStub = ViewUtil.findStubById(view, R.id.emoji_drawer_stub);
@@ -1902,6 +1926,7 @@ public class ConversationParentFragment extends Fragment
attachButton.setOnClickListener(new AttachButtonListener());
attachButton.setOnLongClickListener(new AttachButtonLongClickListener());
sendButton.setOnClickListener(sendButtonListener);
sendEditButton.setOnClickListener(v -> handleSendEditMessage());
sendButton.setScheduledSendListener(new SendButton.ScheduledSendListener() {
@Override
public void onSendScheduled() {
@@ -2759,6 +2784,8 @@ public class ConversationParentFragment extends Fragment
callback.onSendComplete(threadId);
draftViewModel.onSendComplete(threadId);
inputPanel.exitEditMessageMode();
}
private void sendMessage(@Nullable String metricId) {
@@ -2794,6 +2821,7 @@ public class ConversationParentFragment extends Fragment
MessageSendType sendType = sendButton.getSelectedSendType();
long expiresIn = TimeUnit.SECONDS.toMillis(recipient.getExpiresInSeconds());
boolean initiating = threadId == -1;
boolean isEditMessage = inputPanel.inEditMessageMode();
boolean needsSplit = !sendType.usesSmsTransport() && message.length() > sendType.calculateCharacters(message).maxPrimaryMessageSize;
boolean isMediaMessage = attachmentManager.isAttachmentPresent() ||
recipient.isGroup() ||
@@ -2806,14 +2834,19 @@ public class ConversationParentFragment extends Fragment
Log.i(TAG, "[sendMessage] recipient: " + recipient.getId() + ", threadId: " + threadId + ", sendType: " + (sendType.usesSignalTransport() ? "signal" : "sms") + ", isManual: " + sendButton.isManualSelection());
if ((recipient.isMmsGroup() || recipient.getEmail().isPresent()) && !viewModel.getConversationStateSnapshot().isMmsEnabled()) {
if (!sendType.usesSignalTransport() && isEditMessage) {
Toast.makeText(requireContext(),
R.string.ConversationActivity_edit_sms_message_error,
Toast.LENGTH_LONG)
.show();
} else if ((recipient.isMmsGroup() || recipient.getEmail().isPresent()) && !viewModel.getConversationStateSnapshot().isMmsEnabled()) {
handleManualMmsRequired();
} else if (sendType.usesSignalTransport() && (identityRecords.isUnverified(true) || identityRecords.isUntrusted(true))) {
handleRecentSafetyNumberChange();
} else if (isMediaMessage) {
sendMediaMessage(sendType, expiresIn, false, initiating, metricId, scheduledDate);
sendMediaMessage(sendType, expiresIn, false, initiating, metricId, scheduledDate, inputPanel.getEditMessageId());
} else {
sendTextMessage(sendType, expiresIn, initiating, metricId, scheduledDate);
sendTextMessage(sendType, expiresIn, initiating, metricId, scheduledDate, inputPanel.getEditMessageId());
}
} catch (RecipientFormattingException ex) {
Toast.makeText(requireContext(),
@@ -2862,7 +2895,8 @@ public class ConversationParentFragment extends Fragment
null,
true,
result.getBodyRanges(),
-1);
-1,
0);
final Context context = requireContext().getApplicationContext();
@@ -2884,7 +2918,7 @@ public class ConversationParentFragment extends Fragment
}, this::sendComplete);
}
private void sendMediaMessage(@NonNull MessageSendType sendType, final long expiresIn, final boolean viewOnce, final boolean initiating, @Nullable String metricId, long scheduledDate)
private void sendMediaMessage(@NonNull MessageSendType sendType, final long expiresIn, final boolean viewOnce, final boolean initiating, @Nullable String metricId, long scheduledDate, @Nullable MessageId editMessageId)
throws InvalidMessageException
{
Log.i(TAG, "Sending media message...");
@@ -2903,7 +2937,8 @@ public class ConversationParentFragment extends Fragment
initiating,
true,
metricId,
scheduledDate);
scheduledDate,
editMessageId);
}
private ListenableFuture<Void> sendMediaMessage(@NonNull RecipientId recipientId,
@@ -2921,7 +2956,7 @@ public class ConversationParentFragment extends Fragment
final boolean clearComposeBox,
final @Nullable String metricId)
{
return sendMediaMessage(recipientId, sendType, body, slideDeck, quote, contacts, previews, mentions, styling, expiresIn, viewOnce, initiating, clearComposeBox, metricId, -1);
return sendMediaMessage(recipientId, sendType, body, slideDeck, quote, contacts, previews, mentions, styling, expiresIn, viewOnce, initiating, clearComposeBox, metricId, -1, null);
}
private ListenableFuture<Void> sendMediaMessage(@NonNull RecipientId recipientId,
@@ -2938,7 +2973,8 @@ public class ConversationParentFragment extends Fragment
final boolean initiating,
final boolean clearComposeBox,
final @Nullable String metricId,
final long scheduledDate)
final long scheduledDate,
@Nullable MessageId editMessageId)
{
if (ExpiredBuildReminder.isEligible()) {
showExpiredDialog();
@@ -2952,7 +2988,7 @@ public class ConversationParentFragment extends Fragment
if (SignalStore.uiHints().hasNotSeenTextFormattingAlert() && styling != null && styling.getRangesCount() > 0) {
final String finalBody = body;
Dialogs.showFormattedTextDialog(requireContext(), () -> sendMediaMessage(recipientId, sendType, finalBody, slideDeck, quote, contacts, previews, mentions, styling, expiresIn, viewOnce, initiating, clearComposeBox, metricId, scheduledDate));
Dialogs.showFormattedTextDialog(requireContext(), () -> sendMediaMessage(recipientId, sendType, finalBody, slideDeck, quote, contacts, previews, mentions, styling, expiresIn, viewOnce, initiating, clearComposeBox, metricId, scheduledDate, editMessageId));
return new SettableFuture<>(null);
}
@@ -2988,7 +3024,8 @@ public class ConversationParentFragment extends Fragment
null,
false,
styling,
scheduledDate);
scheduledDate,
editMessageId != null ? editMessageId.getId() : 0);
final SettableFuture<Void> future = new SettableFuture<>();
final Context context = requireContext().getApplicationContext();
@@ -3028,7 +3065,12 @@ public class ConversationParentFragment extends Fragment
return future;
}
private void sendTextMessage(@NonNull MessageSendType sendType, final long expiresIn, final boolean initiating, final @Nullable String metricId, long scheduledDate)
private void sendTextMessage(@NonNull MessageSendType sendType,
final long expiresIn,
final boolean initiating,
final @Nullable String metricId,
long scheduledDate,
@Nullable MessageId messageToEdit)
throws InvalidMessageException
{
if (ExpiredBuildReminder.isEligible()) {
@@ -3049,8 +3091,11 @@ public class ConversationParentFragment extends Fragment
final OutgoingMessage message;
if (sendPush) {
if (scheduledDate > 0) {
message = OutgoingMessage.text(recipient.get(), messageBody, expiresIn, System.currentTimeMillis(), null).sendAt(scheduledDate);
if (messageToEdit != null) {
message = OutgoingMessage.editText(recipient.get(), messageBody, System.currentTimeMillis(), null, messageToEdit.getId());
} else if (scheduledDate > 0) {
message = OutgoingMessage.text(recipient.get(), messageBody, expiresIn, System.currentTimeMillis(), null)
.sendAt(scheduledDate);
} else {
message = OutgoingMessage.text(recipient.get(), messageBody, expiresIn, System.currentTimeMillis(), null);
}
@@ -3111,6 +3156,13 @@ public class ConversationParentFragment extends Fragment
return;
}
if (inputPanel.inEditMessageMode()) {
buttonToggle.display(sendEditButton);
quickAttachmentToggle.hide();
inlineAttachmentToggle.hide();
return;
}
if (draftViewModel.getVoiceNoteDraft() != null) {
buttonToggle.display(sendButton);
quickAttachmentToggle.hide();
@@ -3336,7 +3388,8 @@ public class ConversationParentFragment extends Fragment
initiating,
true,
null,
scheduledDate);
scheduledDate,
null);
}
private void sendSticker(@NonNull StickerRecord stickerRecord, boolean clearCompose) {
@@ -3653,7 +3706,11 @@ public class ConversationParentFragment extends Fragment
@Override
public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {
if (actionId == EditorInfo.IME_ACTION_SEND) {
sendButton.performClick();
if (inputPanel.isInEditMode()) {
sendEditButton.performClick();
} else {
sendButton.performClick();
}
return true;
}
return false;
@@ -3737,7 +3794,13 @@ public class ConversationParentFragment extends Fragment
}
private void handleSaveDraftOnTextChange(@NonNull CharSequence text) {
textDraftSaveDebouncer.publish(() -> draftViewModel.setTextDraft(StringUtil.trimSequence(text).toString(), MentionAnnotation.getMentionsFromAnnotations(text), MessageStyler.getStyling(text)));
textDraftSaveDebouncer.publish(() -> {
if (inputPanel.inEditMessageMode()) {
draftViewModel.setMessageEditDraft(inputPanel.getEditMessageId(), StringUtil.trimSequence(text).toString(), MentionAnnotation.getMentionsFromAnnotations(text), MessageStyler.getStyling(text));
} else {
draftViewModel.setTextDraft(StringUtil.trimSequence(text).toString(), MentionAnnotation.getMentionsFromAnnotations(text), MessageStyler.getStyling(text));
}
});
}
private void handleTypingIndicatorOnTextChange(@NonNull String text) {
@@ -4050,6 +4113,62 @@ public class ConversationParentFragment extends Fragment
inputPanel.clickOnComposeInput();
}
@Override
public void handleEditMessage(@NonNull ConversationMessage conversationMessage) {
if (!FeatureFlags.editMessageSending()) {
return;
}
if (isSearchRequested) {
searchViewItem.collapseActionView();
}
disposables.add(viewModel.resolveMessageToEdit(conversationMessage).subscribe(updatedMessage -> {
inputPanel.enterEditMessageMode(glideRequests, updatedMessage, false);
}));
}
private void handleSendEditMessage() {
if (!FeatureFlags.editMessageSending()) {
Log.w(TAG, "Edit message sending disabled, forcing exit of edit mode");
inputPanel.exitEditMessageMode();
return;
}
if (!inputPanel.inEditMessageMode()) {
Log.w(TAG, "Not in edit message mode, unknown state, forcing re-exit");
inputPanel.exitEditMessageMode();
return;
}
MessageRecord editMessage = inputPanel.getEditMessage();
if (editMessage == null) {
Log.w(TAG, "No edit message found, forcing exit");
inputPanel.exitEditMessageMode();
return;
}
if (!MessageConstraintsUtil.isValidEditMessageSend(editMessage, System.currentTimeMillis())) {
Log.i(TAG, "Edit message no longer valid");
final int editDurationHours = MessageConstraintsUtil.getEditMessageThresholdHours();
Dialogs.showAlertDialog(requireContext(), null, getResources().getQuantityString(R.plurals.ConversationActivity_edit_message_too_old, editDurationHours, editDurationHours));
return;
}
String metricId = recipient.get().isGroup() ? SignalLocalMetrics.GroupMessageSend.start()
: SignalLocalMetrics.IndividualMessageSend.start();
sendMessage(metricId);
}
@Override
public void onEnterEditMode() {
updateToggleButtonState();
}
@Override
public void onExitEditMode() {
updateToggleButtonState();
}
@Override
public void onMessageActionToolbarOpened() {
searchViewItem.collapseActionView();

View File

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

View File

@@ -6,6 +6,7 @@ import android.os.Build;
import androidx.annotation.NonNull;
import androidx.annotation.WorkerThread;
import org.signal.core.util.StreamUtil;
import org.signal.core.util.concurrent.SignalExecutors;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery;
@@ -15,22 +16,28 @@ import org.thoughtcrime.securesms.database.RecipientTable;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.database.ThreadTable;
import org.thoughtcrime.securesms.database.model.GroupRecord;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies;
import org.thoughtcrime.securesms.jobs.MultiDeviceViewedUpdateJob;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.mms.PartAuthority;
import org.thoughtcrime.securesms.mms.TextSlide;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.recipients.RecipientUtil;
import org.thoughtcrime.securesms.util.BubbleUtil;
import org.thoughtcrime.securesms.util.ConversationUtil;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.Util;
import java.io.IOException;
import java.io.InputStream;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Observable;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.schedulers.Schedulers;
@@ -184,6 +191,28 @@ public class ConversationRepository {
}).subscribeOn(Schedulers.io());
}
@NonNull
public Single<ConversationMessage> resolveMessageToEdit(@NonNull ConversationMessage message) {
return Single.fromCallable(() -> {
MessageRecord messageRecord = message.getMessageRecord();
if (MessageRecordUtil.hasTextSlide(messageRecord)) {
TextSlide textSlide = MessageRecordUtil.requireTextSlide(messageRecord);
if (textSlide.getUri() == null) {
return message;
}
try (InputStream stream = PartAuthority.getAttachmentStream(context, textSlide.getUri())) {
String body = StreamUtil.readFullyAsString(stream);
return ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(context, messageRecord, body);
} catch (IOException e) {
Log.w(TAG, "Failed to read text slide data.");
}
}
return message;
}).subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread());
}
Observable<Integer> getUnreadCount(long threadId, long afterTime) {
if (threadId <= -1L || afterTime <= 0L) {
return Observable.just(0);

View File

@@ -58,6 +58,7 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.BackpressureStrategy;
import io.reactivex.rxjava3.core.Flowable;
import io.reactivex.rxjava3.core.Observable;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.processors.PublishProcessor;
@@ -441,6 +442,11 @@ public class ConversationViewModel extends ViewModel {
return LiveDataReactiveStreams.fromPublisher(activeProfile.toFlowable(BackpressureStrategy.LATEST));
}
@NonNull
public Single<ConversationMessage> resolveMessageToEdit(@NonNull ConversationMessage message) {
return conversationRepository.resolveMessageToEdit(message);
}
void setArgs(@NonNull ConversationIntents.Args args) {
this.args = args;
}

View File

@@ -8,6 +8,7 @@ import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.database.model.MmsMessageRecord;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import org.thoughtcrime.securesms.util.MessageConstraintsUtil;
import java.util.Set;
import java.util.stream.Collectors;
@@ -25,6 +26,7 @@ final class MenuState {
private final boolean delete;
private final boolean reactions;
private final boolean paymentDetails;
private final boolean edit;
private MenuState(@NonNull Builder builder) {
forward = builder.forward;
@@ -36,6 +38,7 @@ final class MenuState {
delete = builder.delete;
reactions = builder.reactions;
paymentDetails = builder.paymentDetails;
edit = builder.edit;
}
boolean shouldShowForwardAction() {
@@ -74,6 +77,10 @@ final class MenuState {
return paymentDetails;
}
boolean shouldShowEditAction() {
return edit;
}
static MenuState getMenuState(@NonNull Recipient conversationRecipient,
@NonNull Set<MultiselectPart> selectedParts,
boolean shouldShowMessageRequest,
@@ -152,7 +159,8 @@ final class MenuState {
.shouldShowReplyAction(false)
.shouldShowDetailsAction(false)
.shouldShowSaveAttachmentAction(false)
.shouldShowResendAction(false);
.shouldShowResendAction(false)
.shouldShowEdit(false);
} else {
MessageRecord messageRecord = selectedParts.iterator().next().getMessageRecord();
@@ -169,6 +177,10 @@ final class MenuState {
.shouldShowForwardAction(shouldShowForwardAction)
.shouldShowDetailsAction(!actionMessage && !conversationRecipient.isReleaseNotes())
.shouldShowReplyAction(canReplyToMessage(conversationRecipient, actionMessage, messageRecord, shouldShowMessageRequest, isNonAdminInAnnouncementGroup));
builder.shouldShowEdit(!actionMessage &&
hasText &&
MessageConstraintsUtil.isValidEditMessageSend(messageRecord, System.currentTimeMillis()));
}
return builder.shouldShowCopyAction(!actionMessage && !remoteDelete && hasText && !hasGift && !hasPayment)
@@ -204,23 +216,7 @@ final class MenuState {
}
static boolean isActionMessage(@NonNull MessageRecord messageRecord) {
return messageRecord.isGroupAction() ||
messageRecord.isCallLog() ||
messageRecord.isJoined() ||
messageRecord.isExpirationTimerUpdate() ||
messageRecord.isEndSession() ||
messageRecord.isIdentityUpdate() ||
messageRecord.isIdentityVerified() ||
messageRecord.isIdentityDefault() ||
messageRecord.isProfileChange() ||
messageRecord.isGroupV1MigrationEvent() ||
messageRecord.isChatSessionRefresh() ||
messageRecord.isInMemoryMessageRecord() ||
messageRecord.isChangeNumber() ||
messageRecord.isBoostRequest() ||
messageRecord.isPaymentsRequestToActivate() ||
messageRecord.isPaymentsActivated() ||
messageRecord.isSmsExportType();
return messageRecord.isInMemoryMessageRecord() || messageRecord.isUpdate();
}
private final static class Builder {
@@ -234,6 +230,7 @@ final class MenuState {
private boolean delete;
private boolean reactions;
private boolean paymentDetails;
private boolean edit;
@NonNull Builder shouldShowForwardAction(boolean forward) {
this.forward = forward;
@@ -280,6 +277,11 @@ final class MenuState {
return this;
}
@NonNull Builder shouldShowEdit(boolean edit) {
this.edit = edit;
return this;
}
@NonNull
MenuState build() {
return new MenuState(this);

View File

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

View File

@@ -7,7 +7,9 @@ import android.text.SpannableString
import io.reactivex.rxjava3.core.Maybe
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.StreamUtil
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.components.mention.MentionAnnotation
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory
@@ -21,14 +23,19 @@ import org.thoughtcrime.securesms.database.ThreadTable
import org.thoughtcrime.securesms.database.adjustBodyRanges
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
import org.thoughtcrime.securesms.database.model.Mention
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
import org.thoughtcrime.securesms.mms.PartAuthority
import org.thoughtcrime.securesms.mms.QuoteId
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.Base64
import org.thoughtcrime.securesms.util.concurrent.SerialMonoLifoExecutor
import org.thoughtcrime.securesms.util.hasTextSlide
import org.thoughtcrime.securesms.util.requireTextSlide
import java.io.IOException
import java.util.concurrent.Executor
class DraftRepository(
@@ -38,6 +45,10 @@ class DraftRepository(
private val saveDraftsExecutor: Executor = SerialMonoLifoExecutor(SignalExecutors.BOUNDED)
) {
companion object {
val TAG = Log.tag(DraftRepository::class.java)
}
fun deleteVoiceNoteDraftData(draft: DraftTable.Draft?) {
if (draft != null) {
SignalExecutors.BOUNDED.execute {
@@ -56,7 +67,11 @@ class DraftRepository(
}
draftTable.replaceDrafts(actualThreadId, drafts)
threadTable.updateSnippet(actualThreadId, drafts.getSnippet(context), drafts.getUriSnippet(), System.currentTimeMillis(), MessageTypes.BASE_DRAFT_TYPE, true)
if (drafts.shouldUpdateSnippet()) {
threadTable.updateSnippet(actualThreadId, drafts.getSnippet(context), drafts.getUriSnippet(), System.currentTimeMillis(), MessageTypes.BASE_DRAFT_TYPE, true)
} else {
threadTable.update(actualThreadId, unarchive = false, allowDeletion = false)
}
} else if (threadId > 0) {
draftTable.clearDrafts(threadId)
threadTable.update(threadId, unarchive = false, allowDeletion = false)
@@ -101,5 +116,26 @@ class DraftRepository(
}
}
fun loadDraftMessageEdit(serialized: String): Maybe<ConversationMessage> {
return Maybe.fromCallable {
val messageId = MessageId.deserialize(serialized)
val messageRecord: MessageRecord = SignalDatabase.messages.getMessageRecordOrNull(messageId.id) ?: return@fromCallable null
if (messageRecord.hasTextSlide()) {
val textSlide = messageRecord.requireTextSlide()
if (textSlide.uri != null) {
try {
PartAuthority.getAttachmentStream(context, textSlide.uri!!).use { stream ->
val body = StreamUtil.readFullyAsString(stream)
return@fromCallable ConversationMessageFactory.createWithUnresolvedData(context, messageRecord, body)
}
} catch (e: IOException) {
Log.e(TAG, "Failed to load text slide", e)
}
}
}
ConversationMessageFactory.createWithUnresolvedData(context, messageRecord)
}
}
data class DatabaseDraft(val drafts: Drafts, val updatedText: CharSequence?)
}

View File

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

View File

@@ -11,6 +11,7 @@ import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.database.DraftTable.Draft
import org.thoughtcrime.securesms.database.MentionUtil
import org.thoughtcrime.securesms.database.model.Mention
import org.thoughtcrime.securesms.database.model.MessageId
import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList
import org.thoughtcrime.securesms.mms.QuoteId
import org.thoughtcrime.securesms.recipients.Recipient
@@ -67,6 +68,22 @@ class DraftViewModel @JvmOverloads constructor(
store.update { it.copy(recipientId = recipient.id) }
}
fun setMessageEditDraft(messageId: MessageId, text: String, mentions: List<Mention>, styleBodyRanges: BodyRangeList?) {
store.update {
val mentionRanges: BodyRangeList? = MentionUtil.mentionsToBodyRangeList(mentions)
val bodyRanges: BodyRangeList? = if (styleBodyRanges == null) {
mentionRanges
} else if (mentionRanges == null) {
styleBodyRanges
} else {
styleBodyRanges.toBuilder().addAllRanges(mentionRanges.rangesList).build()
}
saveDrafts(it.copy(textDraft = text.toTextDraft(), bodyRangesDraft = bodyRanges?.toDraft(), messageEditDraft = Draft(Draft.MESSAGE_EDIT, messageId.serialize())))
}
}
fun setTextDraft(text: String, mentions: List<Mention>, styleBodyRanges: BodyRangeList?) {
store.update {
val mentionRanges: BodyRangeList? = MentionUtil.mentionsToBodyRangeList(mentions)
@@ -131,6 +148,12 @@ class DraftViewModel @JvmOverloads constructor(
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
}
fun loadDraftEditMessage(serialized: String): Maybe<ConversationMessage> {
return repository.loadDraftMessageEdit(serialized)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
}
}
private fun String.toTextDraft(): Draft? {

View File

@@ -18,6 +18,7 @@ import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialog
import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager
import org.thoughtcrime.securesms.conversation.ConversationAdapter
import org.thoughtcrime.securesms.conversation.ConversationBottomSheetCallback
import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode
import org.thoughtcrime.securesms.conversation.colors.Colorizer
import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
@@ -72,7 +73,7 @@ class MessageQuotesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment() {
val colorizer = Colorizer()
messageAdapter = ConversationAdapter(requireContext(), viewLifecycleOwner, GlideApp.with(this), Locale.getDefault(), ConversationAdapterListener(), conversationRecipient, colorizer).apply {
setCondensedMode(true)
setCondensedMode(ConversationItemDisplayMode.CONDENSED)
}
val list: RecyclerView = view.findViewById<RecyclerView>(R.id.quotes_list).apply {
@@ -250,6 +251,11 @@ class MessageQuotesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment() {
dismiss()
getAdapterListener().onSendPaymentClicked(recipientId)
}
override fun onEditedIndicatorClicked(messageRecord: MessageRecord) {
dismiss()
getAdapterListener().onEditedIndicatorClicked(messageRecord)
}
}
companion object {

View File

@@ -0,0 +1,161 @@
package org.thoughtcrime.securesms.conversation.ui.edit
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.os.bundleOf
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.LinearLayoutManager
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.core.util.concurrent.LifecycleDisposable
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.conversation.ConversationAdapter
import org.thoughtcrime.securesms.conversation.ConversationBottomSheetCallback
import org.thoughtcrime.securesms.conversation.ConversationItemDisplayMode
import org.thoughtcrime.securesms.conversation.colors.Colorizer
import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
import org.thoughtcrime.securesms.databinding.MessageEditHistoryBottomSheetBinding
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ItemDecoration
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackController
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicy
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionPlayerHolder
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionRecycler
import org.thoughtcrime.securesms.groups.GroupId
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.ViewModelFactory
import org.thoughtcrime.securesms.util.fragments.requireListener
import java.util.Locale
/**
* Show history of edits for a specific message.
*/
class EditMessageHistoryDialog : FixedRoundedCornerBottomSheetDialogFragment() {
override val peekHeightPercentage: Float = 0.4f
private val binding: MessageEditHistoryBottomSheetBinding by ViewBinderDelegate(MessageEditHistoryBottomSheetBinding::bind)
private val messageId: Long by lazy { requireArguments().getLong(ARGUMENT_MESSAGE_ID) }
private val conversationRecipient: Recipient by lazy { Recipient.resolved(requireArguments().getParcelable(ARGUMENT_CONVERSATION_RECIPIENT_ID)!!) }
private val viewModel: EditMessageHistoryViewModel by viewModels(factoryProducer = ViewModelFactory.factoryProducer { EditMessageHistoryViewModel(messageId, conversationRecipient) })
private val disposables: LifecycleDisposable = LifecycleDisposable()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = MessageEditHistoryBottomSheetBinding.inflate(inflater, container, false).root
view.minimumHeight = (resources.displayMetrics.heightPixels * peekHeightPercentage).toInt()
return view
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
disposables.bindTo(viewLifecycleOwner)
val colorizer = Colorizer()
val messageAdapter = ConversationAdapter(
requireContext(),
viewLifecycleOwner,
GlideApp.with(this),
Locale.getDefault(),
ConversationAdapterListener(),
conversationRecipient,
colorizer
).apply {
setCondensedMode(ConversationItemDisplayMode.EXTRA_CONDENSED)
}
binding.editHistoryList.apply {
layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.VERTICAL, false)
adapter = messageAdapter
itemAnimator = null
}
val recyclerViewColorizer = RecyclerViewColorizer(binding.editHistoryList)
disposables += viewModel
.getEditHistory()
.subscribeBy { messages ->
if (messages.isEmpty()) {
dismiss()
}
messageAdapter.submitList(messages)
recyclerViewColorizer.setChatColors(conversationRecipient.chatColors)
}
disposables += viewModel.getNameColorsMap().subscribe { map ->
colorizer.onNameColorsChanged(map)
messageAdapter.notifyItemRangeChanged(0, messageAdapter.itemCount, ConversationAdapter.PAYLOAD_NAME_COLORS)
}
initializeGiphyMp4()
}
private fun initializeGiphyMp4(): GiphyMp4ProjectionRecycler {
val maxPlayback = GiphyMp4PlaybackPolicy.maxSimultaneousPlaybackInConversation()
val holders = GiphyMp4ProjectionPlayerHolder.injectVideoViews(
requireContext(),
viewLifecycleOwner.lifecycle,
binding.videoContainer,
maxPlayback
)
val callback = GiphyMp4ProjectionRecycler(holders)
GiphyMp4PlaybackController.attach(binding.editHistoryList, callback, maxPlayback)
binding.editHistoryList.addItemDecoration(GiphyMp4ItemDecoration(callback) {}, 0)
return callback
}
private inner class ConversationAdapterListener : ConversationAdapter.ItemClickListener by requireListener<ConversationBottomSheetCallback>().getConversationAdapterListener() {
override fun onQuoteClicked(messageRecord: MmsMessageRecord) = Unit
override fun onScheduledIndicatorClicked(view: View, messageRecord: MessageRecord) = Unit
override fun onGroupMemberClicked(recipientId: RecipientId, groupId: GroupId) = Unit
override fun onItemClick(item: MultiselectPart) = Unit
override fun onItemLongClick(itemView: View, item: MultiselectPart) = Unit
override fun onQuotedIndicatorClicked(messageRecord: MessageRecord) = Unit
override fun onReactionClicked(multiselectPart: MultiselectPart, messageId: Long, isMms: Boolean) = Unit
override fun onMessageWithRecaptchaNeededClicked(messageRecord: MessageRecord) = Unit
override fun onGroupMigrationLearnMoreClicked(membershipChange: GroupMigrationMembershipChange) = Unit
override fun onChatSessionRefreshLearnMoreClicked() = Unit
override fun onBadDecryptLearnMoreClicked(author: RecipientId) = Unit
override fun onSafetyNumberLearnMoreClicked(recipient: Recipient) = Unit
override fun onJoinGroupCallClicked() = Unit
override fun onInviteFriendsToGroupClicked(groupId: GroupId.V2) = Unit
override fun onEnableCallNotificationsClicked() = Unit
override fun onCallToAction(action: String) = Unit
override fun onDonateClicked() = Unit
override fun onRecipientNameClicked(target: RecipientId) = Unit
override fun onViewGiftBadgeClicked(messageRecord: MessageRecord) = Unit
override fun onActivatePaymentsClicked() = Unit
override fun onSendPaymentClicked(recipientId: RecipientId) = Unit
override fun onEditedIndicatorClicked(messageRecord: MessageRecord) = Unit
}
companion object {
private const val ARGUMENT_MESSAGE_ID = "message_id"
private const val ARGUMENT_CONVERSATION_RECIPIENT_ID = "recipient_id"
@JvmStatic
fun show(fragmentManager: FragmentManager, threadRecipient: RecipientId, messageId: Long) {
EditMessageHistoryDialog()
.apply {
arguments = bundleOf(
ARGUMENT_MESSAGE_ID to messageId,
ARGUMENT_CONVERSATION_RECIPIENT_ID to threadRecipient
)
}
.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
}

View File

@@ -0,0 +1,48 @@
package org.thoughtcrime.securesms.conversation.ui.edit
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.conversation.ConversationDataSource
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.database.DatabaseObserver
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
object EditMessageHistoryRepository {
fun getEditHistory(messageId: Long): Observable<List<ConversationMessage>> {
return Observable.create { emitter ->
val threadId: Long = SignalDatabase.messages.getThreadIdForMessage(messageId)
if (threadId < 0) {
emitter.onNext(emptyList())
return@create
}
val databaseObserver: DatabaseObserver = ApplicationDependencies.getDatabaseObserver()
val observer = DatabaseObserver.Observer { emitter.onNext(getEditHistorySync(messageId)) }
databaseObserver.registerConversationObserver(threadId, observer)
emitter.setCancellable { databaseObserver.unregisterObserver(observer) }
emitter.onNext(getEditHistorySync(messageId))
}.subscribeOn(Schedulers.io())
}
private fun getEditHistorySync(messageId: Long): List<ConversationMessage> {
val context = ApplicationDependencies.getApplication()
val records = SignalDatabase
.messages
.getMessageEditHistory(messageId)
.toList()
val attachmentHelper = ConversationDataSource.AttachmentHelper()
.apply {
addAll(records)
fetchAttachments()
}
return attachmentHelper
.buildUpdatedModels(context, records)
.map { ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(context, it) }
}
}

View File

@@ -0,0 +1,39 @@
package org.thoughtcrime.securesms.conversation.ui.edit
import androidx.lifecycle.ViewModel
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.conversation.ConversationMessage
import org.thoughtcrime.securesms.conversation.colors.GroupAuthorNameColorHelper
import org.thoughtcrime.securesms.conversation.colors.NameColor
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
/**
* View model to show history of edits for a specific message.
*/
class EditMessageHistoryViewModel(private val messageId: Long, private val conversationRecipient: Recipient) : ViewModel() {
private val groupAuthorNameColorHelper = GroupAuthorNameColorHelper()
fun getEditHistory(): Observable<List<ConversationMessage>> {
return EditMessageHistoryRepository
.getEditHistory(messageId)
.observeOn(AndroidSchedulers.mainThread())
}
fun getNameColorsMap(): Observable<Map<RecipientId, NameColor>> {
return conversationRecipient
.live()
.observable()
.map { recipient ->
if (recipient.groupId.isPresent) {
groupAuthorNameColorHelper.getColorMap(recipient.groupId.get())
} else {
emptyMap()
}
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
}
}

View File

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

View File

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

View File

@@ -141,6 +141,7 @@ class DraftTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
const val QUOTE = "quote"
const val BODY_RANGES = "mention"
const val VOICE_NOTE = "voice_note"
const val MESSAGE_EDIT = "message_edit"
}
}
@@ -159,6 +160,10 @@ class DraftTable(context: Context?, databaseHelper: SignalDatabase?) : DatabaseT
return firstOrNull { it.type == type }
}
fun shouldUpdateSnippet(): Boolean {
return none { it.type == Draft.MESSAGE_EDIT }
}
fun getSnippet(context: Context): String {
val textDraft = getDraftOfType(Draft.TEXT)
return if (textDraft != null) {

View File

@@ -134,6 +134,7 @@ import org.thoughtcrime.securesms.util.Base64
import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.JsonUtils
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.MessageConstraintsUtil
import org.thoughtcrime.securesms.util.TextSecurePreferences
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.isStory
@@ -203,6 +204,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
const val STORY_TYPE = "story_type"
const val PARENT_STORY_ID = "parent_story_id"
const val SCHEDULED_DATE = "scheduled_date"
const val LATEST_REVISION_ID = "latest_revision_id"
const val ORIGINAL_MESSAGE_ID = "original_message_id"
const val REVISION_NUMBER = "revision_number"
const val CREATE_TABLE = """
CREATE TABLE $TABLE_NAME (
@@ -254,12 +258,15 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
$PARENT_STORY_ID INTEGER DEFAULT 0,
$EXPORT_STATE BLOB DEFAULT NULL,
$EXPORTED INTEGER DEFAULT 0,
$SCHEDULED_DATE INTEGER DEFAULT -1
$SCHEDULED_DATE INTEGER DEFAULT -1,
$LATEST_REVISION_ID INTEGER DEFAULT NULL REFERENCES $TABLE_NAME ($ID) ON DELETE CASCADE,
$ORIGINAL_MESSAGE_ID INTEGER DEFAULT NULL REFERENCES $TABLE_NAME ($ID) ON DELETE CASCADE,
$REVISION_NUMBER INTEGER DEFAULT 0
)
"""
private const val INDEX_THREAD_DATE = "message_thread_date_index"
private const val INDEX_THREAD_STORY_SCHEDULED_DATE = "message_thread_story_parent_story_scheduled_date_index"
private const val INDEX_THREAD_STORY_SCHEDULED_DATE_LATEST_REVISION_ID = "message_thread_story_parent_story_scheduled_date_latest_revision_id_index"
@JvmField
val CREATE_INDEXS = arrayOf(
@@ -271,8 +278,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
"CREATE INDEX IF NOT EXISTS message_reactions_unread_index ON $TABLE_NAME ($REACTIONS_UNREAD);",
"CREATE INDEX IF NOT EXISTS message_story_type_index ON $TABLE_NAME ($STORY_TYPE);",
"CREATE INDEX IF NOT EXISTS message_parent_story_id_index ON $TABLE_NAME ($PARENT_STORY_ID);",
"CREATE INDEX IF NOT EXISTS $INDEX_THREAD_STORY_SCHEDULED_DATE ON $TABLE_NAME ($THREAD_ID, $DATE_RECEIVED, $STORY_TYPE, $PARENT_STORY_ID, $SCHEDULED_DATE);",
"CREATE INDEX IF NOT EXISTS message_quote_id_quote_author_scheduled_date_index ON $TABLE_NAME ($QUOTE_ID, $QUOTE_AUTHOR, $SCHEDULED_DATE);",
"CREATE INDEX IF NOT EXISTS $INDEX_THREAD_STORY_SCHEDULED_DATE_LATEST_REVISION_ID ON $TABLE_NAME ($THREAD_ID, $DATE_RECEIVED, $STORY_TYPE, $PARENT_STORY_ID, $SCHEDULED_DATE, $LATEST_REVISION_ID);",
"CREATE INDEX IF NOT EXISTS message_quote_id_quote_author_scheduled_date_latest_revision_id_index ON $TABLE_NAME ($QUOTE_ID, $QUOTE_AUTHOR, $SCHEDULED_DATE, $LATEST_REVISION_ID);",
"CREATE INDEX IF NOT EXISTS message_exported_index ON $TABLE_NAME ($EXPORTED);",
"CREATE INDEX IF NOT EXISTS message_id_type_payment_transactions_index ON $TABLE_NAME ($ID,$TYPE) WHERE $TYPE & ${MessageTypes.SPECIAL_TYPE_PAYMENTS_NOTIFICATION} != 0;"
)
@@ -323,7 +330,10 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
MESSAGE_RANGES,
STORY_TYPE,
PARENT_STORY_ID,
SCHEDULED_DATE
SCHEDULED_DATE,
LATEST_REVISION_ID,
ORIGINAL_MESSAGE_ID,
REVISION_NUMBER
)
private val MMS_PROJECTION: Array<String> = MMS_PROJECTION_BASE + "NULL AS ${AttachmentTable.ATTACHMENT_JSON_ALIAS}"
@@ -380,7 +390,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
$TYPE & ${MessageTypes.GROUP_V2_LEAVE_BITS} != ${MessageTypes.GROUP_V2_LEAVE_BITS} AND
$STORY_TYPE = 0 AND
$PARENT_STORY_ID <= 0 AND
$SCHEDULED_DATE = -1 AND
$SCHEDULED_DATE = -1 AND
$LATEST_REVISION_ID IS NULL AND
$TYPE NOT IN (
${MessageTypes.PROFILE_CHANGE_TYPE},
${MessageTypes.GV1_MIGRATION_TYPE},
@@ -965,8 +976,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
}
@JvmOverloads
fun insertMessageInbox(message: IncomingTextMessage, type: Long = MessageTypes.BASE_INBOX_TYPE): Optional<InsertResult> {
var type = type
fun insertMessageInbox(message: IncomingTextMessage, editedMessage: MediaMmsMessageRecord? = null): Optional<InsertResult> {
var type = MessageTypes.BASE_INBOX_TYPE
var tryToCollapseJoinRequestEvents = false
if (message.isJoined) {
@@ -1059,13 +1070,19 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
values.put(THREAD_ID, threadId)
values.put(SERVER_GUID, message.serverGuid)
if (editedMessage != null) {
values.put(ORIGINAL_MESSAGE_ID, editedMessage.getOriginalOrOwnMessageId().id)
} else {
values.putNull(ORIGINAL_MESSAGE_ID)
}
return if (message.isPush && isDuplicate(message, threadId)) {
Log.w(TAG, "Duplicate message (" + message.sentTimestampMillis + "), ignoring...")
Optional.empty()
} else {
val messageId = writableDatabase.insert(TABLE_NAME, null, values)
if (unread) {
if (unread && editedMessage == null) {
threads.incrementUnread(threadId, 1, 0)
}
@@ -1084,6 +1101,48 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
}
}
fun insertEditMessageInbox(threadId: Long, mediaMessage: IncomingMediaMessage, targetMessage: MediaMmsMessageRecord): Optional<InsertResult> {
val insertResult = insertSecureDecryptedMessageInbox(mediaMessage, threadId, targetMessage)
if (insertResult.isPresent) {
val (messageId) = insertResult.get()
if (targetMessage.expireStarted > 0) {
markExpireStarted(messageId, targetMessage.expireStarted)
}
writableDatabase.update(TABLE_NAME)
.values(LATEST_REVISION_ID to messageId)
.where("$ID = ? OR $LATEST_REVISION_ID = ?", targetMessage.id, targetMessage.id)
.run()
reactions.moveReactionsToNewMessage(newMessageId = messageId, previousId = targetMessage.id)
}
return insertResult
}
fun insertEditMessageInbox(textMessage: IncomingTextMessage, targetMessage: MediaMmsMessageRecord): Optional<InsertResult> {
val insertResult = insertMessageInbox(textMessage, targetMessage)
if (insertResult.isPresent) {
val (messageId) = insertResult.get()
if (targetMessage.expireStarted > 0) {
markExpireStarted(messageId, targetMessage.expireStarted)
}
writableDatabase.update(TABLE_NAME)
.values(LATEST_REVISION_ID to messageId)
.where("$ID_WHERE OR $LATEST_REVISION_ID = ?", targetMessage.id, targetMessage.id)
.run()
reactions.moveReactionsToNewMessage(newMessageId = messageId, previousId = targetMessage.id)
}
return insertResult
}
fun insertProfileNameChangeMessages(recipient: Recipient, newProfileName: String, previousProfileName: String) {
writableDatabase.withinTransaction { db ->
val groupRecords = groups.getGroupsContainingMember(recipient.id, false)
@@ -1676,7 +1735,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
fun getScheduledMessageCountForThread(threadId: Long): Int {
return readableDatabase
.select("COUNT(*)")
.from("$TABLE_NAME INDEXED BY $INDEX_THREAD_STORY_SCHEDULED_DATE")
.from("$TABLE_NAME INDEXED BY $INDEX_THREAD_STORY_SCHEDULED_DATE_LATEST_REVISION_ID")
.where("$THREAD_ID = ? AND $STORY_TYPE = ? AND $PARENT_STORY_ID <= ? AND $SCHEDULED_DATE != ?", threadId, 0, 0, -1)
.run()
.readToSingleInt()
@@ -1685,8 +1744,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
fun getMessageCountForThread(threadId: Long): Int {
return readableDatabase
.select("COUNT(*)")
.from("$TABLE_NAME INDEXED BY $INDEX_THREAD_STORY_SCHEDULED_DATE")
.where("$THREAD_ID = ? AND $STORY_TYPE = ? AND $PARENT_STORY_ID <= ? AND $SCHEDULED_DATE = ?", threadId, 0, 0, -1)
.from("$TABLE_NAME INDEXED BY $INDEX_THREAD_STORY_SCHEDULED_DATE_LATEST_REVISION_ID")
.where("$THREAD_ID = ? AND $STORY_TYPE = ? AND $PARENT_STORY_ID <= ? AND $SCHEDULED_DATE = ? AND $LATEST_REVISION_ID IS NULL", threadId, 0, 0, -1)
.run()
.readToSingleInt()
}
@@ -1694,8 +1753,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
fun getMessageCountForThread(threadId: Long, beforeTime: Long): Int {
return readableDatabase
.select("COUNT(*)")
.from("$TABLE_NAME INDEXED BY $INDEX_THREAD_STORY_SCHEDULED_DATE")
.where("$THREAD_ID = ? AND $DATE_RECEIVED < ? AND $STORY_TYPE = ? AND $PARENT_STORY_ID <= ? AND $SCHEDULED_DATE = ?", threadId, beforeTime, 0, 0, -1)
.from("$TABLE_NAME INDEXED BY $INDEX_THREAD_STORY_SCHEDULED_DATE_LATEST_REVISION_ID")
.where("$THREAD_ID = ? AND $DATE_RECEIVED < ? AND $STORY_TYPE = ? AND $PARENT_STORY_ID <= ? AND $SCHEDULED_DATE = ? AND $LATEST_REVISION_ID IS NULL", threadId, beforeTime, 0, 0, -1)
.run()
.readToSingleInt()
}
@@ -1746,6 +1805,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
val query = """
$THREAD_ID = ? AND
$STORY_TYPE = 0 AND
$LATEST_REVISION_ID IS NULL AND
$PARENT_STORY_ID <= 0 AND
(
NOT $TYPE & ${MessageTypes.IGNORABLE_TYPESMASK_WHEN_COUNTING} AND
@@ -1844,11 +1904,31 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
}
}
private fun getOriginalEditedMessageRecord(messageId: Long): Long {
return readableDatabase.select(ID)
.from(TABLE_NAME)
.where("$TABLE_NAME.$LATEST_REVISION_ID = ?", messageId)
.orderBy("$ID DESC")
.limit(1)
.run()
.readToSingleLong(0)
}
fun getMessages(messageIds: Collection<Long?>): MmsReader {
val ids = TextUtils.join(",", messageIds)
return mmsReaderFor(rawQueryWithAttachments("$TABLE_NAME.$ID IN ($ids)", null))
}
fun getMessageEditHistory(id: Long): MmsReader {
val cursor = readableDatabase.select(*MMS_PROJECTION)
.from(TABLE_NAME)
.where("$TABLE_NAME.$ID = ? OR $TABLE_NAME.$LATEST_REVISION_ID = ?", id, id)
.orderBy("$TABLE_NAME.$ID DESC")
.run()
return mmsReaderFor(cursor)
}
private fun updateMailboxBitmask(id: Long, maskOff: Long, maskOn: Long, threadId: Optional<Long>) {
writableDatabase.withinTransaction { db ->
db.execSQL(
@@ -2378,6 +2458,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
null
}
val editedMessage = getOriginalEditedMessageRecord(messageId)
OutgoingMessage(
recipient = threadRecipient,
body = body,
@@ -2399,7 +2481,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
giftBadge = giftBadge,
isSecure = MessageTypes.isSecureType(outboxType),
bodyRanges = messageRanges,
scheduledDate = scheduledDate
scheduledDate = scheduledDate,
messageToEdit = editedMessage
)
}
} ?: throw NoSuchMessageException("No record found for id: $messageId")
@@ -2410,7 +2493,8 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
retrieved: IncomingMediaMessage,
contentLocation: String,
candidateThreadId: Long,
mailbox: Long
mailbox: Long,
editedMessage: MediaMmsMessageRecord?
): Optional<InsertResult> {
val threadId = if (candidateThreadId == -1L || retrieved.isGroupMessage) {
getThreadIdFor(retrieved)
@@ -2443,7 +2527,10 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
PARENT_STORY_ID to if (retrieved.parentStoryId != null) retrieved.parentStoryId.serialize() else 0,
READ to if (silentUpdate || retrieved.isExpirationUpdate) 1 else 0,
UNIDENTIFIED to retrieved.isUnidentified,
SERVER_GUID to retrieved.serverGuid
SERVER_GUID to retrieved.serverGuid,
LATEST_REVISION_ID to null,
ORIGINAL_MESSAGE_ID to editedMessage?.getOriginalOrOwnMessageId()?.id,
REVISION_NUMBER to (editedMessage?.revisionNumber?.inc() ?: 0)
)
val quoteAttachments: MutableList<Attachment> = mutableListOf()
@@ -2483,6 +2570,24 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
unarchive = true
)
if (editedMessage != null) {
if (retrieved.quote != null && editedMessage.quote != null) {
writableDatabase.execSQL(
"""
WITH o as (SELECT $QUOTE_ID, $QUOTE_AUTHOR, $QUOTE_BODY, $QUOTE_TYPE, $QUOTE_MISSING, $QUOTE_BODY_RANGES FROM $TABLE_NAME WHERE $ID = ${editedMessage.id})
UPDATE $TABLE_NAME
SET $QUOTE_ID = old.$QUOTE_ID, $QUOTE_AUTHOR = old.$QUOTE_AUTHOR, $QUOTE_BODY = old.$QUOTE_BODY, $QUOTE_TYPE = old.$QUOTE_TYPE, $QUOTE_MISSING = old.$QUOTE_MISSING, $QUOTE_BODY_RANGES = old.$QUOTE_BODY_RANGES
FROM o old
WHERE $TABLE_NAME.$ID = $messageId
"""
)
}
}
if (retrieved.attachments.isEmpty() && editedMessage?.id != null && attachments.getAttachmentsForMessage(editedMessage.id).isNotEmpty()) {
attachments.duplicateAttachmentsForMessage(messageId, editedMessage.id)
}
val isNotStoryGroupReply = retrieved.parentStoryId == null || !retrieved.parentStoryId.isGroupReply()
if (!MessageTypes.isPaymentsActivated(mailbox) && !MessageTypes.isPaymentsRequestToActivate(mailbox) && !MessageTypes.isExpirationTimerUpdate(mailbox) && !retrieved.storyType.isStory && isNotStoryGroupReply) {
@@ -2528,11 +2633,12 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
type = type or MessageTypes.SPECIAL_TYPE_PAYMENTS_ACTIVATED
}
return insertMessageInbox(retrieved, contentLocation, threadId, type)
return insertMessageInbox(retrieved, contentLocation, threadId, type, editedMessage = null)
}
@JvmOverloads
@Throws(MmsException::class)
fun insertSecureDecryptedMessageInbox(retrieved: IncomingMediaMessage, threadId: Long): Optional<InsertResult> {
fun insertSecureDecryptedMessageInbox(retrieved: IncomingMediaMessage, threadId: Long, edittedMediaMessage: MediaMmsMessageRecord? = null): Optional<InsertResult> {
var type = MessageTypes.BASE_INBOX_TYPE or MessageTypes.SECURE_MESSAGE_BIT
var hasSpecialType = false
@@ -2581,7 +2687,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
hasSpecialType = true
}
return insertMessageInbox(retrieved, "", threadId, type)
return insertMessageInbox(retrieved, "", threadId, type, edittedMediaMessage)
}
fun insertMessageInbox(notification: NotificationInd, subscriptionId: Int): Pair<Long, Long> {
@@ -2836,15 +2942,27 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
Log.w(TAG, "Found early delivery receipts for " + message.sentTimeMillis + ". Applying them.")
}
var editedMessage: MessageRecord? = null
if (message.isMessageEdit) {
try {
editedMessage = getMessageRecord(message.messageToEdit)
if (!MessageConstraintsUtil.isValidEditMessageSend(editedMessage)) {
throw MmsException("Message is not valid to edit")
}
} catch (e: NoSuchMessageException) {
throw MmsException("Unable to locate edited message", e)
}
}
val contentValues = ContentValues()
contentValues.put(DATE_SENT, message.sentTimeMillis)
contentValues.put(MMS_MESSAGE_TYPE, PduHeaders.MESSAGE_TYPE_SEND_REQ)
contentValues.put(TYPE, type)
contentValues.put(THREAD_ID, threadId)
contentValues.put(READ, 1)
contentValues.put(DATE_RECEIVED, System.currentTimeMillis())
contentValues.put(DATE_RECEIVED, editedMessage?.dateReceived ?: System.currentTimeMillis())
contentValues.put(SMS_SUBSCRIPTION_ID, message.subscriptionId)
contentValues.put(EXPIRES_IN, message.expiresIn)
contentValues.put(EXPIRES_IN, editedMessage?.expiresIn ?: message.expiresIn)
contentValues.put(VIEW_ONCE, message.isViewOnce)
contentValues.put(FROM_RECIPIENT_ID, Recipient.self().id.serialize())
contentValues.put(FROM_DEVICE_ID, SignalStore.account().deviceId)
@@ -2854,6 +2972,14 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
contentValues.put(STORY_TYPE, message.storyType.code)
contentValues.put(PARENT_STORY_ID, if (message.parentStoryId != null) message.parentStoryId.serialize() else 0)
contentValues.put(SCHEDULED_DATE, message.scheduledDate)
contentValues.putNull(LATEST_REVISION_ID)
if (editedMessage != null) {
contentValues.put(ORIGINAL_MESSAGE_ID, editedMessage.getOriginalOrOwnMessageId().id)
contentValues.put(REVISION_NUMBER, editedMessage.revisionNumber + 1)
} else {
contentValues.putNull(ORIGINAL_MESSAGE_ID)
}
if (message.threadRecipient.isSelf && hasAudioAttachment(message.attachments)) {
contentValues.put(VIEWED_RECEIPT_COUNT, 1L)
@@ -2935,10 +3061,19 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
}
}
if (message.messageToEdit > 0) {
writableDatabase.update(TABLE_NAME)
.values(LATEST_REVISION_ID to messageId)
.where("$ID_WHERE OR $LATEST_REVISION_ID = ?", message.messageToEdit, message.messageToEdit)
.run()
reactions.moveReactionsToNewMessage(messageId, message.messageToEdit)
}
threads.updateLastSeenAndMarkSentAndLastScrolledSilenty(threadId)
if (!message.storyType.isStory) {
if (message.outgoingQuote == null) {
if (message.outgoingQuote == null && editedMessage == null) {
ApplicationDependencies.getDatabaseObserver().notifyMessageInsertObservers(threadId, MessageId(messageId))
} else {
ApplicationDependencies.getDatabaseObserver().notifyConversationListeners(threadId)
@@ -3273,6 +3408,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
reader.filterNotNull()
}
}
fun getAllRateLimitedMessageIds(): Set<Long> {
val db = databaseHelper.signalReadableDatabase
val where = "(" + TYPE + " & " + MessageTypes.TOTAL_MASK + " & " + MessageTypes.MESSAGE_RATE_LIMITED_BIT + ") > 0"
@@ -3389,7 +3525,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
}
private fun getMessagesInThreadAfterInclusive(threadId: Long, timestamp: Long, limit: Long): List<MessageRecord> {
val where = "$TABLE_NAME.$THREAD_ID = ? AND $TABLE_NAME.$DATE_RECEIVED >= ? AND $TABLE_NAME.$SCHEDULED_DATE = -1"
val where = "$TABLE_NAME.$THREAD_ID = ? AND $TABLE_NAME.$DATE_RECEIVED >= ? AND $TABLE_NAME.$SCHEDULED_DATE = -1 AND $TABLE_NAME.$LATEST_REVISION_ID IS NULL"
val args = buildArgs(threadId, timestamp)
return mmsReaderFor(rawQueryWithAttachments(where, args, false, limit)).use { reader ->
@@ -3858,7 +3994,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
fun getAllMessagesThatQuote(id: MessageId): List<MessageRecord> {
val targetMessage: MessageRecord = getMessageRecord(id.id)
val query = "$QUOTE_ID = ${targetMessage.dateSent} AND $QUOTE_AUTHOR = ${targetMessage.fromRecipient.id.serialize()} AND $SCHEDULED_DATE = -1"
val query = "$QUOTE_ID = ${targetMessage.dateSent} AND $QUOTE_AUTHOR = ${targetMessage.fromRecipient.id.serialize()} AND $SCHEDULED_DATE = -1 AND $LATEST_REVISION_ID IS NULL"
val order = "$DATE_RECEIVED DESC"
val records: MutableList<MessageRecord> = ArrayList()
@@ -3873,8 +4009,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
}
fun getQuotedMessagePosition(threadId: Long, quoteId: Long, authorId: RecipientId): Int {
var position = 0
readableDatabase
.select(DATE_SENT, FROM_RECIPIENT_ID, REMOTE_DELETED)
.select(DATE_SENT, FROM_RECIPIENT_ID, REMOTE_DELETED, LATEST_REVISION_ID)
.from(TABLE_NAME)
.where("$THREAD_ID = $threadId AND $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1")
.orderBy("$DATE_RECEIVED DESC")
@@ -3887,8 +4024,10 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
return if (cursor.requireBoolean(REMOTE_DELETED)) {
-1
} else {
cursor.position
position
}
} else if (cursor.requireLong(LATEST_REVISION_ID) == 0L) {
position++
}
}
@@ -3899,7 +4038,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
readableDatabase
.select(DATE_RECEIVED, FROM_RECIPIENT_ID, REMOTE_DELETED)
.from(TABLE_NAME)
.where("$THREAD_ID = $threadId AND $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1")
.where("$THREAD_ID = $threadId AND $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1 AND $LATEST_REVISION_ID IS NULL")
.orderBy("$DATE_RECEIVED DESC")
.run()
.forEach { cursor ->
@@ -3938,10 +4077,10 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
if (groupStoryId > 0) {
order = "$DATE_RECEIVED ASC"
selection = "$THREAD_ID = $threadId AND $DATE_RECEIVED < $receivedTimestamp AND $STORY_TYPE = 0 AND $PARENT_STORY_ID = $groupStoryId AND $SCHEDULED_DATE = -1"
selection = "$THREAD_ID = $threadId AND $DATE_RECEIVED < $receivedTimestamp AND $STORY_TYPE = 0 AND $PARENT_STORY_ID = $groupStoryId AND $SCHEDULED_DATE = -1 AND $LATEST_REVISION_ID IS NULL"
} else {
order = "$DATE_RECEIVED DESC"
selection = "$THREAD_ID = $threadId AND $DATE_RECEIVED > $receivedTimestamp AND $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1"
selection = "$THREAD_ID = $threadId AND $DATE_RECEIVED > $receivedTimestamp AND $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1 AND $LATEST_REVISION_ID IS NULL"
}
return readableDatabase
@@ -3957,7 +4096,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
return readableDatabase
.select(DATE_RECEIVED)
.from(TABLE_NAME)
.where("$DATE_RECEIVED > $date AND $SCHEDULED_DATE = -1")
.where("$DATE_RECEIVED > $date AND $SCHEDULED_DATE = -1 AND $LATEST_REVISION_ID IS NULL")
.orderBy("$DATE_RECEIVED ASC")
.limit(1)
.run()
@@ -3968,7 +4107,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
return readableDatabase
.select("COUNT(*)")
.from(TABLE_NAME)
.where("$DATE_RECEIVED < $date AND $SCHEDULED_DATE = -1")
.where("$DATE_RECEIVED < $date AND $SCHEDULED_DATE = -1 AND $LATEST_REVISION_ID IS NULL")
.run()
.readToSingleInt()
}
@@ -3986,7 +4125,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
return readableDatabase
.select("COUNT(*)")
.from(TABLE_NAME)
.where("$THREAD_ID = $threadId AND $DATE_RECEIVED >= $timestamp AND $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1")
.where("$THREAD_ID = $threadId AND $DATE_RECEIVED >= $timestamp AND $STORY_TYPE = 0 AND $PARENT_STORY_ID <= 0 AND $SCHEDULED_DATE = -1 AND $LATEST_REVISION_ID IS NULL")
.run()
.readToSingleInt()
}
@@ -4017,7 +4156,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
return readableDatabase
.select("COUNT(*)")
.from("$TABLE_NAME INDEXED BY $INDEX_THREAD_DATE")
.where("$READ = 0 AND $STORY_TYPE = 0 AND $THREAD_ID = $threadId AND $PARENT_STORY_ID <= 0")
.where("$READ = 0 AND $STORY_TYPE = 0 AND $THREAD_ID = $threadId AND $PARENT_STORY_ID <= 0 AND $LATEST_REVISION_ID IS NULL")
.run()
.readToSingleInt()
}
@@ -4410,7 +4549,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
return readableDatabase
.select(*MMS_PROJECTION)
.from(TABLE_NAME)
.where("$THREAD_ID = ? AND $STORY_TYPE = ? AND $PARENT_STORY_ID <= ? AND $SCHEDULED_DATE = ?", threadId, 0, 0, -1)
.where("$THREAD_ID = ? AND $STORY_TYPE = ? AND $PARENT_STORY_ID <= ? AND $SCHEDULED_DATE = ? AND $LATEST_REVISION_ID IS NULL", threadId, 0, 0, -1)
.orderBy("$DATE_RECEIVED DESC")
.limit(limitStr)
.run()
@@ -4422,7 +4561,7 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
fun getScheduledMessagesInThread(threadId: Long): List<MessageRecord> {
val cursor = readableDatabase
.select(*MMS_PROJECTION)
.from("$TABLE_NAME INDEXED BY $INDEX_THREAD_STORY_SCHEDULED_DATE")
.from("$TABLE_NAME INDEXED BY $INDEX_THREAD_STORY_SCHEDULED_DATE_LATEST_REVISION_ID")
.where("$THREAD_ID = ? AND $STORY_TYPE = ? AND $PARENT_STORY_ID <= ? AND $SCHEDULED_DATE != ?", threadId, 0, 0, -1)
.orderBy("$SCHEDULED_DATE DESC, $ID DESC")
.run()
@@ -4690,6 +4829,10 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
}
}
private fun MessageRecord.getOriginalOrOwnMessageId(): MessageId {
return this.originalMessageId ?: MessageId(this.id)
}
protected enum class ReceiptType(val columnName: String, val groupStatus: Int) {
READ(READ_RECEIPT_COUNT, GroupReceiptTable.STATUS_READ),
DELIVERY(DELIVERY_RECEIPT_COUNT, GroupReceiptTable.STATUS_DELIVERED),
@@ -4974,6 +5117,9 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
val storyType = StoryType.fromCode(cursor.requireInt(STORY_TYPE))
val parentStoryId = ParentStoryId.deserialize(cursor.requireLong(PARENT_STORY_ID))
val scheduledDate = cursor.requireLong(SCHEDULED_DATE)
val latestRevisionId: MessageId? = cursor.requireLong(LATEST_REVISION_ID).let { if (it == 0L) null else MessageId(it) }
val originalMessageId: MessageId? = cursor.requireLong(ORIGINAL_MESSAGE_ID).let { if (it == 0L) null else MessageId(it) }
val editCount = cursor.requireInt(REVISION_NUMBER)
if (!TextSecurePreferences.isReadReceiptsEnabled(context)) {
readReceiptCount = 0
@@ -5057,7 +5203,10 @@ open class MessageTable(context: Context?, databaseHelper: SignalDatabase) : Dat
giftBadge,
null,
null,
scheduledDate
scheduledDate,
latestRevisionId,
originalMessageId,
editCount
)
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,189 @@
package org.thoughtcrime.securesms.database.helpers.migration
import android.app.Application
import net.zetetic.database.sqlcipher.SQLiteDatabase
import org.signal.core.util.Stopwatch
import org.signal.core.util.logging.Log
import org.signal.core.util.readToList
import org.signal.core.util.requireNonNullString
/**
* Changes needed for edit message. New foreign keys require recreating the table.
*/
@Suppress("ClassName")
object V186_AddEditMessageColumnsMigration : SignalDatabaseMigration {
private val TAG = Log.tag(V186_AddEditMessageColumnsMigration::class.java)
override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
val stopwatch = Stopwatch("migration")
val dependentItems: List<SqlItem> = getAllDependentItems(db, "message")
dependentItems.forEach { item ->
val sql = "DROP ${item.type} IF EXISTS ${item.name}"
Log.d(TAG, "Executing: $sql")
db.execSQL(sql)
}
stopwatch.split("drop-dependents")
db.execSQL(
"""
CREATE TABLE message_tmp (
_id INTEGER PRIMARY KEY AUTOINCREMENT,
date_sent INTEGER NOT NULL,
date_received INTEGER NOT NULL,
date_server INTEGER DEFAULT -1,
thread_id INTEGER NOT NULL REFERENCES thread (_id) ON DELETE CASCADE,
from_recipient_id INTEGER NOT NULL REFERENCES recipient (_id) ON DELETE CASCADE,
from_device_id INTEGER,
to_recipient_id INTEGER NOT NULL REFERENCES recipient (_id) ON DELETE CASCADE,
type INTEGER NOT NULL,
body TEXT,
read INTEGER DEFAULT 0,
ct_l TEXT,
exp INTEGER,
m_type INTEGER,
m_size INTEGER,
st INTEGER,
tr_id TEXT,
subscription_id INTEGER DEFAULT -1,
receipt_timestamp INTEGER DEFAULT -1,
delivery_receipt_count INTEGER DEFAULT 0,
read_receipt_count INTEGER DEFAULT 0,
viewed_receipt_count INTEGER DEFAULT 0,
mismatched_identities TEXT DEFAULT NULL,
network_failures TEXT DEFAULT NULL,
expires_in INTEGER DEFAULT 0,
expire_started INTEGER DEFAULT 0,
notified INTEGER DEFAULT 0,
quote_id INTEGER DEFAULT 0,
quote_author INTEGER DEFAULT 0,
quote_body TEXT DEFAULT NULL,
quote_missing INTEGER DEFAULT 0,
quote_mentions BLOB DEFAULT NULL,
quote_type INTEGER DEFAULT 0,
shared_contacts TEXT DEFAULT NULL,
unidentified INTEGER DEFAULT 0,
link_previews TEXT DEFAULT NULL,
view_once INTEGER DEFAULT 0,
reactions_unread INTEGER DEFAULT 0,
reactions_last_seen INTEGER DEFAULT -1,
remote_deleted INTEGER DEFAULT 0,
mentions_self INTEGER DEFAULT 0,
notified_timestamp INTEGER DEFAULT 0,
server_guid TEXT DEFAULT NULL,
message_ranges BLOB DEFAULT NULL,
story_type INTEGER DEFAULT 0,
parent_story_id INTEGER DEFAULT 0,
export_state BLOB DEFAULT NULL,
exported INTEGER DEFAULT 0,
scheduled_date INTEGER DEFAULT -1,
latest_revision_id INTEGER DEFAULT NULL REFERENCES message (_id) ON DELETE CASCADE,
original_message_id INTEGER DEFAULT NULL REFERENCES message (_id) ON DELETE CASCADE,
revision_number INTEGER DEFAULT 0
)
"""
)
stopwatch.split("create-table")
db.execSQL(
"""
INSERT INTO message_tmp
SELECT
_id,
date_sent,
date_received,
date_server,
thread_id,
from_recipient_id,
from_device_id,
to_recipient_id,
type,
body,
read,
ct_l,
exp,
m_type,
m_size,
st,
tr_id,
subscription_id,
receipt_timestamp,
delivery_receipt_count,
read_receipt_count,
viewed_receipt_count,
mismatched_identities,
network_failures,
expires_in,
expire_started,
notified,
quote_id,
quote_author,
quote_body,
quote_missing,
quote_mentions,
quote_type,
shared_contacts,
unidentified,
link_previews,
view_once,
reactions_unread,
reactions_last_seen,
remote_deleted,
mentions_self,
notified_timestamp,
server_guid,
message_ranges,
story_type,
parent_story_id,
export_state,
exported,
scheduled_date,
NULL AS latest_revision_id,
NULL AS original_message_id,
0 as revision_number
FROM message
"""
)
stopwatch.split("copy-data")
db.execSQL("DROP TABLE message")
stopwatch.split("drop-old")
db.execSQL("ALTER TABLE message_tmp RENAME TO message")
stopwatch.split("rename-table")
dependentItems.forEach { item ->
val sql = when (item.name) {
"message_thread_story_parent_story_scheduled_date_index" -> "CREATE INDEX message_thread_story_parent_story_scheduled_date_latest_revision_id_index ON message (thread_id, date_received, story_type, parent_story_id, scheduled_date, latest_revision_id)"
"message_quote_id_quote_author_scheduled_date_index" -> "CREATE INDEX message_quote_id_quote_author_scheduled_date_latest_revision_id_index ON message (quote_id, quote_author, scheduled_date, latest_revision_id)"
else -> item.createStatement
}
Log.d(TAG, "Executing: $sql")
db.execSQL(sql)
}
stopwatch.split("recreate-dependents")
db.execSQL("PRAGMA foreign_key_check")
stopwatch.split("fk-check")
stopwatch.stop(TAG)
}
private fun getAllDependentItems(db: SQLiteDatabase, tableName: String): List<SqlItem> {
return db.rawQuery("SELECT type, name, sql FROM sqlite_schema WHERE tbl_name='$tableName' AND type != 'table'").readToList { cursor ->
SqlItem(
type = cursor.requireNonNullString("type"),
name = cursor.requireNonNullString("name"),
createStatement = cursor.requireNonNullString("sql")
)
}
}
data class SqlItem(
val type: String,
val name: String,
val createStatement: String
)
}

View File

@@ -54,7 +54,9 @@ public class InMemoryMessageRecord extends MessageRecord {
false,
0,
0,
-1);
-1,
null,
0);
}
@Override

View File

@@ -70,6 +70,7 @@ public class MediaMmsMessageRecord extends MmsMessageRecord {
private final Payment payment;
private final CallTable.Call call;
private final long scheduledDate;
private final MessageId latestRevisionId;
public MediaMmsMessageRecord(long id,
Recipient fromRecipient,
@@ -106,18 +107,22 @@ public class MediaMmsMessageRecord extends MmsMessageRecord {
@Nullable GiftBadge giftBadge,
@Nullable Payment payment,
@Nullable CallTable.Call call,
long scheduledDate)
long scheduledDate,
@Nullable MessageId latestRevisionId,
@Nullable MessageId originalMessageId,
int revisionNumber)
{
super(id, body, fromRecipient, fromDeviceId, toRecipient, dateSent,
dateReceived, dateServer, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox, mismatches, failures,
subscriptionId, expiresIn, expireStarted, viewOnce, slideDeck,
readReceiptCount, quote, contacts, linkPreviews, unidentified, reactions, remoteDelete, notifiedTimestamp, viewedReceiptCount, receiptTimestamp,
storyType, parentStoryId, giftBadge);
this.mentionsSelf = mentionsSelf;
this.messageRanges = messageRanges;
this.payment = payment;
this.call = call;
this.scheduledDate = scheduledDate;
storyType, parentStoryId, giftBadge, originalMessageId, revisionNumber);
this.mentionsSelf = mentionsSelf;
this.messageRanges = messageRanges;
this.payment = payment;
this.call = call;
this.scheduledDate = scheduledDate;
this.latestRevisionId = latestRevisionId;
}
@Override
@@ -204,18 +209,24 @@ public class MediaMmsMessageRecord extends MmsMessageRecord {
return scheduledDate;
}
public @Nullable MessageId getLatestRevisionId() {
return latestRevisionId;
}
public @NonNull MediaMmsMessageRecord withReactions(@NonNull List<ReactionRecord> reactions) {
return new MediaMmsMessageRecord(getId(), getFromRecipient(), getFromDeviceId(), getToRecipient(), getDateSent(), getDateReceived(), getServerTimestamp(), getDeliveryReceiptCount(), getThreadId(), getBody(), getSlideDeck(),
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), isViewOnce(),
getReadReceiptCount(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), reactions, isRemoteDelete(), mentionsSelf,
getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getScheduledDate());
getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getScheduledDate(), getLatestRevisionId(),
getOriginalMessageId(), getRevisionNumber());
}
public @NonNull MediaMmsMessageRecord withoutQuote() {
return new MediaMmsMessageRecord(getId(), getFromRecipient(), getFromDeviceId(), getToRecipient(), getDateSent(), getDateReceived(), getServerTimestamp(), getDeliveryReceiptCount(), getThreadId(), getBody(), getSlideDeck(),
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), isViewOnce(),
getReadReceiptCount(), null, getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf,
getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getScheduledDate());
getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getScheduledDate(), getLatestRevisionId(),
getOriginalMessageId(), getRevisionNumber());
}
public @NonNull MediaMmsMessageRecord withAttachments(@NonNull Context context, @NonNull List<DatabaseAttachment> attachments) {
@@ -236,14 +247,16 @@ public class MediaMmsMessageRecord extends MmsMessageRecord {
return new MediaMmsMessageRecord(getId(), getFromRecipient(), getFromDeviceId(), getToRecipient(), getDateSent(), getDateReceived(), getServerTimestamp(), getDeliveryReceiptCount(), getThreadId(), getBody(), slideDeck,
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), isViewOnce(),
getReadReceiptCount(), quote, contacts, linkPreviews, isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf,
getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getScheduledDate());
getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getScheduledDate(), getLatestRevisionId(),
getOriginalMessageId(), getRevisionNumber());
}
public @NonNull MediaMmsMessageRecord withPayment(@NonNull Payment payment) {
return new MediaMmsMessageRecord(getId(), getFromRecipient(), getFromDeviceId(), getToRecipient(), getDateSent(), getDateReceived(), getServerTimestamp(), getDeliveryReceiptCount(), getThreadId(), getBody(), getSlideDeck(),
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), isViewOnce(),
getReadReceiptCount(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf,
getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), payment, getCall(), getScheduledDate());
getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), payment, getCall(), getScheduledDate(), getLatestRevisionId(),
getOriginalMessageId(), getRevisionNumber());
}
@@ -251,7 +264,8 @@ public class MediaMmsMessageRecord extends MmsMessageRecord {
return new MediaMmsMessageRecord(getId(), getFromRecipient(), getFromDeviceId(), getToRecipient(), getDateSent(), getDateReceived(), getServerTimestamp(), getDeliveryReceiptCount(), getThreadId(), getBody(), getSlideDeck(),
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), isViewOnce(),
getReadReceiptCount(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf,
getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), call, getScheduledDate());
getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), call, getScheduledDate(), getLatestRevisionId(),
getOriginalMessageId(), getRevisionNumber());
}
private static @NonNull List<Contact> updateContacts(@NonNull List<Contact> contacts, @NonNull Map<AttachmentId, DatabaseAttachment> attachmentIdMap) {

View File

@@ -102,6 +102,8 @@ public abstract class MessageRecord extends DisplayRecord {
private final boolean remoteDelete;
private final long notifiedTimestamp;
private final long receiptTimestamp;
private final MessageId originalMessageId;
private final int revisionNumber;
protected Boolean isJumboji = null;
@@ -110,10 +112,18 @@ public abstract class MessageRecord extends DisplayRecord {
int deliveryStatus, int deliveryReceiptCount, long type,
Set<IdentityKeyMismatch> mismatches,
Set<NetworkFailure> networkFailures,
int subscriptionId, long expiresIn, long expireStarted,
int readReceiptCount, boolean unidentified,
@NonNull List<ReactionRecord> reactions, boolean remoteDelete, long notifiedTimestamp,
int viewedReceiptCount, long receiptTimestamp)
int subscriptionId,
long expiresIn,
long expireStarted,
int readReceiptCount,
boolean unidentified,
@NonNull List<ReactionRecord> reactions,
boolean remoteDelete,
long notifiedTimestamp,
int viewedReceiptCount,
long receiptTimestamp,
@Nullable MessageId originalMessageId,
int revisionNumber)
{
super(body, fromRecipient, toRecipient, dateSent, dateReceived,
threadId, deliveryStatus, deliveryReceiptCount, type,
@@ -131,6 +141,8 @@ public abstract class MessageRecord extends DisplayRecord {
this.remoteDelete = remoteDelete;
this.notifiedTimestamp = notifiedTimestamp;
this.receiptTimestamp = receiptTimestamp;
this.originalMessageId = originalMessageId;
this.revisionNumber = revisionNumber;
}
public abstract boolean isMms();
@@ -721,6 +733,18 @@ public abstract class MessageRecord extends DisplayRecord {
throw new NullPointerException();
}
public boolean isEditMessage() {
return originalMessageId != null;
}
public @Nullable MessageId getOriginalMessageId() {
return originalMessageId;
}
public int getRevisionNumber() {
return revisionNumber;
}
public static final class InviteAddState {
private final boolean invited;

View File

@@ -39,12 +39,13 @@ public abstract class MmsMessageRecord extends MessageRecord {
@NonNull List<LinkPreview> linkPreviews, boolean unidentified,
@NonNull List<ReactionRecord> reactions, boolean remoteDelete, long notifiedTimestamp,
int viewedReceiptCount, long receiptTimestamp, @NonNull StoryType storyType,
@Nullable ParentStoryId parentStoryId, @Nullable GiftBadge giftBadge)
@Nullable ParentStoryId parentStoryId, @Nullable GiftBadge giftBadge, @Nullable MessageId originalMessageId,
int revisionNumber)
{
super(id, body, fromRecipient, fromDeviceId, toRecipient,
dateSent, dateReceived, dateServer, threadId, deliveryStatus, deliveryReceiptCount,
type, mismatches, networkFailures, subscriptionId, expiresIn, expireStarted, readReceiptCount,
unidentified, reactions, remoteDelete, notifiedTimestamp, viewedReceiptCount, receiptTimestamp);
unidentified, reactions, remoteDelete, notifiedTimestamp, viewedReceiptCount, receiptTimestamp, originalMessageId, revisionNumber);
this.slideDeck = slideDeck;
this.quote = quote;

View File

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

View File

@@ -140,9 +140,10 @@ public class IndividualSendJob extends PushSendJob {
throws IOException, MmsException, NoSuchMessageException, UndeliverableMessageException, RetryLaterException
{
ExpiringMessageManager expirationManager = ApplicationDependencies.getExpiringMessageManager();
MessageTable database = SignalDatabase.messages();
OutgoingMessage message = database.getOutgoingMessage(messageId);
long threadId = database.getMessageRecord(messageId).getThreadId();
MessageTable database = SignalDatabase.messages();
OutgoingMessage message = database.getOutgoingMessage(messageId);
long threadId = database.getMessageRecord(messageId).getThreadId();
MessageRecord originalEditedMessage = message.getMessageToEdit() > 0 ? SignalDatabase.messages().getMessageRecordOrNull(message.getMessageToEdit()) : null;
if (database.isSent(messageId)) {
warn(TAG, String.valueOf(message.getSentTimeMillis()), "Message " + messageId + " was already sent. Ignoring.");
@@ -158,7 +159,7 @@ public class IndividualSendJob extends PushSendJob {
byte[] profileKey = recipient.getProfileKey();
UnidentifiedAccessMode accessMode = recipient.getUnidentifiedAccessMode();
boolean unidentified = deliver(message);
boolean unidentified = deliver(message, originalEditedMessage);
database.markAsSent(messageId, true);
markAttachmentsUploaded(messageId, message);
@@ -181,7 +182,10 @@ public class IndividualSendJob extends PushSendJob {
SignalDatabase.recipients().setUnidentifiedAccessMode(recipient.getId(), UnidentifiedAccessMode.DISABLED);
}
if (message.getExpiresIn() > 0 && !message.isExpirationUpdate()) {
if (originalEditedMessage != null && originalEditedMessage.getExpireStarted() > 0) {
database.markExpireStarted(messageId, originalEditedMessage.getExpireStarted());
expirationManager.scheduleDeletion(messageId, true, originalEditedMessage.getExpireStarted(), originalEditedMessage.getExpiresIn());
} else if (message.getExpiresIn() > 0 && !message.isExpirationUpdate()) {
database.markExpireStarted(messageId);
expirationManager.scheduleDeletion(messageId, true, message.getExpiresIn());
}
@@ -214,7 +218,7 @@ public class IndividualSendJob extends PushSendJob {
notifyMediaMessageDeliveryFailed(context, messageId);
}
private boolean deliver(OutgoingMessage message)
private boolean deliver(OutgoingMessage message, MessageRecord originalEditedMessage)
throws IOException, InsecureFallbackApprovalException, UntrustedIdentityException, UndeliverableMessageException
{
if (message.getThreadRecipient() == null) {
@@ -283,7 +287,12 @@ public class IndividualSendJob extends PushSendJob {
SignalServiceDataMessage mediaMessage = mediaMessageBuilder.build();
if (Util.equals(SignalStore.account().getAci(), address.getServiceId())) {
if (originalEditedMessage != null) {
SendMessageResult result = messageSender.sendEditMessage(address, UnidentifiedAccessUtil.getAccessFor(context, messageRecipient), ContentHint.RESENDABLE, mediaMessage, IndividualSendEvents.EMPTY, message.isUrgent(), originalEditedMessage.getDateSent());
SignalDatabase.messageLog().insertIfPossible(messageRecipient.getId(), message.getSentTimeMillis(), result, ContentHint.RESENDABLE, new MessageId(messageId), false);
return result.getSuccess().isUnidentified();
} else if (Util.equals(SignalStore.account().getAci(), address.getServiceId())) {
Optional<UnidentifiedAccessPair> syncAccess = UnidentifiedAccessUtil.getAccessForSync(context);
SendMessageResult result = messageSender.sendSyncMessage(mediaMessage);
SignalDatabase.messageLog().insertIfPossible(messageRecipient.getId(), message.getSentTimeMillis(), result, ContentHint.RESENDABLE, new MessageId(messageId), false);

View File

@@ -52,6 +52,7 @@ import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException;
import org.whispersystems.signalservice.api.messages.SendMessageResult;
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment;
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceEditMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceGroupV2;
import org.whispersystems.signalservice.api.messages.SignalServicePreview;
import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessage;
@@ -177,9 +178,10 @@ public final class PushGroupSendJob extends PushSendJob {
{
SignalLocalMetrics.GroupMessageSend.onJobStarted(messageId);
MessageTable database = SignalDatabase.messages();
OutgoingMessage message = database.getOutgoingMessage(messageId);
long threadId = database.getMessageRecord(messageId).getThreadId();
MessageTable database = SignalDatabase.messages();
OutgoingMessage message = database.getOutgoingMessage(messageId);
long threadId = database.getMessageRecord(messageId).getThreadId();
MessageRecord originalEditedMessage = message.getMessageToEdit() > 0 ? SignalDatabase.messages().getMessageRecordOrNull(message.getMessageToEdit()) : null;
Set<NetworkFailure> existingNetworkFailures = new HashSet<>(message.getNetworkFailures());
Set<IdentityKeyMismatch> existingIdentityMismatches = new HashSet<>(message.getIdentityKeyMismatches());
@@ -227,7 +229,7 @@ public final class PushGroupSendJob extends PushSendJob {
skipped = result.skipped;
}
List<SendMessageResult> results = deliver(message, groupRecipient, target);
List<SendMessageResult> results = deliver(message, originalEditedMessage, groupRecipient, target);
processGroupMessageResults(context, messageId, threadId, groupRecipient, message, results, target, skipped, existingNetworkFailures, existingIdentityMismatches);
Log.i(TAG, JobLogger.format(this, "Finished send."));
@@ -251,7 +253,7 @@ public final class PushGroupSendJob extends PushSendJob {
SignalDatabase.messages().markAsSentFailed(messageId);
}
private List<SendMessageResult> deliver(OutgoingMessage message, @NonNull Recipient groupRecipient, @NonNull List<Recipient> destinations)
private List<SendMessageResult> deliver(OutgoingMessage message, @Nullable MessageRecord originalEditedMessage, @NonNull Recipient groupRecipient, @NonNull List<Recipient> destinations)
throws IOException, UntrustedIdentityException, UndeliverableMessageException
{
try {
@@ -317,7 +319,7 @@ public final class PushGroupSendJob extends PushSendJob {
.withExpiration(groupRecipient.getExpiresInSeconds())
.asGroupMessage(group)
.build();
return GroupSendUtil.sendResendableDataMessage(context, groupRecipient.requireGroupId().requireV2(), null, destinations, isRecipientUpdate, ContentHint.IMPLICIT, new MessageId(messageId), groupDataMessage, message.isUrgent(), false);
return GroupSendUtil.sendResendableDataMessage(context, groupRecipient.requireGroupId().requireV2(), null, destinations, isRecipientUpdate, ContentHint.IMPLICIT, new MessageId(messageId), groupDataMessage, message.isUrgent(), false, null);
} else {
throw new UndeliverableMessageException("Messages can no longer be sent to V1 groups!");
}
@@ -369,6 +371,10 @@ public final class PushGroupSendJob extends PushSendJob {
groupMessageBuilder.withQuote(getQuoteFor(message).orElse(null));
}
SignalServiceDataMessage groupMessage = groupMessageBuilder.build();
SignalServiceEditMessage editMessage = originalEditedMessage != null ? new SignalServiceEditMessage(originalEditedMessage.getDateSent(), groupMessage)
: null;
Log.i(TAG, JobLogger.format(this, "Beginning message send."));
return GroupSendUtil.sendResendableDataMessage(context,
@@ -378,9 +384,10 @@ public final class PushGroupSendJob extends PushSendJob {
isRecipientUpdate,
ContentHint.RESENDABLE,
new MessageId(messageId),
groupMessageBuilder.build(),
groupMessage,
message.isUrgent(),
message.getStoryType().isStory() || message.getParentStoryId() != null);
message.getStoryType().isStory() || message.getParentStoryId() != null,
editMessage);
}
} catch (ServerRejectedException e) {
throw new UndeliverableMessageException(e);

View File

@@ -67,6 +67,7 @@ import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentRemo
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
import org.whispersystems.signalservice.api.messages.SignalServicePreview;
import org.whispersystems.signalservice.api.messages.shared.SharedContact;
import org.whispersystems.signalservice.api.push.ServiceId;
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException;
import org.whispersystems.signalservice.api.push.exceptions.ProofRequiredException;
import org.whispersystems.signalservice.api.push.exceptions.ServerRejectedException;
@@ -313,6 +314,9 @@ public abstract class PushSendJob extends SendJob {
protected Optional<SignalServiceDataMessage.Quote> getQuoteFor(OutgoingMessage message) throws IOException {
if (message.getOutgoingQuote() == null) return Optional.empty();
if (message.isMessageEdit()) {
return Optional.of(new SignalServiceDataMessage.Quote(0, ServiceId.UNKNOWN, "", null, null, SignalServiceDataMessage.Quote.Type.NORMAL, null));
}
long quoteId = message.getOutgoingQuote().getId();
String quoteBody = message.getOutgoingQuote().getText();

View File

@@ -236,7 +236,8 @@ public class ReactionSendJob extends BaseJob {
messageId,
dataMessage,
true,
false);
false,
null);
if (includesSelf) {
results.add(ApplicationDependencies.getSignalServiceMessageSender().sendSyncMessage(dataMessage));

View File

@@ -217,7 +217,8 @@ public class RemoteDeleteSendJob extends BaseJob {
new MessageId(messageId),
dataMessage,
true,
isForStory);
isForStory,
null);
return GroupSendJobHelper.getCompletedSends(destinations, results);
}

View File

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

View File

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

View File

@@ -14,6 +14,7 @@ import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.conversation.ConversationMessage;
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.databinding.MessageDetailsViewEditHistoryBinding;
import org.thoughtcrime.securesms.mms.GlideRequests;
final class MessageDetailsAdapter extends ListAdapter<MessageDetailsAdapter.MessageDetailsViewState<?>, RecyclerView.ViewHolder> {
@@ -40,6 +41,8 @@ final class MessageDetailsAdapter extends ListAdapter<MessageDetailsAdapter.Mess
return new RecipientHeaderViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.message_details_recipient_header, parent, false));
case MessageDetailsViewState.RECIPIENT:
return new RecipientViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.message_details_recipient, parent, false), callbacks);
case MessageDetailsViewState.EDIT_HISTORY:
return new ViewEditHistoryViewHolder(MessageDetailsViewEditHistoryBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false), callbacks);
default:
throw new AssertionError("unknown view type");
}
@@ -53,6 +56,8 @@ final class MessageDetailsAdapter extends ListAdapter<MessageDetailsAdapter.Mess
((RecipientHeaderViewHolder) holder).bind((RecipientHeader) getItem(position).data);
} else if (holder instanceof RecipientViewHolder) {
((RecipientViewHolder) holder).bind((RecipientDeliveryStatus) getItem(position).data);
} else if (holder instanceof ViewEditHistoryViewHolder) {
((ViewEditHistoryViewHolder) holder).bind((MessageRecord) getItem(position).data);
} else {
throw new AssertionError("unknown view holder");
}
@@ -72,6 +77,7 @@ final class MessageDetailsAdapter extends ListAdapter<MessageDetailsAdapter.Mess
if (oldData.getClass() == newData.getClass() && oldItem.itemType == newItem.itemType) {
switch (oldItem.itemType) {
case MessageDetailsViewState.MESSAGE_HEADER:
case MessageDetailsViewState.EDIT_HISTORY:
return true;
case MessageDetailsViewState.RECIPIENT_HEADER:
return oldData == newData;
@@ -94,6 +100,7 @@ final class MessageDetailsAdapter extends ListAdapter<MessageDetailsAdapter.Mess
case MessageDetailsViewState.MESSAGE_HEADER:
return false;
case MessageDetailsViewState.RECIPIENT_HEADER:
case MessageDetailsViewState.EDIT_HISTORY:
return true;
case MessageDetailsViewState.RECIPIENT:
return ((RecipientDeliveryStatus) oldData).getDeliveryStatus() == ((RecipientDeliveryStatus) newData).getDeliveryStatus();
@@ -108,6 +115,7 @@ final class MessageDetailsAdapter extends ListAdapter<MessageDetailsAdapter.Mess
public static final int MESSAGE_HEADER = 0;
public static final int RECIPIENT_HEADER = 1;
public static final int RECIPIENT = 2;
public static final int EDIT_HISTORY = 3;
private final T data;
private int itemType;
@@ -120,5 +128,6 @@ final class MessageDetailsAdapter extends ListAdapter<MessageDetailsAdapter.Mess
interface Callbacks {
void onErrorClicked(@NonNull MessageRecord messageRecord);
void onViewEditHistoryClicked(MessageRecord record);
}
}

View File

@@ -15,6 +15,7 @@ import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.components.FullScreenDialogFragment;
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer;
import org.thoughtcrime.securesms.conversation.ui.edit.EditMessageHistoryDialog;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackController;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionPlayerHolder;
@@ -26,12 +27,13 @@ import org.thoughtcrime.securesms.mms.GlideRequests;
import org.thoughtcrime.securesms.recipients.RecipientId;
import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet;
import org.thoughtcrime.securesms.util.Material3OnScrollHelper;
import org.thoughtcrime.securesms.util.MessageRecordUtil;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
public final class MessageDetailsFragment extends FullScreenDialogFragment {
public final class MessageDetailsFragment extends FullScreenDialogFragment implements MessageDetailsAdapter.Callbacks {
private static final String MESSAGE_ID_EXTRA = "message_id";
private static final String RECIPIENT_EXTRA = "recipient_id";
@@ -89,7 +91,7 @@ public final class MessageDetailsFragment extends FullScreenDialogFragment {
View toolbarShadow = view.findViewById(R.id.toolbar_shadow);
colorizer = new Colorizer();
adapter = new MessageDetailsAdapter(getViewLifecycleOwner(), glideRequests, colorizer, this::onErrorClicked);
adapter = new MessageDetailsAdapter(getViewLifecycleOwner(), glideRequests, colorizer, this);
recyclerViewColorizer = new RecyclerViewColorizer(list);
list.setAdapter(adapter);
@@ -127,6 +129,10 @@ public final class MessageDetailsFragment extends FullScreenDialogFragment {
list.add(new MessageDetailsViewState<>(details.getConversationMessage(), MessageDetailsViewState.MESSAGE_HEADER));
if (MessageRecordUtil.isEditMessage(details.getConversationMessage().getMessageRecord())) {
list.add(new MessageDetailsViewState<>(details.getConversationMessage().getMessageRecord(), MessageDetailsViewState.EDIT_HISTORY));
}
if (details.getConversationMessage().getMessageRecord().isOutgoing()) {
addRecipients(list, RecipientHeader.NOT_SENT, details.getNotSent());
addRecipients(list, RecipientHeader.VIEWED, details.getViewed());
@@ -154,12 +160,22 @@ public final class MessageDetailsFragment extends FullScreenDialogFragment {
return true;
}
private void onErrorClicked(@NonNull MessageRecord messageRecord) {
@Override
public void onErrorClicked(@NonNull MessageRecord messageRecord) {
SafetyNumberBottomSheet
.forMessageRecord(requireContext(), messageRecord)
.show(getChildFragmentManager());
}
@Override
public void onViewEditHistoryClicked(MessageRecord record) {
if (record.isOutgoing()) {
EditMessageHistoryDialog.show(requireParentFragment().getChildFragmentManager(), record.getToRecipient().getId(), record.getId());
} else {
EditMessageHistoryDialog.show(requireParentFragment().getChildFragmentManager(), record.getFromRecipient().getId(), record.getId());
}
}
public interface Callback {
void onMessageDetailsFragmentDismissed();
}

View File

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

View File

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

View File

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

View File

@@ -37,6 +37,7 @@ import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair;
import org.whispersystems.signalservice.api.crypto.UntrustedIdentityException;
import org.whispersystems.signalservice.api.messages.SendMessageResult;
import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceEditMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessage;
import org.whispersystems.signalservice.api.messages.SignalServiceStoryMessageRecipient;
import org.whispersystems.signalservice.api.messages.SignalServiceTypingMessage;
@@ -94,29 +95,15 @@ public final class GroupSendUtil {
@NonNull MessageId messageId,
@NonNull SignalServiceDataMessage message,
boolean urgent,
boolean isForStory)
boolean isForStory,
@Nullable SignalServiceEditMessage editMessage)
throws IOException, UntrustedIdentityException
{
Preconditions.checkArgument(groupId == null || distributionListId == null, "Cannot supply both a groupId and a distributionListId!");
DistributionId distributionId = groupId != null ? getDistributionId(groupId) : getDistributionId(distributionListId);
return sendMessage(context, groupId, distributionId, messageId, allTargets, isRecipientUpdate, isForStory, DataSendOperation.resendable(message, contentHint, messageId, urgent, isForStory), null);
}
@WorkerThread
public static List<SendMessageResult> sendResendableStoryRelatedMessage(@NonNull Context context,
@Nullable GroupId.V2 groupId,
@NonNull DistributionListId distributionListId,
@NonNull List<Recipient> allTargets,
boolean isRecipientUpdate,
ContentHint contentHint,
@NonNull MessageId messageId,
@NonNull SignalServiceDataMessage message,
boolean urgent)
throws IOException, UntrustedIdentityException
{
return sendMessage(context, groupId, getDistributionId(distributionListId), messageId, allTargets, isRecipientUpdate, true, DataSendOperation.resendable(message, contentHint, messageId, urgent, true), null);
return sendMessage(context, groupId, distributionId, messageId, allTargets, isRecipientUpdate, isForStory, DataSendOperation.resendable(message, contentHint, messageId, urgent, isForStory, editMessage), null);
}
/**
@@ -480,22 +467,24 @@ public final class GroupSendUtil {
private final boolean resendable;
private final boolean urgent;
private final boolean isForStory;
private final SignalServiceEditMessage editMessage;
public static DataSendOperation resendable(@NonNull SignalServiceDataMessage message, @NonNull ContentHint contentHint, @NonNull MessageId relatedMessageId, boolean urgent, boolean isForStory) {
return new DataSendOperation(message, contentHint, true, relatedMessageId, urgent, isForStory);
public static DataSendOperation resendable(@NonNull SignalServiceDataMessage message, @NonNull ContentHint contentHint, @NonNull MessageId relatedMessageId, boolean urgent, boolean isForStory, @Nullable SignalServiceEditMessage editMessage) {
return new DataSendOperation(editMessage != null ? editMessage.getDataMessage() : message, contentHint, true, relatedMessageId, urgent, isForStory, editMessage);
}
public static DataSendOperation unresendable(@NonNull SignalServiceDataMessage message, @NonNull ContentHint contentHint, boolean urgent) {
return new DataSendOperation(message, contentHint, false, null, urgent, false);
return new DataSendOperation(message, contentHint, false, null, urgent, false, null);
}
private DataSendOperation(@NonNull SignalServiceDataMessage message, @NonNull ContentHint contentHint, boolean resendable, @Nullable MessageId relatedMessageId, boolean urgent, boolean isForStory) {
private DataSendOperation(@NonNull SignalServiceDataMessage message, @NonNull ContentHint contentHint, boolean resendable, @Nullable MessageId relatedMessageId, boolean urgent, boolean isForStory, @Nullable SignalServiceEditMessage editMessage) {
this.message = message;
this.contentHint = contentHint;
this.resendable = resendable;
this.relatedMessageId = relatedMessageId;
this.urgent = urgent;
this.isForStory = isForStory;
this.editMessage = editMessage;
if (resendable && relatedMessageId == null) {
throw new IllegalArgumentException("If a message is resendable, it must have a related message ID!");
@@ -512,7 +501,7 @@ public final class GroupSendUtil {
throws NoSessionException, UntrustedIdentityException, InvalidKeyException, IOException, InvalidRegistrationIdException
{
SenderKeyGroupEvents listener = relatedMessageId != null ? new SenderKeyMetricEventListener(relatedMessageId.getId()) : SenderKeyGroupEvents.EMPTY;
return messageSender.sendGroupDataMessage(distributionId, targets, access, isRecipientUpdate, contentHint, message, listener, urgent, isForStory, partialListener);
return messageSender.sendGroupDataMessage(distributionId, targets, access, isRecipientUpdate, contentHint, message, listener, urgent, isForStory, editMessage, partialListener);
}
@Override
@@ -527,8 +516,14 @@ public final class GroupSendUtil {
{
// PniSignatures are only needed for 1:1 messages, but some message jobs use the GroupSendUtil methods to send 1:1
if (targets.size() == 1 && relatedMessageId == null) {
Recipient targetRecipient = targetRecipients.get(0);
SendMessageResult result = messageSender.sendDataMessage(targets.get(0), access.get(0), contentHint, message, SignalServiceMessageSender.IndividualSendEvents.EMPTY, urgent, targetRecipient.needsPniSignature());
Recipient targetRecipient = targetRecipients.get(0);
SendMessageResult result;
if (editMessage != null) {
result = messageSender.sendEditMessage(targets.get(0), access.get(0), contentHint, message, SignalServiceMessageSender.IndividualSendEvents.EMPTY, urgent, editMessage.getTargetSentTimestamp());
} else {
result = messageSender.sendDataMessage(targets.get(0), access.get(0), contentHint, message, SignalServiceMessageSender.IndividualSendEvents.EMPTY, urgent, targetRecipient.needsPniSignature());
}
if (targetRecipient.needsPniSignature()) {
SignalDatabase.pendingPniSignatureMessages().insertIfNecessary(targetRecipients.get(0).getId(), getSentTimestamp(), result);

View File

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

View File

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

View File

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

View File

@@ -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.
*/

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,83 @@
package org.thoughtcrime.securesms.util
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import java.util.concurrent.TimeUnit
import kotlin.time.Duration.Companion.hours
/**
* Helpers for determining if a message send/receive is valid for those that
* have strict time limits.
*/
object MessageConstraintsUtil {
private val RECEIVE_THRESHOLD = TimeUnit.DAYS.toMillis(1)
private val SEND_THRESHOLD = TimeUnit.HOURS.toMillis(3)
private val MAX_EDIT_COUNT = 10
@JvmStatic
fun isValidRemoteDeleteReceive(targetMessage: MessageRecord, deleteSenderId: RecipientId, deleteServerTimestamp: Long): Boolean {
val selfIsDeleteSender = isSelf(deleteSenderId)
val isValidIncomingOutgoing = selfIsDeleteSender && targetMessage.isOutgoing || !selfIsDeleteSender && !targetMessage.isOutgoing
val isValidSender = targetMessage.fromRecipient.id == deleteSenderId || selfIsDeleteSender && targetMessage.isOutgoing
val messageTimestamp = if (selfIsDeleteSender && targetMessage.isOutgoing) targetMessage.dateSent else targetMessage.serverTimestamp
return isValidIncomingOutgoing &&
isValidSender &&
((deleteServerTimestamp - messageTimestamp < RECEIVE_THRESHOLD) || (selfIsDeleteSender && targetMessage.isOutgoing))
}
@JvmStatic
fun isValidEditMessageReceive(targetMessage: MessageRecord, editSender: Recipient, editServerTimestamp: Long): Boolean {
return isValidRemoteDeleteReceive(targetMessage, editSender.id, editServerTimestamp)
}
@JvmStatic
fun isValidRemoteDeleteSend(targetMessages: Collection<MessageRecord>, currentTime: Long): Boolean {
// TODO [greyson] [remote-delete] Update with server timestamp when available for outgoing messages
return targetMessages.all { isValidRemoteDeleteSend(it, currentTime) }
}
@JvmStatic
fun getEditMessageThresholdHours(): Int {
return SEND_THRESHOLD.hours.inWholeHours.toInt()
}
/**
* Check if at the current time a target message can be edited
*/
@JvmStatic
fun isValidEditMessageSend(targetMessage: MessageRecord, currentTime: Long): Boolean {
return isValidRemoteDeleteSend(targetMessage, currentTime) &&
targetMessage.revisionNumber < 10 &&
!targetMessage.isViewOnceMessage() &&
!targetMessage.hasAudio() &&
!targetMessage.hasSharedContact()
}
/**
* Check regardless of timing, whether a target message can be edited
*/
@JvmStatic
fun isValidEditMessageSend(targetMessage: MessageRecord): Boolean {
return isValidEditMessageSend(targetMessage, targetMessage.dateSent)
}
private fun isValidRemoteDeleteSend(message: MessageRecord, currentTime: Long): Boolean {
return !message.isUpdate &&
message.isOutgoing &&
message.isPush &&
(!message.toRecipient.isGroup || message.toRecipient.isActiveGroup) &&
!message.isRemoteDelete &&
!message.hasGiftBadge() &&
!message.isPaymentNotification &&
(currentTime - message.dateSent < SEND_THRESHOLD || message.toRecipient.isSelf)
}
private fun isSelf(recipientId: RecipientId): Boolean {
return Recipient.isSelfSet() && Recipient.self().id == recipientId
}
}

View File

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

View File

@@ -1,60 +0,0 @@
package org.thoughtcrime.securesms.util;
import androidx.annotation.NonNull;
import com.annimon.stream.Stream;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.recipients.Recipient;
import org.thoughtcrime.securesms.recipients.RecipientId;
import java.util.Collection;
import java.util.concurrent.TimeUnit;
public final class RemoteDeleteUtil {
private static final long RECEIVE_THRESHOLD = TimeUnit.DAYS.toMillis(1);
private static final long SEND_THRESHOLD = TimeUnit.HOURS.toMillis(3);
private RemoteDeleteUtil() {}
public static boolean isValidReceive(@NonNull MessageRecord targetMessage, @NonNull Recipient deleteSender, long deleteServerTimestamp) {
return isValidReceive(targetMessage, deleteSender.getId(), deleteServerTimestamp);
}
public static boolean isValidReceive(@NonNull MessageRecord targetMessage, @NonNull RecipientId deleteSenderId, long deleteServerTimestamp) {
boolean selfIsDeleteSender = isSelf(deleteSenderId);
boolean isValidIncomingOutgoing = (selfIsDeleteSender && targetMessage.isOutgoing()) ||
(!selfIsDeleteSender && !targetMessage.isOutgoing());
boolean isValidSender = targetMessage.getFromRecipient().getId().equals(deleteSenderId) || selfIsDeleteSender && targetMessage.isOutgoing();
long messageTimestamp = selfIsDeleteSender && targetMessage.isOutgoing() ? targetMessage.getDateSent()
: targetMessage.getServerTimestamp();
return isValidIncomingOutgoing &&
isValidSender &&
(((deleteServerTimestamp - messageTimestamp) < RECEIVE_THRESHOLD) || (selfIsDeleteSender && targetMessage.isOutgoing()));
}
public static boolean isValidSend(@NonNull Collection<MessageRecord> targetMessages, long currentTime) {
// TODO [greyson] [remote-delete] Update with server timestamp when available for outgoing messages
return Stream.of(targetMessages).allMatch(message -> isValidSend(message, currentTime));
}
private static boolean isValidSend(MessageRecord message, long currentTime) {
return !message.isUpdate() &&
message.isOutgoing() &&
message.isPush() &&
(!message.getToRecipient().isGroup() || message.getToRecipient().isActiveGroup()) &&
!message.isRemoteDelete() &&
!MessageRecordUtil.hasGiftBadge(message) &&
!message.isPaymentNotification() &&
(((currentTime - message.getDateSent()) < SEND_THRESHOLD) || message.getToRecipient().isSelf());
}
private static boolean isSelf(@NonNull RecipientId recipientId) {
return Recipient.isSelfSet() && Recipient.self().getId().equals(recipientId);
}
}

View File

@@ -0,0 +1,25 @@
package org.thoughtcrime.securesms.util
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
/**
* Simplifies [ViewModel] creation by providing default implementations of [ViewModelProvider.Factory]
* and a factory producer that call through to a lambda to create the view model instance.
*
* Example use:
*
* private val viewModel: MyViewModel by viewModels(factoryProducer = ViewModelFactory.factoryProducer { MyViewModel(inputParams) })
*/
class ViewModelFactory<MODEL : ViewModel>(private val create: () -> MODEL) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return create() as T
}
companion object {
fun <MODEL : ViewModel> factoryProducer(create: () -> MODEL): () -> ViewModelProvider.Factory {
return { ViewModelFactory(create) }
}
}
}