Add support for scheduled message sends.

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

View File

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

View File

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

View File

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

View File

@@ -28,6 +28,7 @@ class SendButton(context: Context, attributeSet: AttributeSet?) : AppCompatImage
}
private val listeners: MutableList<SendTypeChangedListener> = CopyOnWriteArrayList()
private var scheduledSendListener: ScheduledSendListener? = null
private var availableSendTypes: List<MessageSendType> = 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()
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<Long, Set<Observer>> conversationObservers;
private final Map<Long, Set<Observer>> verboseConversationObservers;
private final Map<UUID, Set<Observer>> paymentObservers;
private final Map<Long, Set<Observer>> scheduledMessageObservers;
private final Set<Observer> allPaymentsObservers;
private final Set<Observer> chatColorsObservers;
private final Set<Observer> 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);

View File

@@ -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<NetworkFailure> 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<Long> 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<MessageRecord> 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<MessageRecord> results = new ArrayList<>(reader.getCount());
while (reader.getNext() != null) {
results.add(reader.getCurrent());
}
return results;
}
}
public List<MessageRecord> 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<MessageRecord> 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<DefaultMessageNotifier.StickyThread> 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<IdentityKeyMismatch> getMismatchedIdentities(String document) {

View File

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

View File

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

View File

@@ -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<ReactionRecord> 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<DatabaseAttachment> 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<Contact> updateContacts(@NonNull List<Contact> contacts, @NonNull Map<AttachmentId, DatabaseAttachment> attachmentIdMap) {

View File

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

View File

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

View File

@@ -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<String> attachmentUploadIds = enqueueCompressingAndUploadAttachmentsChains(jobManager, message);
OutgoingMessage message = SignalDatabase.messages().getOutgoingMessage(messageId);
if (message.getScheduledDate() != -1) {
ApplicationDependencies.getScheduledMessageManager().scheduleIfNecessary();
return;
}
Set<String> attachmentUploadIds = enqueueCompressingAndUploadAttachmentsChains(jobManager, message);
jobManager.add(IndividualSendJob.create(messageId, recipient, attachmentUploadIds.size() > 0), attachmentUploadIds, recipient.getId().toQueueKey());
} catch (NoSuchMessageException | MmsException e) {

View File

@@ -116,7 +116,16 @@ public final class PushGroupSendJob extends PushSendJob {
MessageTable database = SignalDatabase.messages();
OutgoingMessage message = database.getOutgoingMessage(messageId);
Set<String> 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<String> attachmentUploadIds = enqueueCompressingAndUploadAttachmentsChains(jobManager, message);
if (message.getGiftBadge() != null) {
throw new MmsException("Cannot send a gift badge to a group!");

View File

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

View File

@@ -27,7 +27,8 @@ class MediaSendActivityResult(
val isViewOnce: Boolean,
val mentions: List<Mention>,
@TypeParceler<BodyRangeList?, BodyRangeListParceler>() val bodyRanges: BodyRangeList?,
val storyType: StoryType
val storyType: StoryType,
val scheduledTime: Long = -1
) : Parcelable {
val isPushPreUpload: Boolean

View File

@@ -78,7 +78,8 @@ class MediaSelectionRepository(context: Context) {
contacts: List<ContactSearchKey.RecipientSearchKey>,
mentions: List<Mention>,
bodyRanges: BodyRangeList?,
sendType: MessageSendType
sendType: MessageSendType,
scheduledTime: Long = -1
): Maybe<MediaSendActivityResult> {
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 {

View File

@@ -333,7 +333,13 @@ class MediaSelectionViewModel(
}
fun send(
selectedContacts: List<ContactSearchKey.RecipientSearchKey> = emptyList()
selectedContacts: List<ContactSearchKey.RecipientSearchKey> = emptyList(),
scheduledDate: Long? = null
): Maybe<MediaSendActivityResult> = send(selectedContacts, scheduledDate ?: -1)
fun send(
selectedContacts: List<ContactSearchKey.RecipientSearchKey> = emptyList(),
scheduledDate: Long
): Maybe<MediaSendActivityResult> {
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
)
)
}

View File

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

View File

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

View File

@@ -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<IdentityKeyMismatch> = 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 {
/**

View File

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

View File

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

View File

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

View File

@@ -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<SimpleDateFormat> DATE_FORMAT = new ThreadLocal<>();
private static final ThreadLocal<SimpleDateFormat> 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);
}

View File

@@ -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<String, Object> getMemoryValues() {
return new TreeMap<>(REMOTE_VALUES);

View File

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

View File

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