diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5d8629fac..424addd55 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -815,6 +815,8 @@ + + diff --git a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index cf7d655d4..930821cda 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/app/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -182,6 +182,7 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr .addNonBlocking(this::cleanAvatarStorage) .addNonBlocking(this::initializeRevealableMessageManager) .addNonBlocking(this::initializePendingRetryReceiptManager) + .addNonBlocking(this::initializeScheduledMessageManager) .addNonBlocking(this::initializeFcmCheck) .addNonBlocking(PreKeysSyncJob::enqueueIfNeeded) .addNonBlocking(this::initializePeriodicTasks) @@ -390,6 +391,10 @@ public class ApplicationContext extends MultiDexApplication implements AppForegr ApplicationDependencies.getPendingRetryReceiptManager().scheduleIfNecessary(); } + private void initializeScheduledMessageManager() { + ApplicationDependencies.getScheduledMessageManager().scheduleIfNecessary(); + } + private void initializeTrimThreadsByDateManager() { KeepMessagesDuration keepMessagesDuration = SignalStore.settings().getKeepMessagesDuration(); if (keepMessagesDuration != KeepMessagesDuration.FOREVER) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java index ca4ffcca7..ba376430a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/BindableConversationItem.java @@ -106,6 +106,7 @@ public interface BindableConversationItem extends Unbindable, GiphyMp4Playable, void onInviteToSignalClicked(); void onActivatePaymentsClicked(); void onSendPaymentClicked(@NonNull RecipientId recipientId); + void onScheduledIndicatorClicked(@NonNull View view, @NonNull MessageRecord messageRecord); /** @return true if handled, false if you want to let the normal url handling continue */ boolean onUrlClicked(@NonNull String url); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java index 4e1ea9bf8..d82e7b37b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ConversationItemFooter.java @@ -29,6 +29,7 @@ import org.signal.core.util.concurrent.SignalExecutors; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.animation.AnimationCompleteListener; import org.thoughtcrime.securesms.database.SignalDatabase; +import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.database.model.MmsMessageRecord; import org.thoughtcrime.securesms.dependencies.ApplicationDependencies; @@ -36,6 +37,7 @@ import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.service.ExpiringMessageManager; import org.thoughtcrime.securesms.util.DateUtils; +import org.thoughtcrime.securesms.util.MessageRecordUtil; import org.thoughtcrime.securesms.util.Projection; import org.thoughtcrime.securesms.util.SignalLocalMetrics; import org.thoughtcrime.securesms.util.ViewUtil; @@ -315,6 +317,8 @@ public class ConversationItemFooter extends ConstraintLayout { dateView.setText(R.string.ConversationItem_click_to_approve_unencrypted); } else if (messageRecord.isRateLimited()) { dateView.setText(R.string.ConversationItem_send_paused); + } else if (MessageRecordUtil.isScheduled(messageRecord)) { + dateView.setText(DateUtils.getOnlyTimeString(getContext(), locale, ((MediaMmsMessageRecord) messageRecord).getScheduledDate())); } else { dateView.setText(DateUtils.getSimpleRelativeTimeSpanString(getContext(), locale, messageRecord.getTimestamp())); } @@ -392,7 +396,7 @@ public class ConversationItemFooter extends ConstraintLayout { previousMessageId = newMessageId; - if (messageRecord.isFailed() || messageRecord.isPendingInsecureSmsFallback()) { + if (messageRecord.isFailed() || messageRecord.isPendingInsecureSmsFallback() || MessageRecordUtil.isScheduled(messageRecord)) { deliveryStatusView.setNone(); return; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/SendButton.kt b/app/src/main/java/org/thoughtcrime/securesms/components/SendButton.kt index 00d161e83..02c795aef 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/SendButton.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/SendButton.kt @@ -28,6 +28,7 @@ class SendButton(context: Context, attributeSet: AttributeSet?) : AppCompatImage } private val listeners: MutableList = CopyOnWriteArrayList() + private var scheduledSendListener: ScheduledSendListener? = null private var availableSendTypes: List = MessageSendType.getAllAvailable(context, false) private var activeMessageSendType: MessageSendType? = null @@ -98,6 +99,10 @@ class SendButton(context: Context, attributeSet: AttributeSet?) : AppCompatImage onSelectionChanged(newType = selectedSendType, isManualSelection = false) } + fun setScheduledSendListener(listener: ScheduledSendListener?) { + this.scheduledSendListener = listener + } + fun resetAvailableTransports(isMediaMessage: Boolean) { availableSendTypes = MessageSendType.getAllAvailable(context, isMediaMessage) activeMessageSendType = null @@ -150,13 +155,29 @@ class SendButton(context: Context, attributeSet: AttributeSet?) : AppCompatImage } } + fun showSendTypeMenu(): Boolean { + return if (availableSendTypes.size == 1) { + if (scheduledSendListener == null && !SignalStore.misc().smsExportPhase.allowSmsFeatures()) { + Snackbar.make(snackbarContainer, R.string.InputPanel__sms_messaging_is_no_longer_supported_in_signal, Snackbar.LENGTH_SHORT).show() + } + false + } else { + showSendTypeContextMenu(false) + true + } + } + override fun onLongClick(v: View): Boolean { if (!isEnabled) { return false } + val scheduleListener = scheduledSendListener if (availableSendTypes.size == 1) { - return if (!SignalStore.misc().smsExportPhase.allowSmsFeatures()) { + return if (scheduleListener != null) { + scheduleListener.onSendScheduled() + true + } else if (!SignalStore.misc().smsExportPhase.allowSmsFeatures()) { Snackbar.make(snackbarContainer, R.string.InputPanel__sms_messaging_is_no_longer_supported_in_signal, Snackbar.LENGTH_SHORT).show() true } else { @@ -164,8 +185,14 @@ class SendButton(context: Context, attributeSet: AttributeSet?) : AppCompatImage } } - val currentlySelected: MessageSendType = selectedSendType + showSendTypeContextMenu(true) + return true + } + + private fun showSendTypeContextMenu(allowScheduling: Boolean) { + val currentlySelected: MessageSendType = selectedSendType + val listener = scheduledSendListener val items = availableSendTypes .filterNot { it == currentlySelected } .map { option -> @@ -174,17 +201,26 @@ class SendButton(context: Context, attributeSet: AttributeSet?) : AppCompatImage title = option.getTitle(context), action = { setSendType(option) } ) - } + }.toMutableList() + if (allowScheduling && listener != null) { + items += ActionItem( + iconRes = R.drawable.ic_calendar_24, + title = context.getString(R.string.conversation_activity__option_schedule_message), + action = { listener.onSendScheduled() } + ) + } SignalContextMenu.Builder((parent as View), popupContainer!!) .preferredVerticalPosition(SignalContextMenu.VerticalPosition.ABOVE) .offsetY(ViewUtil.dpToPx(8)) .show(items) - - return true } interface SendTypeChangedListener { fun onSendTypeChanged(newType: MessageSendType, manuallySelected: Boolean) } + + interface ScheduledSendListener { + fun onSendScheduled() + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java index 091c9dcb2..a42fe61ea 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationAdapter.java @@ -46,6 +46,7 @@ import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.conversation.colors.Colorizable; import org.thoughtcrime.securesms.conversation.colors.Colorizer; import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart; +import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord; import org.thoughtcrime.securesms.database.model.MessageRecord; import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Playable; import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicyEnforcer; @@ -120,6 +121,7 @@ public class ConversationAdapter private Colorizer colorizer; private boolean isTypingViewEnabled; private boolean condensedMode; + private boolean scheduledMessagesMode; private PulseRequest pulseRequest; public ConversationAdapter(@NonNull Context context, @@ -261,6 +263,11 @@ public class ConversationAdapter notifyDataSetChanged(); } + public void setScheduledMessagesMode(boolean scheduledMessagesMode) { + this.scheduledMessagesMode = scheduledMessagesMode; + notifyDataSetChanged(); + } + @Override public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { switch (getItemViewType(position)) { @@ -331,7 +338,11 @@ public class ConversationAdapter if (conversationMessage == null) return -1; - calendar.setTimeInMillis(conversationMessage.getMessageRecord().getDateSent()); + if (scheduledMessagesMode) { + calendar.setTimeInMillis(((MediaMmsMessageRecord) conversationMessage.getMessageRecord()).getScheduledDate()); + } else { + calendar.setTimeInMillis(conversationMessage.getMessageRecord().getDateSent()); + } return calendar.get(Calendar.YEAR) * 1000L + calendar.get(Calendar.DAY_OF_YEAR); } @@ -345,7 +356,11 @@ public class ConversationAdapter Context context = viewHolder.itemView.getContext(); ConversationMessage conversationMessage = Objects.requireNonNull(getItem(position)); - viewHolder.setText(DateUtils.getConversationDateHeaderString(viewHolder.itemView.getContext(), locale, conversationMessage.getMessageRecord().getDateSent())); + if (scheduledMessagesMode) { + viewHolder.setText(DateUtils.getScheduledMessagesDateHeaderString(viewHolder.itemView.getContext(), locale, ((MediaMmsMessageRecord) conversationMessage.getMessageRecord()).getScheduledDate())); + } else { + viewHolder.setText(DateUtils.getConversationDateHeaderString(viewHolder.itemView.getContext(), locale, conversationMessage.getMessageRecord().getDateSent())); + } if (type == HEADER_TYPE_POPOVER_DATE) { if (hasWallpaper) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationDataSource.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationDataSource.java index 7096f2721..7a4130f5e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationDataSource.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationDataSource.java @@ -186,6 +186,10 @@ public class ConversationDataSource implements PagedDataSource list.scrollToPosition(0)); } - - return messageRecord.getId(); } private void presentConversationMetadata(@NonNull ConversationData conversation) { @@ -2114,6 +2115,11 @@ public class ConversationFragment extends LoggingFragment implements Multiselect public void onSendPaymentClicked(@NonNull RecipientId recipientId) { AttachmentManager.selectPayment(ConversationFragment.this, recipient.get()); } + + @Override + public void onScheduledIndicatorClicked(@NonNull View view, @NonNull MessageRecord messageRecord) { + + } } private boolean isUnopenedGift(View itemView, MessageRecord messageRecord) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java index 76f2783c4..4eb0613e9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -210,6 +210,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo private View storyReactionLabelWrapper; private TextView storyReactionLabel; protected View quotedIndicator; + protected View scheduledIndicator; private @NonNull Set batchSelected = new HashSet<>(); private @NonNull Outliner outliner = new Outliner(); @@ -233,18 +234,19 @@ public final class ConversationItem extends RelativeLayout implements BindableCo private int measureCalls; private boolean updatingFooter; - private final PassthroughClickListener passthroughClickListener = new PassthroughClickListener(); - private final AttachmentDownloadClickListener downloadClickListener = new AttachmentDownloadClickListener(); - private final SlideClickPassthroughListener singleDownloadClickListener = new SlideClickPassthroughListener(downloadClickListener); - private final SharedContactEventListener sharedContactEventListener = new SharedContactEventListener(); - private final SharedContactClickListener sharedContactClickListener = new SharedContactClickListener(); - private final LinkPreviewClickListener linkPreviewClickListener = new LinkPreviewClickListener(); - private final ViewOnceMessageClickListener revealableClickListener = new ViewOnceMessageClickListener(); - private final QuotedIndicatorClickListener quotedIndicatorClickListener = new QuotedIndicatorClickListener(); - private final UrlClickListener urlClickListener = new UrlClickListener(); - private final Rect thumbnailMaskingRect = new Rect(); - private final TouchDelegateChangedListener touchDelegateChangedListener = new TouchDelegateChangedListener(); - private final GiftMessageViewCallback giftMessageViewCallback = new GiftMessageViewCallback(); + private final PassthroughClickListener passthroughClickListener = new PassthroughClickListener(); + private final AttachmentDownloadClickListener downloadClickListener = new AttachmentDownloadClickListener(); + private final SlideClickPassthroughListener singleDownloadClickListener = new SlideClickPassthroughListener(downloadClickListener); + private final SharedContactEventListener sharedContactEventListener = new SharedContactEventListener(); + private final SharedContactClickListener sharedContactClickListener = new SharedContactClickListener(); + private final LinkPreviewClickListener linkPreviewClickListener = new LinkPreviewClickListener(); + private final ViewOnceMessageClickListener revealableClickListener = new ViewOnceMessageClickListener(); + private final QuotedIndicatorClickListener quotedIndicatorClickListener = new QuotedIndicatorClickListener(); + private final ScheduledIndicatorClickListener scheduledIndicatorClickListener = new ScheduledIndicatorClickListener(); + private final UrlClickListener urlClickListener = new UrlClickListener(); + private final Rect thumbnailMaskingRect = new Rect(); + private final TouchDelegateChangedListener touchDelegateChangedListener = new TouchDelegateChangedListener(); + private final GiftMessageViewCallback giftMessageViewCallback = new GiftMessageViewCallback(); private final Context context; @@ -278,6 +280,11 @@ public final class ConversationItem extends RelativeLayout implements BindableCo .scaleX(LONG_PRESS_SCALE_FACTOR) .scaleY(LONG_PRESS_SCALE_FACTOR); } + if (scheduledIndicator != null) { + scheduledIndicator.animate() + .scaleX(LONG_PRESS_SCALE_FACTOR) + .scaleY(LONG_PRESS_SCALE_FACTOR); + } } }; @@ -328,6 +335,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo this.giftViewStub = new Stub<>(findViewById(R.id.gift_view_stub)); this.quotedIndicator = findViewById(R.id.quoted_indicator); this.paymentViewStub = new Stub<>(findViewById(R.id.payment_view_stub)); + this.scheduledIndicator = findViewById(R.id.scheduled_indicator); setOnClickListener(new ClickListener(null)); @@ -395,6 +403,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo setFooter(messageRecord, nextMessageRecord, locale, groupThread, hasWallpaper); setStoryReactionLabel(messageRecord); setHasBeenQuoted(conversationMessage); + setHasBeenScheduled(conversationMessage); if (audioViewStub.resolved()) { audioViewStub.get().setOnLongClickListener(passthroughClickListener); @@ -1035,7 +1044,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo boolean messageRequestAccepted, boolean allowedToPlayInline) { - boolean showControls = !messageRecord.isFailed(); + boolean showControls = !messageRecord.isFailed() && !MessageRecordUtil.isScheduled(messageRecord); ViewUtil.setTopMargin(bodyText, readDimen(R.dimen.message_bubble_top_padding)); @@ -1711,6 +1720,19 @@ public final class ConversationItem extends RelativeLayout implements BindableCo } } + private void setHasBeenScheduled(@NonNull ConversationMessage message) { + if (scheduledIndicator == null) { + return; + } + if (message.hasBeenScheduled()) { + scheduledIndicator.setVisibility(View.VISIBLE); + scheduledIndicator.setOnClickListener(scheduledIndicatorClickListener); + } else { + scheduledIndicator.setVisibility(View.GONE); + scheduledIndicator.setOnClickListener(null); + } + } + private boolean forceFooter(@NonNull MessageRecord messageRecord) { return hasAudio(messageRecord); } @@ -1872,21 +1894,23 @@ public final class ConversationItem extends RelativeLayout implements BindableCo private boolean isStartOfMessageCluster(@NonNull MessageRecord current, @NonNull Optional previous, boolean isGroupThread) { if (isGroupThread) { return !previous.isPresent() || previous.get().isUpdate() || !DateUtils.isSameDay(current.getTimestamp(), previous.get().getTimestamp()) || - !current.getRecipient().equals(previous.get().getRecipient()) || !isWithinClusteringTime(current, previous.get()); + !current.getRecipient().equals(previous.get().getRecipient()) || !isWithinClusteringTime(current, previous.get()) || MessageRecordUtil.isScheduled(current); } else { return !previous.isPresent() || previous.get().isUpdate() || !DateUtils.isSameDay(current.getTimestamp(), previous.get().getTimestamp()) || - current.isOutgoing() != previous.get().isOutgoing() || previous.get().isSecure() != current.isSecure() || !isWithinClusteringTime(current, previous.get()); + current.isOutgoing() != previous.get().isOutgoing() || previous.get().isSecure() != current.isSecure() || !isWithinClusteringTime(current, previous.get()) || + MessageRecordUtil.isScheduled(current); } } private boolean isEndOfMessageCluster(@NonNull MessageRecord current, @NonNull Optional next, boolean isGroupThread) { if (isGroupThread) { return !next.isPresent() || next.get().isUpdate() || !DateUtils.isSameDay(current.getTimestamp(), next.get().getTimestamp()) || - !current.getRecipient().equals(next.get().getRecipient()) || !current.getReactions().isEmpty() || !isWithinClusteringTime(current, next.get()); + !current.getRecipient().equals(next.get().getRecipient()) || !current.getReactions().isEmpty() || !isWithinClusteringTime(current, next.get()) || + MessageRecordUtil.isScheduled(current); } else { return !next.isPresent() || next.get().isUpdate() || !DateUtils.isSameDay(current.getTimestamp(), next.get().getTimestamp()) || current.isOutgoing() != next.get().isOutgoing() || !current.getReactions().isEmpty() || next.get().isSecure() != current.isSecure() || - !isWithinClusteringTime(current, next.get()); + !isWithinClusteringTime(current, next.get()) || MessageRecordUtil.isScheduled(current); } } @@ -2279,6 +2303,16 @@ public final class ConversationItem extends RelativeLayout implements BindableCo } } + private class ScheduledIndicatorClickListener implements View.OnClickListener { + public void onClick(final View view) { + if (eventListener != null && batchSelected.isEmpty()) { + eventListener.onScheduledIndicatorClicked(view, (messageRecord)); + } else { + passthroughClickListener.onClick(view); + } + } + } + private class AttachmentDownloadClickListener implements SlidesClickedListener { @Override public void onClick(View v, final List slides) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationMessage.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationMessage.java index 56572f86c..e0026014a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationMessage.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationMessage.java @@ -115,6 +115,10 @@ public class ConversationMessage { getBottomButton() == null; } + public boolean hasBeenScheduled() { + return MessageRecordUtil.isScheduled(messageRecord); + } + /** * Factory providing multiple ways of creating {@link ConversationMessage}s. */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java index 83d130b54..947379a44 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java @@ -136,6 +136,8 @@ import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel; import org.thoughtcrime.securesms.components.identity.UnverifiedBannerView; import org.thoughtcrime.securesms.components.location.SignalPlace; import org.thoughtcrime.securesms.components.mention.MentionAnnotation; +import org.thoughtcrime.securesms.components.menu.ActionItem; +import org.thoughtcrime.securesms.components.menu.SignalContextMenu; import org.thoughtcrime.securesms.components.reminder.BubbleOptOutReminder; import org.thoughtcrime.securesms.components.reminder.ExpiredBuildReminder; import org.thoughtcrime.securesms.components.reminder.GroupsV1MigrationSuggestionsReminder; @@ -284,6 +286,7 @@ import org.thoughtcrime.securesms.util.ContextUtil; import org.thoughtcrime.securesms.util.ConversationUtil; import org.thoughtcrime.securesms.util.Debouncer; import org.thoughtcrime.securesms.util.DrawableUtil; +import org.thoughtcrime.securesms.util.FeatureFlags; import org.thoughtcrime.securesms.util.FullscreenHelper; import org.thoughtcrime.securesms.util.IdentityUtil; import org.thoughtcrime.securesms.util.LifecycleDisposable; @@ -329,6 +332,7 @@ import io.reactivex.rxjava3.core.Flowable; import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.core.SingleObserver; import io.reactivex.rxjava3.disposables.Disposable; +import kotlin.Unit; import static android.content.res.Configuration.ORIENTATION_LANDSCAPE; @@ -363,7 +367,9 @@ public class ConversationParentFragment extends Fragment EmojiSearchFragment.Callback, StickerKeyboardPageFragment.Callback, Material3OnScrollHelperBinder, - MessageDetailsFragment.Callback + MessageDetailsFragment.Callback, + ScheduleMessageTimePickerBottomSheet.ScheduleCallback, + ScheduledMessagesBottomSheet.Callback { private static final int SHORTCUT_ICON_SIZE = Build.VERSION.SDK_INT >= 26 ? ViewUtil.dpToPx(72) : ViewUtil.dpToPx(48 + 16 * 2); @@ -431,6 +437,7 @@ public class ConversationParentFragment extends Fragment private View cancelJoinRequest; private Stub releaseChannelUnmute; private Stub mentionsSuggestions; + private Stub scheduledMessagesBarStub; private MaterialButton joinGroupCallButton; private boolean callingTooltipShown; private ImageView wallpaper; @@ -556,6 +563,7 @@ public class ConversationParentFragment extends Fragment initializeActionBar(); disposables.add(viewModel.getStoryViewState().subscribe(titleView::setStoryRingFromState)); + disposables.add(viewModel.getScheduledMessageCount().subscribe(this::updateScheduledMessagesBar)); backPressedCallback = new OnBackPressedCallback(true) { @Override @@ -783,7 +791,8 @@ public class ConversationParentFragment extends Fragment result.isViewOnce(), initiating, true, - null).addListener(new AssertedSuccessListener() { + null, + result.getScheduledTime()).addListener(new AssertedSuccessListener() { @Override public void onSuccess(Void result) { AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> { @@ -2039,6 +2048,7 @@ public class ConversationParentFragment extends Fragment wallpaperDim = view.findViewById(R.id.conversation_wallpaper_dim); voiceNotePlayerViewStub = ViewUtil.findStubById(view, R.id.voice_note_player_stub); navigationBarBackground = view.findViewById(R.id.navbar_background); + scheduledMessagesBarStub = ViewUtil.findStubById(view, R.id.scheduled_messages_stub); ImageButton quickCameraToggle = view.findViewById(R.id.quick_camera_toggle); ImageButton inlineAttachmentButton = view.findViewById(R.id.inline_attachment_button); @@ -2073,6 +2083,18 @@ public class ConversationParentFragment extends Fragment attachButton.setOnClickListener(new AttachButtonListener()); attachButton.setOnLongClickListener(new AttachButtonLongClickListener()); sendButton.setOnClickListener(sendButtonListener); + if (FeatureFlags.scheduledMessageSends()) { + sendButton.setScheduledSendListener(() -> { + ScheduleMessageContextMenu.show(sendButton, (ViewGroup) requireView(), time -> { + if (time == -1) { + ScheduleMessageTimePickerBottomSheet.showSchedule(getChildFragmentManager()); + } else { + sendMessage(null, time); + } + return Unit.INSTANCE; + }); + }); + } sendButton.setEnabled(true); sendButton.addOnSelectionChangedListener((newMessageSendType, manuallySelected) -> { if (getContext() == null) { @@ -2916,6 +2938,13 @@ public class ConversationParentFragment extends Fragment } private void sendMessage(@Nullable String metricId) { + sendMessage(metricId, -1); + } + + private void sendMessage(@Nullable String metricId, long scheduledDate) { + if (scheduledDate != -1 && !SignalStore.uiHints().hasSeenScheduledMessagesInfoSheet()) { + ScheduleMessageFtuxBottomSheetDialog.show(getChildFragmentManager()); + } if (inputPanel.isRecordingInLockedMode()) { inputPanel.releaseRecordingLock(); return; @@ -2925,7 +2954,7 @@ public class ConversationParentFragment extends Fragment if (voiceNote != null) { AudioSlide audioSlide = AudioSlide.createFromVoiceNoteDraft(requireContext(), voiceNote); - sendVoiceNote(Objects.requireNonNull(audioSlide.getUri()), audioSlide.getFileSize()); + sendVoiceNote(Objects.requireNonNull(audioSlide.getUri()), audioSlide.getFileSize(), scheduledDate); return; } @@ -2957,9 +2986,9 @@ public class ConversationParentFragment extends Fragment } else if (sendType.usesSignalTransport() && (identityRecords.isUnverified(true) || identityRecords.isUntrusted(true))) { handleRecentSafetyNumberChange(); } else if (isMediaMessage) { - sendMediaMessage(sendType, expiresIn, false, initiating, metricId); + sendMediaMessage(sendType, expiresIn, false, initiating, metricId, scheduledDate); } else { - sendTextMessage(sendType, expiresIn, initiating, metricId); + sendTextMessage(sendType, expiresIn, initiating, metricId, scheduledDate); } } catch (RecipientFormattingException ex) { Toast.makeText(requireContext(), @@ -3002,7 +3031,8 @@ public class ConversationParentFragment extends Fragment Collections.emptySet(), null, true, - result.getBodyRanges()); + result.getBodyRanges(), + -1); final Context context = requireContext().getApplicationContext(); @@ -3012,7 +3042,7 @@ public class ConversationParentFragment extends Fragment attachmentManager.clear(glideRequests, false); silentlySetComposeText(""); - long id = fragment.stageOutgoingMessage(message); + fragment.stageOutgoingMessage(message); SimpleTask.run(() -> { long resultId = MessageSender.sendPushWithPreUploadedMedia(context, message, result.getPreUploadResults(), thread, null); @@ -3024,7 +3054,7 @@ public class ConversationParentFragment extends Fragment }, this::sendComplete); } - private void sendMediaMessage(@NonNull MessageSendType sendType, final long expiresIn, final boolean viewOnce, final boolean initiating, @Nullable String metricId) + private void sendMediaMessage(@NonNull MessageSendType sendType, final long expiresIn, final boolean viewOnce, final boolean initiating, @Nullable String metricId, long scheduledDate) throws InvalidMessageException { Log.i(TAG, "Sending media message..."); @@ -3042,7 +3072,8 @@ public class ConversationParentFragment extends Fragment viewOnce, initiating, true, - metricId); + metricId, + scheduledDate); } private ListenableFuture sendMediaMessage(@NonNull RecipientId recipientId, @@ -3059,6 +3090,25 @@ public class ConversationParentFragment extends Fragment final boolean initiating, final boolean clearComposeBox, final @Nullable String metricId) + { + return sendMediaMessage(recipientId, sendType, body, slideDeck, quote, contacts, previews, mentions, styling, expiresIn, viewOnce, initiating, clearComposeBox, metricId, -1); + } + + private ListenableFuture sendMediaMessage(@NonNull RecipientId recipientId, + @NonNull MessageSendType sendType, + @NonNull String body, + SlideDeck slideDeck, + QuoteModel quote, + List contacts, + List previews, + List mentions, + @Nullable BodyRangeList styling, + final long expiresIn, + final boolean viewOnce, + final boolean initiating, + final boolean clearComposeBox, + final @Nullable String metricId, + final long scheduledDate) { if (ExpiredBuildReminder.isEligible()) { showExpiredDialog(); @@ -3101,7 +3151,8 @@ public class ConversationParentFragment extends Fragment Collections.emptySet(), null, false, - styling); + styling, + scheduledDate); final SettableFuture future = new SettableFuture<>(); final Context context = requireContext().getApplicationContext(); @@ -3126,7 +3177,7 @@ public class ConversationParentFragment extends Fragment silentlySetComposeText(""); } - final long id = fragment.stageOutgoingMessage(outgoingMessage); + fragment.stageOutgoingMessage(outgoingMessage); SimpleTask.run(() -> { return MessageSender.send(context, outgoingMessage, thread, sendType.usesSmsTransport() ? SendType.MMS : SendType.SIGNAL, metricId, null); @@ -3141,7 +3192,7 @@ public class ConversationParentFragment extends Fragment return future; } - private void sendTextMessage(@NonNull MessageSendType sendType, final long expiresIn, final boolean initiating, final @Nullable String metricId) + private void sendTextMessage(@NonNull MessageSendType sendType, final long expiresIn, final boolean initiating, final @Nullable String metricId, long scheduledDate) throws InvalidMessageException { if (ExpiredBuildReminder.isEligible()) { @@ -3159,10 +3210,14 @@ public class ConversationParentFragment extends Fragment final String messageBody = getMessage(); final boolean sendPush = sendType.usesSignalTransport(); - OutgoingMessage message; + final OutgoingMessage message; if (sendPush) { - message = OutgoingMessage.text(recipient.get(), messageBody, expiresIn, System.currentTimeMillis(), null); + if (scheduledDate > 0) { + message = OutgoingMessage.text(recipient.get(), messageBody, expiresIn, System.currentTimeMillis(), null).sendAt(scheduledDate); + } else { + message = OutgoingMessage.text(recipient.get(), messageBody, expiresIn, System.currentTimeMillis(), null); + } ApplicationDependencies.getTypingStatusSender().onTypingStopped(thread); } else { message = OutgoingMessage.sms(recipient.get(), messageBody, sendType.getSimSubscriptionIdOr(-1)); @@ -3260,6 +3315,22 @@ public class ConversationParentFragment extends Fragment } } + private void updateScheduledMessagesBar(@NonNull Integer count) { + if (count <= 0) { + scheduledMessagesBarStub.setVisibility(View.GONE); + } else { + if (!scheduledMessagesBarStub.resolved()) { + View scheduledMessagesBar = scheduledMessagesBarStub.get(); + scheduledMessagesBar.findViewById(R.id.scheduled_messages_show_all).setOnClickListener(v -> { + ScheduledMessagesBottomSheet.show(getChildFragmentManager(), threadId, recipient.getId()); + }); + } + scheduledMessagesBarStub.setVisibility(View.VISIBLE); + TextView scheduledText = scheduledMessagesBarStub.get().findViewById(R.id.scheduled_messages_text); + scheduledText.setText(getResources().getQuantityString(R.plurals.conversation_scheduled_messages_bar__number_of_messages, count, count)); + } + } + private void recordTransportPreference(MessageSendType sendType) { new AsyncTask() { @Override @@ -3403,7 +3474,7 @@ public class ConversationParentFragment extends Fragment container.hideAttachedInput(true); } - private void sendVoiceNote(@NonNull Uri uri, long size) { + private void sendVoiceNote(@NonNull Uri uri, long size, long scheduledDate) { boolean initiating = threadId == -1; long expiresIn = TimeUnit.SECONDS.toMillis(recipient.get().getExpiresInSeconds()); AudioSlide audioSlide = new AudioSlide(requireContext(), uri, size, MediaUtil.AUDIO_AAC, true); @@ -3424,7 +3495,8 @@ public class ConversationParentFragment extends Fragment false, initiating, true, - null); + null, + scheduledDate); } private void sendSticker(@NonNull StickerRecord stickerRecord, boolean clearCompose) { @@ -3589,6 +3661,14 @@ public class ConversationParentFragment extends Fragment }); } + public void onScheduleSend(long scheduledTime) { + sendMessage(null, scheduledTime); + } + + public @NonNull ConversationAdapter.ItemClickListener getConversationAdapterListener() { + return fragment.getConversationAdapterListener(); + } + // Listeners private class RecordingSession implements SingleObserver { @@ -3607,7 +3687,7 @@ public class ConversationParentFragment extends Fragment @Override public void onSuccess(@NonNull VoiceNoteDraft draft) { if (shouldSend) { - sendVoiceNote(draft.getUri(), draft.getSize()); + sendVoiceNote(draft.getUri(), draft.getSize(), -1); } else { if (!saveDraft) { draftViewModel.cancelEphemeralVoiceNoteDraft(draft.asDraft()); @@ -3688,7 +3768,7 @@ public class ConversationParentFragment extends Fragment private class AttachButtonLongClickListener implements View.OnLongClickListener { @Override public boolean onLongClick(View v) { - return sendButton.performLongClick(); + return sendButton.showSendTypeMenu(); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationViewModel.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationViewModel.java index fa2e325a0..4c8b65783 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationViewModel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationViewModel.java @@ -73,6 +73,7 @@ public class ConversationViewModel extends ViewModel { private final Application context; private final MediaRepository mediaRepository; private final ConversationRepository conversationRepository; + private final ScheduledMessagesRepository scheduledMessagesRepository; private final MutableLiveData> recentMedia; private final BehaviorSubject threadId; private final Observable messageData; @@ -99,6 +100,7 @@ public class ConversationViewModel extends ViewModel { private final CompositeDisposable disposables; private final BehaviorSubject conversationStateTick; private final PublishProcessor markReadRequestPublisher; + private final Observable scheduledMessageCount; private ConversationIntents.Args args; private int jumpToPosition; @@ -107,6 +109,7 @@ public class ConversationViewModel extends ViewModel { this.context = ApplicationDependencies.getApplication(); this.mediaRepository = new MediaRepository(); this.conversationRepository = new ConversationRepository(); + this.scheduledMessagesRepository = new ScheduledMessagesRepository(); this.recentMedia = new MutableLiveData<>(); this.showScrollButtons = new MutableLiveData<>(false); this.hasUnreadMentions = new MutableLiveData<>(false); @@ -200,6 +203,10 @@ public class ConversationViewModel extends ViewModel { .withLatestFrom(conversationMetadata, (messages, metadata) -> new MessageData(metadata, messages)) .doOnNext(a -> SignalLocalMetrics.ConversationOpen.onDataLoaded()); + scheduledMessageCount = threadId + .observeOn(Schedulers.io()) + .flatMap(scheduledMessagesRepository::getScheduledMessageCount); + Observable liveRecipient = recipientId.distinctUntilChanged().switchMap(id -> Recipient.live(id).asObservable()); canShowAsBubble = threadId.observeOn(Schedulers.io()).map(conversationRepository::canShowAsBubble); @@ -371,6 +378,10 @@ public class ConversationViewModel extends ViewModel { .observeOn(AndroidSchedulers.mainThread()); } + @NonNull Observable getScheduledMessageCount() { + return scheduledMessageCount.observeOn(AndroidSchedulers.mainThread()); + } + void setHasUnreadMentions(boolean hasUnreadMentions) { this.hasUnreadMentions.setValue(hasUnreadMentions); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ScheduleMessageContextMenu.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ScheduleMessageContextMenu.kt new file mode 100644 index 000000000..f544ac7be --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ScheduleMessageContextMenu.kt @@ -0,0 +1,78 @@ +package org.thoughtcrime.securesms.conversation + +import android.view.View +import android.view.ViewGroup +import androidx.annotation.DrawableRes +import org.signal.core.util.dp +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.menu.ActionItem +import org.thoughtcrime.securesms.components.menu.SignalContextMenu +import org.thoughtcrime.securesms.util.DateUtils +import org.thoughtcrime.securesms.util.toLocalDateTime +import org.thoughtcrime.securesms.util.toMillis +import java.util.Locale + +class ScheduleMessageContextMenu { + + companion object { + + private val presetHours = arrayOf(8, 12, 18, 21) + + @JvmStatic + fun show(anchor: View, container: ViewGroup, action: (Long) -> Unit): SignalContextMenu { + val currentTime = System.currentTimeMillis() + val scheduledTimes = getNextScheduleTimes(currentTime) + val actionItems = scheduledTimes.map { + if (it > 0) { + ActionItem(getIconForTime(it), DateUtils.getScheduledMessageDateString(anchor.context, Locale.getDefault(), it)) { + action(it) + } + } else { + ActionItem(0, anchor.context.getString(R.string.ScheduledMessages_pick_time)) { + action(it) + } + } + } + + return SignalContextMenu.Builder(anchor, container) + .offsetX(12.dp) + .offsetY(12.dp) + .preferredVerticalPosition(SignalContextMenu.VerticalPosition.ABOVE) + .show(actionItems) + } + + @DrawableRes + private fun getIconForTime(timeMs: Long): Int { + val dateTime = timeMs.toLocalDateTime() + return if (dateTime.hour >= 18) { + R.drawable.ic_nighttime_26 + } else { + R.drawable.ic_daytime_24 + } + } + + private fun getNextScheduleTimes(currentTimeMs: Long): List { + var currentDateTime = currentTimeMs.toLocalDateTime() + + val timestampList = ArrayList(4) + var presetIndex = presetHours.indexOfFirst { it > currentDateTime.hour } + if (presetIndex == -1) { + currentDateTime = currentDateTime.plusDays(1) + presetIndex = 0 + } + currentDateTime = currentDateTime.withMinute(0).withSecond(0) + while (timestampList.size < 3) { + currentDateTime = currentDateTime.withHour(presetHours[presetIndex]) + timestampList += currentDateTime.toMillis() + presetIndex++ + if (presetIndex >= presetHours.size) { + presetIndex = 0 + currentDateTime = currentDateTime.plusDays(1) + } + } + timestampList += -1 + + return timestampList.reversed() + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ScheduleMessageFtuxBottomSheetDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ScheduleMessageFtuxBottomSheetDialog.kt new file mode 100644 index 000000000..2fc117163 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ScheduleMessageFtuxBottomSheetDialog.kt @@ -0,0 +1,40 @@ +package org.thoughtcrime.securesms.conversation + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.FragmentManager +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment +import org.thoughtcrime.securesms.components.ViewBinderDelegate +import org.thoughtcrime.securesms.databinding.ScheduleMessageFtuxBottomSheetBinding +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.util.BottomSheetUtil + +class ScheduleMessageFtuxBottomSheetDialog : FixedRoundedCornerBottomSheetDialogFragment() { + override val peekHeightPercentage: Float = 0.66f + override val themeResId: Int = R.style.Widget_Signal_FixedRoundedCorners_Messages + + private val binding by ViewBinderDelegate(ScheduleMessageFtuxBottomSheetBinding::bind) + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + return inflater.inflate(R.layout.schedule_message_ftux_bottom_sheet, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + binding.okay.setOnClickListener { + SignalStore.uiHints().markHasSeenScheduledMessagesInfoSheet() + dismiss() + } + } + + companion object { + @JvmStatic + fun show(fragmentManager: FragmentManager) { + val fragment = ScheduleMessageFtuxBottomSheetDialog() + + fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ScheduleMessageTimePickerBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ScheduleMessageTimePickerBottomSheet.kt new file mode 100644 index 000000000..57e02af9a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ScheduleMessageTimePickerBottomSheet.kt @@ -0,0 +1,203 @@ +package org.thoughtcrime.securesms.conversation + +import android.os.Bundle +import android.text.format.DateFormat +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.FragmentManager +import com.google.android.material.datepicker.CalendarConstraints +import com.google.android.material.datepicker.DateValidatorPointForward +import com.google.android.material.datepicker.MaterialDatePicker +import com.google.android.material.timepicker.MaterialTimePicker +import com.google.android.material.timepicker.TimeFormat +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment +import org.thoughtcrime.securesms.components.ViewBinderDelegate +import org.thoughtcrime.securesms.databinding.ScheduleMessageTimePickerBottomSheetBinding +import org.thoughtcrime.securesms.util.BottomSheetUtil +import org.thoughtcrime.securesms.util.DateUtils +import org.thoughtcrime.securesms.util.atMidnight +import org.thoughtcrime.securesms.util.atUTC +import org.thoughtcrime.securesms.util.formatHours +import org.thoughtcrime.securesms.util.fragments.findListener +import org.thoughtcrime.securesms.util.toLocalDateTime +import org.thoughtcrime.securesms.util.toMillis +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter +import java.util.Locale + +/** + * Bottom sheet dialog that allows selecting a timestamp after the current time for + * scheduling a message send. + * + * Will call [ScheduleCallback.onScheduleSend] with the selected time, if called with [showSchedule] + * Will call [RescheduleCallback.onReschedule] with the selected time, if called with [showReschedule] + */ +class ScheduleMessageTimePickerBottomSheet : FixedRoundedCornerBottomSheetDialogFragment() { + override val peekHeightPercentage: Float = 0.66f + override val themeResId: Int = R.style.Widget_Signal_FixedRoundedCorners_Messages + + private var scheduledDate: Long = 0 + private var scheduledHour: Int = 0 + private var scheduledMinute: Int = 0 + + private val binding by ViewBinderDelegate(ScheduleMessageTimePickerBottomSheetBinding::bind) + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + return inflater.inflate(R.layout.schedule_message_time_picker_bottom_sheet, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val initialTime = arguments?.getLong(KEY_INITIAL_TIME) + scheduledDate = initialTime ?: System.currentTimeMillis() + var scheduledLocalDateTime = scheduledDate.toLocalDateTime() + if (initialTime == null) { + scheduledLocalDateTime = scheduledLocalDateTime.plusMinutes(5L - (scheduledLocalDateTime.minute % 5)) + } + + scheduledHour = scheduledLocalDateTime.hour + scheduledMinute = scheduledLocalDateTime.minute + + binding.scheduleSend.setOnClickListener { + dismiss() + val messageId = arguments?.getLong(KEY_MESSAGE_ID) + if (messageId == null) { + findListener()?.onScheduleSend(getSelectedTimestamp()) + } else { + val selectedTime = getSelectedTimestamp() + if (selectedTime != arguments?.getLong(KEY_INITIAL_TIME)) { + findListener()?.onReschedule(selectedTime, messageId) + } + } + } + + val zoneOffsetFormatter = DateTimeFormatter.ofPattern("OOOO") + val zoneNameFormatter = DateTimeFormatter.ofPattern("zzzz") + val zonedDateTime = ZonedDateTime.now() + binding.timezoneDisclaimer.apply { + text = getString( + R.string.ScheduleMessageTimePickerBottomSheet__timezone_disclaimer, + zoneOffsetFormatter.format(zonedDateTime), + zoneNameFormatter.format(zonedDateTime), + ) + } + + updateSelectedDate() + updateSelectedTime() + + setupDateSelector() + setupTimeSelector() + } + + private fun setupDateSelector() { + binding.daySelector.setOnClickListener { + val local = LocalDateTime.now() + .atMidnight() + .atUTC() + .toMillis() + val datePicker = + MaterialDatePicker.Builder.datePicker() + .setTitleText(getString(R.string.ScheduleMessageTimePickerBottomSheet__select_date_title)) + .setSelection(scheduledDate) + .setCalendarConstraints(CalendarConstraints.Builder().setStart(local).setValidator(DateValidatorPointForward.now()).build()) + .build() + + datePicker.addOnDismissListener { + datePicker.clearOnDismissListeners() + datePicker.clearOnPositiveButtonClickListeners() + } + + datePicker.addOnPositiveButtonClickListener { + it.let { + scheduledDate = it.toLocalDateTime(ZoneOffset.UTC).atZone(ZoneId.systemDefault()).toMillis() + updateSelectedDate() + } + } + datePicker.show(childFragmentManager, "DATE_PICKER") + } + } + + private fun setupTimeSelector() { + binding.timeSelector.setOnClickListener { + val timeFormat = if (DateFormat.is24HourFormat(requireContext())) TimeFormat.CLOCK_24H else TimeFormat.CLOCK_12H + val timePickerFragment = MaterialTimePicker.Builder() + .setTimeFormat(timeFormat) + .setHour(scheduledHour) + .setMinute(scheduledMinute) + .setTitleText(getString(R.string.ScheduleMessageTimePickerBottomSheet__select_time_title)) + .build() + + timePickerFragment.addOnDismissListener { + timePickerFragment.clearOnDismissListeners() + timePickerFragment.clearOnPositiveButtonClickListeners() + } + + timePickerFragment.addOnPositiveButtonClickListener { + scheduledHour = timePickerFragment.hour + scheduledMinute = timePickerFragment.minute + + updateSelectedTime() + } + + timePickerFragment.show(childFragmentManager, "TIME_PICKER") + } + } + + private fun getSelectedTimestamp(): Long { + return scheduledDate.toLocalDateTime() + .withMinute(scheduledMinute) + .withHour(scheduledHour) + .withSecond(0) + .withNano(0) + .toMillis() + } + + private fun updateSelectedDate() { + binding.dateText.text = DateUtils.getDayPrecisionTimeString(requireContext(), Locale.getDefault(), scheduledDate) + } + + private fun updateSelectedTime() { + val scheduledTime = LocalTime.of(scheduledHour, scheduledMinute) + binding.timeText.text = scheduledTime.formatHours(requireContext()) + } + + interface ScheduleCallback { + fun onScheduleSend(scheduledTime: Long) + } + + interface RescheduleCallback { + fun onReschedule(scheduledTime: Long, messageId: Long) + } + + companion object { + + private const val KEY_MESSAGE_ID = "message_id" + private const val KEY_INITIAL_TIME = "initial_time" + + @JvmStatic + fun showSchedule(fragmentManager: FragmentManager) { + val fragment = ScheduleMessageTimePickerBottomSheet() + + fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) + } + + @JvmStatic + fun showReschedule(fragmentManager: FragmentManager, messageId: Long, initialTime: Long) { + val args = Bundle().apply { + putLong(KEY_MESSAGE_ID, messageId) + putLong(KEY_INITIAL_TIME, initialTime) + } + + val fragment = ScheduleMessageTimePickerBottomSheet().apply { + arguments = args + } + + fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ScheduledMessagesBottomSheet.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ScheduledMessagesBottomSheet.kt new file mode 100644 index 000000000..660b50936 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ScheduledMessagesBottomSheet.kt @@ -0,0 +1,295 @@ +package org.thoughtcrime.securesms.conversation + +import android.annotation.SuppressLint +import android.content.DialogInterface +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.app.AlertDialog +import androidx.core.view.doOnNextLayout +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.viewModels +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.signal.core.util.StreamUtil +import org.signal.core.util.concurrent.SimpleTask +import org.signal.core.util.dp +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment +import org.thoughtcrime.securesms.components.SignalProgressDialog +import org.thoughtcrime.securesms.components.menu.ActionItem +import org.thoughtcrime.securesms.components.menu.SignalContextMenu +import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager +import org.thoughtcrime.securesms.conversation.colors.Colorizer +import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer +import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart +import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart.Attachments +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ItemDecoration +import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackController +import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicy +import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionPlayerHolder +import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionRecycler +import org.thoughtcrime.securesms.groups.GroupId +import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange +import org.thoughtcrime.securesms.linkpreview.LinkPreview +import org.thoughtcrime.securesms.mms.GlideApp +import org.thoughtcrime.securesms.mms.PartAuthority +import org.thoughtcrime.securesms.mms.TextSlide +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.util.BottomSheetUtil +import org.thoughtcrime.securesms.util.BottomSheetUtil.requireCoordinatorLayout +import org.thoughtcrime.securesms.util.LifecycleDisposable +import org.thoughtcrime.securesms.util.StickyHeaderDecoration +import org.thoughtcrime.securesms.util.Util +import org.thoughtcrime.securesms.util.fragments.requireListener +import org.thoughtcrime.securesms.util.hasTextSlide +import org.thoughtcrime.securesms.util.requireTextSlide +import java.io.IOException +import java.util.Locale + +/** + * Bottom sheet dialog to view all scheduled messages within a given thread. + */ +class ScheduledMessagesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment(), ScheduleMessageTimePickerBottomSheet.RescheduleCallback { + + override val peekHeightPercentage: Float = 0.66f + override val themeResId: Int = R.style.Widget_Signal_FixedRoundedCorners_Messages + + private var firstRender: Boolean = true + + private lateinit var messageAdapter: ConversationAdapter + private lateinit var callback: Callback + + private val viewModel: ScheduledMessagesViewModel by viewModels( + factoryProducer = { + val threadId = requireArguments().getLong(KEY_THREAD_ID) + ScheduledMessagesViewModel.Factory(threadId) + } + ) + + private val disposables: LifecycleDisposable = LifecycleDisposable() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val view = inflater.inflate(R.layout.scheduled_messages_bottom_sheet, container, false) + disposables.bindTo(viewLifecycleOwner) + return view + } + + @SuppressLint("WrongThread") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + val conversationRecipientId = RecipientId.from(arguments?.getString(KEY_CONVERSATION_RECIPIENT_ID, null) ?: throw IllegalArgumentException()) + val conversationRecipient = Recipient.resolved(conversationRecipientId) + + callback = requireListener() + + val colorizer = Colorizer() + + messageAdapter = ConversationAdapter(requireContext(), viewLifecycleOwner, GlideApp.with(this), Locale.getDefault(), ConversationAdapterListener(), conversationRecipient, colorizer).apply { + setCondensedMode(true) + setScheduledMessagesMode(true) + } + + val list: RecyclerView = view.findViewById(R.id.scheduled_list).apply { + layoutManager = SmoothScrollingLinearLayoutManager(requireContext(), true) + adapter = messageAdapter + itemAnimator = null + + doOnNextLayout { + // Adding this without waiting for a layout pass would result in an indeterminate amount of padding added to the top of the view + addItemDecoration(StickyHeaderDecoration(messageAdapter, false, false, ConversationAdapter.HEADER_TYPE_INLINE_DATE)) + } + } + + val recyclerViewColorizer = RecyclerViewColorizer(list) + + disposables += viewModel.getMessages(requireContext()).subscribe { messages -> + if (messages.isEmpty()) { + dismiss() + } + + messageAdapter.submitList(messages) { + if (firstRender) { + (list.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(messages.size - 1, 100) + + firstRender = false + } else if (!list.canScrollVertically(1)) { + list.layoutManager?.scrollToPosition(0) + } + } + recyclerViewColorizer.setChatColors(conversationRecipient.chatColors) + } + + initializeGiphyMp4(view.findViewById(R.id.video_container) as ViewGroup, list) + } + + private fun initializeGiphyMp4(videoContainer: ViewGroup, list: RecyclerView): GiphyMp4ProjectionRecycler { + val maxPlayback = GiphyMp4PlaybackPolicy.maxSimultaneousPlaybackInConversation() + val holders = GiphyMp4ProjectionPlayerHolder.injectVideoViews( + requireContext(), + viewLifecycleOwner.lifecycle, + videoContainer, + maxPlayback + ) + val callback = GiphyMp4ProjectionRecycler(holders) + + GiphyMp4PlaybackController.attach(list, callback, maxPlayback) + list.addItemDecoration(GiphyMp4ItemDecoration(callback) {}, 0) + + return callback + } + + private fun showScheduledMessageContextMenu(view: View, messageRecord: MessageRecord) { + SignalContextMenu.Builder(view, requireCoordinatorLayout()) + .offsetX(12.dp) + .offsetY(12.dp) + .preferredVerticalPosition(SignalContextMenu.VerticalPosition.ABOVE) + .show(getMenuActionItems(messageRecord)) + } + + private fun getMenuActionItems(messageRecord: MessageRecord): List { + val message = ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(requireContext(), messageRecord) + val canCopy = message.multiselectCollection.toSet().any { it !is Attachments && messageRecord.body.isNotEmpty() } + val items: MutableList = ArrayList() + items.add(ActionItem(R.drawable.ic_delete_tinted_24, resources.getString(R.string.conversation_selection__menu_delete), action = { handleDeleteMessage(messageRecord) })) + if (canCopy) { + items.add(ActionItem(R.drawable.ic_copy_24_tinted, resources.getString(R.string.conversation_selection__menu_copy), action = { handleCopyMessage(message) })) + } + items.add(ActionItem(R.drawable.ic_send_outline_24, resources.getString(R.string.ScheduledMessagesBottomSheet_menu_send_now), action = { handleSendMessageNow(messageRecord) })) + items.add(ActionItem(R.drawable.ic_calendar_24, resources.getString(R.string.ScheduledMessagesBottomSheet_menu_reschedule), action = { handleRescheduleMessage(messageRecord) })) + return items + } + + private fun handleRescheduleMessage(messageRecord: MessageRecord) { + ScheduleMessageTimePickerBottomSheet.showReschedule(childFragmentManager, messageRecord.id, (messageRecord as MediaMmsMessageRecord).scheduledDate) + } + + private fun handleSendMessageNow(messageRecord: MessageRecord) { + viewModel.rescheduleMessage(messageRecord.id, System.currentTimeMillis()) + } + + private fun handleDeleteMessage(messageRecord: MessageRecord) { + buildDeleteScheduledMessageConfirmationDialog(messageRecord).show() + } + + private fun handleCopyMessage(message: ConversationMessage) { + SimpleTask.run( + viewLifecycleOwner.lifecycle, + { getMessageText(message) }, + { bodies: CharSequence? -> + if (!Util.isEmpty(bodies)) { + Util.copyToClipboard(requireContext(), bodies!!) + } + } + ) + } + + private fun buildDeleteScheduledMessageConfirmationDialog(messageRecord: MessageRecord): AlertDialog.Builder { + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(resources.getString(R.string.ScheduledMessagesBottomSheet_delete_dialog_message)) + .setCancelable(true) + .setPositiveButton(R.string.ScheduledMessagesBottomSheet_delete_dialog_action) { _: DialogInterface?, _: Int -> + deleteMessage(messageRecord.id) + } + .setNegativeButton(android.R.string.cancel, null) + } + + private fun getMessageText(message: ConversationMessage): CharSequence { + if (message.messageRecord.hasTextSlide()) { + val textSlide: TextSlide = message.messageRecord.requireTextSlide() + if (textSlide.uri == null) { + return message.getDisplayBody(requireContext()) + } + try { + PartAuthority.getAttachmentStream(requireContext(), textSlide.uri!!).use { stream -> + val body = StreamUtil.readFullyAsString(stream) + return ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(requireContext(), message.messageRecord, body) + .getDisplayBody(requireContext()) + } + } catch (e: IOException) { + Log.w(TAG, "Failed to read text slide data.") + } + } + return ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(requireContext(), message.messageRecord).getDisplayBody(requireContext()) + } + + private fun deleteMessage(messageId: Long) { + val progressDialog = SignalProgressDialog.show( + context = requireContext(), + message = resources.getString(R.string.ScheduledMessagesBottomSheet_deleting_progress_message), + indeterminate = true + ) + SimpleTask.run(viewLifecycleOwner.lifecycle, { + SignalDatabase.messages.deleteScheduledMessage(messageId) + }, { + progressDialog.dismiss() + }) + } + + override fun onReschedule(scheduledTime: Long, messageId: Long) { + viewModel.rescheduleMessage(messageId, scheduledTime) + } + + private fun getAdapterListener(): ConversationAdapter.ItemClickListener { + return callback.getConversationAdapterListener() + } + + private inner class ConversationAdapterListener : ConversationAdapter.ItemClickListener by getAdapterListener() { + override fun onItemClick(item: MultiselectPart) = Unit + override fun onItemLongClick(itemView: View, item: MultiselectPart) = Unit + override fun onQuoteClicked(messageRecord: MmsMessageRecord) = Unit + override fun onLinkPreviewClicked(linkPreview: LinkPreview) = Unit + override fun onQuotedIndicatorClicked(messageRecord: MessageRecord) = Unit + override fun onReactionClicked(multiselectPart: MultiselectPart, messageId: Long, isMms: Boolean) = Unit + override fun onGroupMemberClicked(recipientId: RecipientId, groupId: GroupId) = Unit + override fun onMessageWithRecaptchaNeededClicked(messageRecord: MessageRecord) = Unit + override fun onGroupMigrationLearnMoreClicked(membershipChange: GroupMigrationMembershipChange) = Unit + override fun onChatSessionRefreshLearnMoreClicked() = Unit + override fun onBadDecryptLearnMoreClicked(author: RecipientId) = Unit + override fun onSafetyNumberLearnMoreClicked(recipient: Recipient) = Unit + override fun onJoinGroupCallClicked() = Unit + override fun onInviteFriendsToGroupClicked(groupId: GroupId.V2) = Unit + override fun onEnableCallNotificationsClicked() = Unit + override fun onCallToAction(action: String) = Unit + override fun onDonateClicked() = Unit + override fun onRecipientNameClicked(target: RecipientId) = Unit + override fun onViewGiftBadgeClicked(messageRecord: MessageRecord) = Unit + override fun onActivatePaymentsClicked() = Unit + override fun onSendPaymentClicked(recipientId: RecipientId) = Unit + override fun onScheduledIndicatorClicked(view: View, messageRecord: MessageRecord) { + showScheduledMessageContextMenu(view, messageRecord) + } + } + + interface Callback { + fun getConversationAdapterListener(): ConversationAdapter.ItemClickListener + } + + companion object { + private val TAG = Log.tag(ScheduledMessagesBottomSheet::class.java) + + private const val KEY_THREAD_ID = "thread_id" + private const val KEY_CONVERSATION_RECIPIENT_ID = "conversation_recipient_id" + + @JvmStatic + fun show(fragmentManager: FragmentManager, threadId: Long, conversationRecipientId: RecipientId) { + val args = Bundle().apply { + putLong(KEY_THREAD_ID, threadId) + putString(KEY_CONVERSATION_RECIPIENT_ID, conversationRecipientId.serialize()) + } + + val fragment = ScheduledMessagesBottomSheet().apply { + arguments = args + } + + fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ScheduledMessagesRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ScheduledMessagesRepository.kt new file mode 100644 index 000000000..e9b6c481b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ScheduledMessagesRepository.kt @@ -0,0 +1,68 @@ +package org.thoughtcrime.securesms.conversation + +import android.content.Context +import androidx.annotation.WorkerThread +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.schedulers.Schedulers +import org.thoughtcrime.securesms.database.DatabaseObserver +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies + +/** + * Handles retrieving scheduled messages data to be shown in [ScheduledMessagesBottomSheet] and [ConversationParentFragment] + */ +class ScheduledMessagesRepository { + + /** + * Get all the scheduled messages for the specified thread, ordered by scheduled time + */ + fun getScheduledMessages(context: Context, threadId: Long): Observable> { + return Observable.create { emitter -> + val databaseObserver: DatabaseObserver = ApplicationDependencies.getDatabaseObserver() + val observer = DatabaseObserver.Observer { emitter.onNext(getScheduledMessagesSync(context, threadId)) } + + databaseObserver.registerScheduledMessageObserver(threadId, observer) + + emitter.setCancellable { databaseObserver.unregisterObserver(observer) } + emitter.onNext(getScheduledMessagesSync(context, threadId)) + }.subscribeOn(Schedulers.io()) + } + + @WorkerThread + private fun getScheduledMessagesSync(context: Context, threadId: Long): List { + var scheduledMessages: List = SignalDatabase.messages.getScheduledMessagesInThread(threadId) + + val attachmentHelper = ConversationDataSource.AttachmentHelper() + + attachmentHelper.addAll(scheduledMessages) + + attachmentHelper.fetchAttachments() + + scheduledMessages = attachmentHelper.buildUpdatedModels(ApplicationDependencies.getApplication(), scheduledMessages) + + val replies: List = scheduledMessages + .map { ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(context, it) } + + return replies + } + + /** + * Get the number of scheduled messages for a given thread + */ + fun getScheduledMessageCount(threadId: Long): Observable { + return Observable.create { emitter -> + val databaseObserver: DatabaseObserver = ApplicationDependencies.getDatabaseObserver() + val observer = DatabaseObserver.Observer { emitter.onNext(SignalDatabase.messages.getScheduledMessageCountForThread(threadId)) } + + databaseObserver.registerScheduledMessageObserver(threadId, observer) + + emitter.setCancellable { databaseObserver.unregisterObserver(observer) } + emitter.onNext(SignalDatabase.messages.getScheduledMessageCountForThread(threadId)) + }.subscribeOn(Schedulers.io()) + } + + fun rescheduleMessage(threadId: Long, messageId: Long, scheduleTime: Long) { + SignalDatabase.messages.rescheduleMessage(threadId, messageId, scheduleTime) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ScheduledMessagesViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ScheduledMessagesViewModel.kt new file mode 100644 index 000000000..53a78502a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ScheduledMessagesViewModel.kt @@ -0,0 +1,33 @@ +package org.thoughtcrime.securesms.conversation + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Observable +import org.signal.core.util.logging.Log + +class ScheduledMessagesViewModel @JvmOverloads constructor( + private val threadId: Long, + private val repository: ScheduledMessagesRepository = ScheduledMessagesRepository() +) : ViewModel() { + + fun getMessages(context: Context): Observable> { + return repository.getScheduledMessages(context, threadId) + .observeOn(AndroidSchedulers.mainThread()) + } + + fun rescheduleMessage(messageId: Long, scheduleTime: Long) { + repository.rescheduleMessage(threadId, messageId, scheduleTime) + } + + companion object { + private val TAG = Log.tag(ScheduledMessagesViewModel::class.java) + } + + class Factory(private val threadId: Long) : ViewModelProvider.NewInstanceFactory() { + override fun create(modelClass: Class): T { + return modelClass.cast(ScheduledMessagesViewModel(threadId)) as T + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseObserver.java b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseObserver.java index eddf93e30..cacfba7f4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseObserver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DatabaseObserver.java @@ -42,6 +42,7 @@ public class DatabaseObserver { private static final String KEY_NOTIFICATION_PROFILES = "NotificationProfiles"; private static final String KEY_RECIPIENT = "Recipient"; private static final String KEY_STORY_OBSERVER = "Story"; + private static final String KEY_SCHEDULED_MESSAGES = "ScheduledMessages"; private final Application application; private final Executor executor; @@ -50,6 +51,7 @@ public class DatabaseObserver { private final Map> conversationObservers; private final Map> verboseConversationObservers; private final Map> paymentObservers; + private final Map> scheduledMessageObservers; private final Set allPaymentsObservers; private final Set chatColorsObservers; private final Set stickerObservers; @@ -76,6 +78,7 @@ public class DatabaseObserver { this.messageInsertObservers = new HashMap<>(); this.notificationProfileObservers = new HashSet<>(); this.storyObservers = new HashMap<>(); + this.scheduledMessageObservers = new HashMap<>(); } public void registerConversationListObserver(@NonNull Observer listener) { @@ -159,6 +162,12 @@ public class DatabaseObserver { }); } + public void registerScheduledMessageObserver(long threadId, @NonNull Observer listener) { + executor.execute(() -> { + registerMapped(scheduledMessageObservers, threadId, listener); + }); + } + public void unregisterObserver(@NonNull Observer listener) { executor.execute(() -> { conversationListObservers.remove(listener); @@ -290,6 +299,12 @@ public class DatabaseObserver { } } + public void notifyScheduledMessageObservers(long threadId) { + runPostSuccessfulTransaction(KEY_SCHEDULED_MESSAGES + threadId, () -> { + notifyMapped(scheduledMessageObservers, threadId); + }); + } + private void runPostSuccessfulTransaction(@NonNull String dedupeKey, @NonNull Runnable runnable) { SignalDatabase.runPostSuccessfulTransaction(dedupeKey, () -> { executor.execute(runnable); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.java b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.java index eb9f8427c..754196cb9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessageTable.java @@ -180,6 +180,7 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie public static final String VIEW_ONCE = "view_once"; public static final String STORY_TYPE = "story_type"; public static final String PARENT_STORY_ID = "parent_story_id"; + public static final String SCHEDULED_DATE = "scheduled_date"; public static class Status { public static final int STATUS_NONE = -1; @@ -234,7 +235,8 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie STORY_TYPE + " INTEGER DEFAULT 0, " + PARENT_STORY_ID + " INTEGER DEFAULT 0, " + EXPORT_STATE + " BLOB DEFAULT NULL, " + - EXPORTED + " INTEGER DEFAULT 0);"; + EXPORTED + " INTEGER DEFAULT 0, " + + SCHEDULED_DATE + " INTEGER DEFAULT -1);"; private static final String INDEX_THREAD_DATE = "mms_thread_date_index"; @@ -247,7 +249,7 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie "CREATE INDEX IF NOT EXISTS mms_reactions_unread_index ON " + TABLE_NAME + " (" + REACTIONS_UNREAD + ");", "CREATE INDEX IF NOT EXISTS mms_story_type_index ON " + TABLE_NAME + " (" + STORY_TYPE + ");", "CREATE INDEX IF NOT EXISTS mms_parent_story_id_index ON " + TABLE_NAME + " (" + PARENT_STORY_ID + ");", - "CREATE INDEX IF NOT EXISTS mms_thread_story_parent_story_index ON " + TABLE_NAME + " (" + THREAD_ID + ", " + DATE_RECEIVED + "," + STORY_TYPE + "," + PARENT_STORY_ID + ");", + "CREATE INDEX IF NOT EXISTS mms_thread_story_parent_story_scheduled_date_index ON " + TABLE_NAME + " (" + THREAD_ID + ", " + DATE_RECEIVED + "," + STORY_TYPE + "," + PARENT_STORY_ID + "," + SCHEDULED_DATE + ");", "CREATE INDEX IF NOT EXISTS mms_quote_id_quote_author_index ON " + TABLE_NAME + "(" + QUOTE_ID + ", " + QUOTE_AUTHOR + ");", "CREATE INDEX IF NOT EXISTS mms_exported_index ON " + TABLE_NAME + " (" + EXPORTED + ");", "CREATE INDEX IF NOT EXISTS mms_id_type_payment_transactions_index ON " + TABLE_NAME + " (" + ID + "," + TYPE + ") WHERE " + TYPE + " & " + MessageTypes.SPECIAL_TYPE_PAYMENTS_NOTIFICATION + " != 0;" @@ -298,6 +300,7 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie MESSAGE_RANGES, STORY_TYPE, PARENT_STORY_ID, + SCHEDULED_DATE, }; private static final String[] MMS_PROJECTION = SqlUtil.appendArg(MMS_PROJECTION_BASE, "NULL AS " + AttachmentTable.ATTACHMENT_JSON_ALIAS); @@ -343,6 +346,7 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie MessageTable.TYPE + " & " + MessageTypes.GROUP_V2_LEAVE_BITS + " != " + MessageTypes.GROUP_V2_LEAVE_BITS + " AND " + MessageTable.STORY_TYPE + " = 0 AND " + MessageTable.PARENT_STORY_ID + " <= 0 AND " + + MessageTable.SCHEDULED_DATE + " = -1 AND " + MessageTable.TYPE + " NOT IN (" + MessageTypes.PROFILE_CHANGE_TYPE + ", " + MessageTypes.GV1_MIGRATION_TYPE + ", " + MessageTypes.CHANGE_NUMBER_TYPE + ", " + MessageTypes.BOOST_REQUEST_TYPE + ", " + MessageTypes.SMS_EXPORT_TYPE + ") " + "ORDER BY " + MessageTable.DATE_RECEIVED + " DESC " + "LIMIT 1"; @@ -1628,11 +1632,26 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie return -1; } + public int getScheduledMessageCountForThread(long threadId) { + SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); + + String query = THREAD_ID + " = ? AND " + STORY_TYPE + " = ? AND " + PARENT_STORY_ID + " <= ? AND " + SCHEDULED_DATE + " != ?"; + String[] args = SqlUtil.buildArgs(threadId, 0, 0, -1); + + try (Cursor cursor = db.query(TABLE_NAME, COUNT, query, args, null, null, null)) { + if (cursor.moveToFirst()) { + return cursor.getInt(0); + } + } + + return 0; + } + public int getMessageCountForThread(long threadId) { SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - String query = THREAD_ID + " = ? AND " + STORY_TYPE + " = ? AND " + PARENT_STORY_ID + " <= ?"; - String[] args = SqlUtil.buildArgs(threadId, 0, 0); + String query = THREAD_ID + " = ? AND " + STORY_TYPE + " = ? AND " + PARENT_STORY_ID + " <= ? AND " + SCHEDULED_DATE + " = ?"; + String[] args = SqlUtil.buildArgs(threadId, 0, 0, -1); try (Cursor cursor = db.query(TABLE_NAME, COUNT, query, args, null, null, null)) { if (cursor != null && cursor.moveToFirst()) { @@ -1646,8 +1665,8 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie public int getMessageCountForThread(long threadId, long beforeTime) { SQLiteDatabase db = databaseHelper.getSignalReadableDatabase(); - String query = THREAD_ID + " = ? AND " + DATE_RECEIVED + " < ? AND " + STORY_TYPE + " = ? AND " + PARENT_STORY_ID + " <= ?"; - String[] args = SqlUtil.buildArgs(threadId, beforeTime, 0, 0); + String query = THREAD_ID + " = ? AND " + DATE_RECEIVED + " < ? AND " + STORY_TYPE + " = ? AND " + PARENT_STORY_ID + " <= ? AND " + SCHEDULED_DATE + " = ?"; + String[] args = SqlUtil.buildArgs(threadId, beforeTime, 0, 0, -1); try (Cursor cursor = db.query(TABLE_NAME, COUNT, query, args, null, null, null)) { if (cursor != null && cursor.moveToFirst()) { @@ -1688,8 +1707,8 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie } private @NonNull SqlUtil.Query buildMeaningfulMessagesQuery(long threadId) { - String query = THREAD_ID + " = ? AND " + STORY_TYPE + " = ? AND " + PARENT_STORY_ID + " <= ? AND (NOT " + TYPE + " & ? AND " + TYPE + " != ? AND " + TYPE + " != ? AND " + TYPE + " != ? AND " + TYPE + " != ? AND " + TYPE + " & " + MessageTypes.GROUP_V2_LEAVE_BITS + " != " + MessageTypes.GROUP_V2_LEAVE_BITS + ")"; - return SqlUtil.buildQuery(query, threadId, 0, 0, MessageTypes.IGNORABLE_TYPESMASK_WHEN_COUNTING, MessageTypes.PROFILE_CHANGE_TYPE, MessageTypes.CHANGE_NUMBER_TYPE, MessageTypes.SMS_EXPORT_TYPE, MessageTypes.BOOST_REQUEST_TYPE); + String query = THREAD_ID + " = ? AND " + STORY_TYPE + " = ? AND " + PARENT_STORY_ID + " <= ? AND " + SCHEDULED_DATE + " = ? AND (NOT " + TYPE + " & ? AND " + TYPE + " != ? AND " + TYPE + " != ? AND " + TYPE + " != ? AND " + TYPE + " != ? AND " + TYPE + " & " + MessageTypes.GROUP_V2_LEAVE_BITS + " != " + MessageTypes.GROUP_V2_LEAVE_BITS + ")"; + return SqlUtil.buildQuery(query, threadId, 0, 0, -1, MessageTypes.IGNORABLE_TYPESMASK_WHEN_COUNTING, MessageTypes.PROFILE_CHANGE_TYPE, MessageTypes.CHANGE_NUMBER_TYPE, MessageTypes.SMS_EXPORT_TYPE, MessageTypes.BOOST_REQUEST_TYPE); } public void addFailures(long messageId, List failure) { @@ -1929,6 +1948,33 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie ApplicationDependencies.getDatabaseObserver().notifyMessageUpdateObservers(new MessageId(messageId)); } + public boolean clearScheduledStatus(long threadId, long messageId) { + SQLiteDatabase database = databaseHelper.getSignalWritableDatabase(); + ContentValues contentValues = new ContentValues(); + contentValues.put(SCHEDULED_DATE, -1); + contentValues.put(DATE_SENT, System.currentTimeMillis()); + contentValues.put(DATE_RECEIVED, System.currentTimeMillis()); + + int rowsUpdated = database.update(TABLE_NAME, contentValues, ID_WHERE + " AND " + SCHEDULED_DATE + "!= ?", SqlUtil.buildArgs(messageId, -1)); + ApplicationDependencies.getDatabaseObserver().notifyMessageInsertObservers(threadId, new MessageId(messageId)); + ApplicationDependencies.getDatabaseObserver().notifyScheduledMessageObservers(threadId); + + return rowsUpdated > 0; + } + + public void rescheduleMessage(long threadId, long messageId, long time) { + SQLiteDatabase database = databaseHelper.getSignalWritableDatabase(); + ContentValues contentValues = new ContentValues(); + contentValues.put(SCHEDULED_DATE, time); + + int rowsUpdated = database.update(TABLE_NAME, contentValues, ID_WHERE + " AND " + SCHEDULED_DATE + "!= ?", SqlUtil.buildArgs(messageId, -1)); + ApplicationDependencies.getDatabaseObserver().notifyScheduledMessageObservers(threadId); + ApplicationDependencies.getScheduledMessageManager().scheduleIfNecessary(); + if (rowsUpdated == 0) { + Log.w(TAG, "Failed to reschedule messageId=" + messageId + " to new time " + time + ". may have been sent already"); + } + } + public void markAsInsecure(long messageId) { updateMailboxBitmask(messageId, MessageTypes.SECURE_MESSAGE_BIT, 0, Optional.empty()); } @@ -2191,6 +2237,7 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie StoryType storyType = StoryType.fromCode(CursorUtil.requireInt(cursor, STORY_TYPE)); ParentStoryId parentStoryId = ParentStoryId.deserialize(CursorUtil.requireLong(cursor, PARENT_STORY_ID)); byte[] messageRangesData = CursorUtil.requireBlob(cursor, MESSAGE_RANGES); + long scheduledDate = cursor.getLong(cursor.getColumnIndexOrThrow(SCHEDULED_DATE)); long quoteId = cursor.getLong(cursor.getColumnIndexOrThrow(QUOTE_ID)); long quoteAuthor = cursor.getLong(cursor.getColumnIndexOrThrow(QUOTE_AUTHOR)); @@ -2280,7 +2327,8 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie mismatches, giftBadge, MessageTypes.isSecureType(outboxType), - messageRanges); + messageRanges, + scheduledDate); return message; } @@ -2794,6 +2842,7 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie contentValues.put(RECEIPT_TIMESTAMP, Stream.of(earlyDeliveryReceipts.values()).mapToLong(EarlyReceiptCache.Receipt::getTimestamp).max().orElse(-1)); contentValues.put(STORY_TYPE, message.getStoryType().getCode()); contentValues.put(PARENT_STORY_ID, message.getParentStoryId() != null ? message.getParentStoryId().serialize() : 0); + contentValues.put(SCHEDULED_DATE, message.getScheduledDate()); if (message.getRecipient().isSelf() && hasAudioAttachment(message.getAttachments())) { contentValues.put(VIEWED_RECEIPT_COUNT, 1L); @@ -2874,6 +2923,9 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie } else { ApplicationDependencies.getDatabaseObserver().notifyConversationListeners(threadId); } + if (message.getScheduledDate() != -1) { + ApplicationDependencies.getDatabaseObserver().notifyScheduledMessageObservers(threadId); + } } else { ApplicationDependencies.getDatabaseObserver().notifyStoryObservers(message.getRecipient().getId()); } @@ -2985,9 +3037,13 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie } public boolean deleteMessage(long messageId) { + long threadId = getThreadIdForMessage(messageId); + return deleteMessage(messageId, threadId); + } + + public boolean deleteMessage(long messageId, long threadId) { Log.d(TAG, "deleteMessage(" + messageId + ")"); - long threadId = getThreadIdForMessage(messageId); AttachmentTable attachmentDatabase = SignalDatabase.attachments(); attachmentDatabase.deleteAttachmentsForMessage(messageId); @@ -3008,6 +3064,31 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie return threadDeleted; } + public void deleteScheduledMessage(long messageId) { + Log.d(TAG, "deleteScheduledMessage(" + messageId + ")"); + SQLiteDatabase db = databaseHelper.getSignalWritableDatabase(); + long threadId = getThreadIdForMessage(messageId); + db.beginTransaction(); + try { + ContentValues contentValues = new ContentValues(); + contentValues.put(SCHEDULED_DATE, -1); + contentValues.put(DATE_SENT, System.currentTimeMillis()); + contentValues.put(DATE_RECEIVED, System.currentTimeMillis()); + + int rowsUpdated = db.update(TABLE_NAME, contentValues, ID_WHERE + " AND " + SCHEDULED_DATE + "!= ?", SqlUtil.buildArgs(messageId, -1)); + if (rowsUpdated > 0) { + deleteMessage(messageId, threadId); + db.setTransactionSuccessful(); + } else { + Log.w(TAG, "tried to delete scheduled message but it may have already been sent"); + } + } finally { + db.endTransaction(); + } + ApplicationDependencies.getScheduledMessageManager().scheduleIfNecessary(); + ApplicationDependencies.getDatabaseObserver().notifyScheduledMessageObservers(threadId); + } + public void deleteThread(long threadId) { Log.d(TAG, "deleteThread(" + threadId + ")"); Set singleThreadSet = new HashSet<>(); @@ -4465,13 +4546,55 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie * This does *not* have attachments in it. */ public Cursor getConversation(long threadId, long offset, long limit) { - String selection = THREAD_ID + " = ? AND " + STORY_TYPE + " = ? AND " + PARENT_STORY_ID + " <= ?"; - String[] args = SqlUtil.buildArgs(threadId, 0, 0); + String selection = THREAD_ID + " = ? AND " + STORY_TYPE + " = ? AND " + PARENT_STORY_ID + " <= ? AND " + SCHEDULED_DATE + " = ?"; + String[] args = SqlUtil.buildArgs(threadId, 0, 0, -1); String order = DATE_RECEIVED + " DESC"; String limitStr = limit > 0 || offset > 0 ? offset + ", " + limit : null; return getReadableDatabase().query(TABLE_NAME, MMS_PROJECTION, selection, args, null, null, order, limitStr); } + + public List getScheduledMessagesInThread(long threadId) { + String selection = THREAD_ID + " = ? AND " + STORY_TYPE + " = ? AND " + PARENT_STORY_ID + " <= ? AND " + SCHEDULED_DATE + " != ?"; + String[] args = SqlUtil.buildArgs(threadId, 0, 0, -1); + String order = SCHEDULED_DATE + " DESC"; + + try (MmsReader reader = mmsReaderFor(getReadableDatabase().query(TABLE_NAME, MMS_PROJECTION, selection, args, null, null, order))) { + List results = new ArrayList<>(reader.getCount()); + while (reader.getNext() != null) { + results.add(reader.getCurrent()); + } + + return results; + } + } + + public List getScheduledMessagesBefore(long time) { + String selection = STORY_TYPE + " = ? AND " + PARENT_STORY_ID + " <= ? AND " + SCHEDULED_DATE + " != ? AND " + SCHEDULED_DATE + " <= ?"; + String[] args = SqlUtil.buildArgs(0, 0, -1, time); + String order = SCHEDULED_DATE + " DESC"; + + try (MmsReader reader = mmsReaderFor(getReadableDatabase().query(TABLE_NAME, MMS_PROJECTION, selection, args, null, null, order))) { + List results = new ArrayList<>(reader.getCount()); + while (reader.getNext() != null) { + results.add(reader.getCurrent()); + } + + return results; + } + } + + public @Nullable Long getOldestScheduledSendTimestamp() { + String[] columns = new String[] { SCHEDULED_DATE }; + String selection = STORY_TYPE + " = ? AND " + PARENT_STORY_ID + " <= ? AND " + SCHEDULED_DATE + " != ?"; + String[] args = SqlUtil.buildArgs(0, 0, -1); + String order = SCHEDULED_DATE + " ASC"; + String limit = "1"; + + try (Cursor cursor = getReadableDatabase().query(TABLE_NAME, columns, selection, args, null, null, order, limit)) { + return cursor != null && cursor.moveToNext() ? cursor.getLong(0) : null; + } + } public Cursor getMessagesForNotificationState(Collection stickyThreads) { StringBuilder stickyQuery = new StringBuilder(); @@ -5064,7 +5187,8 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie message.getParentStoryId(), message.getGiftBadge(), null, - null); + null, + -1); } } @@ -5208,6 +5332,7 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie byte[] messageRangesData = CursorUtil.requireBlob(cursor, MESSAGE_RANGES); StoryType storyType = StoryType.fromCode(CursorUtil.requireInt(cursor, STORY_TYPE)); ParentStoryId parentStoryId = ParentStoryId.deserialize(CursorUtil.requireLong(cursor, PARENT_STORY_ID)); + long scheduledDate = CursorUtil.requireLong(cursor, SCHEDULED_DATE); if (!TextSecurePreferences.isReadReceiptsEnabled(context)) { readReceiptCount = 0; @@ -5252,7 +5377,7 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie networkFailures, subscriptionId, expiresIn, expireStarted, isViewOnce, readReceiptCount, quote, contacts, previews, unidentified, Collections.emptyList(), remoteDelete, mentionsSelf, notifiedTimestamp, viewedReceiptCount, receiptTimestamp, messageRanges, - storyType, parentStoryId, giftBadge, null, null); + storyType, parentStoryId, giftBadge, null, null, scheduledDate); } private Set getMismatchedIdentities(String document) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt index acde7cb6d..e0120eb40 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/SignalDatabaseMigrations.kt @@ -28,6 +28,7 @@ import org.thoughtcrime.securesms.database.helpers.migration.V169_EmojiSearchInd import org.thoughtcrime.securesms.database.helpers.migration.V170_CallTableMigration import org.thoughtcrime.securesms.database.helpers.migration.V171_ThreadForeignKeyFix import org.thoughtcrime.securesms.database.helpers.migration.V172_GroupMembershipMigration +import org.thoughtcrime.securesms.database.helpers.migration.V173_ScheduledMessagesMigration /** * Contains all of the database migrations for [SignalDatabase]. Broken into a separate file for cleanliness. @@ -36,7 +37,7 @@ object SignalDatabaseMigrations { val TAG: String = Log.tag(SignalDatabaseMigrations.javaClass) - const val DATABASE_VERSION = 172 + const val DATABASE_VERSION = 173 @JvmStatic fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { @@ -135,6 +136,10 @@ object SignalDatabaseMigrations { if (oldVersion < 172) { V172_GroupMembershipMigration.migrate(context, db, oldVersion, newVersion) } + + if (oldVersion < 173) { + V173_ScheduledMessagesMigration.migrate(context, db, oldVersion, newVersion) + } } @JvmStatic diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V173_ScheduledMessagesMigration.kt b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V173_ScheduledMessagesMigration.kt new file mode 100644 index 000000000..690eab7db --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/database/helpers/migration/V173_ScheduledMessagesMigration.kt @@ -0,0 +1,18 @@ +package org.thoughtcrime.securesms.database.helpers.migration + +import android.app.Application +import net.zetetic.database.sqlcipher.SQLiteDatabase + +/** + * In order to support scheduled sending, we need to add another column to keep track of when to send the message. We also use this + * column to hide future scheduled messages from views. + */ +object V173_ScheduledMessagesMigration : SignalDatabaseMigration { + override fun migrate(context: Application, db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + db.execSQL("ALTER TABLE mms ADD COLUMN scheduled_date INTEGER DEFAULT -1") + db.execSQL("DROP INDEX mms_thread_story_parent_story_index") + db.execSQL( + "CREATE INDEX IF NOT EXISTS mms_thread_story_parent_story_scheduled_date_index ON mms (thread_id, date_received,story_type,parent_story_id,scheduled_date);" + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java b/app/src/main/java/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java index 755802323..5b0b1156f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/model/MediaMmsMessageRecord.java @@ -69,6 +69,7 @@ public class MediaMmsMessageRecord extends MmsMessageRecord { private final BodyRangeList messageRanges; private final Payment payment; private final CallTable.Call call; + private final long scheduledDate; public MediaMmsMessageRecord(long id, Recipient conversationRecipient, @@ -104,7 +105,8 @@ public class MediaMmsMessageRecord extends MmsMessageRecord { @Nullable ParentStoryId parentStoryId, @Nullable GiftBadge giftBadge, @Nullable Payment payment, - @Nullable CallTable.Call call) + @Nullable CallTable.Call call, + long scheduledDate) { super(id, body, conversationRecipient, individualRecipient, recipientDeviceId, dateSent, dateReceived, dateServer, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox, mismatches, failures, @@ -115,6 +117,7 @@ public class MediaMmsMessageRecord extends MmsMessageRecord { this.messageRanges = messageRanges; this.payment = payment; this.call = call; + this.scheduledDate = scheduledDate; } @Override @@ -197,18 +200,22 @@ public class MediaMmsMessageRecord extends MmsMessageRecord { return call; } + public long getScheduledDate() { + return scheduledDate; + } + public @NonNull MediaMmsMessageRecord withReactions(@NonNull List reactions) { return new MediaMmsMessageRecord(getId(), getRecipient(), getIndividualRecipient(), getRecipientDeviceId(), getDateSent(), getDateReceived(), getServerTimestamp(), getDeliveryReceiptCount(), getThreadId(), getBody(), getSlideDeck(), getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), isViewOnce(), getReadReceiptCount(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), reactions, isRemoteDelete(), mentionsSelf, - getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall()); + getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getScheduledDate()); } public @NonNull MediaMmsMessageRecord withoutQuote() { return new MediaMmsMessageRecord(getId(), getRecipient(), getIndividualRecipient(), getRecipientDeviceId(), getDateSent(), getDateReceived(), getServerTimestamp(), getDeliveryReceiptCount(), getThreadId(), getBody(), getSlideDeck(), getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), isViewOnce(), getReadReceiptCount(), null, getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf, - getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall()); + getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getScheduledDate()); } public @NonNull MediaMmsMessageRecord withAttachments(@NonNull Context context, @NonNull List attachments) { @@ -229,14 +236,14 @@ public class MediaMmsMessageRecord extends MmsMessageRecord { return new MediaMmsMessageRecord(getId(), getRecipient(), getIndividualRecipient(), getRecipientDeviceId(), getDateSent(), getDateReceived(), getServerTimestamp(), getDeliveryReceiptCount(), getThreadId(), getBody(), slideDeck, getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), isViewOnce(), getReadReceiptCount(), quote, contacts, linkPreviews, isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf, - getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall()); + getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getScheduledDate()); } public @NonNull MediaMmsMessageRecord withPayment(@NonNull Payment payment) { return new MediaMmsMessageRecord(getId(), getRecipient(), getIndividualRecipient(), getRecipientDeviceId(), getDateSent(), getDateReceived(), getServerTimestamp(), getDeliveryReceiptCount(), getThreadId(), getBody(), getSlideDeck(), getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), isViewOnce(), getReadReceiptCount(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf, - getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), payment, getCall()); + getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), payment, getCall(), getScheduledDate()); } @@ -244,7 +251,7 @@ public class MediaMmsMessageRecord extends MmsMessageRecord { return new MediaMmsMessageRecord(getId(), getRecipient(), getIndividualRecipient(), getRecipientDeviceId(), getDateSent(), getDateReceived(), getServerTimestamp(), getDeliveryReceiptCount(), getThreadId(), getBody(), getSlideDeck(), getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), isViewOnce(), getReadReceiptCount(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf, - getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), call); + getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), call, getScheduledDate()); } private static @NonNull List updateContacts(@NonNull List contacts, @NonNull Map attachmentIdMap) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java index b22849d96..96f9262bd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencies.java @@ -35,6 +35,7 @@ import org.thoughtcrime.securesms.revealable.ViewOnceMessageManager; import org.thoughtcrime.securesms.service.ExpiringMessageManager; import org.thoughtcrime.securesms.service.ExpiringStoriesManager; import org.thoughtcrime.securesms.service.PendingRetryReceiptManager; +import org.thoughtcrime.securesms.service.ScheduledMessageManager; import org.thoughtcrime.securesms.service.TrimThreadsByDateManager; import org.thoughtcrime.securesms.service.webrtc.SignalCallManager; import org.thoughtcrime.securesms.shakereport.ShakeToReport; @@ -129,6 +130,7 @@ public class ApplicationDependencies { private static volatile ProfileService profileService; private static volatile DeadlockDetector deadlockDetector; private static volatile ClientZkReceiptOperations clientZkReceiptOperations; + private static volatile ScheduledMessageManager scheduledMessagesManager; @MainThread public static void init(@NonNull Application application, @NonNull Provider provider) { @@ -441,6 +443,18 @@ public class ApplicationDependencies { return expiringMessageManager; } + public static @NonNull ScheduledMessageManager getScheduledMessageManager() { + if (scheduledMessagesManager == null) { + synchronized (LOCK) { + if (scheduledMessagesManager == null) { + scheduledMessagesManager = provider.provideScheduledMessageManager(); + } + } + } + + return scheduledMessagesManager; + } + public static TypingStatusRepository getTypingStatusRepository() { if (typingStatusRepository == null) { synchronized (LOCK) { @@ -710,5 +724,6 @@ public class ApplicationDependencies { @NonNull DeadlockDetector provideDeadlockDetector(); @NonNull ClientZkReceiptOperations provideClientZkReceiptOperations(@NonNull SignalServiceConfiguration signalServiceConfiguration); @NonNull KeyBackupService provideKeyBackupService(@NonNull SignalServiceAccountManager signalServiceAccountManager, @NonNull KeyStore keyStore, @NonNull KbsEnclave enclave); + @NonNull ScheduledMessageManager provideScheduledMessageManager(); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java index 866fb27e1..8014e9c77 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/ApplicationDependencyProvider.java @@ -59,6 +59,7 @@ import org.thoughtcrime.securesms.revealable.ViewOnceMessageManager; import org.thoughtcrime.securesms.service.ExpiringMessageManager; import org.thoughtcrime.securesms.service.ExpiringStoriesManager; import org.thoughtcrime.securesms.service.PendingRetryReceiptManager; +import org.thoughtcrime.securesms.service.ScheduledMessageManager; import org.thoughtcrime.securesms.service.TrimThreadsByDateManager; import org.thoughtcrime.securesms.service.webrtc.SignalCallManager; import org.thoughtcrime.securesms.shakereport.ShakeToReport; @@ -229,6 +230,11 @@ public class ApplicationDependencyProvider implements ApplicationDependencies.Pr return new ExpiringMessageManager(context); } + @Override + public @NonNull ScheduledMessageManager provideScheduledMessageManager() { + return new ScheduledMessageManager(context); + } + @Override public @NonNull TypingStatusRepository provideTypingStatusRepository() { return new TypingStatusRepository(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/IndividualSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/IndividualSendJob.java index 74b7c8b70..96a02ebae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/IndividualSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/IndividualSendJob.java @@ -100,8 +100,12 @@ public class IndividualSendJob extends PushSendJob { @WorkerThread public static void enqueue(@NonNull Context context, @NonNull JobManager jobManager, long messageId, @NonNull Recipient recipient) { try { - OutgoingMessage message = SignalDatabase.messages().getOutgoingMessage(messageId); - Set attachmentUploadIds = enqueueCompressingAndUploadAttachmentsChains(jobManager, message); + OutgoingMessage message = SignalDatabase.messages().getOutgoingMessage(messageId); + if (message.getScheduledDate() != -1) { + ApplicationDependencies.getScheduledMessageManager().scheduleIfNecessary(); + return; + } + Set attachmentUploadIds = enqueueCompressingAndUploadAttachmentsChains(jobManager, message); jobManager.add(IndividualSendJob.create(messageId, recipient, attachmentUploadIds.size() > 0), attachmentUploadIds, recipient.getId().toQueueKey()); } catch (NoSuchMessageException | MmsException e) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java index 0b810cdc8..49cd4fa73 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/PushGroupSendJob.java @@ -116,7 +116,16 @@ public final class PushGroupSendJob extends PushSendJob { MessageTable database = SignalDatabase.messages(); OutgoingMessage message = database.getOutgoingMessage(messageId); - Set attachmentUploadIds = enqueueCompressingAndUploadAttachmentsChains(jobManager, message); + + if (message.getScheduledDate() != -1) { + if (!filterAddresses.isEmpty()) { + throw new MmsException("Cannot schedule a group message with filter addresses!"); + } + ApplicationDependencies.getScheduledMessageManager().scheduleIfNecessary(); + return; + } + + Set attachmentUploadIds = enqueueCompressingAndUploadAttachmentsChains(jobManager, message); if (message.getGiftBadge() != null) { throw new MmsException("Cannot send a gift badge to a group!"); diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/UiHints.java b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/UiHints.java index bab344c25..b73fa6e38 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/UiHints.java +++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/UiHints.java @@ -13,6 +13,7 @@ public class UiHints extends SignalStoreValues { private static final String HAS_CONFIRMED_DELETE_FOR_EVERYONE_ONCE = "uihints.has_confirmed_delete_for_everyone_once"; private static final String HAS_SET_OR_SKIPPED_USERNAME_CREATION = "uihints.has_set_or_skipped_username_creation"; private static final String NEVER_DISPLAY_PULL_TO_FILTER_TIP = "uihints.never_display_pull_to_filter_tip"; + private static final String HAS_SEEN_SCHEDULED_MESSAGES_INFO_ONCE = "uihints.has_seen_scheduled_messages_info_once"; UiHints(@NonNull KeyValueStore store) { super(store); @@ -36,6 +37,14 @@ public class UiHints extends SignalStoreValues { return getBoolean(HAS_SEEN_GROUP_SETTINGS_MENU_TOAST, false); } + public void markHasSeenScheduledMessagesInfoSheet() { + putBoolean(HAS_SEEN_SCHEDULED_MESSAGES_INFO_ONCE, true); + } + + public boolean hasSeenScheduledMessagesInfoSheet() { + return getBoolean(HAS_SEEN_SCHEDULED_MESSAGES_INFO_ONCE, false); + } + public void markHasConfirmedDeleteForEveryoneOnce() { putBoolean(HAS_CONFIRMED_DELETE_FOR_EVERYONE_ONCE, true); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivityResult.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivityResult.kt index 5c1c0f506..5581dde6f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivityResult.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaSendActivityResult.kt @@ -27,7 +27,8 @@ class MediaSendActivityResult( val isViewOnce: Boolean, val mentions: List, @TypeParceler() val bodyRanges: BodyRangeList?, - val storyType: StoryType + val storyType: StoryType, + val scheduledTime: Long = -1 ) : Parcelable { val isPushPreUpload: Boolean diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt index 23147e466..fa92da803 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionRepository.kt @@ -78,7 +78,8 @@ class MediaSelectionRepository(context: Context) { contacts: List, mentions: List, bodyRanges: BodyRangeList?, - sendType: MessageSendType + sendType: MessageSendType, + scheduledTime: Long = -1 ): Maybe { if (isSms && contacts.isNotEmpty()) { throw IllegalStateException("Provided recipients to send to, but this is SMS!") @@ -112,8 +113,8 @@ class MediaSelectionRepository(context: Context) { StoryType.NONE } - if (isSms || MessageSender.isLocalSelfSend(context, singleRecipient, MessageSender.SendType.SIGNAL)) { - Log.i(TAG, "SMS or local self-send. Skipping pre-upload.") + if (isSms || MessageSender.isLocalSelfSend(context, singleRecipient, MessageSender.SendType.SIGNAL) || (scheduledTime != -1L && storyType == StoryType.NONE)) { + Log.i(TAG, "SMS, local self-send, or scheduled send. Skipping pre-upload.") emitter.onSuccess( MediaSendActivityResult( recipientId = singleRecipient!!.id, @@ -123,7 +124,8 @@ class MediaSelectionRepository(context: Context) { isViewOnce = isViewOnce, mentions = trimmedMentions, bodyRanges = trimmedBodyRanges, - storyType = StoryType.NONE + storyType = StoryType.NONE, + scheduledTime = scheduledTime ) ) } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt index 84da9f5a8..6ca26a2a2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt @@ -333,7 +333,13 @@ class MediaSelectionViewModel( } fun send( - selectedContacts: List = emptyList() + selectedContacts: List = emptyList(), + scheduledDate: Long? = null + ): Maybe = send(selectedContacts, scheduledDate ?: -1) + + fun send( + selectedContacts: List = emptyList(), + scheduledDate: Long ): Maybe { return UntrustedRecords.checkForBadIdentityRecords(selectedContacts.toSet(), identityChangesSince).andThen( repository.send( @@ -347,7 +353,8 @@ class MediaSelectionViewModel( contacts = selectedContacts.ifEmpty { destination.getRecipientSearchKeyList() }, mentions = MentionAnnotation.getMentionsFromAnnotations(store.state.message), bodyRanges = MessageStyler.getStyling(store.state.message), - sendType = store.state.sendType + sendType = store.state.sendType, + scheduledTime = scheduledDate ) ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewFragment.kt index 04ef205c7..142fd178d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaReviewFragment.kt @@ -6,6 +6,7 @@ import android.content.res.ColorStateList import android.graphics.Color import android.os.Bundle import android.view.View +import android.view.ViewGroup import android.widget.ImageView import android.widget.ProgressBar import android.widget.TextView @@ -29,6 +30,8 @@ import org.signal.core.util.concurrent.SimpleTask import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey import org.thoughtcrime.securesms.conversation.MessageSendType +import org.thoughtcrime.securesms.conversation.ScheduleMessageContextMenu +import org.thoughtcrime.securesms.conversation.ScheduleMessageTimePickerBottomSheet import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardActivity import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult @@ -44,6 +47,7 @@ import org.thoughtcrime.securesms.mms.SentMediaQuality import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.scribbles.ImageEditorFragment +import org.thoughtcrime.securesms.util.FeatureFlags import org.thoughtcrime.securesms.util.LifecycleDisposable import org.thoughtcrime.securesms.util.MediaUtil import org.thoughtcrime.securesms.util.SystemWindowInsetsSetter @@ -55,7 +59,7 @@ import org.thoughtcrime.securesms.util.visible /** * Allows the user to view and edit selected media. */ -class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment) { +class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment), ScheduleMessageTimePickerBottomSheet.ScheduleCallback { private val sharedViewModel: MediaSelectionViewModel by viewModels( ownerProducer = { requireActivity() } @@ -88,6 +92,8 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment) { private var animatorSet: AnimatorSet? = null private var disposables: LifecycleDisposable = LifecycleDisposable() + private var scheduledSendTime: Long? = null + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { postponeEnterTransition() @@ -198,6 +204,7 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment) { } else { storiesLauncher.launch(StoriesMultiselectForwardActivity.Args(args, emptyList())) } + scheduledSendTime = null } else { multiselectLauncher.launch(args) } @@ -207,10 +214,25 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment) { .setPositiveButton(R.string.MediaReviewFragment__add_to_story) { _, _ -> performSend() } .setNegativeButton(android.R.string.cancel) { _, _ -> } .show() + scheduledSendTime = null } else { performSend() } } + if (FeatureFlags.scheduledMessageSends()) { + sendButton.setOnLongClickListener { + ScheduleMessageContextMenu.show(it, (requireView() as ViewGroup)) { time: Long -> + if (time == -1L) { + scheduledSendTime = null + ScheduleMessageTimePickerBottomSheet.showSchedule(childFragmentManager) + } else { + scheduledSendTime = time + sendButton.performClick() + } + } + true + } + } addMediaButton.setOnClickListener { launchGallery() @@ -325,7 +347,7 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment) { .alpha(1f) disposables += sharedViewModel - .send(selection.filterIsInstance(ContactSearchKey.RecipientSearchKey::class.java)) + .send(selection.filterIsInstance(ContactSearchKey.RecipientSearchKey::class.java), scheduledSendTime) .subscribe( { result -> callback.onSentWithResult(result) }, { error -> callback.onSendError(error) }, @@ -560,4 +582,9 @@ class MediaReviewFragment : Fragment(R.layout.v2_media_review_fragment) { fun onNoMediaSelected() fun onPopFromReview() } + + override fun onScheduleSend(scheduledTime: Long) { + scheduledSendTime = scheduledTime + sendButton.performClick() + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java index 2497a29da..2922014d2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java +++ b/app/src/main/java/org/thoughtcrime/securesms/messages/MessageContentProcessor.java @@ -2111,7 +2111,8 @@ public final class MessageContentProcessor { Collections.emptySet(), null, true, - null); + null, + -1); if (recipient.getExpiresInSeconds() != message.getDataMessage().get().getExpiresInSeconds()) { handleSynchronizeSentExpirationUpdate(message); @@ -2231,7 +2232,8 @@ public final class MessageContentProcessor { Collections.emptySet(), null, true, - null); + null, + -1); MessageTable messageTable = SignalDatabase.messages(); long threadId = SignalDatabase.threads().getOrCreateThreadIdFor(recipient); @@ -2329,7 +2331,8 @@ public final class MessageContentProcessor { Collections.emptySet(), giftBadge.orElse(null), true, - null); + null, + -1); if (recipients.getExpiresInSeconds() != message.getDataMessage().get().getExpiresInSeconds()) { handleSynchronizeSentExpirationUpdate(message); diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMessage.kt index 59dfc601f..174371722 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMessage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/OutgoingMessage.kt @@ -50,6 +50,7 @@ data class OutgoingMessage( val isEndSession: Boolean = false, val isIdentityVerified: Boolean = false, val isIdentityDefault: Boolean = false, + val scheduledDate: Long = -1, ) { val isV2Group: Boolean = messageGroupContext != null && GroupV2UpdateMessageUtil.isGroupV2(messageGroupContext) @@ -78,7 +79,8 @@ data class OutgoingMessage( mismatches: Set = emptySet(), giftBadge: GiftBadge? = null, isSecure: Boolean = false, - bodyRanges: BodyRangeList? = null + bodyRanges: BodyRangeList? = null, + scheduledDate: Long = -1 ) : this( recipient = recipient, body = body ?: "", @@ -99,7 +101,8 @@ data class OutgoingMessage( identityKeyMismatches = mismatches, giftBadge = giftBadge, isSecure = isSecure, - bodyRanges = bodyRanges + bodyRanges = bodyRanges, + scheduledDate = scheduledDate ) /** @@ -153,6 +156,10 @@ data class OutgoingMessage( return messageGroupContext!!.requireGroupV2Properties() } + fun sendAt(scheduledDate: Long): OutgoingMessage { + return copy(scheduledDate = scheduledDate) + } + companion object { /** diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java index d8b584047..bc8daf0d2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/RemoteReplyReceiver.java @@ -101,7 +101,8 @@ public class RemoteReplyReceiver extends BroadcastReceiver { Collections.emptySet(), null, recipient.isPushGroup(), - null); + null, + -1); threadId = MessageSender.send(context, reply, -1, MessageSender.SendType.SIGNAL, null, null); break; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/ScheduledMessageManager.kt b/app/src/main/java/org/thoughtcrime/securesms/service/ScheduledMessageManager.kt new file mode 100644 index 000000000..3c370a5f7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/service/ScheduledMessageManager.kt @@ -0,0 +1,78 @@ +package org.thoughtcrime.securesms.service + +import android.app.Application +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.annotation.WorkerThread +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.jobs.IndividualSendJob +import org.thoughtcrime.securesms.jobs.PushGroupSendJob + +/** + * Manages waking up and sending scheduled messages at the correct time + */ +class ScheduledMessageManager( + val application: Application +) : TimedEventManager(application, "ScheduledMessagesManager") { + + companion object { + private val TAG = Log.tag(ScheduledMessageManager::class.java) + } + + private val messagesTable = SignalDatabase.messages + + init { + scheduleIfNecessary() + } + + @WorkerThread + override fun getNextClosestEvent(): Event? { + val oldestTimestamp = messagesTable.oldestScheduledSendTimestamp ?: return null + + val delay = (oldestTimestamp - System.currentTimeMillis()).coerceAtLeast(0) + Log.i(TAG, "The next scheduled message needs to be sent in $delay ms.") + + return Event(delay) + } + + @WorkerThread + override fun executeEvent(event: Event) { + val scheduledMessagesToSend = messagesTable.getScheduledMessagesBefore(System.currentTimeMillis()) + for (record in scheduledMessagesToSend) { + if (SignalDatabase.messages.clearScheduledStatus(record.threadId, record.id)) { + if (record.recipient.isPushGroup) { + PushGroupSendJob.enqueue(application, ApplicationDependencies.getJobManager(), record.id, record.recipient.id, emptySet()) + } else { + IndividualSendJob.enqueue(application, ApplicationDependencies.getJobManager(), record.id, record.recipient) + } + } else { + Log.i(TAG, "messageId=${record.id} was not a scheduled message, ignoring") + } + } + } + + @WorkerThread + override fun getDelayForEvent(event: Event): Long = event.delay + + @WorkerThread + override fun scheduleAlarm(application: Application, delay: Long) { + trySetExactAlarm(application, System.currentTimeMillis() + delay, ScheduledMessagesAlarm::class.java) + } + + data class Event(val delay: Long) + + class ScheduledMessagesAlarm : BroadcastReceiver() { + + companion object { + private val TAG = Log.tag(ScheduledMessagesAlarm::class.java) + } + + override fun onReceive(context: Context?, intent: Intent?) { + Log.d(TAG, "onReceive()") + ApplicationDependencies.getScheduledMessageManager().scheduleIfNecessary() + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/TimedEventManager.java b/app/src/main/java/org/thoughtcrime/securesms/service/TimedEventManager.java index 3dff08ec4..7de419043 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/TimedEventManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/TimedEventManager.java @@ -5,6 +5,7 @@ import android.app.Application; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; +import android.os.Build; import android.os.Handler; import android.os.HandlerThread; @@ -14,6 +15,7 @@ import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; import org.signal.core.util.PendingIntentFlags; +import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.util.ServiceUtil; /** @@ -21,6 +23,8 @@ import org.thoughtcrime.securesms.util.ServiceUtil; */ public abstract class TimedEventManager { + private static final String TAG = Log.tag(TimedEventManager.class); + private final Application application; private final Handler handler; @@ -91,4 +95,29 @@ public abstract class TimedEventManager { alarmManager.cancel(pendingIntent); alarmManager.set(AlarmManager.RTC_WAKEUP, System.currentTimeMillis() + delay, pendingIntent); } + + protected static void trySetExactAlarm(@NonNull Context context, long timestamp, @NonNull Class alarmClass) { + Intent intent = new Intent(context, alarmClass); + PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, intent, PendingIntentFlags.mutable()); + AlarmManager alarmManager = ServiceUtil.getAlarmManager(context); + + alarmManager.cancel(pendingIntent); + + boolean hasManagerPermission = Build.VERSION.SDK_INT < 31 || alarmManager.canScheduleExactAlarms(); + if (hasManagerPermission) { + try { + if (Build.VERSION.SDK_INT >= 23) { + alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, timestamp, pendingIntent); + } else { + alarmManager.setExact(AlarmManager.RTC_WAKEUP, timestamp, pendingIntent); + } + return; + } catch (Exception e) { + Log.w(TAG, e); + } + } + + Log.w(TAG, "Unable to schedule exact alarm, falling back to inexact alarm, scheduling alarm for: " + timestamp); + alarmManager.set(AlarmManager.RTC_WAKEUP, timestamp, pendingIntent); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java b/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java index 0e3a8785e..4775c657e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DateUtils.java @@ -30,6 +30,7 @@ import org.thoughtcrime.securesms.R; import java.text.DateFormatSymbols; import java.text.ParseException; import java.text.SimpleDateFormat; +import java.util.Calendar; import java.util.Date; import java.util.Locale; import java.util.concurrent.TimeUnit; @@ -44,6 +45,7 @@ public class DateUtils extends android.text.format.DateUtils { private static final ThreadLocal DATE_FORMAT = new ThreadLocal<>(); private static final ThreadLocal BRIEF_EXACT_FORMAT = new ThreadLocal<>(); private static final long MAX_RELATIVE_TIMESTAMP = TimeUnit.MINUTES.toMillis(3); + private static final int HALF_A_YEAR_IN_DAYS = 182; private static boolean isWithin(final long millis, final long span, final TimeUnit unit) { return System.currentTimeMillis() - millis <= unit.toMillis(span); @@ -110,11 +112,22 @@ public class DateUtils extends android.text.format.DateUtils { int mins = (int) TimeUnit.MINUTES.convert(System.currentTimeMillis() - timestamp, TimeUnit.MILLISECONDS); return context.getResources().getString(R.string.DateUtils_minutes_ago, mins); } else { - String format = DateFormat.is24HourFormat(context) ? "HH:mm" : "hh:mm a"; - return getFormattedDateTime(timestamp, format, locale); + return getOnlyTimeString(context, locale, timestamp); } } + /** + * Formats a given timestamp as just the time. + * + * For example: + * For 12 hour locale: 7:23 pm + * For 24 hour locale: 19:23 + */ + public static String getOnlyTimeString(final Context context, final Locale locale, final long timestamp) { + String format = DateFormat.is24HourFormat(context) ? "HH:mm" : "hh:mm a"; + return getFormattedDateTime(timestamp, format, locale); + } + public static String getTimeString(final Context c, final Locale locale, final long timestamp) { StringBuilder format = new StringBuilder(); @@ -129,6 +142,37 @@ public class DateUtils extends android.text.format.DateUtils { return getFormattedDateTime(timestamp, format.toString(), locale); } + /** + * Formats the passed timestamp based on the current time at a day precision. + * + * For example: + * - Today + * - Wed + * - Mon + * - Jan 31 + * - Feb 4 + * - Jan 12, 2033 + */ + public static String getDayPrecisionTimeString(Context context, Locale locale, long timestamp) { + SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyyMMdd"); + + if (simpleDateFormat.format(System.currentTimeMillis()).equals(simpleDateFormat.format(timestamp))) { + return context.getString(R.string.DeviceListItem_today); + } else { + String format; + + if (isWithinAbs(timestamp, 6, TimeUnit.DAYS)) { + format = "EEE "; + } else if (isWithinAbs(timestamp, 365, TimeUnit.DAYS)) { + format = "MMM d"; + } else { + format = "MMM d, yyy"; + } + + return getFormattedDateTime(timestamp, format, locale); + } + } + public static String getDayPrecisionTimeSpanString(Context context, Locale locale, long timestamp) { SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyyMMdd"); @@ -165,13 +209,44 @@ public class DateUtils extends android.text.format.DateUtils { return context.getString(R.string.DateUtils_today); } else if (isYesterday(timestamp)) { return context.getString(R.string.DateUtils_yesterday); - } else if (isWithin(timestamp, 182, TimeUnit.DAYS)) { + } else if (isWithin(timestamp, HALF_A_YEAR_IN_DAYS, TimeUnit.DAYS)) { return formatDateWithDayOfWeek(locale, timestamp); } else { return formatDateWithYear(locale, timestamp); } } + public static String getScheduledMessagesDateHeaderString(@NonNull Context context, + @NonNull Locale locale, + long timestamp) + { + if (isToday(timestamp)) { + return context.getString(R.string.DateUtils_today); + } else if (isWithinAbs(timestamp, HALF_A_YEAR_IN_DAYS, TimeUnit.DAYS)) { + return formatDateWithDayOfWeek(locale, timestamp); + } else { + return formatDateWithYear(locale, timestamp); + } + } + + public static String getScheduledMessageDateString(@NonNull Context context, @NonNull Locale locale, long timestamp) { + String dayModifier; + if (isToday(timestamp)) { + Calendar calendar = Calendar.getInstance(locale); + if (calendar.get(Calendar.HOUR_OF_DAY) >= 19) { + dayModifier = context.getString(R.string.DateUtils_tonight); + } else { + dayModifier = context.getString(R.string.DateUtils_today); + } + } else { + dayModifier = context.getString(R.string.DateUtils_tomorrow); + } + String format = DateFormat.is24HourFormat(context) ? "HH:mm" : "hh:mm a"; + String time = getFormattedDateTime(timestamp, format, locale); + + return context.getString(R.string.DateUtils_schedule_at, dayModifier, time); + } + public static String formatDateWithDayOfWeek(@NonNull Locale locale, long timestamp) { return getFormattedDateTime(timestamp, "EEE, MMM d", locale); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java index f21e0481a..f7f9b9c7c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -105,6 +105,7 @@ public final class FeatureFlags { private static final String PAYPAL_ONE_TIME_DONATIONS = "android.oneTimePayPalDonations.2"; private static final String PAYPAL_RECURRING_DONATIONS = "android.recurringPayPalDonations.2"; private static final String TEXT_FORMATTING = "android.textFormatting"; + private static final String SCHEDULED_MESSAGE_SENDS = "android.scheduledMessageSends"; /** * We will only store remote values for flags in this set. If you want a flag to be controllable @@ -160,7 +161,8 @@ public final class FeatureFlags { CHAT_FILTERS, PAYPAL_ONE_TIME_DONATIONS, PAYPAL_RECURRING_DONATIONS, - TEXT_FORMATTING + TEXT_FORMATTING, + SCHEDULED_MESSAGE_SENDS ); @VisibleForTesting @@ -574,6 +576,13 @@ public final class FeatureFlags { return getBoolean(TEXT_FORMATTING, false); } + /** + * Whether or not we allow the user to schedule message sends. This takes over the entry point for SMS message sends + */ + public static boolean scheduledMessageSends() { + return getBoolean(SCHEDULED_MESSAGE_SENDS, false); + } + /** Only for rendering debug info. */ public static synchronized @NonNull Map getMemoryValues() { return new TreeMap<>(REMOTE_VALUES); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/JavaTimeExtensions.kt b/app/src/main/java/org/thoughtcrime/securesms/util/JavaTimeExtensions.kt index 2fbcd17ee..6cdbdb85b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/JavaTimeExtensions.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/JavaTimeExtensions.kt @@ -10,6 +10,7 @@ import java.time.LocalTime import java.time.OffsetDateTime import java.time.ZoneId import java.time.ZoneOffset +import java.time.ZonedDateTime import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.time.temporal.WeekFields @@ -31,6 +32,28 @@ fun LocalDateTime.toMillis(zoneOffset: ZoneOffset = ZoneId.systemDefault().toOff return TimeUnit.SECONDS.toMillis(toEpochSecond(zoneOffset)) } +/** + * Convert [ZonedDateTime] to be same as [System.currentTimeMillis] + */ +fun ZonedDateTime.toMillis(): Long { + return TimeUnit.SECONDS.toMillis(toEpochSecond()) +} + +/** + * Convert [LocalDateTime] to a [ZonedDateTime] at the UTC offset + */ +fun LocalDateTime.atUTC(): ZonedDateTime { + return atZone(ZoneId.ofOffset("UTC", ZoneOffset.UTC)) +} + +/** + * Create a LocalDateTime with the same year, month, and day, but set + * to midnight. + */ +fun LocalDateTime.atMidnight(): LocalDateTime { + return LocalDateTime.of(year, month, dayOfMonth, 0, 0) +} + /** * Return true if the [LocalDateTime] is within [start] and [end] inclusive. */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MessageRecordUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/util/MessageRecordUtil.kt index 32734743c..569a7221a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/MessageRecordUtil.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MessageRecordUtil.kt @@ -141,6 +141,10 @@ fun MessageRecord.isTextOnly(context: Context): Boolean { ) } +fun MessageRecord.isScheduled(): Boolean { + return (this as? MediaMmsMessageRecord)?.scheduledDate?.let { it != -1L } ?: false +} + /** * Returns the QuoteType for this record, as if it was being quoted. */ diff --git a/app/src/main/res/drawable/ic_calendar_24.xml b/app/src/main/res/drawable/ic_calendar_24.xml new file mode 100644 index 000000000..140cdfa9d --- /dev/null +++ b/app/src/main/res/drawable/ic_calendar_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_daytime_24.xml b/app/src/main/res/drawable/ic_daytime_24.xml new file mode 100644 index 000000000..78d655d94 --- /dev/null +++ b/app/src/main/res/drawable/ic_daytime_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_expand_down_24.xml b/app/src/main/res/drawable/ic_expand_down_24.xml new file mode 100644 index 000000000..bd110e357 --- /dev/null +++ b/app/src/main/res/drawable/ic_expand_down_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_nighttime_26.xml b/app/src/main/res/drawable/ic_nighttime_26.xml new file mode 100644 index 000000000..e6f5932a2 --- /dev/null +++ b/app/src/main/res/drawable/ic_nighttime_26.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_send_outline_24.xml b/app/src/main/res/drawable/ic_send_outline_24.xml new file mode 100644 index 000000000..7187556fe --- /dev/null +++ b/app/src/main/res/drawable/ic_send_outline_24.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/app/src/main/res/drawable/schedule_message_large.xml b/app/src/main/res/drawable/schedule_message_large.xml new file mode 100644 index 000000000..efb911b09 --- /dev/null +++ b/app/src/main/res/drawable/schedule_message_large.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/conversation_activity.xml b/app/src/main/res/layout/conversation_activity.xml index 6b6bb97e0..4f3c49ca1 100644 --- a/app/src/main/res/layout/conversation_activity.xml +++ b/app/src/main/res/layout/conversation_activity.xml @@ -62,6 +62,13 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/conversation_item_sent_multimedia.xml b/app/src/main/res/layout/conversation_item_sent_multimedia.xml index 66b45d4f4..3c6d698f8 100644 --- a/app/src/main/res/layout/conversation_item_sent_multimedia.xml +++ b/app/src/main/res/layout/conversation_item_sent_multimedia.xml @@ -236,6 +236,17 @@ android:tint="@color/signal_colorOnSurfaceVariant" app:srcCompat="@drawable/ic_replies_outline_20" /> + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/schedule_message_time_picker_bottom_sheet.xml b/app/src/main/res/layout/schedule_message_time_picker_bottom_sheet.xml new file mode 100644 index 000000000..930fbd97c --- /dev/null +++ b/app/src/main/res/layout/schedule_message_time_picker_bottom_sheet.xml @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/scheduled_messages_bottom_sheet.xml b/app/src/main/res/layout/scheduled_messages_bottom_sheet.xml new file mode 100644 index 000000000..a3aebebad --- /dev/null +++ b/app/src/main/res/layout/scheduled_messages_bottom_sheet.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/signal_styles.xml b/app/src/main/res/values/signal_styles.xml index 9e53ee0e4..ae36222ac 100644 --- a/app/src/main/res/values/signal_styles.xml +++ b/app/src/main/res/values/signal_styles.xml @@ -253,6 +253,31 @@ @color/signal_colorOnSurfaceVariant + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d1fb27f46..81ae0d2f9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -630,6 +630,47 @@ %dm Today Yesterday + + %1$s at %2$s + + Tomorrow + + Tonight + + + + Scheduled messages + + Pick Date & Time + + Scheduled messages + + When you send a scheduled message, make sure your device will be on and connected to the internet at the time of sending. If not, your message will send when your device reconnects. + + Okay + + + Select date + + Select time + + Schedule message + + Schedule send + + + All times in (%1$s) %2$s + + + Send now + + Reschedule + + Delete + + Delete selected scheduled message? + + Deleting scheduled message… Chat session refreshed @@ -2212,6 +2253,8 @@ Signal message Unsecured SMS Unsecured MMS + + Schedule message From %1$s SIM %1$d Send @@ -3100,6 +3143,16 @@ Add to contacts + + + + See all + + + %1$d message scheduled + %1$d messages scheduled + + Recipients list Delivery diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 20b620345..34c29d694 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -229,6 +229,9 @@ @style/ThemeOverlay.Signal.MaterialAlertDialog @style/Signal.ThemeOverlay.TimePicker @style/Signal.Widget.TimePicker + + @style/Signal.Widget.Calendar + @style/Signal.ThemeOverlay.Calendar