diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 5d8629fac..424addd55 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -815,6 +815,8 @@
+
+
diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java
index cf7d655d4..930821cda 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java
@@ -182,6 +182,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
.addNonBlocking(this::cleanAvatarStorage)
.addNonBlocking(this::initializeRevealableMessageManager)
.addNonBlocking(this::initializePendingRetryReceiptManager)
+ .addNonBlocking(this::initializeScheduledMessageManager)
.addNonBlocking(this::initializeFcmCheck)
.addNonBlocking(PreKeysSyncJob::enqueueIfNeeded)
.addNonBlocking(this::initializePeriodicTasks)
@@ -390,6 +391,10 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr
ApplicationDependencies.getPendingRetryReceiptManager().scheduleIfNecessary();
}
+ private void initializeScheduledMessageManager() {
+ ApplicationDependencies.getScheduledMessageManager().scheduleIfNecessary();
+ }
+
private void initializeTrimThreadsByDateManager() {
KeepMessagesDuration keepMessagesDuration = SignalStore.settings().getKeepMessagesDuration();
if (keepMessagesDuration != KeepMessagesDuration.FOREVER) {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java
index ca4ffcca7..ba376430a 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java
@@ -106,6 +106,7 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable,
void onInviteToSignalClicked();
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);
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java
index 4e1ea9bf8..d82e7b37b 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java
@@ -29,6 +29,7 @@ import org.signal.core.util.concurrent.SignalExecutors;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.animation.AnimationCompleteListener;
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.dependencies.ApplicationDependencies;
@@ -36,6 +37,7 @@ 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;
import org.thoughtcrime.securesms.util.SignalLocalMetrics;
import org.thoughtcrime.securesms.util.ViewUtil;
@@ -315,6 +317,8 @@ public class ConversationItemFooter extends ConstraintLayout {
dateView.setText(R.string.ConversationItem_click_to_approve_unencrypted);
} else if (messageRecord.isRateLimited()) {
dateView.setText(R.string.ConversationItem_send_paused);
+ } else if (MessageRecordUtil.isScheduled(messageRecord)) {
+ dateView.setText(DateUtils.getOnlyTimeString(getContext(), locale, ((MediaMmsMessageRecord) messageRecord).getScheduledDate()));
} else {
dateView.setText(DateUtils.getSimpleRelativeTimeSpanString(getContext(), locale, messageRecord.getTimestamp()));
}
@@ -392,7 +396,7 @@ public class ConversationItemFooter extends ConstraintLayout {
previousMessageId = newMessageId;
- if (messageRecord.isFailed() || messageRecord.isPendingInsecureSmsFallback()) {
+ if (messageRecord.isFailed() || messageRecord.isPendingInsecureSmsFallback() || MessageRecordUtil.isScheduled(messageRecord)) {
deliveryStatusView.setNone();
return;
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/SendButton.kt b/app/src/main/java/org/thoughtcrime/securesms/components/SendButton.kt
index 00d161e83..02c795aef 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/SendButton.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/SendButton.kt
@@ -28,6 +28,7 @@ class SendButton(context: Context, attributeSet: AttributeSet?) : AppCompatImage
}
private val listeners: MutableList = CopyOnWriteArrayList()
+ private var scheduledSendListener: ScheduledSendListener? = null
private var availableSendTypes: List = MessageSendType.getAllAvailable(context, false)
private var activeMessageSendType: MessageSendType? = null
@@ -98,6 +99,10 @@ class SendButton(context: Context, attributeSet: AttributeSet?) : AppCompatImage
onSelectionChanged(newType = selectedSendType, isManualSelection = false)
}
+ fun setScheduledSendListener(listener: ScheduledSendListener?) {
+ this.scheduledSendListener = listener
+ }
+
fun resetAvailableTransports(isMediaMessage: Boolean) {
availableSendTypes = MessageSendType.getAllAvailable(context, isMediaMessage)
activeMessageSendType = null
@@ -150,13 +155,29 @@ class SendButton(context: Context, attributeSet: AttributeSet?) : AppCompatImage
}
}
+ fun showSendTypeMenu(): Boolean {
+ return if (availableSendTypes.size == 1) {
+ if (scheduledSendListener == null && !SignalStore.misc().smsExportPhase.allowSmsFeatures()) {
+ Snackbar.make(snackbarContainer, R.string.InputPanel__sms_messaging_is_no_longer_supported_in_signal, Snackbar.LENGTH_SHORT).show()
+ }
+ false
+ } else {
+ showSendTypeContextMenu(false)
+ true
+ }
+ }
+
override fun onLongClick(v: View): Boolean {
if (!isEnabled) {
return false
}
+ val scheduleListener = scheduledSendListener
if (availableSendTypes.size == 1) {
- return if (!SignalStore.misc().smsExportPhase.allowSmsFeatures()) {
+ return if (scheduleListener != null) {
+ scheduleListener.onSendScheduled()
+ true
+ } else if (!SignalStore.misc().smsExportPhase.allowSmsFeatures()) {
Snackbar.make(snackbarContainer, R.string.InputPanel__sms_messaging_is_no_longer_supported_in_signal, Snackbar.LENGTH_SHORT).show()
true
} else {
@@ -164,8 +185,14 @@ class SendButton(context: Context, attributeSet: AttributeSet?) : AppCompatImage
}
}
- val currentlySelected: MessageSendType = selectedSendType
+ showSendTypeContextMenu(true)
+ return true
+ }
+
+ private fun showSendTypeContextMenu(allowScheduling: Boolean) {
+ val currentlySelected: MessageSendType = selectedSendType
+ val listener = scheduledSendListener
val items = availableSendTypes
.filterNot { it == currentlySelected }
.map { option ->
@@ -174,17 +201,26 @@ class SendButton(context: Context, attributeSet: AttributeSet?) : AppCompatImage
title = option.getTitle(context),
action = { setSendType(option) }
)
- }
+ }.toMutableList()
+ if (allowScheduling && listener != null) {
+ items += ActionItem(
+ iconRes = R.drawable.ic_calendar_24,
+ title = context.getString(R.string.conversation_activity__option_schedule_message),
+ action = { listener.onSendScheduled() }
+ )
+ }
SignalContextMenu.Builder((parent as View), popupContainer!!)
.preferredVerticalPosition(SignalContextMenu.VerticalPosition.ABOVE)
.offsetY(ViewUtil.dpToPx(8))
.show(items)
-
- return true
}
interface SendTypeChangedListener {
fun onSendTypeChanged(newType: MessageSendType, manuallySelected: Boolean)
}
+
+ interface ScheduledSendListener {
+ fun onSendScheduled()
+ }
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java
index 091c9dcb2..a42fe61ea 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java
@@ -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) {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationDataSource.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationDataSource.java
index 7096f2721..7a4130f5e 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationDataSource.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationDataSource.java
@@ -186,6 +186,10 @@ public class ConversationDataSource implements PagedDataSource 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) {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java
index 76f2783c4..4eb0613e9 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java
@@ -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 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 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 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 slides) {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationMessage.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationMessage.java
index 56572f86c..e0026014a 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationMessage.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationMessage.java
@@ -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.
*/
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java
index 83d130b54..947379a44 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java
@@ -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 releaseChannelUnmute;
private Stub mentionsSuggestions;
+ private Stub 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() {
+ null,
+ result.getScheduledTime()).addListener(new AssertedSuccessListener() {
@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 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 sendMediaMessage(@NonNull RecipientId recipientId,
+ @NonNull MessageSendType sendType,
+ @NonNull String body,
+ SlideDeck slideDeck,
+ QuoteModel quote,
+ List contacts,
+ List previews,
+ List 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 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() {
@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 {
@@ -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();
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationViewModel.java
index fa2e325a0..4c8b65783 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationViewModel.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationViewModel.java
@@ -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> recentMedia;
private final BehaviorSubject threadId;
private final Observable messageData;
@@ -99,6 +100,7 @@ public class ConversationViewModel extends ViewModel {
private final CompositeDisposable disposables;
private final BehaviorSubject conversationStateTick;
private final PublishProcessor markReadRequestPublisher;
+ private final Observable 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 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 getScheduledMessageCount() {
+ return scheduledMessageCount.observeOn(AndroidSchedulers.mainThread());
+ }
+
void setHasUnreadMentions(boolean hasUnreadMentions) {
this.hasUnreadMentions.setValue(hasUnreadMentions);
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ScheduleMessageContextMenu.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ScheduleMessageContextMenu.kt
new file mode 100644
index 000000000..f544ac7be
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ScheduleMessageContextMenu.kt
@@ -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 {
+ var currentDateTime = currentTimeMs.toLocalDateTime()
+
+ val timestampList = ArrayList(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()
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ScheduleMessageFtuxBottomSheetDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ScheduleMessageFtuxBottomSheetDialog.kt
new file mode 100644
index 000000000..2fc117163
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ScheduleMessageFtuxBottomSheetDialog.kt
@@ -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)
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ScheduleMessageTimePickerBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ScheduleMessageTimePickerBottomSheet.kt
new file mode 100644
index 000000000..57e02af9a
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ScheduleMessageTimePickerBottomSheet.kt
@@ -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()?.onScheduleSend(getSelectedTimestamp())
+ } else {
+ val selectedTime = getSelectedTimestamp()
+ if (selectedTime != arguments?.getLong(KEY_INITIAL_TIME)) {
+ findListener()?.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)
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ScheduledMessagesBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ScheduledMessagesBottomSheet.kt
new file mode 100644
index 000000000..660b50936
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ScheduledMessagesBottomSheet.kt
@@ -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(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 {
+ val message = ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(requireContext(), messageRecord)
+ val canCopy = message.multiselectCollection.toSet().any { it !is Attachments && messageRecord.body.isNotEmpty() }
+ val items: MutableList = 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)
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ScheduledMessagesRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ScheduledMessagesRepository.kt
new file mode 100644
index 000000000..e9b6c481b
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ScheduledMessagesRepository.kt
@@ -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> {
+ 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 {
+ var scheduledMessages: List = SignalDatabase.messages.getScheduledMessagesInThread(threadId)
+
+ val attachmentHelper = ConversationDataSource.AttachmentHelper()
+
+ attachmentHelper.addAll(scheduledMessages)
+
+ attachmentHelper.fetchAttachments()
+
+ scheduledMessages = attachmentHelper.buildUpdatedModels(ApplicationDependencies.getApplication(), scheduledMessages)
+
+ val replies: List = scheduledMessages
+ .map { ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(context, it) }
+
+ return replies
+ }
+
+ /**
+ * Get the number of scheduled messages for a given thread
+ */
+ fun getScheduledMessageCount(threadId: Long): Observable {
+ 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)
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ScheduledMessagesViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ScheduledMessagesViewModel.kt
new file mode 100644
index 000000000..53a78502a
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ScheduledMessagesViewModel.kt
@@ -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> {
+ 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 create(modelClass: Class): T {
+ return modelClass.cast(ScheduledMessagesViewModel(threadId)) as T
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseObserver.java b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseObserver.java
index eddf93e30..cacfba7f4 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseObserver.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseObserver.java
@@ -42,6 +42,7 @@ public class DatabaseObserver {
private static final String KEY_NOTIFICATION_PROFILES = "NotificationProfiles";
private static final String KEY_RECIPIENT = "Recipient";
private static final String KEY_STORY_OBSERVER = "Story";
+ private static final String KEY_SCHEDULED_MESSAGES = "ScheduledMessages";
private final Application application;
private final Executor executor;
@@ -50,6 +51,7 @@ public class DatabaseObserver {
private final Map> conversationObservers;
private final Map> verboseConversationObservers;
private final Map> paymentObservers;
+ private final Map> scheduledMessageObservers;
private final Set allPaymentsObservers;
private final Set chatColorsObservers;
private final Set stickerObservers;
@@ -76,6 +78,7 @@ public class DatabaseObserver {
this.messageInsertObservers = new HashMap<>();
this.notificationProfileObservers = new HashSet<>();
this.storyObservers = new HashMap<>();
+ this.scheduledMessageObservers = new HashMap<>();
}
public void registerConversationListObserver(@NonNull Observer listener) {
@@ -159,6 +162,12 @@ public class DatabaseObserver {
});
}
+ public void registerScheduledMessageObserver(long threadId, @NonNull Observer listener) {
+ executor.execute(() -> {
+ registerMapped(scheduledMessageObservers, threadId, listener);
+ });
+ }
+
public void unregisterObserver(@NonNull Observer listener) {
executor.execute(() -> {
conversationListObservers.remove(listener);
@@ -290,6 +299,12 @@ public class DatabaseObserver {
}
}
+ public void notifyScheduledMessageObservers(long threadId) {
+ runPostSuccessfulTransaction(KEY_SCHEDULED_MESSAGES + threadId, () -> {
+ notifyMapped(scheduledMessageObservers, threadId);
+ });
+ }
+
private void runPostSuccessfulTransaction(@NonNull String dedupeKey, @NonNull Runnable runnable) {
SignalDatabase.runPostSuccessfulTransaction(dedupeKey, () -> {
executor.execute(runnable);
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.java b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.java
index eb9f8427c..754196cb9 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.java
@@ -180,6 +180,7 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie
public static final String VIEW_ONCE = "view_once";
public static final String STORY_TYPE = "story_type";
public static final String PARENT_STORY_ID = "parent_story_id";
+ public static final String SCHEDULED_DATE = "scheduled_date";
public static class Status {
public static final int STATUS_NONE = -1;
@@ -234,7 +235,8 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie
STORY_TYPE + " INTEGER DEFAULT 0, " +
PARENT_STORY_ID + " INTEGER DEFAULT 0, " +
EXPORT_STATE + " BLOB DEFAULT NULL, " +
- EXPORTED + " INTEGER DEFAULT 0);";
+ EXPORTED + " INTEGER DEFAULT 0, " +
+ SCHEDULED_DATE + " INTEGER DEFAULT -1);";
private static final String INDEX_THREAD_DATE = "mms_thread_date_index";
@@ -247,7 +249,7 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie
"CREATE INDEX IF NOT EXISTS mms_reactions_unread_index ON " + TABLE_NAME + " (" + REACTIONS_UNREAD + ");",
"CREATE INDEX IF NOT EXISTS mms_story_type_index ON " + TABLE_NAME + " (" + STORY_TYPE + ");",
"CREATE INDEX IF NOT EXISTS mms_parent_story_id_index ON " + TABLE_NAME + " (" + PARENT_STORY_ID + ");",
- "CREATE INDEX IF NOT EXISTS mms_thread_story_parent_story_index ON " + TABLE_NAME + " (" + THREAD_ID + ", " + DATE_RECEIVED + "," + STORY_TYPE + "," + PARENT_STORY_ID + ");",
+ "CREATE INDEX IF NOT EXISTS mms_thread_story_parent_story_scheduled_date_index ON " + TABLE_NAME + " (" + THREAD_ID + ", " + DATE_RECEIVED + "," + STORY_TYPE + "," + PARENT_STORY_ID + "," + SCHEDULED_DATE + ");",
"CREATE INDEX IF NOT EXISTS mms_quote_id_quote_author_index ON " + TABLE_NAME + "(" + QUOTE_ID + ", " + QUOTE_AUTHOR + ");",
"CREATE INDEX IF NOT EXISTS mms_exported_index ON " + TABLE_NAME + " (" + EXPORTED + ");",
"CREATE INDEX IF NOT EXISTS mms_id_type_payment_transactions_index ON " + TABLE_NAME + " (" + ID + "," + TYPE + ") WHERE " + TYPE + " & " + MessageTypes.SPECIAL_TYPE_PAYMENTS_NOTIFICATION + " != 0;"
@@ -298,6 +300,7 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie
MESSAGE_RANGES,
STORY_TYPE,
PARENT_STORY_ID,
+ SCHEDULED_DATE,
};
private static final String[] MMS_PROJECTION = SqlUtil.appendArg(MMS_PROJECTION_BASE, "NULL AS " + AttachmentTable.ATTACHMENT_JSON_ALIAS);
@@ -343,6 +346,7 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie
MessageTable.TYPE + " & " + MessageTypes.GROUP_V2_LEAVE_BITS + " != " + MessageTypes.GROUP_V2_LEAVE_BITS + " AND " +
MessageTable.STORY_TYPE + " = 0 AND " +
MessageTable.PARENT_STORY_ID + " <= 0 AND " +
+ MessageTable.SCHEDULED_DATE + " = -1 AND " +
MessageTable.TYPE + " NOT IN (" + MessageTypes.PROFILE_CHANGE_TYPE + ", " + MessageTypes.GV1_MIGRATION_TYPE + ", " + MessageTypes.CHANGE_NUMBER_TYPE + ", " + MessageTypes.BOOST_REQUEST_TYPE + ", " + MessageTypes.SMS_EXPORT_TYPE + ") " +
"ORDER BY " + MessageTable.DATE_RECEIVED + " DESC " +
"LIMIT 1";
@@ -1628,11 +1632,26 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie
return -1;
}
+ public int getScheduledMessageCountForThread(long threadId) {
+ SQLiteDatabase db = databaseHelper.getSignalReadableDatabase();
+
+ String query = THREAD_ID + " = ? AND " + STORY_TYPE + " = ? AND " + PARENT_STORY_ID + " <= ? AND " + SCHEDULED_DATE + " != ?";
+ String[] args = SqlUtil.buildArgs(threadId, 0, 0, -1);
+
+ try (Cursor cursor = db.query(TABLE_NAME, COUNT, query, args, null, null, null)) {
+ if (cursor.moveToFirst()) {
+ return cursor.getInt(0);
+ }
+ }
+
+ return 0;
+ }
+
public int getMessageCountForThread(long threadId) {
SQLiteDatabase db = databaseHelper.getSignalReadableDatabase();
- String query = THREAD_ID + " = ? AND " + STORY_TYPE + " = ? AND " + PARENT_STORY_ID + " <= ?";
- String[] args = SqlUtil.buildArgs(threadId, 0, 0);
+ String query = THREAD_ID + " = ? AND " + STORY_TYPE + " = ? AND " + PARENT_STORY_ID + " <= ? AND " + SCHEDULED_DATE + " = ?";
+ String[] args = SqlUtil.buildArgs(threadId, 0, 0, -1);
try (Cursor cursor = db.query(TABLE_NAME, COUNT, query, args, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
@@ -1646,8 +1665,8 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie
public int getMessageCountForThread(long threadId, long beforeTime) {
SQLiteDatabase db = databaseHelper.getSignalReadableDatabase();
- String query = THREAD_ID + " = ? AND " + DATE_RECEIVED + " < ? AND " + STORY_TYPE + " = ? AND " + PARENT_STORY_ID + " <= ?";
- String[] args = SqlUtil.buildArgs(threadId, beforeTime, 0, 0);
+ String query = THREAD_ID + " = ? AND " + DATE_RECEIVED + " < ? AND " + STORY_TYPE + " = ? AND " + PARENT_STORY_ID + " <= ? AND " + SCHEDULED_DATE + " = ?";
+ String[] args = SqlUtil.buildArgs(threadId, beforeTime, 0, 0, -1);
try (Cursor cursor = db.query(TABLE_NAME, COUNT, query, args, null, null, null)) {
if (cursor != null && cursor.moveToFirst()) {
@@ -1688,8 +1707,8 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie
}
private @NonNull SqlUtil.Query buildMeaningfulMessagesQuery(long threadId) {
- String query = THREAD_ID + " = ? AND " + STORY_TYPE + " = ? AND " + PARENT_STORY_ID + " <= ? AND (NOT " + TYPE + " & ? AND " + TYPE + " != ? AND " + TYPE + " != ? AND " + TYPE + " != ? AND " + TYPE + " != ? AND " + TYPE + " & " + MessageTypes.GROUP_V2_LEAVE_BITS + " != " + MessageTypes.GROUP_V2_LEAVE_BITS + ")";
- return SqlUtil.buildQuery(query, threadId, 0, 0, MessageTypes.IGNORABLE_TYPESMASK_WHEN_COUNTING, MessageTypes.PROFILE_CHANGE_TYPE, MessageTypes.CHANGE_NUMBER_TYPE, MessageTypes.SMS_EXPORT_TYPE, MessageTypes.BOOST_REQUEST_TYPE);
+ String query = THREAD_ID + " = ? AND " + STORY_TYPE + " = ? AND " + PARENT_STORY_ID + " <= ? AND " + SCHEDULED_DATE + " = ? AND (NOT " + TYPE + " & ? AND " + TYPE + " != ? AND " + TYPE + " != ? AND " + TYPE + " != ? AND " + TYPE + " != ? AND " + TYPE + " & " + MessageTypes.GROUP_V2_LEAVE_BITS + " != " + MessageTypes.GROUP_V2_LEAVE_BITS + ")";
+ return SqlUtil.buildQuery(query, threadId, 0, 0, -1, MessageTypes.IGNORABLE_TYPESMASK_WHEN_COUNTING, MessageTypes.PROFILE_CHANGE_TYPE, MessageTypes.CHANGE_NUMBER_TYPE, MessageTypes.SMS_EXPORT_TYPE, MessageTypes.BOOST_REQUEST_TYPE);
}
public void addFailures(long messageId, List failure) {
@@ -1929,6 +1948,33 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie
ApplicationDependencies.getDatabaseObserver().notifyMessageUpdateObservers(new MessageId(messageId));
}
+ public boolean clearScheduledStatus(long threadId, long messageId) {
+ SQLiteDatabase database = databaseHelper.getSignalWritableDatabase();
+ ContentValues contentValues = new ContentValues();
+ contentValues.put(SCHEDULED_DATE, -1);
+ contentValues.put(DATE_SENT, System.currentTimeMillis());
+ contentValues.put(DATE_RECEIVED, System.currentTimeMillis());
+
+ int rowsUpdated = database.update(TABLE_NAME, contentValues, ID_WHERE + " AND " + SCHEDULED_DATE + "!= ?", SqlUtil.buildArgs(messageId, -1));
+ ApplicationDependencies.getDatabaseObserver().notifyMessageInsertObservers(threadId, new MessageId(messageId));
+ ApplicationDependencies.getDatabaseObserver().notifyScheduledMessageObservers(threadId);
+
+ return rowsUpdated > 0;
+ }
+
+ public void rescheduleMessage(long threadId, long messageId, long time) {
+ SQLiteDatabase database = databaseHelper.getSignalWritableDatabase();
+ ContentValues contentValues = new ContentValues();
+ contentValues.put(SCHEDULED_DATE, time);
+
+ int rowsUpdated = database.update(TABLE_NAME, contentValues, ID_WHERE + " AND " + SCHEDULED_DATE + "!= ?", SqlUtil.buildArgs(messageId, -1));
+ ApplicationDependencies.getDatabaseObserver().notifyScheduledMessageObservers(threadId);
+ ApplicationDependencies.getScheduledMessageManager().scheduleIfNecessary();
+ if (rowsUpdated == 0) {
+ Log.w(TAG, "Failed to reschedule messageId=" + messageId + " to new time " + time + ". may have been sent already");
+ }
+ }
+
public void markAsInsecure(long messageId) {
updateMailboxBitmask(messageId, MessageTypes.SECURE_MESSAGE_BIT, 0, Optional.empty());
}
@@ -2191,6 +2237,7 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie
StoryType storyType = StoryType.fromCode(CursorUtil.requireInt(cursor, STORY_TYPE));
ParentStoryId parentStoryId = ParentStoryId.deserialize(CursorUtil.requireLong(cursor, PARENT_STORY_ID));
byte[] messageRangesData = CursorUtil.requireBlob(cursor, MESSAGE_RANGES);
+ long scheduledDate = cursor.getLong(cursor.getColumnIndexOrThrow(SCHEDULED_DATE));
long quoteId = cursor.getLong(cursor.getColumnIndexOrThrow(QUOTE_ID));
long quoteAuthor = cursor.getLong(cursor.getColumnIndexOrThrow(QUOTE_AUTHOR));
@@ -2280,7 +2327,8 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie
mismatches,
giftBadge,
MessageTypes.isSecureType(outboxType),
- messageRanges);
+ messageRanges,
+ scheduledDate);
return message;
}
@@ -2794,6 +2842,7 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie
contentValues.put(RECEIPT_TIMESTAMP, Stream.of(earlyDeliveryReceipts.values()).mapToLong(EarlyReceiptCache.Receipt::getTimestamp).max().orElse(-1));
contentValues.put(STORY_TYPE, message.getStoryType().getCode());
contentValues.put(PARENT_STORY_ID, message.getParentStoryId() != null ? message.getParentStoryId().serialize() : 0);
+ contentValues.put(SCHEDULED_DATE, message.getScheduledDate());
if (message.getRecipient().isSelf() && hasAudioAttachment(message.getAttachments())) {
contentValues.put(VIEWED_RECEIPT_COUNT, 1L);
@@ -2874,6 +2923,9 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie
} else {
ApplicationDependencies.getDatabaseObserver().notifyConversationListeners(threadId);
}
+ if (message.getScheduledDate() != -1) {
+ ApplicationDependencies.getDatabaseObserver().notifyScheduledMessageObservers(threadId);
+ }
} else {
ApplicationDependencies.getDatabaseObserver().notifyStoryObservers(message.getRecipient().getId());
}
@@ -2985,9 +3037,13 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie
}
public boolean deleteMessage(long messageId) {
+ long threadId = getThreadIdForMessage(messageId);
+ return deleteMessage(messageId, threadId);
+ }
+
+ public boolean deleteMessage(long messageId, long threadId) {
Log.d(TAG, "deleteMessage(" + messageId + ")");
- long threadId = getThreadIdForMessage(messageId);
AttachmentTable attachmentDatabase = SignalDatabase.attachments();
attachmentDatabase.deleteAttachmentsForMessage(messageId);
@@ -3008,6 +3064,31 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie
return threadDeleted;
}
+ public void deleteScheduledMessage(long messageId) {
+ Log.d(TAG, "deleteScheduledMessage(" + messageId + ")");
+ SQLiteDatabase db = databaseHelper.getSignalWritableDatabase();
+ long threadId = getThreadIdForMessage(messageId);
+ db.beginTransaction();
+ try {
+ ContentValues contentValues = new ContentValues();
+ contentValues.put(SCHEDULED_DATE, -1);
+ contentValues.put(DATE_SENT, System.currentTimeMillis());
+ contentValues.put(DATE_RECEIVED, System.currentTimeMillis());
+
+ int rowsUpdated = db.update(TABLE_NAME, contentValues, ID_WHERE + " AND " + SCHEDULED_DATE + "!= ?", SqlUtil.buildArgs(messageId, -1));
+ if (rowsUpdated > 0) {
+ deleteMessage(messageId, threadId);
+ db.setTransactionSuccessful();
+ } else {
+ Log.w(TAG, "tried to delete scheduled message but it may have already been sent");
+ }
+ } finally {
+ db.endTransaction();
+ }
+ ApplicationDependencies.getScheduledMessageManager().scheduleIfNecessary();
+ ApplicationDependencies.getDatabaseObserver().notifyScheduledMessageObservers(threadId);
+ }
+
public void deleteThread(long threadId) {
Log.d(TAG, "deleteThread(" + threadId + ")");
Set singleThreadSet = new HashSet<>();
@@ -4465,13 +4546,55 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie
* This does *not* have attachments in it.
*/
public Cursor getConversation(long threadId, long offset, long limit) {
- String selection = THREAD_ID + " = ? AND " + STORY_TYPE + " = ? AND " + PARENT_STORY_ID + " <= ?";
- String[] args = SqlUtil.buildArgs(threadId, 0, 0);
+ String selection = THREAD_ID + " = ? AND " + STORY_TYPE + " = ? AND " + PARENT_STORY_ID + " <= ? AND " + SCHEDULED_DATE + " = ?";
+ String[] args = SqlUtil.buildArgs(threadId, 0, 0, -1);
String order = DATE_RECEIVED + " DESC";
String limitStr = limit > 0 || offset > 0 ? offset + ", " + limit : null;
return getReadableDatabase().query(TABLE_NAME, MMS_PROJECTION, selection, args, null, null, order, limitStr);
}
+
+ public List getScheduledMessagesInThread(long threadId) {
+ String selection = THREAD_ID + " = ? AND " + STORY_TYPE + " = ? AND " + PARENT_STORY_ID + " <= ? AND " + SCHEDULED_DATE + " != ?";
+ String[] args = SqlUtil.buildArgs(threadId, 0, 0, -1);
+ String order = SCHEDULED_DATE + " DESC";
+
+ try (MmsReader reader = mmsReaderFor(getReadableDatabase().query(TABLE_NAME, MMS_PROJECTION, selection, args, null, null, order))) {
+ List results = new ArrayList<>(reader.getCount());
+ while (reader.getNext() != null) {
+ results.add(reader.getCurrent());
+ }
+
+ return results;
+ }
+ }
+
+ public List getScheduledMessagesBefore(long time) {
+ String selection = STORY_TYPE + " = ? AND " + PARENT_STORY_ID + " <= ? AND " + SCHEDULED_DATE + " != ? AND " + SCHEDULED_DATE + " <= ?";
+ String[] args = SqlUtil.buildArgs(0, 0, -1, time);
+ String order = SCHEDULED_DATE + " DESC";
+
+ try (MmsReader reader = mmsReaderFor(getReadableDatabase().query(TABLE_NAME, MMS_PROJECTION, selection, args, null, null, order))) {
+ List results = new ArrayList<>(reader.getCount());
+ while (reader.getNext() != null) {
+ results.add(reader.getCurrent());
+ }
+
+ return results;
+ }
+ }
+
+ public @Nullable Long getOldestScheduledSendTimestamp() {
+ String[] columns = new String[] { SCHEDULED_DATE };
+ String selection = STORY_TYPE + " = ? AND " + PARENT_STORY_ID + " <= ? AND " + SCHEDULED_DATE + " != ?";
+ String[] args = SqlUtil.buildArgs(0, 0, -1);
+ String order = SCHEDULED_DATE + " ASC";
+ String limit = "1";
+
+ try (Cursor cursor = getReadableDatabase().query(TABLE_NAME, columns, selection, args, null, null, order, limit)) {
+ return cursor != null && cursor.moveToNext() ? cursor.getLong(0) : null;
+ }
+ }
public Cursor getMessagesForNotificationState(Collection stickyThreads) {
StringBuilder stickyQuery = new StringBuilder();
@@ -5064,7 +5187,8 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie
message.getParentStoryId(),
message.getGiftBadge(),
null,
- null);
+ null,
+ -1);
}
}
@@ -5208,6 +5332,7 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie
byte[] messageRangesData = CursorUtil.requireBlob(cursor, MESSAGE_RANGES);
StoryType storyType = StoryType.fromCode(CursorUtil.requireInt(cursor, STORY_TYPE));
ParentStoryId parentStoryId = ParentStoryId.deserialize(CursorUtil.requireLong(cursor, PARENT_STORY_ID));
+ long scheduledDate = CursorUtil.requireLong(cursor, SCHEDULED_DATE);
if (!TextSecurePreferences.isReadReceiptsEnabled(context)) {
readReceiptCount = 0;
@@ -5252,7 +5377,7 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie
networkFailures, subscriptionId, expiresIn, expireStarted,
isViewOnce, readReceiptCount, quote, contacts, previews, unidentified, Collections.emptyList(),
remoteDelete, mentionsSelf, notifiedTimestamp, viewedReceiptCount, receiptTimestamp, messageRanges,
- storyType, parentStoryId, giftBadge, null, null);
+ storyType, parentStoryId, giftBadge, null, null, scheduledDate);
}
private Set getMismatchedIdentities(String document) {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt
index acde7cb6d..e0120eb40 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt
@@ -28,6 +28,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V169_EmojiSearchInd
import org.thoughtcrime.securesms.database.helpers.migration.V170_CallTableMigration
import org.thoughtcrime.securesms.database.helpers.migration.V171_ThreadForeignKeyFix
import org.thoughtcrime.securesms.database.helpers.migration.V172_GroupMembershipMigration
+import org.thoughtcrime.securesms.database.helpers.migration.V173_ScheduledMessagesMigration
/**
* Contains all of the database migrations for [SignalDatabase]. Broken into a separate file for cleanliness.
@@ -36,7 +37,7 @@ object SignalDatabaseMigrations {
val TAG: String = Log.tag(SignalDatabaseMigrations.javaClass)
- const val DATABASE_VERSION = 172
+ const val DATABASE_VERSION = 173
@JvmStatic
fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
@@ -135,6 +136,10 @@ object SignalDatabaseMigrations {
if (oldVersion < 172) {
V172_GroupMembershipMigration.migrate(context, db, oldVersion, newVersion)
}
+
+ if (oldVersion < 173) {
+ V173_ScheduledMessagesMigration.migrate(context, db, oldVersion, newVersion)
+ }
}
@JvmStatic
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V173_ScheduledMessagesMigration.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V173_ScheduledMessagesMigration.kt
new file mode 100644
index 000000000..690eab7db
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V173_ScheduledMessagesMigration.kt
@@ -0,0 +1,18 @@
+package org.thoughtcrime.securesms.database.helpers.migration
+
+import android.app.Application
+import net.zetetic.database.sqlcipher.SQLiteDatabase
+
+/**
+ * In order to support scheduled sending, we need to add another column to keep track of when to send the message. We also use this
+ * column to hide future scheduled messages from views.
+ */
+object V173_ScheduledMessagesMigration : SignalDatabaseMigration {
+ override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
+ db.execSQL("ALTER TABLE mms ADD COLUMN scheduled_date INTEGER DEFAULT -1")
+ db.execSQL("DROP INDEX mms_thread_story_parent_story_index")
+ db.execSQL(
+ "CREATE INDEX IF NOT EXISTS mms_thread_story_parent_story_scheduled_date_index ON mms (thread_id, date_received,story_type,parent_story_id,scheduled_date);"
+ )
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java
index 755802323..5b0b1156f 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java
@@ -69,6 +69,7 @@ public class MediaMmsMessageRecord extends MmsMessageRecord {
private final BodyRangeList messageRanges;
private final Payment payment;
private final CallTable.Call call;
+ private final long scheduledDate;
public MediaMmsMessageRecord(long id,
Recipient conversationRecipient,
@@ -104,7 +105,8 @@ public class MediaMmsMessageRecord extends MmsMessageRecord {
@Nullable ParentStoryId parentStoryId,
@Nullable GiftBadge giftBadge,
@Nullable Payment payment,
- @Nullable CallTable.Call call)
+ @Nullable CallTable.Call call,
+ long scheduledDate)
{
super(id, body, conversationRecipient, individualRecipient, recipientDeviceId, dateSent,
dateReceived, dateServer, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox, mismatches, failures,
@@ -115,6 +117,7 @@ public class MediaMmsMessageRecord extends MmsMessageRecord {
this.messageRanges = messageRanges;
this.payment = payment;
this.call = call;
+ this.scheduledDate = scheduledDate;
}
@Override
@@ -197,18 +200,22 @@ public class MediaMmsMessageRecord extends MmsMessageRecord {
return call;
}
+ public long getScheduledDate() {
+ return scheduledDate;
+ }
+
public @NonNull MediaMmsMessageRecord withReactions(@NonNull List reactions) {
return new MediaMmsMessageRecord(getId(), getRecipient(), getIndividualRecipient(), getRecipientDeviceId(), 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());
+ getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getScheduledDate());
}
public @NonNull MediaMmsMessageRecord withoutQuote() {
return new MediaMmsMessageRecord(getId(), getRecipient(), getIndividualRecipient(), getRecipientDeviceId(), 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());
+ getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getScheduledDate());
}
public @NonNull MediaMmsMessageRecord withAttachments(@NonNull Context context, @NonNull List attachments) {
@@ -229,14 +236,14 @@ public class MediaMmsMessageRecord extends MmsMessageRecord {
return new MediaMmsMessageRecord(getId(), getRecipient(), getIndividualRecipient(), getRecipientDeviceId(), 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());
+ getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getScheduledDate());
}
public @NonNull MediaMmsMessageRecord withPayment(@NonNull Payment payment) {
return new MediaMmsMessageRecord(getId(), getRecipient(), getIndividualRecipient(), getRecipientDeviceId(), 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());
+ getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), payment, getCall(), getScheduledDate());
}
@@ -244,7 +251,7 @@ public class MediaMmsMessageRecord extends MmsMessageRecord {
return new MediaMmsMessageRecord(getId(), getRecipient(), getIndividualRecipient(), getRecipientDeviceId(), 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);
+ getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), call, getScheduledDate());
}
private static @NonNull List updateContacts(@NonNull List contacts, @NonNull Map attachmentIdMap) {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java
index b22849d96..96f9262bd 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java
@@ -35,6 +35,7 @@ import org.thoughtcrime.securesms.revealable.ViewOnceMessageManager;
import org.thoughtcrime.securesms.service.ExpiringMessageManager;
import org.thoughtcrime.securesms.service.ExpiringStoriesManager;
import org.thoughtcrime.securesms.service.PendingRetryReceiptManager;
+import org.thoughtcrime.securesms.service.ScheduledMessageManager;
import org.thoughtcrime.securesms.service.TrimThreadsByDateManager;
import org.thoughtcrime.securesms.service.webrtc.SignalCallManager;
import org.thoughtcrime.securesms.shakereport.ShakeToReport;
@@ -129,6 +130,7 @@ public class ApplicationDependencies {
private static volatile ProfileService profileService;
private static volatile DeadlockDetector deadlockDetector;
private static volatile ClientZkReceiptOperations clientZkReceiptOperations;
+ private static volatile ScheduledMessageManager scheduledMessagesManager;
@MainThread
public static void init(@NonNull Application application, @NonNull Provider provider) {
@@ -441,6 +443,18 @@ public class ApplicationDependencies {
return expiringMessageManager;
}
+ public static @NonNull ScheduledMessageManager getScheduledMessageManager() {
+ if (scheduledMessagesManager == null) {
+ synchronized (LOCK) {
+ if (scheduledMessagesManager == null) {
+ scheduledMessagesManager = provider.provideScheduledMessageManager();
+ }
+ }
+ }
+
+ return scheduledMessagesManager;
+ }
+
public static TypingStatusRepository getTypingStatusRepository() {
if (typingStatusRepository == null) {
synchronized (LOCK) {
@@ -710,5 +724,6 @@ public class ApplicationDependencies {
@NonNull DeadlockDetector provideDeadlockDetector();
@NonNull ClientZkReceiptOperations provideClientZkReceiptOperations(@NonNull SignalServiceConfiguration signalServiceConfiguration);
@NonNull KeyBackupService provideKeyBackupService(@NonNull SignalServiceAccountManager signalServiceAccountManager, @NonNull KeyStore keyStore, @NonNull KbsEnclave enclave);
+ @NonNull ScheduledMessageManager provideScheduledMessageManager();
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java
index 866fb27e1..8014e9c77 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java
@@ -59,6 +59,7 @@ import org.thoughtcrime.securesms.revealable.ViewOnceMessageManager;
import org.thoughtcrime.securesms.service.ExpiringMessageManager;
import org.thoughtcrime.securesms.service.ExpiringStoriesManager;
import org.thoughtcrime.securesms.service.PendingRetryReceiptManager;
+import org.thoughtcrime.securesms.service.ScheduledMessageManager;
import org.thoughtcrime.securesms.service.TrimThreadsByDateManager;
import org.thoughtcrime.securesms.service.webrtc.SignalCallManager;
import org.thoughtcrime.securesms.shakereport.ShakeToReport;
@@ -229,6 +230,11 @@ public class ApplicationDependencyProvider implements ApplicationDependencies.Pr
return new ExpiringMessageManager(context);
}
+ @Override
+ public @NonNull ScheduledMessageManager provideScheduledMessageManager() {
+ return new ScheduledMessageManager(context);
+ }
+
@Override
public @NonNull TypingStatusRepository provideTypingStatusRepository() {
return new TypingStatusRepository();
diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/IndividualSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/IndividualSendJob.java
index 74b7c8b70..96a02ebae 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/jobs/IndividualSendJob.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/IndividualSendJob.java
@@ -100,8 +100,12 @@ public class IndividualSendJob extends PushSendJob {
@WorkerThread
public static void enqueue(@NonNull Context context, @NonNull JobManager jobManager, long messageId, @NonNull Recipient recipient) {
try {
- OutgoingMessage message = SignalDatabase.messages().getOutgoingMessage(messageId);
- Set attachmentUploadIds = enqueueCompressingAndUploadAttachmentsChains(jobManager, message);
+ OutgoingMessage message = SignalDatabase.messages().getOutgoingMessage(messageId);
+ if (message.getScheduledDate() != -1) {
+ ApplicationDependencies.getScheduledMessageManager().scheduleIfNecessary();
+ return;
+ }
+ Set attachmentUploadIds = enqueueCompressingAndUploadAttachmentsChains(jobManager, message);
jobManager.add(IndividualSendJob.create(messageId, recipient, attachmentUploadIds.size() > 0), attachmentUploadIds, recipient.getId().toQueueKey());
} catch (NoSuchMessageException | MmsException e) {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java
index 0b810cdc8..49cd4fa73 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java
@@ -116,7 +116,16 @@ public final class PushGroupSendJob extends PushSendJob {
MessageTable database = SignalDatabase.messages();
OutgoingMessage message = database.getOutgoingMessage(messageId);
- Set attachmentUploadIds = enqueueCompressingAndUploadAttachmentsChains(jobManager, message);
+
+ if (message.getScheduledDate() != -1) {
+ if (!filterAddresses.isEmpty()) {
+ throw new MmsException("Cannot schedule a group message with filter addresses!");
+ }
+ ApplicationDependencies.getScheduledMessageManager().scheduleIfNecessary();
+ return;
+ }
+
+ Set attachmentUploadIds = enqueueCompressingAndUploadAttachmentsChains(jobManager, message);
if (message.getGiftBadge() != null) {
throw new MmsException("Cannot send a gift badge to a group!");
diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/UiHints.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/UiHints.java
index bab344c25..b73fa6e38 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/UiHints.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/UiHints.java
@@ -13,6 +13,7 @@ public class UiHints extends SignalStoreValues {
private static final String HAS_CONFIRMED_DELETE_FOR_EVERYONE_ONCE = "uihints.has_confirmed_delete_for_everyone_once";
private static final String HAS_SET_OR_SKIPPED_USERNAME_CREATION = "uihints.has_set_or_skipped_username_creation";
private static final String NEVER_DISPLAY_PULL_TO_FILTER_TIP = "uihints.never_display_pull_to_filter_tip";
+ private static final String HAS_SEEN_SCHEDULED_MESSAGES_INFO_ONCE = "uihints.has_seen_scheduled_messages_info_once";
UiHints(@NonNull KeyValueStore store) {
super(store);
@@ -36,6 +37,14 @@ public class UiHints extends SignalStoreValues {
return getBoolean(HAS_SEEN_GROUP_SETTINGS_MENU_TOAST, false);
}
+ public void markHasSeenScheduledMessagesInfoSheet() {
+ putBoolean(HAS_SEEN_SCHEDULED_MESSAGES_INFO_ONCE, true);
+ }
+
+ public boolean hasSeenScheduledMessagesInfoSheet() {
+ return getBoolean(HAS_SEEN_SCHEDULED_MESSAGES_INFO_ONCE, false);
+ }
+
public void markHasConfirmedDeleteForEveryoneOnce() {
putBoolean(HAS_CONFIRMED_DELETE_FOR_EVERYONE_ONCE, true);
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivityResult.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivityResult.kt
index 5c1c0f506..5581dde6f 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivityResult.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivityResult.kt
@@ -27,7 +27,8 @@ class MediaSendActivityResult(
val isViewOnce: Boolean,
val mentions: List,
@TypeParceler() val bodyRanges: BodyRangeList?,
- val storyType: StoryType
+ val storyType: StoryType,
+ val scheduledTime: Long = -1
) : Parcelable {
val isPushPreUpload: Boolean
diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt
index 23147e466..fa92da803 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt
@@ -78,7 +78,8 @@ class MediaSelectionRepository(context: Context) {
contacts: List,
mentions: List,
bodyRanges: BodyRangeList?,
- sendType: MessageSendType
+ sendType: MessageSendType,
+ scheduledTime: Long = -1
): Maybe {
if (isSms && contacts.isNotEmpty()) {
throw IllegalStateException("Provided recipients to send to, but this is SMS!")
@@ -112,8 +113,8 @@ class MediaSelectionRepository(context: Context) {
StoryType.NONE
}
- if (isSms || MessageSender.isLocalSelfSend(context, singleRecipient, MessageSender.SendType.SIGNAL)) {
- Log.i(TAG, "SMS or local self-send. Skipping pre-upload.")
+ if (isSms || MessageSender.isLocalSelfSend(context, singleRecipient, MessageSender.SendType.SIGNAL) || (scheduledTime != -1L && storyType == StoryType.NONE)) {
+ Log.i(TAG, "SMS, local self-send, or scheduled send. Skipping pre-upload.")
emitter.onSuccess(
MediaSendActivityResult(
recipientId = singleRecipient!!.id,
@@ -123,7 +124,8 @@ class MediaSelectionRepository(context: Context) {
isViewOnce = isViewOnce,
mentions = trimmedMentions,
bodyRanges = trimmedBodyRanges,
- storyType = StoryType.NONE
+ storyType = StoryType.NONE,
+ scheduledTime = scheduledTime
)
)
} else {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt
index 84da9f5a8..6ca26a2a2 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt
@@ -333,7 +333,13 @@ class MediaSelectionViewModel(
}
fun send(
- selectedContacts: List = emptyList()
+ selectedContacts: List = emptyList(),
+ scheduledDate: Long? = null
+ ): Maybe = send(selectedContacts, scheduledDate ?: -1)
+
+ fun send(
+ selectedContacts: List = emptyList(),
+ scheduledDate: Long
): Maybe {
return UntrustedRecords.checkForBadIdentityRecords(selectedContacts.toSet(), identityChangesSince).andThen(
repository.send(
@@ -347,7 +353,8 @@ class MediaSelectionViewModel(
contacts = selectedContacts.ifEmpty { destination.getRecipientSearchKeyList() },
mentions = MentionAnnotation.getMentionsFromAnnotations(store.state.message),
bodyRanges = MessageStyler.getStyling(store.state.message),
- sendType = store.state.sendType
+ sendType = store.state.sendType,
+ scheduledTime = scheduledDate
)
)
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewFragment.kt
index 04ef205c7..142fd178d 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewFragment.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewFragment.kt
@@ -6,6 +6,7 @@ import android.content.res.ColorStateList
import android.graphics.Color
import android.os.Bundle
import android.view.View
+import android.view.ViewGroup
import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.TextView
@@ -29,6 +30,8 @@ import org.signal.core.util.concurrent.SimpleTask
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.conversation.MessageSendType
+import org.thoughtcrime.securesms.conversation.ScheduleMessageContextMenu
+import org.thoughtcrime.securesms.conversation.ScheduleMessageTimePickerBottomSheet
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardActivity
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs
import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult
@@ -44,6 +47,7 @@ import org.thoughtcrime.securesms.mms.SentMediaQuality
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.scribbles.ImageEditorFragment
+import org.thoughtcrime.securesms.util.FeatureFlags
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.SystemWindowInsetsSetter
@@ -55,7 +59,7 @@ import org.thoughtcrime.securesms.util.visible
/**
* Allows the user to view and edit selected media.
*/
-class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment) {
+class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), ScheduleMessageTimePickerBottomSheet.ScheduleCallback {
private val sharedViewModel: MediaSelectionViewModel by viewModels(
ownerProducer = { requireActivity() }
@@ -88,6 +92,8 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment) {
private var animatorSet: AnimatorSet? = null
private var disposables: LifecycleDisposable = LifecycleDisposable()
+ private var scheduledSendTime: Long? = null
+
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
postponeEnterTransition()
@@ -198,6 +204,7 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment) {
} else {
storiesLauncher.launch(StoriesMultiselectForwardActivity.Args(args, emptyList()))
}
+ scheduledSendTime = null
} else {
multiselectLauncher.launch(args)
}
@@ -207,10 +214,25 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment) {
.setPositiveButton(R.string.MediaReviewFragment__add_to_story) { _, _ -> performSend() }
.setNegativeButton(android.R.string.cancel) { _, _ -> }
.show()
+ scheduledSendTime = null
} else {
performSend()
}
}
+ if (FeatureFlags.scheduledMessageSends()) {
+ sendButton.setOnLongClickListener {
+ ScheduleMessageContextMenu.show(it, (requireView() as ViewGroup)) { time: Long ->
+ if (time == -1L) {
+ scheduledSendTime = null
+ ScheduleMessageTimePickerBottomSheet.showSchedule(childFragmentManager)
+ } else {
+ scheduledSendTime = time
+ sendButton.performClick()
+ }
+ }
+ true
+ }
+ }
addMediaButton.setOnClickListener {
launchGallery()
@@ -325,7 +347,7 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment) {
.alpha(1f)
disposables += sharedViewModel
- .send(selection.filterIsInstance(ContactSearchKey.RecipientSearchKey::class.java))
+ .send(selection.filterIsInstance(ContactSearchKey.RecipientSearchKey::class.java), scheduledSendTime)
.subscribe(
{ result -> callback.onSentWithResult(result) },
{ error -> callback.onSendError(error) },
@@ -560,4 +582,9 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment) {
fun onNoMediaSelected()
fun onPopFromReview()
}
+
+ override fun onScheduleSend(scheduledTime: Long) {
+ scheduledSendTime = scheduledTime
+ sendButton.performClick()
+ }
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java
index 2497a29da..2922014d2 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java
@@ -2111,7 +2111,8 @@ public final class MessageContentProcessor {
Collections.emptySet(),
null,
true,
- null);
+ null,
+ -1);
if (recipient.getExpiresInSeconds() != message.getDataMessage().get().getExpiresInSeconds()) {
handleSynchronizeSentExpirationUpdate(message);
@@ -2231,7 +2232,8 @@ public final class MessageContentProcessor {
Collections.emptySet(),
null,
true,
- null);
+ null,
+ -1);
MessageTable messageTable = SignalDatabase.messages();
long threadId = SignalDatabase.threads().getOrCreateThreadIdFor(recipient);
@@ -2329,7 +2331,8 @@ public final class MessageContentProcessor {
Collections.emptySet(),
giftBadge.orElse(null),
true,
- null);
+ null,
+ -1);
if (recipients.getExpiresInSeconds() != message.getDataMessage().get().getExpiresInSeconds()) {
handleSynchronizeSentExpirationUpdate(message);
diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMessage.kt
index 59dfc601f..174371722 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMessage.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMessage.kt
@@ -50,6 +50,7 @@ data class OutgoingMessage(
val isEndSession: Boolean = false,
val isIdentityVerified: Boolean = false,
val isIdentityDefault: Boolean = false,
+ val scheduledDate: Long = -1,
) {
val isV2Group: Boolean = messageGroupContext != null && GroupV2UpdateMessageUtil.isGroupV2(messageGroupContext)
@@ -78,7 +79,8 @@ data class OutgoingMessage(
mismatches: Set = emptySet(),
giftBadge: GiftBadge? = null,
isSecure: Boolean = false,
- bodyRanges: BodyRangeList? = null
+ bodyRanges: BodyRangeList? = null,
+ scheduledDate: Long = -1
) : this(
recipient = recipient,
body = body ?: "",
@@ -99,7 +101,8 @@ data class OutgoingMessage(
identityKeyMismatches = mismatches,
giftBadge = giftBadge,
isSecure = isSecure,
- bodyRanges = bodyRanges
+ bodyRanges = bodyRanges,
+ scheduledDate = scheduledDate
)
/**
@@ -153,6 +156,10 @@ data class OutgoingMessage(
return messageGroupContext!!.requireGroupV2Properties()
}
+ fun sendAt(scheduledDate: Long): OutgoingMessage {
+ return copy(scheduledDate = scheduledDate)
+ }
+
companion object {
/**
diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java
index d8b584047..bc8daf0d2 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java
@@ -101,7 +101,8 @@ public class RemoteReplyReceiver extends BroadcastReceiver {
Collections.emptySet(),
null,
recipient.isPushGroup(),
- null);
+ null,
+ -1);
threadId = MessageSender.send(context, reply, -1, MessageSender.SendType.SIGNAL, null, null);
break;
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/ScheduledMessageManager.kt b/app/src/main/java/org/thoughtcrime/securesms/service/ScheduledMessageManager.kt
new file mode 100644
index 000000000..3c370a5f7
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/service/ScheduledMessageManager.kt
@@ -0,0 +1,78 @@
+package org.thoughtcrime.securesms.service
+
+import android.app.Application
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import androidx.annotation.WorkerThread
+import org.signal.core.util.logging.Log
+import org.thoughtcrime.securesms.database.SignalDatabase
+import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
+import org.thoughtcrime.securesms.jobs.IndividualSendJob
+import org.thoughtcrime.securesms.jobs.PushGroupSendJob
+
+/**
+ * Manages waking up and sending scheduled messages at the correct time
+ */
+class ScheduledMessageManager(
+ val application: Application
+) : TimedEventManager(application, "ScheduledMessagesManager") {
+
+ companion object {
+ private val TAG = Log.tag(ScheduledMessageManager::class.java)
+ }
+
+ private val messagesTable = SignalDatabase.messages
+
+ init {
+ scheduleIfNecessary()
+ }
+
+ @WorkerThread
+ override fun getNextClosestEvent(): Event? {
+ val oldestTimestamp = messagesTable.oldestScheduledSendTimestamp ?: return null
+
+ val delay = (oldestTimestamp - System.currentTimeMillis()).coerceAtLeast(0)
+ Log.i(TAG, "The next scheduled message needs to be sent in $delay ms.")
+
+ return Event(delay)
+ }
+
+ @WorkerThread
+ override fun executeEvent(event: Event) {
+ val scheduledMessagesToSend = messagesTable.getScheduledMessagesBefore(System.currentTimeMillis())
+ for (record in scheduledMessagesToSend) {
+ if (SignalDatabase.messages.clearScheduledStatus(record.threadId, record.id)) {
+ if (record.recipient.isPushGroup) {
+ PushGroupSendJob.enqueue(application, ApplicationDependencies.getJobManager(), record.id, record.recipient.id, emptySet())
+ } else {
+ IndividualSendJob.enqueue(application, ApplicationDependencies.getJobManager(), record.id, record.recipient)
+ }
+ } else {
+ Log.i(TAG, "messageId=${record.id} was not a scheduled message, ignoring")
+ }
+ }
+ }
+
+ @WorkerThread
+ override fun getDelayForEvent(event: Event): Long = event.delay
+
+ @WorkerThread
+ override fun scheduleAlarm(application: Application, delay: Long) {
+ trySetExactAlarm(application, System.currentTimeMillis() + delay, ScheduledMessagesAlarm::class.java)
+ }
+
+ data class Event(val delay: Long)
+
+ class ScheduledMessagesAlarm : BroadcastReceiver() {
+
+ companion object {
+ private val TAG = Log.tag(ScheduledMessagesAlarm::class.java)
+ }
+
+ override fun onReceive(context: Context?, intent: Intent?) {
+ Log.d(TAG, "onReceive()")
+ ApplicationDependencies.getScheduledMessageManager().scheduleIfNecessary()
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/TimedEventManager.java b/app/src/main/java/org/thoughtcrime/securesms/service/TimedEventManager.java
index 3dff08ec4..7de419043 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/service/TimedEventManager.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/service/TimedEventManager.java
@@ -5,6 +5,7 @@ import android.app.Application;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
+import android.os.Build;
import android.os.Handler;
import android.os.HandlerThread;
@@ -14,6 +15,7 @@ import androidx.annotation.Nullable;
import androidx.annotation.WorkerThread;
import org.signal.core.util.PendingIntentFlags;
+import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.util.ServiceUtil;
/**
@@ -21,6 +23,8 @@ import org.thoughtcrime.securesms.util.ServiceUtil;
*/
public abstract class TimedEventManager {
+ private static final String TAG = Log.tag(TimedEventManager.class);
+
private final Application application;
private final Handler handler;
@@ -91,4 +95,29 @@ public abstract class TimedEventManager {
alarmManager.cancel(pendingIntent);
alarmManager.set(AlarmManager.RTC_WAKEUP, System.currentTimeMillis() + delay, pendingIntent);
}
+
+ protected static void trySetExactAlarm(@NonNull Context context, long timestamp, @NonNull Class alarmClass) {
+ Intent intent = new Intent(context, alarmClass);
+ PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, intent, PendingIntentFlags.mutable());
+ AlarmManager alarmManager = ServiceUtil.getAlarmManager(context);
+
+ alarmManager.cancel(pendingIntent);
+
+ boolean hasManagerPermission = Build.VERSION.SDK_INT < 31 || alarmManager.canScheduleExactAlarms();
+ if (hasManagerPermission) {
+ try {
+ if (Build.VERSION.SDK_INT >= 23) {
+ alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, timestamp, pendingIntent);
+ } else {
+ alarmManager.setExact(AlarmManager.RTC_WAKEUP, timestamp, pendingIntent);
+ }
+ return;
+ } catch (Exception e) {
+ Log.w(TAG, e);
+ }
+ }
+
+ Log.w(TAG, "Unable to schedule exact alarm, falling back to inexact alarm, scheduling alarm for: " + timestamp);
+ alarmManager.set(AlarmManager.RTC_WAKEUP, timestamp, pendingIntent);
+ }
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java b/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java
index 0e3a8785e..4775c657e 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java
@@ -30,6 +30,7 @@ import org.thoughtcrime.securesms.R;
import java.text.DateFormatSymbols;
import java.text.ParseException;
import java.text.SimpleDateFormat;
+import java.util.Calendar;
import java.util.Date;
import java.util.Locale;
import java.util.concurrent.TimeUnit;
@@ -44,6 +45,7 @@ public class DateUtils extends android.text.format.DateUtils {
private static final ThreadLocal DATE_FORMAT = new ThreadLocal<>();
private static final ThreadLocal BRIEF_EXACT_FORMAT = new ThreadLocal<>();
private static final long MAX_RELATIVE_TIMESTAMP = TimeUnit.MINUTES.toMillis(3);
+ private static final int HALF_A_YEAR_IN_DAYS = 182;
private static boolean isWithin(final long millis, final long span, final TimeUnit unit) {
return System.currentTimeMillis() - millis <= unit.toMillis(span);
@@ -110,11 +112,22 @@ public class DateUtils extends android.text.format.DateUtils {
int mins = (int) TimeUnit.MINUTES.convert(System.currentTimeMillis() - timestamp, TimeUnit.MILLISECONDS);
return context.getResources().getString(R.string.DateUtils_minutes_ago, mins);
} else {
- String format = DateFormat.is24HourFormat(context) ? "HH:mm" : "hh:mm a";
- return getFormattedDateTime(timestamp, format, locale);
+ return getOnlyTimeString(context, locale, timestamp);
}
}
+ /**
+ * Formats a given timestamp as just the time.
+ *
+ * For example:
+ * For 12 hour locale: 7:23 pm
+ * For 24 hour locale: 19:23
+ */
+ public static String getOnlyTimeString(final Context context, final Locale locale, final long timestamp) {
+ String format = DateFormat.is24HourFormat(context) ? "HH:mm" : "hh:mm a";
+ return getFormattedDateTime(timestamp, format, locale);
+ }
+
public static String getTimeString(final Context c, final Locale locale, final long timestamp) {
StringBuilder format = new StringBuilder();
@@ -129,6 +142,37 @@ public class DateUtils extends android.text.format.DateUtils {
return getFormattedDateTime(timestamp, format.toString(), locale);
}
+ /**
+ * Formats the passed timestamp based on the current time at a day precision.
+ *
+ * For example:
+ * - Today
+ * - Wed
+ * - Mon
+ * - Jan 31
+ * - Feb 4
+ * - Jan 12, 2033
+ */
+ public static String getDayPrecisionTimeString(Context context, Locale locale, long timestamp) {
+ SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyyMMdd");
+
+ if (simpleDateFormat.format(System.currentTimeMillis()).equals(simpleDateFormat.format(timestamp))) {
+ return context.getString(R.string.DeviceListItem_today);
+ } else {
+ String format;
+
+ if (isWithinAbs(timestamp, 6, TimeUnit.DAYS)) {
+ format = "EEE ";
+ } else if (isWithinAbs(timestamp, 365, TimeUnit.DAYS)) {
+ format = "MMM d";
+ } else {
+ format = "MMM d, yyy";
+ }
+
+ return getFormattedDateTime(timestamp, format, locale);
+ }
+ }
+
public static String getDayPrecisionTimeSpanString(Context context, Locale locale, long timestamp) {
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyyMMdd");
@@ -165,13 +209,44 @@ public class DateUtils extends android.text.format.DateUtils {
return context.getString(R.string.DateUtils_today);
} else if (isYesterday(timestamp)) {
return context.getString(R.string.DateUtils_yesterday);
- } else if (isWithin(timestamp, 182, TimeUnit.DAYS)) {
+ } else if (isWithin(timestamp, HALF_A_YEAR_IN_DAYS, TimeUnit.DAYS)) {
return formatDateWithDayOfWeek(locale, timestamp);
} else {
return formatDateWithYear(locale, timestamp);
}
}
+ public static String getScheduledMessagesDateHeaderString(@NonNull Context context,
+ @NonNull Locale locale,
+ long timestamp)
+ {
+ if (isToday(timestamp)) {
+ return context.getString(R.string.DateUtils_today);
+ } else if (isWithinAbs(timestamp, HALF_A_YEAR_IN_DAYS, TimeUnit.DAYS)) {
+ return formatDateWithDayOfWeek(locale, timestamp);
+ } else {
+ return formatDateWithYear(locale, timestamp);
+ }
+ }
+
+ public static String getScheduledMessageDateString(@NonNull Context context, @NonNull Locale locale, long timestamp) {
+ String dayModifier;
+ if (isToday(timestamp)) {
+ Calendar calendar = Calendar.getInstance(locale);
+ if (calendar.get(Calendar.HOUR_OF_DAY) >= 19) {
+ dayModifier = context.getString(R.string.DateUtils_tonight);
+ } else {
+ dayModifier = context.getString(R.string.DateUtils_today);
+ }
+ } else {
+ dayModifier = context.getString(R.string.DateUtils_tomorrow);
+ }
+ String format = DateFormat.is24HourFormat(context) ? "HH:mm" : "hh:mm a";
+ String time = getFormattedDateTime(timestamp, format, locale);
+
+ return context.getString(R.string.DateUtils_schedule_at, dayModifier, time);
+ }
+
public static String formatDateWithDayOfWeek(@NonNull Locale locale, long timestamp) {
return getFormattedDateTime(timestamp, "EEE, MMM d", locale);
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java
index f21e0481a..f7f9b9c7c 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java
@@ -105,6 +105,7 @@ public final class FeatureFlags {
private static final String PAYPAL_ONE_TIME_DONATIONS = "android.oneTimePayPalDonations.2";
private static final String PAYPAL_RECURRING_DONATIONS = "android.recurringPayPalDonations.2";
private static final String TEXT_FORMATTING = "android.textFormatting";
+ private static final String SCHEDULED_MESSAGE_SENDS = "android.scheduledMessageSends";
/**
* We will only store remote values for flags in this set. If you want a flag to be controllable
@@ -160,7 +161,8 @@ public final class FeatureFlags {
CHAT_FILTERS,
PAYPAL_ONE_TIME_DONATIONS,
PAYPAL_RECURRING_DONATIONS,
- TEXT_FORMATTING
+ TEXT_FORMATTING,
+ SCHEDULED_MESSAGE_SENDS
);
@VisibleForTesting
@@ -574,6 +576,13 @@ public final class FeatureFlags {
return getBoolean(TEXT_FORMATTING, false);
}
+ /**
+ * Whether or not we allow the user to schedule message sends. This takes over the entry point for SMS message sends
+ */
+ public static boolean scheduledMessageSends() {
+ return getBoolean(SCHEDULED_MESSAGE_SENDS, false);
+ }
+
/** Only for rendering debug info. */
public static synchronized @NonNull Map getMemoryValues() {
return new TreeMap<>(REMOTE_VALUES);
diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/JavaTimeExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/util/JavaTimeExtensions.kt
index 2fbcd17ee..6cdbdb85b 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/util/JavaTimeExtensions.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/util/JavaTimeExtensions.kt
@@ -10,6 +10,7 @@ import java.time.LocalTime
import java.time.OffsetDateTime
import java.time.ZoneId
import java.time.ZoneOffset
+import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.time.temporal.WeekFields
@@ -31,6 +32,28 @@ fun LocalDateTime.toMillis(zoneOffset: ZoneOffset = ZoneId.systemDefault().toOff
return TimeUnit.SECONDS.toMillis(toEpochSecond(zoneOffset))
}
+/**
+ * Convert [ZonedDateTime] to be same as [System.currentTimeMillis]
+ */
+fun ZonedDateTime.toMillis(): Long {
+ return TimeUnit.SECONDS.toMillis(toEpochSecond())
+}
+
+/**
+ * Convert [LocalDateTime] to a [ZonedDateTime] at the UTC offset
+ */
+fun LocalDateTime.atUTC(): ZonedDateTime {
+ return atZone(ZoneId.ofOffset("UTC", ZoneOffset.UTC))
+}
+
+/**
+ * Create a LocalDateTime with the same year, month, and day, but set
+ * to midnight.
+ */
+fun LocalDateTime.atMidnight(): LocalDateTime {
+ return LocalDateTime.of(year, month, dayOfMonth, 0, 0)
+}
+
/**
* Return true if the [LocalDateTime] is within [start] and [end] inclusive.
*/
diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MessageRecordUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/util/MessageRecordUtil.kt
index 32734743c..569a7221a 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/util/MessageRecordUtil.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/util/MessageRecordUtil.kt
@@ -141,6 +141,10 @@ fun MessageRecord.isTextOnly(context: Context): Boolean {
)
}
+fun MessageRecord.isScheduled(): Boolean {
+ return (this as? MediaMmsMessageRecord)?.scheduledDate?.let { it != -1L } ?: false
+}
+
/**
* Returns the QuoteType for this record, as if it was being quoted.
*/
diff --git a/app/src/main/res/drawable/ic_calendar_24.xml b/app/src/main/res/drawable/ic_calendar_24.xml
new file mode 100644
index 000000000..140cdfa9d
--- /dev/null
+++ b/app/src/main/res/drawable/ic_calendar_24.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_daytime_24.xml b/app/src/main/res/drawable/ic_daytime_24.xml
new file mode 100644
index 000000000..78d655d94
--- /dev/null
+++ b/app/src/main/res/drawable/ic_daytime_24.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_expand_down_24.xml b/app/src/main/res/drawable/ic_expand_down_24.xml
new file mode 100644
index 000000000..bd110e357
--- /dev/null
+++ b/app/src/main/res/drawable/ic_expand_down_24.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_nighttime_26.xml b/app/src/main/res/drawable/ic_nighttime_26.xml
new file mode 100644
index 000000000..e6f5932a2
--- /dev/null
+++ b/app/src/main/res/drawable/ic_nighttime_26.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_send_outline_24.xml b/app/src/main/res/drawable/ic_send_outline_24.xml
new file mode 100644
index 000000000..7187556fe
--- /dev/null
+++ b/app/src/main/res/drawable/ic_send_outline_24.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/schedule_message_large.xml b/app/src/main/res/drawable/schedule_message_large.xml
new file mode 100644
index 000000000..efb911b09
--- /dev/null
+++ b/app/src/main/res/drawable/schedule_message_large.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/conversation_activity.xml b/app/src/main/res/layout/conversation_activity.xml
index 6b6bb97e0..4f3c49ca1 100644
--- a/app/src/main/res/layout/conversation_activity.xml
+++ b/app/src/main/res/layout/conversation_activity.xml
@@ -62,6 +62,13 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/conversation_item_sent_multimedia.xml b/app/src/main/res/layout/conversation_item_sent_multimedia.xml
index 66b45d4f4..3c6d698f8 100644
--- a/app/src/main/res/layout/conversation_item_sent_multimedia.xml
+++ b/app/src/main/res/layout/conversation_item_sent_multimedia.xml
@@ -236,6 +236,17 @@
android:tint="@color/signal_colorOnSurfaceVariant"
app:srcCompat="@drawable/ic_replies_outline_20" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/schedule_message_time_picker_bottom_sheet.xml b/app/src/main/res/layout/schedule_message_time_picker_bottom_sheet.xml
new file mode 100644
index 000000000..930fbd97c
--- /dev/null
+++ b/app/src/main/res/layout/schedule_message_time_picker_bottom_sheet.xml
@@ -0,0 +1,113 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/scheduled_messages_bottom_sheet.xml b/app/src/main/res/layout/scheduled_messages_bottom_sheet.xml
new file mode 100644
index 000000000..a3aebebad
--- /dev/null
+++ b/app/src/main/res/layout/scheduled_messages_bottom_sheet.xml
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/signal_styles.xml b/app/src/main/res/values/signal_styles.xml
index 9e53ee0e4..ae36222ac 100644
--- a/app/src/main/res/values/signal_styles.xml
+++ b/app/src/main/res/values/signal_styles.xml
@@ -253,6 +253,31 @@
- @color/signal_colorOnSurfaceVariant
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index d1fb27f46..81ae0d2f9 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -630,6 +630,47 @@
%dm
Today
Yesterday
+
+ %1$s at %2$s
+
+ Tomorrow
+
+ Tonight
+
+
+
+ Scheduled messages
+
+ Pick Date & Time
+
+ Scheduled messages
+
+ When you send a scheduled message, make sure your device will be on and connected to the internet at the time of sending. If not, your message will send when your device reconnects.
+
+ Okay
+
+
+ Select date
+
+ Select time
+
+ Schedule message
+
+ Schedule send
+
+
+ All times in (%1$s) %2$s
+
+
+ Send now
+
+ Reschedule
+
+ Delete
+
+ Delete selected scheduled message?
+
+ Deleting scheduled messageā¦
Chat session refreshed
@@ -2212,6 +2253,8 @@
Signal message
Unsecured SMS
Unsecured MMS
+
+ Schedule message
From %1$s
SIM %1$d
Send
@@ -3100,6 +3143,16 @@
Add to contacts
+
+
+
+ See all
+
+
+ - %1$d message scheduled
+ - %1$d messages scheduled
+
+
Recipients list
Delivery
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index 20b620345..34c29d694 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -229,6 +229,9 @@
- @style/ThemeOverlay.Signal.MaterialAlertDialog
- @style/Signal.ThemeOverlay.TimePicker
- @style/Signal.Widget.TimePicker
+
+ - @style/Signal.Widget.Calendar
+ - @style/Signal.ThemeOverlay.Calendar