Add support for scheduled message sends.

This commit is contained in:
Clark
2023-01-26 10:37:08 -05:00
committed by Greyson Parrelli
parent df695f7611
commit f3e715e069
59 changed files with 1948 additions and 90 deletions

View File

@@ -46,6 +46,7 @@ import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.conversation.colors.Colorizable;
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart;
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
import org.thoughtcrime.securesms.database.model.MessageRecord;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Playable;
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicyEnforcer;
@@ -120,6 +121,7 @@ public class ConversationAdapter
private Colorizer colorizer;
private boolean isTypingViewEnabled;
private boolean condensedMode;
private boolean scheduledMessagesMode;
private PulseRequest pulseRequest;
public ConversationAdapter(@NonNull Context context,
@@ -261,6 +263,11 @@ public class ConversationAdapter
notifyDataSetChanged();
}
public void setScheduledMessagesMode(boolean scheduledMessagesMode) {
this.scheduledMessagesMode = scheduledMessagesMode;
notifyDataSetChanged();
}
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
switch (getItemViewType(position)) {
@@ -331,7 +338,11 @@ public class ConversationAdapter
if (conversationMessage == null) return -1;
calendar.setTimeInMillis(conversationMessage.getMessageRecord().getDateSent());
if (scheduledMessagesMode) {
calendar.setTimeInMillis(((MediaMmsMessageRecord) conversationMessage.getMessageRecord()).getScheduledDate());
} else {
calendar.setTimeInMillis(conversationMessage.getMessageRecord().getDateSent());
}
return calendar.get(Calendar.YEAR) * 1000L + calendar.get(Calendar.DAY_OF_YEAR);
}
@@ -345,7 +356,11 @@ public class ConversationAdapter
Context context = viewHolder.itemView.getContext();
ConversationMessage conversationMessage = Objects.requireNonNull(getItem(position));
viewHolder.setText(DateUtils.getConversationDateHeaderString(viewHolder.itemView.getContext(), locale, conversationMessage.getMessageRecord().getDateSent()));
if (scheduledMessagesMode) {
viewHolder.setText(DateUtils.getScheduledMessagesDateHeaderString(viewHolder.itemView.getContext(), locale, ((MediaMmsMessageRecord) conversationMessage.getMessageRecord()).getScheduledDate()));
} else {
viewHolder.setText(DateUtils.getConversationDateHeaderString(viewHolder.itemView.getContext(), locale, conversationMessage.getMessageRecord().getDateSent()));
}
if (type == HEADER_TYPE_POPOVER_DATE) {
if (hasWallpaper) {

View File

@@ -186,6 +186,10 @@ public class ConversationDataSource implements PagedDataSource<MessageId, Conver
return null;
}
if (record instanceof MediaMmsMessageRecord && ((MediaMmsMessageRecord) record).getScheduledDate() != -1) {
return null;
}
stopwatch.split("message");
try {

View File

@@ -1188,15 +1188,16 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
Toast.LENGTH_LONG).show();
}
public long stageOutgoingMessage(OutgoingMessage message) {
public void stageOutgoingMessage(OutgoingMessage message) {
if (message.getScheduledDate() != -1) {
return;
}
MessageRecord messageRecord = MessageTable.readerFor(message, threadId).getCurrent();
if (getListAdapter() != null) {
setLastSeen(0);
list.post(() -> list.scrollToPosition(0));
}
return messageRecord.getId();
}
private void presentConversationMetadata(@NonNull ConversationData conversation) {
@@ -2114,6 +2115,11 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
public void onSendPaymentClicked(@NonNull RecipientId recipientId) {
AttachmentManager.selectPayment(ConversationFragment.this, recipient.get());
}
@Override
public void onScheduledIndicatorClicked(@NonNull View view, @NonNull MessageRecord messageRecord) {
}
}
private boolean isUnopenedGift(View itemView, MessageRecord messageRecord) {

View File

@@ -210,6 +210,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
private View storyReactionLabelWrapper;
private TextView storyReactionLabel;
protected View quotedIndicator;
protected View scheduledIndicator;
private @NonNull Set<MultiselectPart> batchSelected = new HashSet<>();
private @NonNull Outliner outliner = new Outliner();
@@ -233,18 +234,19 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
private int measureCalls;
private boolean updatingFooter;
private final PassthroughClickListener passthroughClickListener = new PassthroughClickListener();
private final AttachmentDownloadClickListener downloadClickListener = new AttachmentDownloadClickListener();
private final SlideClickPassthroughListener singleDownloadClickListener = new SlideClickPassthroughListener(downloadClickListener);
private final SharedContactEventListener sharedContactEventListener = new SharedContactEventListener();
private final SharedContactClickListener sharedContactClickListener = new SharedContactClickListener();
private final LinkPreviewClickListener linkPreviewClickListener = new LinkPreviewClickListener();
private final ViewOnceMessageClickListener revealableClickListener = new ViewOnceMessageClickListener();
private final QuotedIndicatorClickListener quotedIndicatorClickListener = new QuotedIndicatorClickListener();
private final UrlClickListener urlClickListener = new UrlClickListener();
private final Rect thumbnailMaskingRect = new Rect();
private final TouchDelegateChangedListener touchDelegateChangedListener = new TouchDelegateChangedListener();
private final GiftMessageViewCallback giftMessageViewCallback = new GiftMessageViewCallback();
private final PassthroughClickListener passthroughClickListener = new PassthroughClickListener();
private final AttachmentDownloadClickListener downloadClickListener = new AttachmentDownloadClickListener();
private final SlideClickPassthroughListener singleDownloadClickListener = new SlideClickPassthroughListener(downloadClickListener);
private final SharedContactEventListener sharedContactEventListener = new SharedContactEventListener();
private final SharedContactClickListener sharedContactClickListener = new SharedContactClickListener();
private final LinkPreviewClickListener linkPreviewClickListener = new LinkPreviewClickListener();
private final ViewOnceMessageClickListener revealableClickListener = new ViewOnceMessageClickListener();
private final QuotedIndicatorClickListener quotedIndicatorClickListener = new QuotedIndicatorClickListener();
private final ScheduledIndicatorClickListener scheduledIndicatorClickListener = new ScheduledIndicatorClickListener();
private final UrlClickListener urlClickListener = new UrlClickListener();
private final Rect thumbnailMaskingRect = new Rect();
private final TouchDelegateChangedListener touchDelegateChangedListener = new TouchDelegateChangedListener();
private final GiftMessageViewCallback giftMessageViewCallback = new GiftMessageViewCallback();
private final Context context;
@@ -278,6 +280,11 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
.scaleX(LONG_PRESS_SCALE_FACTOR)
.scaleY(LONG_PRESS_SCALE_FACTOR);
}
if (scheduledIndicator != null) {
scheduledIndicator.animate()
.scaleX(LONG_PRESS_SCALE_FACTOR)
.scaleY(LONG_PRESS_SCALE_FACTOR);
}
}
};
@@ -328,6 +335,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
this.giftViewStub = new Stub<>(findViewById(R.id.gift_view_stub));
this.quotedIndicator = findViewById(R.id.quoted_indicator);
this.paymentViewStub = new Stub<>(findViewById(R.id.payment_view_stub));
this.scheduledIndicator = findViewById(R.id.scheduled_indicator);
setOnClickListener(new ClickListener(null));
@@ -395,6 +403,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
setFooter(messageRecord, nextMessageRecord, locale, groupThread, hasWallpaper);
setStoryReactionLabel(messageRecord);
setHasBeenQuoted(conversationMessage);
setHasBeenScheduled(conversationMessage);
if (audioViewStub.resolved()) {
audioViewStub.get().setOnLongClickListener(passthroughClickListener);
@@ -1035,7 +1044,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
boolean messageRequestAccepted,
boolean allowedToPlayInline)
{
boolean showControls = !messageRecord.isFailed();
boolean showControls = !messageRecord.isFailed() && !MessageRecordUtil.isScheduled(messageRecord);
ViewUtil.setTopMargin(bodyText, readDimen(R.dimen.message_bubble_top_padding));
@@ -1711,6 +1720,19 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
}
private void setHasBeenScheduled(@NonNull ConversationMessage message) {
if (scheduledIndicator == null) {
return;
}
if (message.hasBeenScheduled()) {
scheduledIndicator.setVisibility(View.VISIBLE);
scheduledIndicator.setOnClickListener(scheduledIndicatorClickListener);
} else {
scheduledIndicator.setVisibility(View.GONE);
scheduledIndicator.setOnClickListener(null);
}
}
private boolean forceFooter(@NonNull MessageRecord messageRecord) {
return hasAudio(messageRecord);
}
@@ -1872,21 +1894,23 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
private boolean isStartOfMessageCluster(@NonNull MessageRecord current, @NonNull Optional<MessageRecord> previous, boolean isGroupThread) {
if (isGroupThread) {
return !previous.isPresent() || previous.get().isUpdate() || !DateUtils.isSameDay(current.getTimestamp(), previous.get().getTimestamp()) ||
!current.getRecipient().equals(previous.get().getRecipient()) || !isWithinClusteringTime(current, previous.get());
!current.getRecipient().equals(previous.get().getRecipient()) || !isWithinClusteringTime(current, previous.get()) || MessageRecordUtil.isScheduled(current);
} else {
return !previous.isPresent() || previous.get().isUpdate() || !DateUtils.isSameDay(current.getTimestamp(), previous.get().getTimestamp()) ||
current.isOutgoing() != previous.get().isOutgoing() || previous.get().isSecure() != current.isSecure() || !isWithinClusteringTime(current, previous.get());
current.isOutgoing() != previous.get().isOutgoing() || previous.get().isSecure() != current.isSecure() || !isWithinClusteringTime(current, previous.get()) ||
MessageRecordUtil.isScheduled(current);
}
}
private boolean isEndOfMessageCluster(@NonNull MessageRecord current, @NonNull Optional<MessageRecord> next, boolean isGroupThread) {
if (isGroupThread) {
return !next.isPresent() || next.get().isUpdate() || !DateUtils.isSameDay(current.getTimestamp(), next.get().getTimestamp()) ||
!current.getRecipient().equals(next.get().getRecipient()) || !current.getReactions().isEmpty() || !isWithinClusteringTime(current, next.get());
!current.getRecipient().equals(next.get().getRecipient()) || !current.getReactions().isEmpty() || !isWithinClusteringTime(current, next.get()) ||
MessageRecordUtil.isScheduled(current);
} else {
return !next.isPresent() || next.get().isUpdate() || !DateUtils.isSameDay(current.getTimestamp(), next.get().getTimestamp()) ||
current.isOutgoing() != next.get().isOutgoing() || !current.getReactions().isEmpty() || next.get().isSecure() != current.isSecure() ||
!isWithinClusteringTime(current, next.get());
!isWithinClusteringTime(current, next.get()) || MessageRecordUtil.isScheduled(current);
}
}
@@ -2279,6 +2303,16 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
}
}
private class ScheduledIndicatorClickListener implements View.OnClickListener {
public void onClick(final View view) {
if (eventListener != null && batchSelected.isEmpty()) {
eventListener.onScheduledIndicatorClicked(view, (messageRecord));
} else {
passthroughClickListener.onClick(view);
}
}
}
private class AttachmentDownloadClickListener implements SlidesClickedListener {
@Override
public void onClick(View v, final List<Slide> slides) {

View File

@@ -115,6 +115,10 @@ public class ConversationMessage {
getBottomButton() == null;
}
public boolean hasBeenScheduled() {
return MessageRecordUtil.isScheduled(messageRecord);
}
/**
* Factory providing multiple ways of creating {@link ConversationMessage}s.
*/

View File

@@ -136,6 +136,8 @@ import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel;
import org.thoughtcrime.securesms.components.identity.UnverifiedBannerView;
import org.thoughtcrime.securesms.components.location.SignalPlace;
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
import org.thoughtcrime.securesms.components.menu.ActionItem;
import org.thoughtcrime.securesms.components.menu.SignalContextMenu;
import org.thoughtcrime.securesms.components.reminder.BubbleOptOutReminder;
import org.thoughtcrime.securesms.components.reminder.ExpiredBuildReminder;
import org.thoughtcrime.securesms.components.reminder.GroupsV1MigrationSuggestionsReminder;
@@ -284,6 +286,7 @@ import org.thoughtcrime.securesms.util.ContextUtil;
import org.thoughtcrime.securesms.util.ConversationUtil;
import org.thoughtcrime.securesms.util.Debouncer;
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.thoughtcrime.securesms.util.LifecycleDisposable;
@@ -329,6 +332,7 @@ import io.reactivex.rxjava3.core.Flowable;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.core.SingleObserver;
import io.reactivex.rxjava3.disposables.Disposable;
import kotlin.Unit;
import static android.content.res.Configuration.ORIENTATION_LANDSCAPE;
@@ -363,7 +367,9 @@ public class ConversationParentFragment extends Fragment
EmojiSearchFragment.Callback,
StickerKeyboardPageFragment.Callback,
Material3OnScrollHelperBinder,
MessageDetailsFragment.Callback
MessageDetailsFragment.Callback,
ScheduleMessageTimePickerBottomSheet.ScheduleCallback,
ScheduledMessagesBottomSheet.Callback
{
private static final int SHORTCUT_ICON_SIZE = Build.VERSION.SDK_INT >= 26 ? ViewUtil.dpToPx(72) : ViewUtil.dpToPx(48 + 16 * 2);
@@ -431,6 +437,7 @@ public class ConversationParentFragment extends Fragment
private View cancelJoinRequest;
private Stub<View> releaseChannelUnmute;
private Stub<View> mentionsSuggestions;
private Stub<View> scheduledMessagesBarStub;
private MaterialButton joinGroupCallButton;
private boolean callingTooltipShown;
private ImageView wallpaper;
@@ -556,6 +563,7 @@ public class ConversationParentFragment extends Fragment
initializeActionBar();
disposables.add(viewModel.getStoryViewState().subscribe(titleView::setStoryRingFromState));
disposables.add(viewModel.getScheduledMessageCount().subscribe(this::updateScheduledMessagesBar));
backPressedCallback = new OnBackPressedCallback(true) {
@Override
@@ -783,7 +791,8 @@ public class ConversationParentFragment extends Fragment
result.isViewOnce(),
initiating,
true,
null).addListener(new AssertedSuccessListener<Void>() {
null,
result.getScheduledTime()).addListener(new AssertedSuccessListener<Void>() {
@Override
public void onSuccess(Void result) {
AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> {
@@ -2039,6 +2048,7 @@ public class ConversationParentFragment extends Fragment
wallpaperDim = view.findViewById(R.id.conversation_wallpaper_dim);
voiceNotePlayerViewStub = ViewUtil.findStubById(view, R.id.voice_note_player_stub);
navigationBarBackground = view.findViewById(R.id.navbar_background);
scheduledMessagesBarStub = ViewUtil.findStubById(view, R.id.scheduled_messages_stub);
ImageButton quickCameraToggle = view.findViewById(R.id.quick_camera_toggle);
ImageButton inlineAttachmentButton = view.findViewById(R.id.inline_attachment_button);
@@ -2073,6 +2083,18 @@ public class ConversationParentFragment extends Fragment
attachButton.setOnClickListener(new AttachButtonListener());
attachButton.setOnLongClickListener(new AttachButtonLongClickListener());
sendButton.setOnClickListener(sendButtonListener);
if (FeatureFlags.scheduledMessageSends()) {
sendButton.setScheduledSendListener(() -> {
ScheduleMessageContextMenu.show(sendButton, (ViewGroup) requireView(), time -> {
if (time == -1) {
ScheduleMessageTimePickerBottomSheet.showSchedule(getChildFragmentManager());
} else {
sendMessage(null, time);
}
return Unit.INSTANCE;
});
});
}
sendButton.setEnabled(true);
sendButton.addOnSelectionChangedListener((newMessageSendType, manuallySelected) -> {
if (getContext() == null) {
@@ -2916,6 +2938,13 @@ public class ConversationParentFragment extends Fragment
}
private void sendMessage(@Nullable String metricId) {
sendMessage(metricId, -1);
}
private void sendMessage(@Nullable String metricId, long scheduledDate) {
if (scheduledDate != -1 && !SignalStore.uiHints().hasSeenScheduledMessagesInfoSheet()) {
ScheduleMessageFtuxBottomSheetDialog.show(getChildFragmentManager());
}
if (inputPanel.isRecordingInLockedMode()) {
inputPanel.releaseRecordingLock();
return;
@@ -2925,7 +2954,7 @@ public class ConversationParentFragment extends Fragment
if (voiceNote != null) {
AudioSlide audioSlide = AudioSlide.createFromVoiceNoteDraft(requireContext(), voiceNote);
sendVoiceNote(Objects.requireNonNull(audioSlide.getUri()), audioSlide.getFileSize());
sendVoiceNote(Objects.requireNonNull(audioSlide.getUri()), audioSlide.getFileSize(), scheduledDate);
return;
}
@@ -2957,9 +2986,9 @@ public class ConversationParentFragment extends Fragment
} else if (sendType.usesSignalTransport() && (identityRecords.isUnverified(true) || identityRecords.isUntrusted(true))) {
handleRecentSafetyNumberChange();
} else if (isMediaMessage) {
sendMediaMessage(sendType, expiresIn, false, initiating, metricId);
sendMediaMessage(sendType, expiresIn, false, initiating, metricId, scheduledDate);
} else {
sendTextMessage(sendType, expiresIn, initiating, metricId);
sendTextMessage(sendType, expiresIn, initiating, metricId, scheduledDate);
}
} catch (RecipientFormattingException ex) {
Toast.makeText(requireContext(),
@@ -3002,7 +3031,8 @@ public class ConversationParentFragment extends Fragment
Collections.emptySet(),
null,
true,
result.getBodyRanges());
result.getBodyRanges(),
-1);
final Context context = requireContext().getApplicationContext();
@@ -3012,7 +3042,7 @@ public class ConversationParentFragment extends Fragment
attachmentManager.clear(glideRequests, false);
silentlySetComposeText("");
long id = fragment.stageOutgoingMessage(message);
fragment.stageOutgoingMessage(message);
SimpleTask.run(() -> {
long resultId = MessageSender.sendPushWithPreUploadedMedia(context, message, result.getPreUploadResults(), thread, null);
@@ -3024,7 +3054,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)
private void sendMediaMessage(@NonNull MessageSendType sendType, final long expiresIn, final boolean viewOnce, final boolean initiating, @Nullable String metricId, long scheduledDate)
throws InvalidMessageException
{
Log.i(TAG, "Sending media message...");
@@ -3042,7 +3072,8 @@ public class ConversationParentFragment extends Fragment
viewOnce,
initiating,
true,
metricId);
metricId,
scheduledDate);
}
private ListenableFuture<Void> sendMediaMessage(@NonNull RecipientId recipientId,
@@ -3059,6 +3090,25 @@ public class ConversationParentFragment extends Fragment
final boolean initiating,
final boolean clearComposeBox,
final @Nullable String metricId)
{
return sendMediaMessage(recipientId, sendType, body, slideDeck, quote, contacts, previews, mentions, styling, expiresIn, viewOnce, initiating, clearComposeBox, metricId, -1);
}
private ListenableFuture<Void> sendMediaMessage(@NonNull RecipientId recipientId,
@NonNull MessageSendType sendType,
@NonNull String body,
SlideDeck slideDeck,
QuoteModel quote,
List<Contact> contacts,
List<LinkPreview> previews,
List<Mention> mentions,
@Nullable BodyRangeList styling,
final long expiresIn,
final boolean viewOnce,
final boolean initiating,
final boolean clearComposeBox,
final @Nullable String metricId,
final long scheduledDate)
{
if (ExpiredBuildReminder.isEligible()) {
showExpiredDialog();
@@ -3101,7 +3151,8 @@ public class ConversationParentFragment extends Fragment
Collections.emptySet(),
null,
false,
styling);
styling,
scheduledDate);
final SettableFuture<Void> future = new SettableFuture<>();
final Context context = requireContext().getApplicationContext();
@@ -3126,7 +3177,7 @@ public class ConversationParentFragment extends Fragment
silentlySetComposeText("");
}
final long id = fragment.stageOutgoingMessage(outgoingMessage);
fragment.stageOutgoingMessage(outgoingMessage);
SimpleTask.run(() -> {
return MessageSender.send(context, outgoingMessage, thread, sendType.usesSmsTransport() ? SendType.MMS : SendType.SIGNAL, metricId, null);
@@ -3141,7 +3192,7 @@ public class ConversationParentFragment extends Fragment
return future;
}
private void sendTextMessage(@NonNull MessageSendType sendType, final long expiresIn, final boolean initiating, final @Nullable String metricId)
private void sendTextMessage(@NonNull MessageSendType sendType, final long expiresIn, final boolean initiating, final @Nullable String metricId, long scheduledDate)
throws InvalidMessageException
{
if (ExpiredBuildReminder.isEligible()) {
@@ -3159,10 +3210,14 @@ public class ConversationParentFragment extends Fragment
final String messageBody = getMessage();
final boolean sendPush = sendType.usesSignalTransport();
OutgoingMessage message;
final OutgoingMessage message;
if (sendPush) {
message = OutgoingMessage.text(recipient.get(), messageBody, expiresIn, System.currentTimeMillis(), null);
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);
}
ApplicationDependencies.getTypingStatusSender().onTypingStopped(thread);
} else {
message = OutgoingMessage.sms(recipient.get(), messageBody, sendType.getSimSubscriptionIdOr(-1));
@@ -3260,6 +3315,22 @@ public class ConversationParentFragment extends Fragment
}
}
private void updateScheduledMessagesBar(@NonNull Integer count) {
if (count <= 0) {
scheduledMessagesBarStub.setVisibility(View.GONE);
} else {
if (!scheduledMessagesBarStub.resolved()) {
View scheduledMessagesBar = scheduledMessagesBarStub.get();
scheduledMessagesBar.findViewById(R.id.scheduled_messages_show_all).setOnClickListener(v -> {
ScheduledMessagesBottomSheet.show(getChildFragmentManager(), threadId, recipient.getId());
});
}
scheduledMessagesBarStub.setVisibility(View.VISIBLE);
TextView scheduledText = scheduledMessagesBarStub.get().findViewById(R.id.scheduled_messages_text);
scheduledText.setText(getResources().getQuantityString(R.plurals.conversation_scheduled_messages_bar__number_of_messages, count, count));
}
}
private void recordTransportPreference(MessageSendType sendType) {
new AsyncTask<Void, Void, Void>() {
@Override
@@ -3403,7 +3474,7 @@ public class ConversationParentFragment extends Fragment
container.hideAttachedInput(true);
}
private void sendVoiceNote(@NonNull Uri uri, long size) {
private void sendVoiceNote(@NonNull Uri uri, long size, long scheduledDate) {
boolean initiating = threadId == -1;
long expiresIn = TimeUnit.SECONDS.toMillis(recipient.get().getExpiresInSeconds());
AudioSlide audioSlide = new AudioSlide(requireContext(), uri, size, MediaUtil.AUDIO_AAC, true);
@@ -3424,7 +3495,8 @@ public class ConversationParentFragment extends Fragment
false,
initiating,
true,
null);
null,
scheduledDate);
}
private void sendSticker(@NonNull StickerRecord stickerRecord, boolean clearCompose) {
@@ -3589,6 +3661,14 @@ public class ConversationParentFragment extends Fragment
});
}
public void onScheduleSend(long scheduledTime) {
sendMessage(null, scheduledTime);
}
public @NonNull ConversationAdapter.ItemClickListener getConversationAdapterListener() {
return fragment.getConversationAdapterListener();
}
// Listeners
private class RecordingSession implements SingleObserver<VoiceNoteDraft> {
@@ -3607,7 +3687,7 @@ public class ConversationParentFragment extends Fragment
@Override
public void onSuccess(@NonNull VoiceNoteDraft draft) {
if (shouldSend) {
sendVoiceNote(draft.getUri(), draft.getSize());
sendVoiceNote(draft.getUri(), draft.getSize(), -1);
} else {
if (!saveDraft) {
draftViewModel.cancelEphemeralVoiceNoteDraft(draft.asDraft());
@@ -3688,7 +3768,7 @@ public class ConversationParentFragment extends Fragment
private class AttachButtonLongClickListener implements View.OnLongClickListener {
@Override
public boolean onLongClick(View v) {
return sendButton.performLongClick();
return sendButton.showSendTypeMenu();
}
}

View File

@@ -73,6 +73,7 @@ public class ConversationViewModel extends ViewModel {
private final Application context;
private final MediaRepository mediaRepository;
private final ConversationRepository conversationRepository;
private final ScheduledMessagesRepository scheduledMessagesRepository;
private final MutableLiveData<List<Media>> recentMedia;
private final BehaviorSubject<Long> threadId;
private final Observable<MessageData> messageData;
@@ -99,6 +100,7 @@ public class ConversationViewModel extends ViewModel {
private final CompositeDisposable disposables;
private final BehaviorSubject<Unit> conversationStateTick;
private final PublishProcessor<Long> markReadRequestPublisher;
private final Observable<Integer> scheduledMessageCount;
private ConversationIntents.Args args;
private int jumpToPosition;
@@ -107,6 +109,7 @@ public class ConversationViewModel extends ViewModel {
this.context = ApplicationDependencies.getApplication();
this.mediaRepository = new MediaRepository();
this.conversationRepository = new ConversationRepository();
this.scheduledMessagesRepository = new ScheduledMessagesRepository();
this.recentMedia = new MutableLiveData<>();
this.showScrollButtons = new MutableLiveData<>(false);
this.hasUnreadMentions = new MutableLiveData<>(false);
@@ -200,6 +203,10 @@ public class ConversationViewModel extends ViewModel {
.withLatestFrom(conversationMetadata, (messages, metadata) -> new MessageData(metadata, messages))
.doOnNext(a -> SignalLocalMetrics.ConversationOpen.onDataLoaded());
scheduledMessageCount = threadId
.observeOn(Schedulers.io())
.flatMap(scheduledMessagesRepository::getScheduledMessageCount);
Observable<Recipient> liveRecipient = recipientId.distinctUntilChanged().switchMap(id -> Recipient.live(id).asObservable());
canShowAsBubble = threadId.observeOn(Schedulers.io()).map(conversationRepository::canShowAsBubble);
@@ -371,6 +378,10 @@ public class ConversationViewModel extends ViewModel {
.observeOn(AndroidSchedulers.mainThread());
}
@NonNull Observable<Integer> getScheduledMessageCount() {
return scheduledMessageCount.observeOn(AndroidSchedulers.mainThread());
}
void setHasUnreadMentions(boolean hasUnreadMentions) {
this.hasUnreadMentions.setValue(hasUnreadMentions);
}

View File

@@ -0,0 +1,78 @@
package org.thoughtcrime.securesms.conversation
import android.view.View
import android.view.ViewGroup
import androidx.annotation.DrawableRes
import org.signal.core.util.dp
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.toLocalDateTime
import org.thoughtcrime.securesms.util.toMillis
import java.util.Locale
class ScheduleMessageContextMenu {
companion object {
private val presetHours = arrayOf(8, 12, 18, 21)
@JvmStatic
fun show(anchor: View, container: ViewGroup, action: (Long) -> Unit): SignalContextMenu {
val currentTime = System.currentTimeMillis()
val scheduledTimes = getNextScheduleTimes(currentTime)
val actionItems = scheduledTimes.map {
if (it > 0) {
ActionItem(getIconForTime(it), DateUtils.getScheduledMessageDateString(anchor.context, Locale.getDefault(), it)) {
action(it)
}
} else {
ActionItem(0, anchor.context.getString(R.string.ScheduledMessages_pick_time)) {
action(it)
}
}
}
return SignalContextMenu.Builder(anchor, container)
.offsetX(12.dp)
.offsetY(12.dp)
.preferredVerticalPosition(SignalContextMenu.VerticalPosition.ABOVE)
.show(actionItems)
}
@DrawableRes
private fun getIconForTime(timeMs: Long): Int {
val dateTime = timeMs.toLocalDateTime()
return if (dateTime.hour >= 18) {
R.drawable.ic_nighttime_26
} else {
R.drawable.ic_daytime_24
}
}
private fun getNextScheduleTimes(currentTimeMs: Long): List<Long> {
var currentDateTime = currentTimeMs.toLocalDateTime()
val timestampList = ArrayList<Long>(4)
var presetIndex = presetHours.indexOfFirst { it > currentDateTime.hour }
if (presetIndex == -1) {
currentDateTime = currentDateTime.plusDays(1)
presetIndex = 0
}
currentDateTime = currentDateTime.withMinute(0).withSecond(0)
while (timestampList.size < 3) {
currentDateTime = currentDateTime.withHour(presetHours[presetIndex])
timestampList += currentDateTime.toMillis()
presetIndex++
if (presetIndex >= presetHours.size) {
presetIndex = 0
currentDateTime = currentDateTime.plusDays(1)
}
}
timestampList += -1
return timestampList.reversed()
}
}
}

View File

@@ -0,0 +1,40 @@
package org.thoughtcrime.securesms.conversation
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.FragmentManager
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.databinding.ScheduleMessageFtuxBottomSheetBinding
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.util.BottomSheetUtil
class ScheduleMessageFtuxBottomSheetDialog : FixedRoundedCornerBottomSheetDialogFragment() {
override val peekHeightPercentage: Float = 0.66f
override val themeResId: Int = R.style.Widget_Signal_FixedRoundedCorners_Messages
private val binding by ViewBinderDelegate(ScheduleMessageFtuxBottomSheetBinding::bind)
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
return inflater.inflate(R.layout.schedule_message_ftux_bottom_sheet, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
binding.okay.setOnClickListener {
SignalStore.uiHints().markHasSeenScheduledMessagesInfoSheet()
dismiss()
}
}
companion object {
@JvmStatic
fun show(fragmentManager: FragmentManager) {
val fragment = ScheduleMessageFtuxBottomSheetDialog()
fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
}

View File

@@ -0,0 +1,203 @@
package org.thoughtcrime.securesms.conversation
import android.os.Bundle
import android.text.format.DateFormat
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.FragmentManager
import com.google.android.material.datepicker.CalendarConstraints
import com.google.android.material.datepicker.DateValidatorPointForward
import com.google.android.material.datepicker.MaterialDatePicker
import com.google.android.material.timepicker.MaterialTimePicker
import com.google.android.material.timepicker.TimeFormat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.databinding.ScheduleMessageTimePickerBottomSheetBinding
import org.thoughtcrime.securesms.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.atMidnight
import org.thoughtcrime.securesms.util.atUTC
import org.thoughtcrime.securesms.util.formatHours
import org.thoughtcrime.securesms.util.fragments.findListener
import org.thoughtcrime.securesms.util.toLocalDateTime
import org.thoughtcrime.securesms.util.toMillis
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZoneOffset
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.util.Locale
/**
* Bottom sheet dialog that allows selecting a timestamp after the current time for
* scheduling a message send.
*
* Will call [ScheduleCallback.onScheduleSend] with the selected time, if called with [showSchedule]
* Will call [RescheduleCallback.onReschedule] with the selected time, if called with [showReschedule]
*/
class ScheduleMessageTimePickerBottomSheet : FixedRoundedCornerBottomSheetDialogFragment() {
override val peekHeightPercentage: Float = 0.66f
override val themeResId: Int = R.style.Widget_Signal_FixedRoundedCorners_Messages
private var scheduledDate: Long = 0
private var scheduledHour: Int = 0
private var scheduledMinute: Int = 0
private val binding by ViewBinderDelegate(ScheduleMessageTimePickerBottomSheetBinding::bind)
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
return inflater.inflate(R.layout.schedule_message_time_picker_bottom_sheet, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val initialTime = arguments?.getLong(KEY_INITIAL_TIME)
scheduledDate = initialTime ?: System.currentTimeMillis()
var scheduledLocalDateTime = scheduledDate.toLocalDateTime()
if (initialTime == null) {
scheduledLocalDateTime = scheduledLocalDateTime.plusMinutes(5L - (scheduledLocalDateTime.minute % 5))
}
scheduledHour = scheduledLocalDateTime.hour
scheduledMinute = scheduledLocalDateTime.minute
binding.scheduleSend.setOnClickListener {
dismiss()
val messageId = arguments?.getLong(KEY_MESSAGE_ID)
if (messageId == null) {
findListener<ScheduleCallback>()?.onScheduleSend(getSelectedTimestamp())
} else {
val selectedTime = getSelectedTimestamp()
if (selectedTime != arguments?.getLong(KEY_INITIAL_TIME)) {
findListener<RescheduleCallback>()?.onReschedule(selectedTime, messageId)
}
}
}
val zoneOffsetFormatter = DateTimeFormatter.ofPattern("OOOO")
val zoneNameFormatter = DateTimeFormatter.ofPattern("zzzz")
val zonedDateTime = ZonedDateTime.now()
binding.timezoneDisclaimer.apply {
text = getString(
R.string.ScheduleMessageTimePickerBottomSheet__timezone_disclaimer,
zoneOffsetFormatter.format(zonedDateTime),
zoneNameFormatter.format(zonedDateTime),
)
}
updateSelectedDate()
updateSelectedTime()
setupDateSelector()
setupTimeSelector()
}
private fun setupDateSelector() {
binding.daySelector.setOnClickListener {
val local = LocalDateTime.now()
.atMidnight()
.atUTC()
.toMillis()
val datePicker =
MaterialDatePicker.Builder.datePicker()
.setTitleText(getString(R.string.ScheduleMessageTimePickerBottomSheet__select_date_title))
.setSelection(scheduledDate)
.setCalendarConstraints(CalendarConstraints.Builder().setStart(local).setValidator(DateValidatorPointForward.now()).build())
.build()
datePicker.addOnDismissListener {
datePicker.clearOnDismissListeners()
datePicker.clearOnPositiveButtonClickListeners()
}
datePicker.addOnPositiveButtonClickListener {
it.let {
scheduledDate = it.toLocalDateTime(ZoneOffset.UTC).atZone(ZoneId.systemDefault()).toMillis()
updateSelectedDate()
}
}
datePicker.show(childFragmentManager, "DATE_PICKER")
}
}
private fun setupTimeSelector() {
binding.timeSelector.setOnClickListener {
val timeFormat = if (DateFormat.is24HourFormat(requireContext())) TimeFormat.CLOCK_24H else TimeFormat.CLOCK_12H
val timePickerFragment = MaterialTimePicker.Builder()
.setTimeFormat(timeFormat)
.setHour(scheduledHour)
.setMinute(scheduledMinute)
.setTitleText(getString(R.string.ScheduleMessageTimePickerBottomSheet__select_time_title))
.build()
timePickerFragment.addOnDismissListener {
timePickerFragment.clearOnDismissListeners()
timePickerFragment.clearOnPositiveButtonClickListeners()
}
timePickerFragment.addOnPositiveButtonClickListener {
scheduledHour = timePickerFragment.hour
scheduledMinute = timePickerFragment.minute
updateSelectedTime()
}
timePickerFragment.show(childFragmentManager, "TIME_PICKER")
}
}
private fun getSelectedTimestamp(): Long {
return scheduledDate.toLocalDateTime()
.withMinute(scheduledMinute)
.withHour(scheduledHour)
.withSecond(0)
.withNano(0)
.toMillis()
}
private fun updateSelectedDate() {
binding.dateText.text = DateUtils.getDayPrecisionTimeString(requireContext(), Locale.getDefault(), scheduledDate)
}
private fun updateSelectedTime() {
val scheduledTime = LocalTime.of(scheduledHour, scheduledMinute)
binding.timeText.text = scheduledTime.formatHours(requireContext())
}
interface ScheduleCallback {
fun onScheduleSend(scheduledTime: Long)
}
interface RescheduleCallback {
fun onReschedule(scheduledTime: Long, messageId: Long)
}
companion object {
private const val KEY_MESSAGE_ID = "message_id"
private const val KEY_INITIAL_TIME = "initial_time"
@JvmStatic
fun showSchedule(fragmentManager: FragmentManager) {
val fragment = ScheduleMessageTimePickerBottomSheet()
fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
@JvmStatic
fun showReschedule(fragmentManager: FragmentManager, messageId: Long, initialTime: Long) {
val args = Bundle().apply {
putLong(KEY_MESSAGE_ID, messageId)
putLong(KEY_INITIAL_TIME, initialTime)
}
val fragment = ScheduleMessageTimePickerBottomSheet().apply {
arguments = args
}
fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
}

View File

@@ -0,0 +1,295 @@
package org.thoughtcrime.securesms.conversation
import android.annotation.SuppressLint
import android.content.DialogInterface
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AlertDialog
import androidx.core.view.doOnNextLayout
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.signal.core.util.StreamUtil
import org.signal.core.util.concurrent.SimpleTask
import org.signal.core.util.dp
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
import org.thoughtcrime.securesms.components.SignalProgressDialog
import org.thoughtcrime.securesms.components.menu.ActionItem
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager
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.conversation.mutiselect.MultiselectPart.Attachments
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
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.linkpreview.LinkPreview
import org.thoughtcrime.securesms.mms.GlideApp
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.util.BottomSheetUtil
import org.thoughtcrime.securesms.util.BottomSheetUtil.requireCoordinatorLayout
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.StickyHeaderDecoration
import org.thoughtcrime.securesms.util.Util
import org.thoughtcrime.securesms.util.fragments.requireListener
import org.thoughtcrime.securesms.util.hasTextSlide
import org.thoughtcrime.securesms.util.requireTextSlide
import java.io.IOException
import java.util.Locale
/**
* Bottom sheet dialog to view all scheduled messages within a given thread.
*/
class ScheduledMessagesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment(), ScheduleMessageTimePickerBottomSheet.RescheduleCallback {
override val peekHeightPercentage: Float = 0.66f
override val themeResId: Int = R.style.Widget_Signal_FixedRoundedCorners_Messages
private var firstRender: Boolean = true
private lateinit var messageAdapter: ConversationAdapter
private lateinit var callback: Callback
private val viewModel: ScheduledMessagesViewModel by viewModels(
factoryProducer = {
val threadId = requireArguments().getLong(KEY_THREAD_ID)
ScheduledMessagesViewModel.Factory(threadId)
}
)
private val disposables: LifecycleDisposable = LifecycleDisposable()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val view = inflater.inflate(R.layout.scheduled_messages_bottom_sheet, container, false)
disposables.bindTo(viewLifecycleOwner)
return view
}
@SuppressLint("WrongThread")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val conversationRecipientId = RecipientId.from(arguments?.getString(KEY_CONVERSATION_RECIPIENT_ID, null) ?: throw IllegalArgumentException())
val conversationRecipient = Recipient.resolved(conversationRecipientId)
callback = requireListener()
val colorizer = Colorizer()
messageAdapter = ConversationAdapter(requireContext(), viewLifecycleOwner, GlideApp.with(this), Locale.getDefault(), ConversationAdapterListener(), conversationRecipient, colorizer).apply {
setCondensedMode(true)
setScheduledMessagesMode(true)
}
val list: RecyclerView = view.findViewById<RecyclerView>(R.id.scheduled_list).apply {
layoutManager = SmoothScrollingLinearLayoutManager(requireContext(), true)
adapter = messageAdapter
itemAnimator = null
doOnNextLayout {
// Adding this without waiting for a layout pass would result in an indeterminate amount of padding added to the top of the view
addItemDecoration(StickyHeaderDecoration(messageAdapter, false, false, ConversationAdapter.HEADER_TYPE_INLINE_DATE))
}
}
val recyclerViewColorizer = RecyclerViewColorizer(list)
disposables += viewModel.getMessages(requireContext()).subscribe { messages ->
if (messages.isEmpty()) {
dismiss()
}
messageAdapter.submitList(messages) {
if (firstRender) {
(list.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(messages.size - 1, 100)
firstRender = false
} else if (!list.canScrollVertically(1)) {
list.layoutManager?.scrollToPosition(0)
}
}
recyclerViewColorizer.setChatColors(conversationRecipient.chatColors)
}
initializeGiphyMp4(view.findViewById(R.id.video_container) as ViewGroup, list)
}
private fun initializeGiphyMp4(videoContainer: ViewGroup, list: RecyclerView): GiphyMp4ProjectionRecycler {
val maxPlayback = GiphyMp4PlaybackPolicy.maxSimultaneousPlaybackInConversation()
val holders = GiphyMp4ProjectionPlayerHolder.injectVideoViews(
requireContext(),
viewLifecycleOwner.lifecycle,
videoContainer,
maxPlayback
)
val callback = GiphyMp4ProjectionRecycler(holders)
GiphyMp4PlaybackController.attach(list, callback, maxPlayback)
list.addItemDecoration(GiphyMp4ItemDecoration(callback) {}, 0)
return callback
}
private fun showScheduledMessageContextMenu(view: View, messageRecord: MessageRecord) {
SignalContextMenu.Builder(view, requireCoordinatorLayout())
.offsetX(12.dp)
.offsetY(12.dp)
.preferredVerticalPosition(SignalContextMenu.VerticalPosition.ABOVE)
.show(getMenuActionItems(messageRecord))
}
private fun getMenuActionItems(messageRecord: MessageRecord): List<ActionItem> {
val message = ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(requireContext(), messageRecord)
val canCopy = message.multiselectCollection.toSet().any { it !is Attachments && messageRecord.body.isNotEmpty() }
val items: MutableList<ActionItem> = ArrayList()
items.add(ActionItem(R.drawable.ic_delete_tinted_24, resources.getString(R.string.conversation_selection__menu_delete), action = { handleDeleteMessage(messageRecord) }))
if (canCopy) {
items.add(ActionItem(R.drawable.ic_copy_24_tinted, resources.getString(R.string.conversation_selection__menu_copy), action = { handleCopyMessage(message) }))
}
items.add(ActionItem(R.drawable.ic_send_outline_24, resources.getString(R.string.ScheduledMessagesBottomSheet_menu_send_now), action = { handleSendMessageNow(messageRecord) }))
items.add(ActionItem(R.drawable.ic_calendar_24, resources.getString(R.string.ScheduledMessagesBottomSheet_menu_reschedule), action = { handleRescheduleMessage(messageRecord) }))
return items
}
private fun handleRescheduleMessage(messageRecord: MessageRecord) {
ScheduleMessageTimePickerBottomSheet.showReschedule(childFragmentManager, messageRecord.id, (messageRecord as MediaMmsMessageRecord).scheduledDate)
}
private fun handleSendMessageNow(messageRecord: MessageRecord) {
viewModel.rescheduleMessage(messageRecord.id, System.currentTimeMillis())
}
private fun handleDeleteMessage(messageRecord: MessageRecord) {
buildDeleteScheduledMessageConfirmationDialog(messageRecord).show()
}
private fun handleCopyMessage(message: ConversationMessage) {
SimpleTask.run(
viewLifecycleOwner.lifecycle,
{ getMessageText(message) },
{ bodies: CharSequence? ->
if (!Util.isEmpty(bodies)) {
Util.copyToClipboard(requireContext(), bodies!!)
}
}
)
}
private fun buildDeleteScheduledMessageConfirmationDialog(messageRecord: MessageRecord): AlertDialog.Builder {
return MaterialAlertDialogBuilder(requireContext())
.setTitle(resources.getString(R.string.ScheduledMessagesBottomSheet_delete_dialog_message))
.setCancelable(true)
.setPositiveButton(R.string.ScheduledMessagesBottomSheet_delete_dialog_action) { _: DialogInterface?, _: Int ->
deleteMessage(messageRecord.id)
}
.setNegativeButton(android.R.string.cancel, null)
}
private fun getMessageText(message: ConversationMessage): CharSequence {
if (message.messageRecord.hasTextSlide()) {
val textSlide: TextSlide = message.messageRecord.requireTextSlide()
if (textSlide.uri == null) {
return message.getDisplayBody(requireContext())
}
try {
PartAuthority.getAttachmentStream(requireContext(), textSlide.uri!!).use { stream ->
val body = StreamUtil.readFullyAsString(stream)
return ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(requireContext(), message.messageRecord, body)
.getDisplayBody(requireContext())
}
} catch (e: IOException) {
Log.w(TAG, "Failed to read text slide data.")
}
}
return ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(requireContext(), message.messageRecord).getDisplayBody(requireContext())
}
private fun deleteMessage(messageId: Long) {
val progressDialog = SignalProgressDialog.show(
context = requireContext(),
message = resources.getString(R.string.ScheduledMessagesBottomSheet_deleting_progress_message),
indeterminate = true
)
SimpleTask.run(viewLifecycleOwner.lifecycle, {
SignalDatabase.messages.deleteScheduledMessage(messageId)
}, {
progressDialog.dismiss()
})
}
override fun onReschedule(scheduledTime: Long, messageId: Long) {
viewModel.rescheduleMessage(messageId, scheduledTime)
}
private fun getAdapterListener(): ConversationAdapter.ItemClickListener {
return callback.getConversationAdapterListener()
}
private inner class ConversationAdapterListener : ConversationAdapter.ItemClickListener by getAdapterListener() {
override fun onItemClick(item: MultiselectPart) = Unit
override fun onItemLongClick(itemView: View, item: MultiselectPart) = Unit
override fun onQuoteClicked(messageRecord: MmsMessageRecord) = Unit
override fun onLinkPreviewClicked(linkPreview: LinkPreview) = Unit
override fun onQuotedIndicatorClicked(messageRecord: MessageRecord) = Unit
override fun onReactionClicked(multiselectPart: MultiselectPart, messageId: Long, isMms: Boolean) = Unit
override fun onGroupMemberClicked(recipientId: RecipientId, groupId: GroupId) = 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 onScheduledIndicatorClicked(view: View, messageRecord: MessageRecord) {
showScheduledMessageContextMenu(view, messageRecord)
}
}
interface Callback {
fun getConversationAdapterListener(): ConversationAdapter.ItemClickListener
}
companion object {
private val TAG = Log.tag(ScheduledMessagesBottomSheet::class.java)
private const val KEY_THREAD_ID = "thread_id"
private const val KEY_CONVERSATION_RECIPIENT_ID = "conversation_recipient_id"
@JvmStatic
fun show(fragmentManager: FragmentManager, threadId: Long, conversationRecipientId: RecipientId) {
val args = Bundle().apply {
putLong(KEY_THREAD_ID, threadId)
putString(KEY_CONVERSATION_RECIPIENT_ID, conversationRecipientId.serialize())
}
val fragment = ScheduledMessagesBottomSheet().apply {
arguments = args
}
fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
}

View File

@@ -0,0 +1,68 @@
package org.thoughtcrime.securesms.conversation
import android.content.Context
import androidx.annotation.WorkerThread
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.schedulers.Schedulers
import org.thoughtcrime.securesms.database.DatabaseObserver
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.MessageRecord
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
/**
* Handles retrieving scheduled messages data to be shown in [ScheduledMessagesBottomSheet] and [ConversationParentFragment]
*/
class ScheduledMessagesRepository {
/**
* Get all the scheduled messages for the specified thread, ordered by scheduled time
*/
fun getScheduledMessages(context: Context, threadId: Long): Observable<List<ConversationMessage>> {
return Observable.create { emitter ->
val databaseObserver: DatabaseObserver = ApplicationDependencies.getDatabaseObserver()
val observer = DatabaseObserver.Observer { emitter.onNext(getScheduledMessagesSync(context, threadId)) }
databaseObserver.registerScheduledMessageObserver(threadId, observer)
emitter.setCancellable { databaseObserver.unregisterObserver(observer) }
emitter.onNext(getScheduledMessagesSync(context, threadId))
}.subscribeOn(Schedulers.io())
}
@WorkerThread
private fun getScheduledMessagesSync(context: Context, threadId: Long): List<ConversationMessage> {
var scheduledMessages: List<MessageRecord> = SignalDatabase.messages.getScheduledMessagesInThread(threadId)
val attachmentHelper = ConversationDataSource.AttachmentHelper()
attachmentHelper.addAll(scheduledMessages)
attachmentHelper.fetchAttachments()
scheduledMessages = attachmentHelper.buildUpdatedModels(ApplicationDependencies.getApplication(), scheduledMessages)
val replies: List<ConversationMessage> = scheduledMessages
.map { ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(context, it) }
return replies
}
/**
* Get the number of scheduled messages for a given thread
*/
fun getScheduledMessageCount(threadId: Long): Observable<Int> {
return Observable.create { emitter ->
val databaseObserver: DatabaseObserver = ApplicationDependencies.getDatabaseObserver()
val observer = DatabaseObserver.Observer { emitter.onNext(SignalDatabase.messages.getScheduledMessageCountForThread(threadId)) }
databaseObserver.registerScheduledMessageObserver(threadId, observer)
emitter.setCancellable { databaseObserver.unregisterObserver(observer) }
emitter.onNext(SignalDatabase.messages.getScheduledMessageCountForThread(threadId))
}.subscribeOn(Schedulers.io())
}
fun rescheduleMessage(threadId: Long, messageId: Long, scheduleTime: Long) {
SignalDatabase.messages.rescheduleMessage(threadId, messageId, scheduleTime)
}
}

View File

@@ -0,0 +1,33 @@
package org.thoughtcrime.securesms.conversation
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import org.signal.core.util.logging.Log
class ScheduledMessagesViewModel @JvmOverloads constructor(
private val threadId: Long,
private val repository: ScheduledMessagesRepository = ScheduledMessagesRepository()
) : ViewModel() {
fun getMessages(context: Context): Observable<List<ConversationMessage>> {
return repository.getScheduledMessages(context, threadId)
.observeOn(AndroidSchedulers.mainThread())
}
fun rescheduleMessage(messageId: Long, scheduleTime: Long) {
repository.rescheduleMessage(threadId, messageId, scheduleTime)
}
companion object {
private val TAG = Log.tag(ScheduledMessagesViewModel::class.java)
}
class Factory(private val threadId: Long) : ViewModelProvider.NewInstanceFactory() {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return modelClass.cast(ScheduledMessagesViewModel(threadId)) as T
}
}
}