mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-23 18:30:20 +01:00
Add support for scheduled message sends.
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ class SendButton(context: Context, attributeSet: AttributeSet?) : AppCompatImage
|
||||
}
|
||||
|
||||
private val listeners: MutableList<SendTypeChangedListener> = CopyOnWriteArrayList()
|
||||
private var scheduledSendListener: ScheduledSendListener? = null
|
||||
|
||||
private var availableSendTypes: List<MessageSendType> = MessageSendType.getAllAvailable(context, false)
|
||||
private var activeMessageSendType: MessageSendType? = null
|
||||
@@ -98,6 +99,10 @@ class SendButton(context: Context, attributeSet: AttributeSet?) : AppCompatImage
|
||||
onSelectionChanged(newType = selectedSendType, isManualSelection = false)
|
||||
}
|
||||
|
||||
fun setScheduledSendListener(listener: ScheduledSendListener?) {
|
||||
this.scheduledSendListener = listener
|
||||
}
|
||||
|
||||
fun resetAvailableTransports(isMediaMessage: Boolean) {
|
||||
availableSendTypes = MessageSendType.getAllAvailable(context, isMediaMessage)
|
||||
activeMessageSendType = null
|
||||
@@ -150,13 +155,29 @@ class SendButton(context: Context, attributeSet: AttributeSet?) : AppCompatImage
|
||||
}
|
||||
}
|
||||
|
||||
fun showSendTypeMenu(): Boolean {
|
||||
return if (availableSendTypes.size == 1) {
|
||||
if (scheduledSendListener == null && !SignalStore.misc().smsExportPhase.allowSmsFeatures()) {
|
||||
Snackbar.make(snackbarContainer, R.string.InputPanel__sms_messaging_is_no_longer_supported_in_signal, Snackbar.LENGTH_SHORT).show()
|
||||
}
|
||||
false
|
||||
} else {
|
||||
showSendTypeContextMenu(false)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLongClick(v: View): Boolean {
|
||||
if (!isEnabled) {
|
||||
return false
|
||||
}
|
||||
|
||||
val scheduleListener = scheduledSendListener
|
||||
if (availableSendTypes.size == 1) {
|
||||
return if (!SignalStore.misc().smsExportPhase.allowSmsFeatures()) {
|
||||
return if (scheduleListener != null) {
|
||||
scheduleListener.onSendScheduled()
|
||||
true
|
||||
} else if (!SignalStore.misc().smsExportPhase.allowSmsFeatures()) {
|
||||
Snackbar.make(snackbarContainer, R.string.InputPanel__sms_messaging_is_no_longer_supported_in_signal, Snackbar.LENGTH_SHORT).show()
|
||||
true
|
||||
} else {
|
||||
@@ -164,8 +185,14 @@ class SendButton(context: Context, attributeSet: AttributeSet?) : AppCompatImage
|
||||
}
|
||||
}
|
||||
|
||||
val currentlySelected: MessageSendType = selectedSendType
|
||||
showSendTypeContextMenu(true)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun showSendTypeContextMenu(allowScheduling: Boolean) {
|
||||
val currentlySelected: MessageSendType = selectedSendType
|
||||
val listener = scheduledSendListener
|
||||
val items = availableSendTypes
|
||||
.filterNot { it == currentlySelected }
|
||||
.map { option ->
|
||||
@@ -174,17 +201,26 @@ class SendButton(context: Context, attributeSet: AttributeSet?) : AppCompatImage
|
||||
title = option.getTitle(context),
|
||||
action = { setSendType(option) }
|
||||
)
|
||||
}
|
||||
}.toMutableList()
|
||||
if (allowScheduling && listener != null) {
|
||||
items += ActionItem(
|
||||
iconRes = R.drawable.ic_calendar_24,
|
||||
title = context.getString(R.string.conversation_activity__option_schedule_message),
|
||||
action = { listener.onSendScheduled() }
|
||||
)
|
||||
}
|
||||
|
||||
SignalContextMenu.Builder((parent as View), popupContainer!!)
|
||||
.preferredVerticalPosition(SignalContextMenu.VerticalPosition.ABOVE)
|
||||
.offsetY(ViewUtil.dpToPx(8))
|
||||
.show(items)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
interface SendTypeChangedListener {
|
||||
fun onSendTypeChanged(newType: MessageSendType, manuallySelected: Boolean)
|
||||
}
|
||||
|
||||
interface ScheduledSendListener {
|
||||
fun onSendScheduled()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -186,6 +186,10 @@ public class ConversationDataSource implements PagedDataSource<MessageId, Conver
|
||||
return null;
|
||||
}
|
||||
|
||||
if (record instanceof MediaMmsMessageRecord && ((MediaMmsMessageRecord) record).getScheduledDate() != -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
stopwatch.split("message");
|
||||
|
||||
try {
|
||||
|
||||
@@ -1188,15 +1188,16 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||
Toast.LENGTH_LONG).show();
|
||||
}
|
||||
|
||||
public long stageOutgoingMessage(OutgoingMessage message) {
|
||||
public void stageOutgoingMessage(OutgoingMessage message) {
|
||||
if (message.getScheduledDate() != -1) {
|
||||
return;
|
||||
}
|
||||
MessageRecord messageRecord = MessageTable.readerFor(message, threadId).getCurrent();
|
||||
|
||||
if (getListAdapter() != null) {
|
||||
setLastSeen(0);
|
||||
list.post(() -> list.scrollToPosition(0));
|
||||
}
|
||||
|
||||
return messageRecord.getId();
|
||||
}
|
||||
|
||||
private void presentConversationMetadata(@NonNull ConversationData conversation) {
|
||||
@@ -2114,6 +2115,11 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||
public void onSendPaymentClicked(@NonNull RecipientId recipientId) {
|
||||
AttachmentManager.selectPayment(ConversationFragment.this, recipient.get());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onScheduledIndicatorClicked(@NonNull View view, @NonNull MessageRecord messageRecord) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isUnopenedGift(View itemView, MessageRecord messageRecord) {
|
||||
|
||||
@@ -210,6 +210,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
private View storyReactionLabelWrapper;
|
||||
private TextView storyReactionLabel;
|
||||
protected View quotedIndicator;
|
||||
protected View scheduledIndicator;
|
||||
|
||||
private @NonNull Set<MultiselectPart> batchSelected = new HashSet<>();
|
||||
private @NonNull Outliner outliner = new Outliner();
|
||||
@@ -233,18 +234,19 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
private int measureCalls;
|
||||
private boolean updatingFooter;
|
||||
|
||||
private final PassthroughClickListener passthroughClickListener = new PassthroughClickListener();
|
||||
private final AttachmentDownloadClickListener downloadClickListener = new AttachmentDownloadClickListener();
|
||||
private final SlideClickPassthroughListener singleDownloadClickListener = new SlideClickPassthroughListener(downloadClickListener);
|
||||
private final SharedContactEventListener sharedContactEventListener = new SharedContactEventListener();
|
||||
private final SharedContactClickListener sharedContactClickListener = new SharedContactClickListener();
|
||||
private final LinkPreviewClickListener linkPreviewClickListener = new LinkPreviewClickListener();
|
||||
private final ViewOnceMessageClickListener revealableClickListener = new ViewOnceMessageClickListener();
|
||||
private final QuotedIndicatorClickListener quotedIndicatorClickListener = new QuotedIndicatorClickListener();
|
||||
private final UrlClickListener urlClickListener = new UrlClickListener();
|
||||
private final Rect thumbnailMaskingRect = new Rect();
|
||||
private final TouchDelegateChangedListener touchDelegateChangedListener = new TouchDelegateChangedListener();
|
||||
private final GiftMessageViewCallback giftMessageViewCallback = new GiftMessageViewCallback();
|
||||
private final PassthroughClickListener passthroughClickListener = new PassthroughClickListener();
|
||||
private final AttachmentDownloadClickListener downloadClickListener = new AttachmentDownloadClickListener();
|
||||
private final SlideClickPassthroughListener singleDownloadClickListener = new SlideClickPassthroughListener(downloadClickListener);
|
||||
private final SharedContactEventListener sharedContactEventListener = new SharedContactEventListener();
|
||||
private final SharedContactClickListener sharedContactClickListener = new SharedContactClickListener();
|
||||
private final LinkPreviewClickListener linkPreviewClickListener = new LinkPreviewClickListener();
|
||||
private final ViewOnceMessageClickListener revealableClickListener = new ViewOnceMessageClickListener();
|
||||
private final QuotedIndicatorClickListener quotedIndicatorClickListener = new QuotedIndicatorClickListener();
|
||||
private final ScheduledIndicatorClickListener scheduledIndicatorClickListener = new ScheduledIndicatorClickListener();
|
||||
private final UrlClickListener urlClickListener = new UrlClickListener();
|
||||
private final Rect thumbnailMaskingRect = new Rect();
|
||||
private final TouchDelegateChangedListener touchDelegateChangedListener = new TouchDelegateChangedListener();
|
||||
private final GiftMessageViewCallback giftMessageViewCallback = new GiftMessageViewCallback();
|
||||
|
||||
private final Context context;
|
||||
|
||||
@@ -278,6 +280,11 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
.scaleX(LONG_PRESS_SCALE_FACTOR)
|
||||
.scaleY(LONG_PRESS_SCALE_FACTOR);
|
||||
}
|
||||
if (scheduledIndicator != null) {
|
||||
scheduledIndicator.animate()
|
||||
.scaleX(LONG_PRESS_SCALE_FACTOR)
|
||||
.scaleY(LONG_PRESS_SCALE_FACTOR);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -328,6 +335,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
this.giftViewStub = new Stub<>(findViewById(R.id.gift_view_stub));
|
||||
this.quotedIndicator = findViewById(R.id.quoted_indicator);
|
||||
this.paymentViewStub = new Stub<>(findViewById(R.id.payment_view_stub));
|
||||
this.scheduledIndicator = findViewById(R.id.scheduled_indicator);
|
||||
|
||||
setOnClickListener(new ClickListener(null));
|
||||
|
||||
@@ -395,6 +403,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
setFooter(messageRecord, nextMessageRecord, locale, groupThread, hasWallpaper);
|
||||
setStoryReactionLabel(messageRecord);
|
||||
setHasBeenQuoted(conversationMessage);
|
||||
setHasBeenScheduled(conversationMessage);
|
||||
|
||||
if (audioViewStub.resolved()) {
|
||||
audioViewStub.get().setOnLongClickListener(passthroughClickListener);
|
||||
@@ -1035,7 +1044,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
boolean messageRequestAccepted,
|
||||
boolean allowedToPlayInline)
|
||||
{
|
||||
boolean showControls = !messageRecord.isFailed();
|
||||
boolean showControls = !messageRecord.isFailed() && !MessageRecordUtil.isScheduled(messageRecord);
|
||||
|
||||
ViewUtil.setTopMargin(bodyText, readDimen(R.dimen.message_bubble_top_padding));
|
||||
|
||||
@@ -1711,6 +1720,19 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
}
|
||||
}
|
||||
|
||||
private void setHasBeenScheduled(@NonNull ConversationMessage message) {
|
||||
if (scheduledIndicator == null) {
|
||||
return;
|
||||
}
|
||||
if (message.hasBeenScheduled()) {
|
||||
scheduledIndicator.setVisibility(View.VISIBLE);
|
||||
scheduledIndicator.setOnClickListener(scheduledIndicatorClickListener);
|
||||
} else {
|
||||
scheduledIndicator.setVisibility(View.GONE);
|
||||
scheduledIndicator.setOnClickListener(null);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean forceFooter(@NonNull MessageRecord messageRecord) {
|
||||
return hasAudio(messageRecord);
|
||||
}
|
||||
@@ -1872,21 +1894,23 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
private boolean isStartOfMessageCluster(@NonNull MessageRecord current, @NonNull Optional<MessageRecord> previous, boolean isGroupThread) {
|
||||
if (isGroupThread) {
|
||||
return !previous.isPresent() || previous.get().isUpdate() || !DateUtils.isSameDay(current.getTimestamp(), previous.get().getTimestamp()) ||
|
||||
!current.getRecipient().equals(previous.get().getRecipient()) || !isWithinClusteringTime(current, previous.get());
|
||||
!current.getRecipient().equals(previous.get().getRecipient()) || !isWithinClusteringTime(current, previous.get()) || MessageRecordUtil.isScheduled(current);
|
||||
} else {
|
||||
return !previous.isPresent() || previous.get().isUpdate() || !DateUtils.isSameDay(current.getTimestamp(), previous.get().getTimestamp()) ||
|
||||
current.isOutgoing() != previous.get().isOutgoing() || previous.get().isSecure() != current.isSecure() || !isWithinClusteringTime(current, previous.get());
|
||||
current.isOutgoing() != previous.get().isOutgoing() || previous.get().isSecure() != current.isSecure() || !isWithinClusteringTime(current, previous.get()) ||
|
||||
MessageRecordUtil.isScheduled(current);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isEndOfMessageCluster(@NonNull MessageRecord current, @NonNull Optional<MessageRecord> next, boolean isGroupThread) {
|
||||
if (isGroupThread) {
|
||||
return !next.isPresent() || next.get().isUpdate() || !DateUtils.isSameDay(current.getTimestamp(), next.get().getTimestamp()) ||
|
||||
!current.getRecipient().equals(next.get().getRecipient()) || !current.getReactions().isEmpty() || !isWithinClusteringTime(current, next.get());
|
||||
!current.getRecipient().equals(next.get().getRecipient()) || !current.getReactions().isEmpty() || !isWithinClusteringTime(current, next.get()) ||
|
||||
MessageRecordUtil.isScheduled(current);
|
||||
} else {
|
||||
return !next.isPresent() || next.get().isUpdate() || !DateUtils.isSameDay(current.getTimestamp(), next.get().getTimestamp()) ||
|
||||
current.isOutgoing() != next.get().isOutgoing() || !current.getReactions().isEmpty() || next.get().isSecure() != current.isSecure() ||
|
||||
!isWithinClusteringTime(current, next.get());
|
||||
!isWithinClusteringTime(current, next.get()) || MessageRecordUtil.isScheduled(current);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2279,6 +2303,16 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
}
|
||||
}
|
||||
|
||||
private class ScheduledIndicatorClickListener implements View.OnClickListener {
|
||||
public void onClick(final View view) {
|
||||
if (eventListener != null && batchSelected.isEmpty()) {
|
||||
eventListener.onScheduledIndicatorClicked(view, (messageRecord));
|
||||
} else {
|
||||
passthroughClickListener.onClick(view);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class AttachmentDownloadClickListener implements SlidesClickedListener {
|
||||
@Override
|
||||
public void onClick(View v, final List<Slide> slides) {
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -136,6 +136,8 @@ import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel;
|
||||
import org.thoughtcrime.securesms.components.identity.UnverifiedBannerView;
|
||||
import org.thoughtcrime.securesms.components.location.SignalPlace;
|
||||
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
|
||||
import org.thoughtcrime.securesms.components.menu.ActionItem;
|
||||
import org.thoughtcrime.securesms.components.menu.SignalContextMenu;
|
||||
import org.thoughtcrime.securesms.components.reminder.BubbleOptOutReminder;
|
||||
import org.thoughtcrime.securesms.components.reminder.ExpiredBuildReminder;
|
||||
import org.thoughtcrime.securesms.components.reminder.GroupsV1MigrationSuggestionsReminder;
|
||||
@@ -284,6 +286,7 @@ import org.thoughtcrime.securesms.util.ContextUtil;
|
||||
import org.thoughtcrime.securesms.util.ConversationUtil;
|
||||
import org.thoughtcrime.securesms.util.Debouncer;
|
||||
import org.thoughtcrime.securesms.util.DrawableUtil;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.FullscreenHelper;
|
||||
import org.thoughtcrime.securesms.util.IdentityUtil;
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable;
|
||||
@@ -329,6 +332,7 @@ import io.reactivex.rxjava3.core.Flowable;
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
import io.reactivex.rxjava3.core.SingleObserver;
|
||||
import io.reactivex.rxjava3.disposables.Disposable;
|
||||
import kotlin.Unit;
|
||||
|
||||
import static android.content.res.Configuration.ORIENTATION_LANDSCAPE;
|
||||
|
||||
@@ -363,7 +367,9 @@ public class ConversationParentFragment extends Fragment
|
||||
EmojiSearchFragment.Callback,
|
||||
StickerKeyboardPageFragment.Callback,
|
||||
Material3OnScrollHelperBinder,
|
||||
MessageDetailsFragment.Callback
|
||||
MessageDetailsFragment.Callback,
|
||||
ScheduleMessageTimePickerBottomSheet.ScheduleCallback,
|
||||
ScheduledMessagesBottomSheet.Callback
|
||||
{
|
||||
|
||||
private static final int SHORTCUT_ICON_SIZE = Build.VERSION.SDK_INT >= 26 ? ViewUtil.dpToPx(72) : ViewUtil.dpToPx(48 + 16 * 2);
|
||||
@@ -431,6 +437,7 @@ public class ConversationParentFragment extends Fragment
|
||||
private View cancelJoinRequest;
|
||||
private Stub<View> releaseChannelUnmute;
|
||||
private Stub<View> mentionsSuggestions;
|
||||
private Stub<View> scheduledMessagesBarStub;
|
||||
private MaterialButton joinGroupCallButton;
|
||||
private boolean callingTooltipShown;
|
||||
private ImageView wallpaper;
|
||||
@@ -556,6 +563,7 @@ public class ConversationParentFragment extends Fragment
|
||||
initializeActionBar();
|
||||
|
||||
disposables.add(viewModel.getStoryViewState().subscribe(titleView::setStoryRingFromState));
|
||||
disposables.add(viewModel.getScheduledMessageCount().subscribe(this::updateScheduledMessagesBar));
|
||||
|
||||
backPressedCallback = new OnBackPressedCallback(true) {
|
||||
@Override
|
||||
@@ -783,7 +791,8 @@ public class ConversationParentFragment extends Fragment
|
||||
result.isViewOnce(),
|
||||
initiating,
|
||||
true,
|
||||
null).addListener(new AssertedSuccessListener<Void>() {
|
||||
null,
|
||||
result.getScheduledTime()).addListener(new AssertedSuccessListener<Void>() {
|
||||
@Override
|
||||
public void onSuccess(Void result) {
|
||||
AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> {
|
||||
@@ -2039,6 +2048,7 @@ public class ConversationParentFragment extends Fragment
|
||||
wallpaperDim = view.findViewById(R.id.conversation_wallpaper_dim);
|
||||
voiceNotePlayerViewStub = ViewUtil.findStubById(view, R.id.voice_note_player_stub);
|
||||
navigationBarBackground = view.findViewById(R.id.navbar_background);
|
||||
scheduledMessagesBarStub = ViewUtil.findStubById(view, R.id.scheduled_messages_stub);
|
||||
|
||||
ImageButton quickCameraToggle = view.findViewById(R.id.quick_camera_toggle);
|
||||
ImageButton inlineAttachmentButton = view.findViewById(R.id.inline_attachment_button);
|
||||
@@ -2073,6 +2083,18 @@ public class ConversationParentFragment extends Fragment
|
||||
attachButton.setOnClickListener(new AttachButtonListener());
|
||||
attachButton.setOnLongClickListener(new AttachButtonLongClickListener());
|
||||
sendButton.setOnClickListener(sendButtonListener);
|
||||
if (FeatureFlags.scheduledMessageSends()) {
|
||||
sendButton.setScheduledSendListener(() -> {
|
||||
ScheduleMessageContextMenu.show(sendButton, (ViewGroup) requireView(), time -> {
|
||||
if (time == -1) {
|
||||
ScheduleMessageTimePickerBottomSheet.showSchedule(getChildFragmentManager());
|
||||
} else {
|
||||
sendMessage(null, time);
|
||||
}
|
||||
return Unit.INSTANCE;
|
||||
});
|
||||
});
|
||||
}
|
||||
sendButton.setEnabled(true);
|
||||
sendButton.addOnSelectionChangedListener((newMessageSendType, manuallySelected) -> {
|
||||
if (getContext() == null) {
|
||||
@@ -2916,6 +2938,13 @@ public class ConversationParentFragment extends Fragment
|
||||
}
|
||||
|
||||
private void sendMessage(@Nullable String metricId) {
|
||||
sendMessage(metricId, -1);
|
||||
}
|
||||
|
||||
private void sendMessage(@Nullable String metricId, long scheduledDate) {
|
||||
if (scheduledDate != -1 && !SignalStore.uiHints().hasSeenScheduledMessagesInfoSheet()) {
|
||||
ScheduleMessageFtuxBottomSheetDialog.show(getChildFragmentManager());
|
||||
}
|
||||
if (inputPanel.isRecordingInLockedMode()) {
|
||||
inputPanel.releaseRecordingLock();
|
||||
return;
|
||||
@@ -2925,7 +2954,7 @@ public class ConversationParentFragment extends Fragment
|
||||
if (voiceNote != null) {
|
||||
AudioSlide audioSlide = AudioSlide.createFromVoiceNoteDraft(requireContext(), voiceNote);
|
||||
|
||||
sendVoiceNote(Objects.requireNonNull(audioSlide.getUri()), audioSlide.getFileSize());
|
||||
sendVoiceNote(Objects.requireNonNull(audioSlide.getUri()), audioSlide.getFileSize(), scheduledDate);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2957,9 +2986,9 @@ public class ConversationParentFragment extends Fragment
|
||||
} else if (sendType.usesSignalTransport() && (identityRecords.isUnverified(true) || identityRecords.isUntrusted(true))) {
|
||||
handleRecentSafetyNumberChange();
|
||||
} else if (isMediaMessage) {
|
||||
sendMediaMessage(sendType, expiresIn, false, initiating, metricId);
|
||||
sendMediaMessage(sendType, expiresIn, false, initiating, metricId, scheduledDate);
|
||||
} else {
|
||||
sendTextMessage(sendType, expiresIn, initiating, metricId);
|
||||
sendTextMessage(sendType, expiresIn, initiating, metricId, scheduledDate);
|
||||
}
|
||||
} catch (RecipientFormattingException ex) {
|
||||
Toast.makeText(requireContext(),
|
||||
@@ -3002,7 +3031,8 @@ public class ConversationParentFragment extends Fragment
|
||||
Collections.emptySet(),
|
||||
null,
|
||||
true,
|
||||
result.getBodyRanges());
|
||||
result.getBodyRanges(),
|
||||
-1);
|
||||
|
||||
final Context context = requireContext().getApplicationContext();
|
||||
|
||||
@@ -3012,7 +3042,7 @@ public class ConversationParentFragment extends Fragment
|
||||
attachmentManager.clear(glideRequests, false);
|
||||
silentlySetComposeText("");
|
||||
|
||||
long id = fragment.stageOutgoingMessage(message);
|
||||
fragment.stageOutgoingMessage(message);
|
||||
|
||||
SimpleTask.run(() -> {
|
||||
long resultId = MessageSender.sendPushWithPreUploadedMedia(context, message, result.getPreUploadResults(), thread, null);
|
||||
@@ -3024,7 +3054,7 @@ public class ConversationParentFragment extends Fragment
|
||||
}, this::sendComplete);
|
||||
}
|
||||
|
||||
private void sendMediaMessage(@NonNull MessageSendType sendType, final long expiresIn, final boolean viewOnce, final boolean initiating, @Nullable String metricId)
|
||||
private void sendMediaMessage(@NonNull MessageSendType sendType, final long expiresIn, final boolean viewOnce, final boolean initiating, @Nullable String metricId, long scheduledDate)
|
||||
throws InvalidMessageException
|
||||
{
|
||||
Log.i(TAG, "Sending media message...");
|
||||
@@ -3042,7 +3072,8 @@ public class ConversationParentFragment extends Fragment
|
||||
viewOnce,
|
||||
initiating,
|
||||
true,
|
||||
metricId);
|
||||
metricId,
|
||||
scheduledDate);
|
||||
}
|
||||
|
||||
private ListenableFuture<Void> sendMediaMessage(@NonNull RecipientId recipientId,
|
||||
@@ -3059,6 +3090,25 @@ public class ConversationParentFragment extends Fragment
|
||||
final boolean initiating,
|
||||
final boolean clearComposeBox,
|
||||
final @Nullable String metricId)
|
||||
{
|
||||
return sendMediaMessage(recipientId, sendType, body, slideDeck, quote, contacts, previews, mentions, styling, expiresIn, viewOnce, initiating, clearComposeBox, metricId, -1);
|
||||
}
|
||||
|
||||
private ListenableFuture<Void> sendMediaMessage(@NonNull RecipientId recipientId,
|
||||
@NonNull MessageSendType sendType,
|
||||
@NonNull String body,
|
||||
SlideDeck slideDeck,
|
||||
QuoteModel quote,
|
||||
List<Contact> contacts,
|
||||
List<LinkPreview> previews,
|
||||
List<Mention> mentions,
|
||||
@Nullable BodyRangeList styling,
|
||||
final long expiresIn,
|
||||
final boolean viewOnce,
|
||||
final boolean initiating,
|
||||
final boolean clearComposeBox,
|
||||
final @Nullable String metricId,
|
||||
final long scheduledDate)
|
||||
{
|
||||
if (ExpiredBuildReminder.isEligible()) {
|
||||
showExpiredDialog();
|
||||
@@ -3101,7 +3151,8 @@ public class ConversationParentFragment extends Fragment
|
||||
Collections.emptySet(),
|
||||
null,
|
||||
false,
|
||||
styling);
|
||||
styling,
|
||||
scheduledDate);
|
||||
|
||||
final SettableFuture<Void> future = new SettableFuture<>();
|
||||
final Context context = requireContext().getApplicationContext();
|
||||
@@ -3126,7 +3177,7 @@ public class ConversationParentFragment extends Fragment
|
||||
silentlySetComposeText("");
|
||||
}
|
||||
|
||||
final long id = fragment.stageOutgoingMessage(outgoingMessage);
|
||||
fragment.stageOutgoingMessage(outgoingMessage);
|
||||
|
||||
SimpleTask.run(() -> {
|
||||
return MessageSender.send(context, outgoingMessage, thread, sendType.usesSmsTransport() ? SendType.MMS : SendType.SIGNAL, metricId, null);
|
||||
@@ -3141,7 +3192,7 @@ public class ConversationParentFragment extends Fragment
|
||||
return future;
|
||||
}
|
||||
|
||||
private void sendTextMessage(@NonNull MessageSendType sendType, final long expiresIn, final boolean initiating, final @Nullable String metricId)
|
||||
private void sendTextMessage(@NonNull MessageSendType sendType, final long expiresIn, final boolean initiating, final @Nullable String metricId, long scheduledDate)
|
||||
throws InvalidMessageException
|
||||
{
|
||||
if (ExpiredBuildReminder.isEligible()) {
|
||||
@@ -3159,10 +3210,14 @@ public class ConversationParentFragment extends Fragment
|
||||
final String messageBody = getMessage();
|
||||
final boolean sendPush = sendType.usesSignalTransport();
|
||||
|
||||
OutgoingMessage message;
|
||||
final OutgoingMessage message;
|
||||
|
||||
if (sendPush) {
|
||||
message = OutgoingMessage.text(recipient.get(), messageBody, expiresIn, System.currentTimeMillis(), null);
|
||||
if (scheduledDate > 0) {
|
||||
message = OutgoingMessage.text(recipient.get(), messageBody, expiresIn, System.currentTimeMillis(), null).sendAt(scheduledDate);
|
||||
} else {
|
||||
message = OutgoingMessage.text(recipient.get(), messageBody, expiresIn, System.currentTimeMillis(), null);
|
||||
}
|
||||
ApplicationDependencies.getTypingStatusSender().onTypingStopped(thread);
|
||||
} else {
|
||||
message = OutgoingMessage.sms(recipient.get(), messageBody, sendType.getSimSubscriptionIdOr(-1));
|
||||
@@ -3260,6 +3315,22 @@ public class ConversationParentFragment extends Fragment
|
||||
}
|
||||
}
|
||||
|
||||
private void updateScheduledMessagesBar(@NonNull Integer count) {
|
||||
if (count <= 0) {
|
||||
scheduledMessagesBarStub.setVisibility(View.GONE);
|
||||
} else {
|
||||
if (!scheduledMessagesBarStub.resolved()) {
|
||||
View scheduledMessagesBar = scheduledMessagesBarStub.get();
|
||||
scheduledMessagesBar.findViewById(R.id.scheduled_messages_show_all).setOnClickListener(v -> {
|
||||
ScheduledMessagesBottomSheet.show(getChildFragmentManager(), threadId, recipient.getId());
|
||||
});
|
||||
}
|
||||
scheduledMessagesBarStub.setVisibility(View.VISIBLE);
|
||||
TextView scheduledText = scheduledMessagesBarStub.get().findViewById(R.id.scheduled_messages_text);
|
||||
scheduledText.setText(getResources().getQuantityString(R.plurals.conversation_scheduled_messages_bar__number_of_messages, count, count));
|
||||
}
|
||||
}
|
||||
|
||||
private void recordTransportPreference(MessageSendType sendType) {
|
||||
new AsyncTask<Void, Void, Void>() {
|
||||
@Override
|
||||
@@ -3403,7 +3474,7 @@ public class ConversationParentFragment extends Fragment
|
||||
container.hideAttachedInput(true);
|
||||
}
|
||||
|
||||
private void sendVoiceNote(@NonNull Uri uri, long size) {
|
||||
private void sendVoiceNote(@NonNull Uri uri, long size, long scheduledDate) {
|
||||
boolean initiating = threadId == -1;
|
||||
long expiresIn = TimeUnit.SECONDS.toMillis(recipient.get().getExpiresInSeconds());
|
||||
AudioSlide audioSlide = new AudioSlide(requireContext(), uri, size, MediaUtil.AUDIO_AAC, true);
|
||||
@@ -3424,7 +3495,8 @@ public class ConversationParentFragment extends Fragment
|
||||
false,
|
||||
initiating,
|
||||
true,
|
||||
null);
|
||||
null,
|
||||
scheduledDate);
|
||||
}
|
||||
|
||||
private void sendSticker(@NonNull StickerRecord stickerRecord, boolean clearCompose) {
|
||||
@@ -3589,6 +3661,14 @@ public class ConversationParentFragment extends Fragment
|
||||
});
|
||||
}
|
||||
|
||||
public void onScheduleSend(long scheduledTime) {
|
||||
sendMessage(null, scheduledTime);
|
||||
}
|
||||
|
||||
public @NonNull ConversationAdapter.ItemClickListener getConversationAdapterListener() {
|
||||
return fragment.getConversationAdapterListener();
|
||||
}
|
||||
|
||||
// Listeners
|
||||
|
||||
private class RecordingSession implements SingleObserver<VoiceNoteDraft> {
|
||||
@@ -3607,7 +3687,7 @@ public class ConversationParentFragment extends Fragment
|
||||
@Override
|
||||
public void onSuccess(@NonNull VoiceNoteDraft draft) {
|
||||
if (shouldSend) {
|
||||
sendVoiceNote(draft.getUri(), draft.getSize());
|
||||
sendVoiceNote(draft.getUri(), draft.getSize(), -1);
|
||||
} else {
|
||||
if (!saveDraft) {
|
||||
draftViewModel.cancelEphemeralVoiceNoteDraft(draft.asDraft());
|
||||
@@ -3688,7 +3768,7 @@ public class ConversationParentFragment extends Fragment
|
||||
private class AttachButtonLongClickListener implements View.OnLongClickListener {
|
||||
@Override
|
||||
public boolean onLongClick(View v) {
|
||||
return sendButton.performLongClick();
|
||||
return sendButton.showSendTypeMenu();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ public class ConversationViewModel extends ViewModel {
|
||||
private final Application context;
|
||||
private final MediaRepository mediaRepository;
|
||||
private final ConversationRepository conversationRepository;
|
||||
private final ScheduledMessagesRepository scheduledMessagesRepository;
|
||||
private final MutableLiveData<List<Media>> recentMedia;
|
||||
private final BehaviorSubject<Long> threadId;
|
||||
private final Observable<MessageData> messageData;
|
||||
@@ -99,6 +100,7 @@ public class ConversationViewModel extends ViewModel {
|
||||
private final CompositeDisposable disposables;
|
||||
private final BehaviorSubject<Unit> conversationStateTick;
|
||||
private final PublishProcessor<Long> markReadRequestPublisher;
|
||||
private final Observable<Integer> scheduledMessageCount;
|
||||
|
||||
private ConversationIntents.Args args;
|
||||
private int jumpToPosition;
|
||||
@@ -107,6 +109,7 @@ public class ConversationViewModel extends ViewModel {
|
||||
this.context = ApplicationDependencies.getApplication();
|
||||
this.mediaRepository = new MediaRepository();
|
||||
this.conversationRepository = new ConversationRepository();
|
||||
this.scheduledMessagesRepository = new ScheduledMessagesRepository();
|
||||
this.recentMedia = new MutableLiveData<>();
|
||||
this.showScrollButtons = new MutableLiveData<>(false);
|
||||
this.hasUnreadMentions = new MutableLiveData<>(false);
|
||||
@@ -200,6 +203,10 @@ public class ConversationViewModel extends ViewModel {
|
||||
.withLatestFrom(conversationMetadata, (messages, metadata) -> new MessageData(metadata, messages))
|
||||
.doOnNext(a -> SignalLocalMetrics.ConversationOpen.onDataLoaded());
|
||||
|
||||
scheduledMessageCount = threadId
|
||||
.observeOn(Schedulers.io())
|
||||
.flatMap(scheduledMessagesRepository::getScheduledMessageCount);
|
||||
|
||||
Observable<Recipient> liveRecipient = recipientId.distinctUntilChanged().switchMap(id -> Recipient.live(id).asObservable());
|
||||
|
||||
canShowAsBubble = threadId.observeOn(Schedulers.io()).map(conversationRepository::canShowAsBubble);
|
||||
@@ -371,6 +378,10 @@ public class ConversationViewModel extends ViewModel {
|
||||
.observeOn(AndroidSchedulers.mainThread());
|
||||
}
|
||||
|
||||
@NonNull Observable<Integer> getScheduledMessageCount() {
|
||||
return scheduledMessageCount.observeOn(AndroidSchedulers.mainThread());
|
||||
}
|
||||
|
||||
void setHasUnreadMentions(boolean hasUnreadMentions) {
|
||||
this.hasUnreadMentions.setValue(hasUnreadMentions);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package org.thoughtcrime.securesms.conversation
|
||||
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.DrawableRes
|
||||
import org.signal.core.util.dp
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.menu.ActionItem
|
||||
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.toLocalDateTime
|
||||
import org.thoughtcrime.securesms.util.toMillis
|
||||
import java.util.Locale
|
||||
|
||||
class ScheduleMessageContextMenu {
|
||||
|
||||
companion object {
|
||||
|
||||
private val presetHours = arrayOf(8, 12, 18, 21)
|
||||
|
||||
@JvmStatic
|
||||
fun show(anchor: View, container: ViewGroup, action: (Long) -> Unit): SignalContextMenu {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
val scheduledTimes = getNextScheduleTimes(currentTime)
|
||||
val actionItems = scheduledTimes.map {
|
||||
if (it > 0) {
|
||||
ActionItem(getIconForTime(it), DateUtils.getScheduledMessageDateString(anchor.context, Locale.getDefault(), it)) {
|
||||
action(it)
|
||||
}
|
||||
} else {
|
||||
ActionItem(0, anchor.context.getString(R.string.ScheduledMessages_pick_time)) {
|
||||
action(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return SignalContextMenu.Builder(anchor, container)
|
||||
.offsetX(12.dp)
|
||||
.offsetY(12.dp)
|
||||
.preferredVerticalPosition(SignalContextMenu.VerticalPosition.ABOVE)
|
||||
.show(actionItems)
|
||||
}
|
||||
|
||||
@DrawableRes
|
||||
private fun getIconForTime(timeMs: Long): Int {
|
||||
val dateTime = timeMs.toLocalDateTime()
|
||||
return if (dateTime.hour >= 18) {
|
||||
R.drawable.ic_nighttime_26
|
||||
} else {
|
||||
R.drawable.ic_daytime_24
|
||||
}
|
||||
}
|
||||
|
||||
private fun getNextScheduleTimes(currentTimeMs: Long): List<Long> {
|
||||
var currentDateTime = currentTimeMs.toLocalDateTime()
|
||||
|
||||
val timestampList = ArrayList<Long>(4)
|
||||
var presetIndex = presetHours.indexOfFirst { it > currentDateTime.hour }
|
||||
if (presetIndex == -1) {
|
||||
currentDateTime = currentDateTime.plusDays(1)
|
||||
presetIndex = 0
|
||||
}
|
||||
currentDateTime = currentDateTime.withMinute(0).withSecond(0)
|
||||
while (timestampList.size < 3) {
|
||||
currentDateTime = currentDateTime.withHour(presetHours[presetIndex])
|
||||
timestampList += currentDateTime.toMillis()
|
||||
presetIndex++
|
||||
if (presetIndex >= presetHours.size) {
|
||||
presetIndex = 0
|
||||
currentDateTime = currentDateTime.plusDays(1)
|
||||
}
|
||||
}
|
||||
timestampList += -1
|
||||
|
||||
return timestampList.reversed()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package org.thoughtcrime.securesms.conversation
|
||||
|
||||
import android.os.Bundle
|
||||
import android.text.format.DateFormat
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import com.google.android.material.datepicker.CalendarConstraints
|
||||
import com.google.android.material.datepicker.DateValidatorPointForward
|
||||
import com.google.android.material.datepicker.MaterialDatePicker
|
||||
import com.google.android.material.timepicker.MaterialTimePicker
|
||||
import com.google.android.material.timepicker.TimeFormat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.components.ViewBinderDelegate
|
||||
import org.thoughtcrime.securesms.databinding.ScheduleMessageTimePickerBottomSheetBinding
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.atMidnight
|
||||
import org.thoughtcrime.securesms.util.atUTC
|
||||
import org.thoughtcrime.securesms.util.formatHours
|
||||
import org.thoughtcrime.securesms.util.fragments.findListener
|
||||
import org.thoughtcrime.securesms.util.toLocalDateTime
|
||||
import org.thoughtcrime.securesms.util.toMillis
|
||||
import java.time.LocalDateTime
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Bottom sheet dialog that allows selecting a timestamp after the current time for
|
||||
* scheduling a message send.
|
||||
*
|
||||
* Will call [ScheduleCallback.onScheduleSend] with the selected time, if called with [showSchedule]
|
||||
* Will call [RescheduleCallback.onReschedule] with the selected time, if called with [showReschedule]
|
||||
*/
|
||||
class ScheduleMessageTimePickerBottomSheet : FixedRoundedCornerBottomSheetDialogFragment() {
|
||||
override val peekHeightPercentage: Float = 0.66f
|
||||
override val themeResId: Int = R.style.Widget_Signal_FixedRoundedCorners_Messages
|
||||
|
||||
private var scheduledDate: Long = 0
|
||||
private var scheduledHour: Int = 0
|
||||
private var scheduledMinute: Int = 0
|
||||
|
||||
private val binding by ViewBinderDelegate(ScheduleMessageTimePickerBottomSheetBinding::bind)
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
return inflater.inflate(R.layout.schedule_message_time_picker_bottom_sheet, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val initialTime = arguments?.getLong(KEY_INITIAL_TIME)
|
||||
scheduledDate = initialTime ?: System.currentTimeMillis()
|
||||
var scheduledLocalDateTime = scheduledDate.toLocalDateTime()
|
||||
if (initialTime == null) {
|
||||
scheduledLocalDateTime = scheduledLocalDateTime.plusMinutes(5L - (scheduledLocalDateTime.minute % 5))
|
||||
}
|
||||
|
||||
scheduledHour = scheduledLocalDateTime.hour
|
||||
scheduledMinute = scheduledLocalDateTime.minute
|
||||
|
||||
binding.scheduleSend.setOnClickListener {
|
||||
dismiss()
|
||||
val messageId = arguments?.getLong(KEY_MESSAGE_ID)
|
||||
if (messageId == null) {
|
||||
findListener<ScheduleCallback>()?.onScheduleSend(getSelectedTimestamp())
|
||||
} else {
|
||||
val selectedTime = getSelectedTimestamp()
|
||||
if (selectedTime != arguments?.getLong(KEY_INITIAL_TIME)) {
|
||||
findListener<RescheduleCallback>()?.onReschedule(selectedTime, messageId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val zoneOffsetFormatter = DateTimeFormatter.ofPattern("OOOO")
|
||||
val zoneNameFormatter = DateTimeFormatter.ofPattern("zzzz")
|
||||
val zonedDateTime = ZonedDateTime.now()
|
||||
binding.timezoneDisclaimer.apply {
|
||||
text = getString(
|
||||
R.string.ScheduleMessageTimePickerBottomSheet__timezone_disclaimer,
|
||||
zoneOffsetFormatter.format(zonedDateTime),
|
||||
zoneNameFormatter.format(zonedDateTime),
|
||||
)
|
||||
}
|
||||
|
||||
updateSelectedDate()
|
||||
updateSelectedTime()
|
||||
|
||||
setupDateSelector()
|
||||
setupTimeSelector()
|
||||
}
|
||||
|
||||
private fun setupDateSelector() {
|
||||
binding.daySelector.setOnClickListener {
|
||||
val local = LocalDateTime.now()
|
||||
.atMidnight()
|
||||
.atUTC()
|
||||
.toMillis()
|
||||
val datePicker =
|
||||
MaterialDatePicker.Builder.datePicker()
|
||||
.setTitleText(getString(R.string.ScheduleMessageTimePickerBottomSheet__select_date_title))
|
||||
.setSelection(scheduledDate)
|
||||
.setCalendarConstraints(CalendarConstraints.Builder().setStart(local).setValidator(DateValidatorPointForward.now()).build())
|
||||
.build()
|
||||
|
||||
datePicker.addOnDismissListener {
|
||||
datePicker.clearOnDismissListeners()
|
||||
datePicker.clearOnPositiveButtonClickListeners()
|
||||
}
|
||||
|
||||
datePicker.addOnPositiveButtonClickListener {
|
||||
it.let {
|
||||
scheduledDate = it.toLocalDateTime(ZoneOffset.UTC).atZone(ZoneId.systemDefault()).toMillis()
|
||||
updateSelectedDate()
|
||||
}
|
||||
}
|
||||
datePicker.show(childFragmentManager, "DATE_PICKER")
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupTimeSelector() {
|
||||
binding.timeSelector.setOnClickListener {
|
||||
val timeFormat = if (DateFormat.is24HourFormat(requireContext())) TimeFormat.CLOCK_24H else TimeFormat.CLOCK_12H
|
||||
val timePickerFragment = MaterialTimePicker.Builder()
|
||||
.setTimeFormat(timeFormat)
|
||||
.setHour(scheduledHour)
|
||||
.setMinute(scheduledMinute)
|
||||
.setTitleText(getString(R.string.ScheduleMessageTimePickerBottomSheet__select_time_title))
|
||||
.build()
|
||||
|
||||
timePickerFragment.addOnDismissListener {
|
||||
timePickerFragment.clearOnDismissListeners()
|
||||
timePickerFragment.clearOnPositiveButtonClickListeners()
|
||||
}
|
||||
|
||||
timePickerFragment.addOnPositiveButtonClickListener {
|
||||
scheduledHour = timePickerFragment.hour
|
||||
scheduledMinute = timePickerFragment.minute
|
||||
|
||||
updateSelectedTime()
|
||||
}
|
||||
|
||||
timePickerFragment.show(childFragmentManager, "TIME_PICKER")
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSelectedTimestamp(): Long {
|
||||
return scheduledDate.toLocalDateTime()
|
||||
.withMinute(scheduledMinute)
|
||||
.withHour(scheduledHour)
|
||||
.withSecond(0)
|
||||
.withNano(0)
|
||||
.toMillis()
|
||||
}
|
||||
|
||||
private fun updateSelectedDate() {
|
||||
binding.dateText.text = DateUtils.getDayPrecisionTimeString(requireContext(), Locale.getDefault(), scheduledDate)
|
||||
}
|
||||
|
||||
private fun updateSelectedTime() {
|
||||
val scheduledTime = LocalTime.of(scheduledHour, scheduledMinute)
|
||||
binding.timeText.text = scheduledTime.formatHours(requireContext())
|
||||
}
|
||||
|
||||
interface ScheduleCallback {
|
||||
fun onScheduleSend(scheduledTime: Long)
|
||||
}
|
||||
|
||||
interface RescheduleCallback {
|
||||
fun onReschedule(scheduledTime: Long, messageId: Long)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val KEY_MESSAGE_ID = "message_id"
|
||||
private const val KEY_INITIAL_TIME = "initial_time"
|
||||
|
||||
@JvmStatic
|
||||
fun showSchedule(fragmentManager: FragmentManager) {
|
||||
val fragment = ScheduleMessageTimePickerBottomSheet()
|
||||
|
||||
fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun showReschedule(fragmentManager: FragmentManager, messageId: Long, initialTime: Long) {
|
||||
val args = Bundle().apply {
|
||||
putLong(KEY_MESSAGE_ID, messageId)
|
||||
putLong(KEY_INITIAL_TIME, initialTime)
|
||||
}
|
||||
|
||||
val fragment = ScheduleMessageTimePickerBottomSheet().apply {
|
||||
arguments = args
|
||||
}
|
||||
|
||||
fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
package org.thoughtcrime.securesms.conversation
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.DialogInterface
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.view.doOnNextLayout
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.signal.core.util.StreamUtil
|
||||
import org.signal.core.util.concurrent.SimpleTask
|
||||
import org.signal.core.util.dp
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.components.SignalProgressDialog
|
||||
import org.thoughtcrime.securesms.components.menu.ActionItem
|
||||
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
|
||||
import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager
|
||||
import org.thoughtcrime.securesms.conversation.colors.Colorizer
|
||||
import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart.Attachments
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ItemDecoration
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackController
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicy
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionPlayerHolder
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionRecycler
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview
|
||||
import org.thoughtcrime.securesms.mms.GlideApp
|
||||
import org.thoughtcrime.securesms.mms.PartAuthority
|
||||
import org.thoughtcrime.securesms.mms.TextSlide
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil.requireCoordinatorLayout
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.StickyHeaderDecoration
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||
import org.thoughtcrime.securesms.util.hasTextSlide
|
||||
import org.thoughtcrime.securesms.util.requireTextSlide
|
||||
import java.io.IOException
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Bottom sheet dialog to view all scheduled messages within a given thread.
|
||||
*/
|
||||
class ScheduledMessagesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment(), ScheduleMessageTimePickerBottomSheet.RescheduleCallback {
|
||||
|
||||
override val peekHeightPercentage: Float = 0.66f
|
||||
override val themeResId: Int = R.style.Widget_Signal_FixedRoundedCorners_Messages
|
||||
|
||||
private var firstRender: Boolean = true
|
||||
|
||||
private lateinit var messageAdapter: ConversationAdapter
|
||||
private lateinit var callback: Callback
|
||||
|
||||
private val viewModel: ScheduledMessagesViewModel by viewModels(
|
||||
factoryProducer = {
|
||||
val threadId = requireArguments().getLong(KEY_THREAD_ID)
|
||||
ScheduledMessagesViewModel.Factory(threadId)
|
||||
}
|
||||
)
|
||||
|
||||
private val disposables: LifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
val view = inflater.inflate(R.layout.scheduled_messages_bottom_sheet, container, false)
|
||||
disposables.bindTo(viewLifecycleOwner)
|
||||
return view
|
||||
}
|
||||
|
||||
@SuppressLint("WrongThread")
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val conversationRecipientId = RecipientId.from(arguments?.getString(KEY_CONVERSATION_RECIPIENT_ID, null) ?: throw IllegalArgumentException())
|
||||
val conversationRecipient = Recipient.resolved(conversationRecipientId)
|
||||
|
||||
callback = requireListener()
|
||||
|
||||
val colorizer = Colorizer()
|
||||
|
||||
messageAdapter = ConversationAdapter(requireContext(), viewLifecycleOwner, GlideApp.with(this), Locale.getDefault(), ConversationAdapterListener(), conversationRecipient, colorizer).apply {
|
||||
setCondensedMode(true)
|
||||
setScheduledMessagesMode(true)
|
||||
}
|
||||
|
||||
val list: RecyclerView = view.findViewById<RecyclerView>(R.id.scheduled_list).apply {
|
||||
layoutManager = SmoothScrollingLinearLayoutManager(requireContext(), true)
|
||||
adapter = messageAdapter
|
||||
itemAnimator = null
|
||||
|
||||
doOnNextLayout {
|
||||
// Adding this without waiting for a layout pass would result in an indeterminate amount of padding added to the top of the view
|
||||
addItemDecoration(StickyHeaderDecoration(messageAdapter, false, false, ConversationAdapter.HEADER_TYPE_INLINE_DATE))
|
||||
}
|
||||
}
|
||||
|
||||
val recyclerViewColorizer = RecyclerViewColorizer(list)
|
||||
|
||||
disposables += viewModel.getMessages(requireContext()).subscribe { messages ->
|
||||
if (messages.isEmpty()) {
|
||||
dismiss()
|
||||
}
|
||||
|
||||
messageAdapter.submitList(messages) {
|
||||
if (firstRender) {
|
||||
(list.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(messages.size - 1, 100)
|
||||
|
||||
firstRender = false
|
||||
} else if (!list.canScrollVertically(1)) {
|
||||
list.layoutManager?.scrollToPosition(0)
|
||||
}
|
||||
}
|
||||
recyclerViewColorizer.setChatColors(conversationRecipient.chatColors)
|
||||
}
|
||||
|
||||
initializeGiphyMp4(view.findViewById(R.id.video_container) as ViewGroup, list)
|
||||
}
|
||||
|
||||
private fun initializeGiphyMp4(videoContainer: ViewGroup, list: RecyclerView): GiphyMp4ProjectionRecycler {
|
||||
val maxPlayback = GiphyMp4PlaybackPolicy.maxSimultaneousPlaybackInConversation()
|
||||
val holders = GiphyMp4ProjectionPlayerHolder.injectVideoViews(
|
||||
requireContext(),
|
||||
viewLifecycleOwner.lifecycle,
|
||||
videoContainer,
|
||||
maxPlayback
|
||||
)
|
||||
val callback = GiphyMp4ProjectionRecycler(holders)
|
||||
|
||||
GiphyMp4PlaybackController.attach(list, callback, maxPlayback)
|
||||
list.addItemDecoration(GiphyMp4ItemDecoration(callback) {}, 0)
|
||||
|
||||
return callback
|
||||
}
|
||||
|
||||
private fun showScheduledMessageContextMenu(view: View, messageRecord: MessageRecord) {
|
||||
SignalContextMenu.Builder(view, requireCoordinatorLayout())
|
||||
.offsetX(12.dp)
|
||||
.offsetY(12.dp)
|
||||
.preferredVerticalPosition(SignalContextMenu.VerticalPosition.ABOVE)
|
||||
.show(getMenuActionItems(messageRecord))
|
||||
}
|
||||
|
||||
private fun getMenuActionItems(messageRecord: MessageRecord): List<ActionItem> {
|
||||
val message = ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(requireContext(), messageRecord)
|
||||
val canCopy = message.multiselectCollection.toSet().any { it !is Attachments && messageRecord.body.isNotEmpty() }
|
||||
val items: MutableList<ActionItem> = ArrayList()
|
||||
items.add(ActionItem(R.drawable.ic_delete_tinted_24, resources.getString(R.string.conversation_selection__menu_delete), action = { handleDeleteMessage(messageRecord) }))
|
||||
if (canCopy) {
|
||||
items.add(ActionItem(R.drawable.ic_copy_24_tinted, resources.getString(R.string.conversation_selection__menu_copy), action = { handleCopyMessage(message) }))
|
||||
}
|
||||
items.add(ActionItem(R.drawable.ic_send_outline_24, resources.getString(R.string.ScheduledMessagesBottomSheet_menu_send_now), action = { handleSendMessageNow(messageRecord) }))
|
||||
items.add(ActionItem(R.drawable.ic_calendar_24, resources.getString(R.string.ScheduledMessagesBottomSheet_menu_reschedule), action = { handleRescheduleMessage(messageRecord) }))
|
||||
return items
|
||||
}
|
||||
|
||||
private fun handleRescheduleMessage(messageRecord: MessageRecord) {
|
||||
ScheduleMessageTimePickerBottomSheet.showReschedule(childFragmentManager, messageRecord.id, (messageRecord as MediaMmsMessageRecord).scheduledDate)
|
||||
}
|
||||
|
||||
private fun handleSendMessageNow(messageRecord: MessageRecord) {
|
||||
viewModel.rescheduleMessage(messageRecord.id, System.currentTimeMillis())
|
||||
}
|
||||
|
||||
private fun handleDeleteMessage(messageRecord: MessageRecord) {
|
||||
buildDeleteScheduledMessageConfirmationDialog(messageRecord).show()
|
||||
}
|
||||
|
||||
private fun handleCopyMessage(message: ConversationMessage) {
|
||||
SimpleTask.run(
|
||||
viewLifecycleOwner.lifecycle,
|
||||
{ getMessageText(message) },
|
||||
{ bodies: CharSequence? ->
|
||||
if (!Util.isEmpty(bodies)) {
|
||||
Util.copyToClipboard(requireContext(), bodies!!)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildDeleteScheduledMessageConfirmationDialog(messageRecord: MessageRecord): AlertDialog.Builder {
|
||||
return MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(resources.getString(R.string.ScheduledMessagesBottomSheet_delete_dialog_message))
|
||||
.setCancelable(true)
|
||||
.setPositiveButton(R.string.ScheduledMessagesBottomSheet_delete_dialog_action) { _: DialogInterface?, _: Int ->
|
||||
deleteMessage(messageRecord.id)
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
}
|
||||
|
||||
private fun getMessageText(message: ConversationMessage): CharSequence {
|
||||
if (message.messageRecord.hasTextSlide()) {
|
||||
val textSlide: TextSlide = message.messageRecord.requireTextSlide()
|
||||
if (textSlide.uri == null) {
|
||||
return message.getDisplayBody(requireContext())
|
||||
}
|
||||
try {
|
||||
PartAuthority.getAttachmentStream(requireContext(), textSlide.uri!!).use { stream ->
|
||||
val body = StreamUtil.readFullyAsString(stream)
|
||||
return ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(requireContext(), message.messageRecord, body)
|
||||
.getDisplayBody(requireContext())
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Failed to read text slide data.")
|
||||
}
|
||||
}
|
||||
return ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(requireContext(), message.messageRecord).getDisplayBody(requireContext())
|
||||
}
|
||||
|
||||
private fun deleteMessage(messageId: Long) {
|
||||
val progressDialog = SignalProgressDialog.show(
|
||||
context = requireContext(),
|
||||
message = resources.getString(R.string.ScheduledMessagesBottomSheet_deleting_progress_message),
|
||||
indeterminate = true
|
||||
)
|
||||
SimpleTask.run(viewLifecycleOwner.lifecycle, {
|
||||
SignalDatabase.messages.deleteScheduledMessage(messageId)
|
||||
}, {
|
||||
progressDialog.dismiss()
|
||||
})
|
||||
}
|
||||
|
||||
override fun onReschedule(scheduledTime: Long, messageId: Long) {
|
||||
viewModel.rescheduleMessage(messageId, scheduledTime)
|
||||
}
|
||||
|
||||
private fun getAdapterListener(): ConversationAdapter.ItemClickListener {
|
||||
return callback.getConversationAdapterListener()
|
||||
}
|
||||
|
||||
private inner class ConversationAdapterListener : ConversationAdapter.ItemClickListener by getAdapterListener() {
|
||||
override fun onItemClick(item: MultiselectPart) = Unit
|
||||
override fun onItemLongClick(itemView: View, item: MultiselectPart) = Unit
|
||||
override fun onQuoteClicked(messageRecord: MmsMessageRecord) = Unit
|
||||
override fun onLinkPreviewClicked(linkPreview: LinkPreview) = Unit
|
||||
override fun onQuotedIndicatorClicked(messageRecord: MessageRecord) = Unit
|
||||
override fun onReactionClicked(multiselectPart: MultiselectPart, messageId: Long, isMms: Boolean) = Unit
|
||||
override fun onGroupMemberClicked(recipientId: RecipientId, groupId: GroupId) = Unit
|
||||
override fun onMessageWithRecaptchaNeededClicked(messageRecord: MessageRecord) = Unit
|
||||
override fun onGroupMigrationLearnMoreClicked(membershipChange: GroupMigrationMembershipChange) = Unit
|
||||
override fun onChatSessionRefreshLearnMoreClicked() = Unit
|
||||
override fun onBadDecryptLearnMoreClicked(author: RecipientId) = Unit
|
||||
override fun onSafetyNumberLearnMoreClicked(recipient: Recipient) = Unit
|
||||
override fun onJoinGroupCallClicked() = Unit
|
||||
override fun onInviteFriendsToGroupClicked(groupId: GroupId.V2) = Unit
|
||||
override fun onEnableCallNotificationsClicked() = Unit
|
||||
override fun onCallToAction(action: String) = Unit
|
||||
override fun onDonateClicked() = Unit
|
||||
override fun onRecipientNameClicked(target: RecipientId) = Unit
|
||||
override fun onViewGiftBadgeClicked(messageRecord: MessageRecord) = Unit
|
||||
override fun onActivatePaymentsClicked() = Unit
|
||||
override fun onSendPaymentClicked(recipientId: RecipientId) = Unit
|
||||
override fun onScheduledIndicatorClicked(view: View, messageRecord: MessageRecord) {
|
||||
showScheduledMessageContextMenu(view, messageRecord)
|
||||
}
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
fun getConversationAdapterListener(): ConversationAdapter.ItemClickListener
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(ScheduledMessagesBottomSheet::class.java)
|
||||
|
||||
private const val KEY_THREAD_ID = "thread_id"
|
||||
private const val KEY_CONVERSATION_RECIPIENT_ID = "conversation_recipient_id"
|
||||
|
||||
@JvmStatic
|
||||
fun show(fragmentManager: FragmentManager, threadId: Long, conversationRecipientId: RecipientId) {
|
||||
val args = Bundle().apply {
|
||||
putLong(KEY_THREAD_ID, threadId)
|
||||
putString(KEY_CONVERSATION_RECIPIENT_ID, conversationRecipientId.serialize())
|
||||
}
|
||||
|
||||
val fragment = ScheduledMessagesBottomSheet().apply {
|
||||
arguments = args
|
||||
}
|
||||
|
||||
fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package org.thoughtcrime.securesms.conversation
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.WorkerThread
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.thoughtcrime.securesms.database.DatabaseObserver
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
|
||||
/**
|
||||
* Handles retrieving scheduled messages data to be shown in [ScheduledMessagesBottomSheet] and [ConversationParentFragment]
|
||||
*/
|
||||
class ScheduledMessagesRepository {
|
||||
|
||||
/**
|
||||
* Get all the scheduled messages for the specified thread, ordered by scheduled time
|
||||
*/
|
||||
fun getScheduledMessages(context: Context, threadId: Long): Observable<List<ConversationMessage>> {
|
||||
return Observable.create { emitter ->
|
||||
val databaseObserver: DatabaseObserver = ApplicationDependencies.getDatabaseObserver()
|
||||
val observer = DatabaseObserver.Observer { emitter.onNext(getScheduledMessagesSync(context, threadId)) }
|
||||
|
||||
databaseObserver.registerScheduledMessageObserver(threadId, observer)
|
||||
|
||||
emitter.setCancellable { databaseObserver.unregisterObserver(observer) }
|
||||
emitter.onNext(getScheduledMessagesSync(context, threadId))
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun getScheduledMessagesSync(context: Context, threadId: Long): List<ConversationMessage> {
|
||||
var scheduledMessages: List<MessageRecord> = SignalDatabase.messages.getScheduledMessagesInThread(threadId)
|
||||
|
||||
val attachmentHelper = ConversationDataSource.AttachmentHelper()
|
||||
|
||||
attachmentHelper.addAll(scheduledMessages)
|
||||
|
||||
attachmentHelper.fetchAttachments()
|
||||
|
||||
scheduledMessages = attachmentHelper.buildUpdatedModels(ApplicationDependencies.getApplication(), scheduledMessages)
|
||||
|
||||
val replies: List<ConversationMessage> = scheduledMessages
|
||||
.map { ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(context, it) }
|
||||
|
||||
return replies
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of scheduled messages for a given thread
|
||||
*/
|
||||
fun getScheduledMessageCount(threadId: Long): Observable<Int> {
|
||||
return Observable.create { emitter ->
|
||||
val databaseObserver: DatabaseObserver = ApplicationDependencies.getDatabaseObserver()
|
||||
val observer = DatabaseObserver.Observer { emitter.onNext(SignalDatabase.messages.getScheduledMessageCountForThread(threadId)) }
|
||||
|
||||
databaseObserver.registerScheduledMessageObserver(threadId, observer)
|
||||
|
||||
emitter.setCancellable { databaseObserver.unregisterObserver(observer) }
|
||||
emitter.onNext(SignalDatabase.messages.getScheduledMessageCountForThread(threadId))
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun rescheduleMessage(threadId: Long, messageId: Long, scheduleTime: Long) {
|
||||
SignalDatabase.messages.rescheduleMessage(threadId, messageId, scheduleTime)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package org.thoughtcrime.securesms.conversation
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import org.signal.core.util.logging.Log
|
||||
|
||||
class ScheduledMessagesViewModel @JvmOverloads constructor(
|
||||
private val threadId: Long,
|
||||
private val repository: ScheduledMessagesRepository = ScheduledMessagesRepository()
|
||||
) : ViewModel() {
|
||||
|
||||
fun getMessages(context: Context): Observable<List<ConversationMessage>> {
|
||||
return repository.getScheduledMessages(context, threadId)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
fun rescheduleMessage(messageId: Long, scheduleTime: Long) {
|
||||
repository.rescheduleMessage(threadId, messageId, scheduleTime)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(ScheduledMessagesViewModel::class.java)
|
||||
}
|
||||
|
||||
class Factory(private val threadId: Long) : ViewModelProvider.NewInstanceFactory() {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(ScheduledMessagesViewModel(threadId)) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ public class DatabaseObserver {
|
||||
private static final String KEY_NOTIFICATION_PROFILES = "NotificationProfiles";
|
||||
private static final String KEY_RECIPIENT = "Recipient";
|
||||
private static final String KEY_STORY_OBSERVER = "Story";
|
||||
private static final String KEY_SCHEDULED_MESSAGES = "ScheduledMessages";
|
||||
|
||||
private final Application application;
|
||||
private final Executor executor;
|
||||
@@ -50,6 +51,7 @@ public class DatabaseObserver {
|
||||
private final Map<Long, Set<Observer>> conversationObservers;
|
||||
private final Map<Long, Set<Observer>> verboseConversationObservers;
|
||||
private final Map<UUID, Set<Observer>> paymentObservers;
|
||||
private final Map<Long, Set<Observer>> scheduledMessageObservers;
|
||||
private final Set<Observer> allPaymentsObservers;
|
||||
private final Set<Observer> chatColorsObservers;
|
||||
private final Set<Observer> stickerObservers;
|
||||
@@ -76,6 +78,7 @@ public class DatabaseObserver {
|
||||
this.messageInsertObservers = new HashMap<>();
|
||||
this.notificationProfileObservers = new HashSet<>();
|
||||
this.storyObservers = new HashMap<>();
|
||||
this.scheduledMessageObservers = new HashMap<>();
|
||||
}
|
||||
|
||||
public void registerConversationListObserver(@NonNull Observer listener) {
|
||||
@@ -159,6 +162,12 @@ public class DatabaseObserver {
|
||||
});
|
||||
}
|
||||
|
||||
public void registerScheduledMessageObserver(long threadId, @NonNull Observer listener) {
|
||||
executor.execute(() -> {
|
||||
registerMapped(scheduledMessageObservers, threadId, listener);
|
||||
});
|
||||
}
|
||||
|
||||
public void unregisterObserver(@NonNull Observer listener) {
|
||||
executor.execute(() -> {
|
||||
conversationListObservers.remove(listener);
|
||||
@@ -290,6 +299,12 @@ public class DatabaseObserver {
|
||||
}
|
||||
}
|
||||
|
||||
public void notifyScheduledMessageObservers(long threadId) {
|
||||
runPostSuccessfulTransaction(KEY_SCHEDULED_MESSAGES + threadId, () -> {
|
||||
notifyMapped(scheduledMessageObservers, threadId);
|
||||
});
|
||||
}
|
||||
|
||||
private void runPostSuccessfulTransaction(@NonNull String dedupeKey, @NonNull Runnable runnable) {
|
||||
SignalDatabase.runPostSuccessfulTransaction(dedupeKey, () -> {
|
||||
executor.execute(runnable);
|
||||
|
||||
@@ -180,6 +180,7 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie
|
||||
public static final String VIEW_ONCE = "view_once";
|
||||
public static final String STORY_TYPE = "story_type";
|
||||
public static final String PARENT_STORY_ID = "parent_story_id";
|
||||
public static final String SCHEDULED_DATE = "scheduled_date";
|
||||
|
||||
public static class Status {
|
||||
public static final int STATUS_NONE = -1;
|
||||
@@ -234,7 +235,8 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie
|
||||
STORY_TYPE + " INTEGER DEFAULT 0, " +
|
||||
PARENT_STORY_ID + " INTEGER DEFAULT 0, " +
|
||||
EXPORT_STATE + " BLOB DEFAULT NULL, " +
|
||||
EXPORTED + " INTEGER DEFAULT 0);";
|
||||
EXPORTED + " INTEGER DEFAULT 0, " +
|
||||
SCHEDULED_DATE + " INTEGER DEFAULT -1);";
|
||||
|
||||
private static final String INDEX_THREAD_DATE = "mms_thread_date_index";
|
||||
|
||||
@@ -247,7 +249,7 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie
|
||||
"CREATE INDEX IF NOT EXISTS mms_reactions_unread_index ON " + TABLE_NAME + " (" + REACTIONS_UNREAD + ");",
|
||||
"CREATE INDEX IF NOT EXISTS mms_story_type_index ON " + TABLE_NAME + " (" + STORY_TYPE + ");",
|
||||
"CREATE INDEX IF NOT EXISTS mms_parent_story_id_index ON " + TABLE_NAME + " (" + PARENT_STORY_ID + ");",
|
||||
"CREATE INDEX IF NOT EXISTS mms_thread_story_parent_story_index ON " + TABLE_NAME + " (" + THREAD_ID + ", " + DATE_RECEIVED + "," + STORY_TYPE + "," + PARENT_STORY_ID + ");",
|
||||
"CREATE INDEX IF NOT EXISTS mms_thread_story_parent_story_scheduled_date_index ON " + TABLE_NAME + " (" + THREAD_ID + ", " + DATE_RECEIVED + "," + STORY_TYPE + "," + PARENT_STORY_ID + "," + SCHEDULED_DATE + ");",
|
||||
"CREATE INDEX IF NOT EXISTS mms_quote_id_quote_author_index ON " + TABLE_NAME + "(" + QUOTE_ID + ", " + QUOTE_AUTHOR + ");",
|
||||
"CREATE INDEX IF NOT EXISTS mms_exported_index ON " + TABLE_NAME + " (" + EXPORTED + ");",
|
||||
"CREATE INDEX IF NOT EXISTS mms_id_type_payment_transactions_index ON " + TABLE_NAME + " (" + ID + "," + TYPE + ") WHERE " + TYPE + " & " + MessageTypes.SPECIAL_TYPE_PAYMENTS_NOTIFICATION + " != 0;"
|
||||
@@ -298,6 +300,7 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie
|
||||
MESSAGE_RANGES,
|
||||
STORY_TYPE,
|
||||
PARENT_STORY_ID,
|
||||
SCHEDULED_DATE,
|
||||
};
|
||||
|
||||
private static final String[] MMS_PROJECTION = SqlUtil.appendArg(MMS_PROJECTION_BASE, "NULL AS " + AttachmentTable.ATTACHMENT_JSON_ALIAS);
|
||||
@@ -343,6 +346,7 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie
|
||||
MessageTable.TYPE + " & " + MessageTypes.GROUP_V2_LEAVE_BITS + " != " + MessageTypes.GROUP_V2_LEAVE_BITS + " AND " +
|
||||
MessageTable.STORY_TYPE + " = 0 AND " +
|
||||
MessageTable.PARENT_STORY_ID + " <= 0 AND " +
|
||||
MessageTable.SCHEDULED_DATE + " = -1 AND " +
|
||||
MessageTable.TYPE + " NOT IN (" + MessageTypes.PROFILE_CHANGE_TYPE + ", " + MessageTypes.GV1_MIGRATION_TYPE + ", " + MessageTypes.CHANGE_NUMBER_TYPE + ", " + MessageTypes.BOOST_REQUEST_TYPE + ", " + MessageTypes.SMS_EXPORT_TYPE + ") " +
|
||||
"ORDER BY " + MessageTable.DATE_RECEIVED + " DESC " +
|
||||
"LIMIT 1";
|
||||
@@ -1628,11 +1632,26 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie
|
||||
return -1;
|
||||
}
|
||||
|
||||
public int getScheduledMessageCountForThread(long threadId) {
|
||||
SQLiteDatabase db = databaseHelper.getSignalReadableDatabase();
|
||||
|
||||
String query = THREAD_ID + " = ? AND " + STORY_TYPE + " = ? AND " + PARENT_STORY_ID + " <= ? AND " + SCHEDULED_DATE + " != ?";
|
||||
String[] args = SqlUtil.buildArgs(threadId, 0, 0, -1);
|
||||
|
||||
try (Cursor cursor = db.query(TABLE_NAME, COUNT, query, args, null, null, null)) {
|
||||
if (cursor.moveToFirst()) {
|
||||
return cursor.getInt(0);
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
public int getMessageCountForThread(long threadId) {
|
||||
SQLiteDatabase db = databaseHelper.getSignalReadableDatabase();
|
||||
|
||||
String query = THREAD_ID + " = ? AND " + STORY_TYPE + " = ? AND " + PARENT_STORY_ID + " <= ?";
|
||||
String[] args = SqlUtil.buildArgs(threadId, 0, 0);
|
||||
String query = THREAD_ID + " = ? AND " + STORY_TYPE + " = ? AND " + PARENT_STORY_ID + " <= ? AND " + SCHEDULED_DATE + " = ?";
|
||||
String[] args = SqlUtil.buildArgs(threadId, 0, 0, -1);
|
||||
|
||||
try (Cursor cursor = db.query(TABLE_NAME, COUNT, query, args, null, null, null)) {
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
@@ -1646,8 +1665,8 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie
|
||||
public int getMessageCountForThread(long threadId, long beforeTime) {
|
||||
SQLiteDatabase db = databaseHelper.getSignalReadableDatabase();
|
||||
|
||||
String query = THREAD_ID + " = ? AND " + DATE_RECEIVED + " < ? AND " + STORY_TYPE + " = ? AND " + PARENT_STORY_ID + " <= ?";
|
||||
String[] args = SqlUtil.buildArgs(threadId, beforeTime, 0, 0);
|
||||
String query = THREAD_ID + " = ? AND " + DATE_RECEIVED + " < ? AND " + STORY_TYPE + " = ? AND " + PARENT_STORY_ID + " <= ? AND " + SCHEDULED_DATE + " = ?";
|
||||
String[] args = SqlUtil.buildArgs(threadId, beforeTime, 0, 0, -1);
|
||||
|
||||
try (Cursor cursor = db.query(TABLE_NAME, COUNT, query, args, null, null, null)) {
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
@@ -1688,8 +1707,8 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie
|
||||
}
|
||||
|
||||
private @NonNull SqlUtil.Query buildMeaningfulMessagesQuery(long threadId) {
|
||||
String query = THREAD_ID + " = ? AND " + STORY_TYPE + " = ? AND " + PARENT_STORY_ID + " <= ? AND (NOT " + TYPE + " & ? AND " + TYPE + " != ? AND " + TYPE + " != ? AND " + TYPE + " != ? AND " + TYPE + " != ? AND " + TYPE + " & " + MessageTypes.GROUP_V2_LEAVE_BITS + " != " + MessageTypes.GROUP_V2_LEAVE_BITS + ")";
|
||||
return SqlUtil.buildQuery(query, threadId, 0, 0, MessageTypes.IGNORABLE_TYPESMASK_WHEN_COUNTING, MessageTypes.PROFILE_CHANGE_TYPE, MessageTypes.CHANGE_NUMBER_TYPE, MessageTypes.SMS_EXPORT_TYPE, MessageTypes.BOOST_REQUEST_TYPE);
|
||||
String query = THREAD_ID + " = ? AND " + STORY_TYPE + " = ? AND " + PARENT_STORY_ID + " <= ? AND " + SCHEDULED_DATE + " = ? AND (NOT " + TYPE + " & ? AND " + TYPE + " != ? AND " + TYPE + " != ? AND " + TYPE + " != ? AND " + TYPE + " != ? AND " + TYPE + " & " + MessageTypes.GROUP_V2_LEAVE_BITS + " != " + MessageTypes.GROUP_V2_LEAVE_BITS + ")";
|
||||
return SqlUtil.buildQuery(query, threadId, 0, 0, -1, MessageTypes.IGNORABLE_TYPESMASK_WHEN_COUNTING, MessageTypes.PROFILE_CHANGE_TYPE, MessageTypes.CHANGE_NUMBER_TYPE, MessageTypes.SMS_EXPORT_TYPE, MessageTypes.BOOST_REQUEST_TYPE);
|
||||
}
|
||||
|
||||
public void addFailures(long messageId, List<NetworkFailure> failure) {
|
||||
@@ -1929,6 +1948,33 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie
|
||||
ApplicationDependencies.getDatabaseObserver().notifyMessageUpdateObservers(new MessageId(messageId));
|
||||
}
|
||||
|
||||
public boolean clearScheduledStatus(long threadId, long messageId) {
|
||||
SQLiteDatabase database = databaseHelper.getSignalWritableDatabase();
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(SCHEDULED_DATE, -1);
|
||||
contentValues.put(DATE_SENT, System.currentTimeMillis());
|
||||
contentValues.put(DATE_RECEIVED, System.currentTimeMillis());
|
||||
|
||||
int rowsUpdated = database.update(TABLE_NAME, contentValues, ID_WHERE + " AND " + SCHEDULED_DATE + "!= ?", SqlUtil.buildArgs(messageId, -1));
|
||||
ApplicationDependencies.getDatabaseObserver().notifyMessageInsertObservers(threadId, new MessageId(messageId));
|
||||
ApplicationDependencies.getDatabaseObserver().notifyScheduledMessageObservers(threadId);
|
||||
|
||||
return rowsUpdated > 0;
|
||||
}
|
||||
|
||||
public void rescheduleMessage(long threadId, long messageId, long time) {
|
||||
SQLiteDatabase database = databaseHelper.getSignalWritableDatabase();
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(SCHEDULED_DATE, time);
|
||||
|
||||
int rowsUpdated = database.update(TABLE_NAME, contentValues, ID_WHERE + " AND " + SCHEDULED_DATE + "!= ?", SqlUtil.buildArgs(messageId, -1));
|
||||
ApplicationDependencies.getDatabaseObserver().notifyScheduledMessageObservers(threadId);
|
||||
ApplicationDependencies.getScheduledMessageManager().scheduleIfNecessary();
|
||||
if (rowsUpdated == 0) {
|
||||
Log.w(TAG, "Failed to reschedule messageId=" + messageId + " to new time " + time + ". may have been sent already");
|
||||
}
|
||||
}
|
||||
|
||||
public void markAsInsecure(long messageId) {
|
||||
updateMailboxBitmask(messageId, MessageTypes.SECURE_MESSAGE_BIT, 0, Optional.empty());
|
||||
}
|
||||
@@ -2191,6 +2237,7 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie
|
||||
StoryType storyType = StoryType.fromCode(CursorUtil.requireInt(cursor, STORY_TYPE));
|
||||
ParentStoryId parentStoryId = ParentStoryId.deserialize(CursorUtil.requireLong(cursor, PARENT_STORY_ID));
|
||||
byte[] messageRangesData = CursorUtil.requireBlob(cursor, MESSAGE_RANGES);
|
||||
long scheduledDate = cursor.getLong(cursor.getColumnIndexOrThrow(SCHEDULED_DATE));
|
||||
|
||||
long quoteId = cursor.getLong(cursor.getColumnIndexOrThrow(QUOTE_ID));
|
||||
long quoteAuthor = cursor.getLong(cursor.getColumnIndexOrThrow(QUOTE_AUTHOR));
|
||||
@@ -2280,7 +2327,8 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie
|
||||
mismatches,
|
||||
giftBadge,
|
||||
MessageTypes.isSecureType(outboxType),
|
||||
messageRanges);
|
||||
messageRanges,
|
||||
scheduledDate);
|
||||
|
||||
return message;
|
||||
}
|
||||
@@ -2794,6 +2842,7 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie
|
||||
contentValues.put(RECEIPT_TIMESTAMP, Stream.of(earlyDeliveryReceipts.values()).mapToLong(EarlyReceiptCache.Receipt::getTimestamp).max().orElse(-1));
|
||||
contentValues.put(STORY_TYPE, message.getStoryType().getCode());
|
||||
contentValues.put(PARENT_STORY_ID, message.getParentStoryId() != null ? message.getParentStoryId().serialize() : 0);
|
||||
contentValues.put(SCHEDULED_DATE, message.getScheduledDate());
|
||||
|
||||
if (message.getRecipient().isSelf() && hasAudioAttachment(message.getAttachments())) {
|
||||
contentValues.put(VIEWED_RECEIPT_COUNT, 1L);
|
||||
@@ -2874,6 +2923,9 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie
|
||||
} else {
|
||||
ApplicationDependencies.getDatabaseObserver().notifyConversationListeners(threadId);
|
||||
}
|
||||
if (message.getScheduledDate() != -1) {
|
||||
ApplicationDependencies.getDatabaseObserver().notifyScheduledMessageObservers(threadId);
|
||||
}
|
||||
} else {
|
||||
ApplicationDependencies.getDatabaseObserver().notifyStoryObservers(message.getRecipient().getId());
|
||||
}
|
||||
@@ -2985,9 +3037,13 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie
|
||||
}
|
||||
|
||||
public boolean deleteMessage(long messageId) {
|
||||
long threadId = getThreadIdForMessage(messageId);
|
||||
return deleteMessage(messageId, threadId);
|
||||
}
|
||||
|
||||
public boolean deleteMessage(long messageId, long threadId) {
|
||||
Log.d(TAG, "deleteMessage(" + messageId + ")");
|
||||
|
||||
long threadId = getThreadIdForMessage(messageId);
|
||||
AttachmentTable attachmentDatabase = SignalDatabase.attachments();
|
||||
attachmentDatabase.deleteAttachmentsForMessage(messageId);
|
||||
|
||||
@@ -3008,6 +3064,31 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie
|
||||
return threadDeleted;
|
||||
}
|
||||
|
||||
public void deleteScheduledMessage(long messageId) {
|
||||
Log.d(TAG, "deleteScheduledMessage(" + messageId + ")");
|
||||
SQLiteDatabase db = databaseHelper.getSignalWritableDatabase();
|
||||
long threadId = getThreadIdForMessage(messageId);
|
||||
db.beginTransaction();
|
||||
try {
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(SCHEDULED_DATE, -1);
|
||||
contentValues.put(DATE_SENT, System.currentTimeMillis());
|
||||
contentValues.put(DATE_RECEIVED, System.currentTimeMillis());
|
||||
|
||||
int rowsUpdated = db.update(TABLE_NAME, contentValues, ID_WHERE + " AND " + SCHEDULED_DATE + "!= ?", SqlUtil.buildArgs(messageId, -1));
|
||||
if (rowsUpdated > 0) {
|
||||
deleteMessage(messageId, threadId);
|
||||
db.setTransactionSuccessful();
|
||||
} else {
|
||||
Log.w(TAG, "tried to delete scheduled message but it may have already been sent");
|
||||
}
|
||||
} finally {
|
||||
db.endTransaction();
|
||||
}
|
||||
ApplicationDependencies.getScheduledMessageManager().scheduleIfNecessary();
|
||||
ApplicationDependencies.getDatabaseObserver().notifyScheduledMessageObservers(threadId);
|
||||
}
|
||||
|
||||
public void deleteThread(long threadId) {
|
||||
Log.d(TAG, "deleteThread(" + threadId + ")");
|
||||
Set<Long> singleThreadSet = new HashSet<>();
|
||||
@@ -4465,13 +4546,55 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie
|
||||
* This does *not* have attachments in it.
|
||||
*/
|
||||
public Cursor getConversation(long threadId, long offset, long limit) {
|
||||
String selection = THREAD_ID + " = ? AND " + STORY_TYPE + " = ? AND " + PARENT_STORY_ID + " <= ?";
|
||||
String[] args = SqlUtil.buildArgs(threadId, 0, 0);
|
||||
String selection = THREAD_ID + " = ? AND " + STORY_TYPE + " = ? AND " + PARENT_STORY_ID + " <= ? AND " + SCHEDULED_DATE + " = ?";
|
||||
String[] args = SqlUtil.buildArgs(threadId, 0, 0, -1);
|
||||
String order = DATE_RECEIVED + " DESC";
|
||||
String limitStr = limit > 0 || offset > 0 ? offset + ", " + limit : null;
|
||||
|
||||
return getReadableDatabase().query(TABLE_NAME, MMS_PROJECTION, selection, args, null, null, order, limitStr);
|
||||
}
|
||||
|
||||
public List<MessageRecord> getScheduledMessagesInThread(long threadId) {
|
||||
String selection = THREAD_ID + " = ? AND " + STORY_TYPE + " = ? AND " + PARENT_STORY_ID + " <= ? AND " + SCHEDULED_DATE + " != ?";
|
||||
String[] args = SqlUtil.buildArgs(threadId, 0, 0, -1);
|
||||
String order = SCHEDULED_DATE + " DESC";
|
||||
|
||||
try (MmsReader reader = mmsReaderFor(getReadableDatabase().query(TABLE_NAME, MMS_PROJECTION, selection, args, null, null, order))) {
|
||||
List<MessageRecord> results = new ArrayList<>(reader.getCount());
|
||||
while (reader.getNext() != null) {
|
||||
results.add(reader.getCurrent());
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
public List<MessageRecord> getScheduledMessagesBefore(long time) {
|
||||
String selection = STORY_TYPE + " = ? AND " + PARENT_STORY_ID + " <= ? AND " + SCHEDULED_DATE + " != ? AND " + SCHEDULED_DATE + " <= ?";
|
||||
String[] args = SqlUtil.buildArgs(0, 0, -1, time);
|
||||
String order = SCHEDULED_DATE + " DESC";
|
||||
|
||||
try (MmsReader reader = mmsReaderFor(getReadableDatabase().query(TABLE_NAME, MMS_PROJECTION, selection, args, null, null, order))) {
|
||||
List<MessageRecord> results = new ArrayList<>(reader.getCount());
|
||||
while (reader.getNext() != null) {
|
||||
results.add(reader.getCurrent());
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
public @Nullable Long getOldestScheduledSendTimestamp() {
|
||||
String[] columns = new String[] { SCHEDULED_DATE };
|
||||
String selection = STORY_TYPE + " = ? AND " + PARENT_STORY_ID + " <= ? AND " + SCHEDULED_DATE + " != ?";
|
||||
String[] args = SqlUtil.buildArgs(0, 0, -1);
|
||||
String order = SCHEDULED_DATE + " ASC";
|
||||
String limit = "1";
|
||||
|
||||
try (Cursor cursor = getReadableDatabase().query(TABLE_NAME, columns, selection, args, null, null, order, limit)) {
|
||||
return cursor != null && cursor.moveToNext() ? cursor.getLong(0) : null;
|
||||
}
|
||||
}
|
||||
|
||||
public Cursor getMessagesForNotificationState(Collection<DefaultMessageNotifier.StickyThread> stickyThreads) {
|
||||
StringBuilder stickyQuery = new StringBuilder();
|
||||
@@ -5064,7 +5187,8 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie
|
||||
message.getParentStoryId(),
|
||||
message.getGiftBadge(),
|
||||
null,
|
||||
null);
|
||||
null,
|
||||
-1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5208,6 +5332,7 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie
|
||||
byte[] messageRangesData = CursorUtil.requireBlob(cursor, MESSAGE_RANGES);
|
||||
StoryType storyType = StoryType.fromCode(CursorUtil.requireInt(cursor, STORY_TYPE));
|
||||
ParentStoryId parentStoryId = ParentStoryId.deserialize(CursorUtil.requireLong(cursor, PARENT_STORY_ID));
|
||||
long scheduledDate = CursorUtil.requireLong(cursor, SCHEDULED_DATE);
|
||||
|
||||
if (!TextSecurePreferences.isReadReceiptsEnabled(context)) {
|
||||
readReceiptCount = 0;
|
||||
@@ -5252,7 +5377,7 @@ public class MessageTable extends DatabaseTable implements MessageTypes, Recipie
|
||||
networkFailures, subscriptionId, expiresIn, expireStarted,
|
||||
isViewOnce, readReceiptCount, quote, contacts, previews, unidentified, Collections.emptyList(),
|
||||
remoteDelete, mentionsSelf, notifiedTimestamp, viewedReceiptCount, receiptTimestamp, messageRanges,
|
||||
storyType, parentStoryId, giftBadge, null, null);
|
||||
storyType, parentStoryId, giftBadge, null, null, scheduledDate);
|
||||
}
|
||||
|
||||
private Set<IdentityKeyMismatch> getMismatchedIdentities(String document) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,7 @@ public class MediaMmsMessageRecord extends MmsMessageRecord {
|
||||
private final BodyRangeList messageRanges;
|
||||
private final Payment payment;
|
||||
private final CallTable.Call call;
|
||||
private final long scheduledDate;
|
||||
|
||||
public MediaMmsMessageRecord(long id,
|
||||
Recipient conversationRecipient,
|
||||
@@ -104,7 +105,8 @@ public class MediaMmsMessageRecord extends MmsMessageRecord {
|
||||
@Nullable ParentStoryId parentStoryId,
|
||||
@Nullable GiftBadge giftBadge,
|
||||
@Nullable Payment payment,
|
||||
@Nullable CallTable.Call call)
|
||||
@Nullable CallTable.Call call,
|
||||
long scheduledDate)
|
||||
{
|
||||
super(id, body, conversationRecipient, individualRecipient, recipientDeviceId, dateSent,
|
||||
dateReceived, dateServer, threadId, Status.STATUS_NONE, deliveryReceiptCount, mailbox, mismatches, failures,
|
||||
@@ -115,6 +117,7 @@ public class MediaMmsMessageRecord extends MmsMessageRecord {
|
||||
this.messageRanges = messageRanges;
|
||||
this.payment = payment;
|
||||
this.call = call;
|
||||
this.scheduledDate = scheduledDate;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -197,18 +200,22 @@ public class MediaMmsMessageRecord extends MmsMessageRecord {
|
||||
return call;
|
||||
}
|
||||
|
||||
public long getScheduledDate() {
|
||||
return scheduledDate;
|
||||
}
|
||||
|
||||
public @NonNull MediaMmsMessageRecord withReactions(@NonNull List<ReactionRecord> reactions) {
|
||||
return new MediaMmsMessageRecord(getId(), getRecipient(), getIndividualRecipient(), getRecipientDeviceId(), getDateSent(), getDateReceived(), getServerTimestamp(), getDeliveryReceiptCount(), getThreadId(), getBody(), getSlideDeck(),
|
||||
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), isViewOnce(),
|
||||
getReadReceiptCount(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), reactions, isRemoteDelete(), mentionsSelf,
|
||||
getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall());
|
||||
getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getScheduledDate());
|
||||
}
|
||||
|
||||
public @NonNull MediaMmsMessageRecord withoutQuote() {
|
||||
return new MediaMmsMessageRecord(getId(), getRecipient(), getIndividualRecipient(), getRecipientDeviceId(), getDateSent(), getDateReceived(), getServerTimestamp(), getDeliveryReceiptCount(), getThreadId(), getBody(), getSlideDeck(),
|
||||
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), isViewOnce(),
|
||||
getReadReceiptCount(), null, getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf,
|
||||
getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall());
|
||||
getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getScheduledDate());
|
||||
}
|
||||
|
||||
public @NonNull MediaMmsMessageRecord withAttachments(@NonNull Context context, @NonNull List<DatabaseAttachment> attachments) {
|
||||
@@ -229,14 +236,14 @@ public class MediaMmsMessageRecord extends MmsMessageRecord {
|
||||
return new MediaMmsMessageRecord(getId(), getRecipient(), getIndividualRecipient(), getRecipientDeviceId(), getDateSent(), getDateReceived(), getServerTimestamp(), getDeliveryReceiptCount(), getThreadId(), getBody(), slideDeck,
|
||||
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), isViewOnce(),
|
||||
getReadReceiptCount(), quote, contacts, linkPreviews, isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf,
|
||||
getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall());
|
||||
getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), getCall(), getScheduledDate());
|
||||
}
|
||||
|
||||
public @NonNull MediaMmsMessageRecord withPayment(@NonNull Payment payment) {
|
||||
return new MediaMmsMessageRecord(getId(), getRecipient(), getIndividualRecipient(), getRecipientDeviceId(), getDateSent(), getDateReceived(), getServerTimestamp(), getDeliveryReceiptCount(), getThreadId(), getBody(), getSlideDeck(),
|
||||
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), isViewOnce(),
|
||||
getReadReceiptCount(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf,
|
||||
getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), payment, getCall());
|
||||
getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), payment, getCall(), getScheduledDate());
|
||||
}
|
||||
|
||||
|
||||
@@ -244,7 +251,7 @@ public class MediaMmsMessageRecord extends MmsMessageRecord {
|
||||
return new MediaMmsMessageRecord(getId(), getRecipient(), getIndividualRecipient(), getRecipientDeviceId(), getDateSent(), getDateReceived(), getServerTimestamp(), getDeliveryReceiptCount(), getThreadId(), getBody(), getSlideDeck(),
|
||||
getType(), getIdentityKeyMismatches(), getNetworkFailures(), getSubscriptionId(), getExpiresIn(), getExpireStarted(), isViewOnce(),
|
||||
getReadReceiptCount(), getQuote(), getSharedContacts(), getLinkPreviews(), isUnidentified(), getReactions(), isRemoteDelete(), mentionsSelf,
|
||||
getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), call);
|
||||
getNotifiedTimestamp(), getViewedReceiptCount(), getReceiptTimestamp(), getMessageRanges(), getStoryType(), getParentStoryId(), getGiftBadge(), getPayment(), call, getScheduledDate());
|
||||
}
|
||||
|
||||
private static @NonNull List<Contact> updateContacts(@NonNull List<Contact> contacts, @NonNull Map<AttachmentId, DatabaseAttachment> attachmentIdMap) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -100,8 +100,12 @@ public class IndividualSendJob extends PushSendJob {
|
||||
@WorkerThread
|
||||
public static void enqueue(@NonNull Context context, @NonNull JobManager jobManager, long messageId, @NonNull Recipient recipient) {
|
||||
try {
|
||||
OutgoingMessage message = SignalDatabase.messages().getOutgoingMessage(messageId);
|
||||
Set<String> attachmentUploadIds = enqueueCompressingAndUploadAttachmentsChains(jobManager, message);
|
||||
OutgoingMessage message = SignalDatabase.messages().getOutgoingMessage(messageId);
|
||||
if (message.getScheduledDate() != -1) {
|
||||
ApplicationDependencies.getScheduledMessageManager().scheduleIfNecessary();
|
||||
return;
|
||||
}
|
||||
Set<String> attachmentUploadIds = enqueueCompressingAndUploadAttachmentsChains(jobManager, message);
|
||||
|
||||
jobManager.add(IndividualSendJob.create(messageId, recipient, attachmentUploadIds.size() > 0), attachmentUploadIds, recipient.getId().toQueueKey());
|
||||
} catch (NoSuchMessageException | MmsException e) {
|
||||
|
||||
@@ -116,7 +116,16 @@ public final class PushGroupSendJob extends PushSendJob {
|
||||
|
||||
MessageTable database = SignalDatabase.messages();
|
||||
OutgoingMessage message = database.getOutgoingMessage(messageId);
|
||||
Set<String> attachmentUploadIds = enqueueCompressingAndUploadAttachmentsChains(jobManager, message);
|
||||
|
||||
if (message.getScheduledDate() != -1) {
|
||||
if (!filterAddresses.isEmpty()) {
|
||||
throw new MmsException("Cannot schedule a group message with filter addresses!");
|
||||
}
|
||||
ApplicationDependencies.getScheduledMessageManager().scheduleIfNecessary();
|
||||
return;
|
||||
}
|
||||
|
||||
Set<String> attachmentUploadIds = enqueueCompressingAndUploadAttachmentsChains(jobManager, message);
|
||||
|
||||
if (message.getGiftBadge() != null) {
|
||||
throw new MmsException("Cannot send a gift badge to a group!");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,8 @@ class MediaSendActivityResult(
|
||||
val isViewOnce: Boolean,
|
||||
val mentions: List<Mention>,
|
||||
@TypeParceler<BodyRangeList?, BodyRangeListParceler>() val bodyRanges: BodyRangeList?,
|
||||
val storyType: StoryType
|
||||
val storyType: StoryType,
|
||||
val scheduledTime: Long = -1
|
||||
) : Parcelable {
|
||||
|
||||
val isPushPreUpload: Boolean
|
||||
|
||||
@@ -78,7 +78,8 @@ class MediaSelectionRepository(context: Context) {
|
||||
contacts: List<ContactSearchKey.RecipientSearchKey>,
|
||||
mentions: List<Mention>,
|
||||
bodyRanges: BodyRangeList?,
|
||||
sendType: MessageSendType
|
||||
sendType: MessageSendType,
|
||||
scheduledTime: Long = -1
|
||||
): Maybe<MediaSendActivityResult> {
|
||||
if (isSms && contacts.isNotEmpty()) {
|
||||
throw IllegalStateException("Provided recipients to send to, but this is SMS!")
|
||||
@@ -112,8 +113,8 @@ class MediaSelectionRepository(context: Context) {
|
||||
StoryType.NONE
|
||||
}
|
||||
|
||||
if (isSms || MessageSender.isLocalSelfSend(context, singleRecipient, MessageSender.SendType.SIGNAL)) {
|
||||
Log.i(TAG, "SMS or local self-send. Skipping pre-upload.")
|
||||
if (isSms || MessageSender.isLocalSelfSend(context, singleRecipient, MessageSender.SendType.SIGNAL) || (scheduledTime != -1L && storyType == StoryType.NONE)) {
|
||||
Log.i(TAG, "SMS, local self-send, or scheduled send. Skipping pre-upload.")
|
||||
emitter.onSuccess(
|
||||
MediaSendActivityResult(
|
||||
recipientId = singleRecipient!!.id,
|
||||
@@ -123,7 +124,8 @@ class MediaSelectionRepository(context: Context) {
|
||||
isViewOnce = isViewOnce,
|
||||
mentions = trimmedMentions,
|
||||
bodyRanges = trimmedBodyRanges,
|
||||
storyType = StoryType.NONE
|
||||
storyType = StoryType.NONE,
|
||||
scheduledTime = scheduledTime
|
||||
)
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -333,7 +333,13 @@ class MediaSelectionViewModel(
|
||||
}
|
||||
|
||||
fun send(
|
||||
selectedContacts: List<ContactSearchKey.RecipientSearchKey> = emptyList()
|
||||
selectedContacts: List<ContactSearchKey.RecipientSearchKey> = emptyList(),
|
||||
scheduledDate: Long? = null
|
||||
): Maybe<MediaSendActivityResult> = send(selectedContacts, scheduledDate ?: -1)
|
||||
|
||||
fun send(
|
||||
selectedContacts: List<ContactSearchKey.RecipientSearchKey> = emptyList(),
|
||||
scheduledDate: Long
|
||||
): Maybe<MediaSendActivityResult> {
|
||||
return UntrustedRecords.checkForBadIdentityRecords(selectedContacts.toSet(), identityChangesSince).andThen(
|
||||
repository.send(
|
||||
@@ -347,7 +353,8 @@ class MediaSelectionViewModel(
|
||||
contacts = selectedContacts.ifEmpty { destination.getRecipientSearchKeyList() },
|
||||
mentions = MentionAnnotation.getMentionsFromAnnotations(store.state.message),
|
||||
bodyRanges = MessageStyler.getStyling(store.state.message),
|
||||
sendType = store.state.sendType
|
||||
sendType = store.state.sendType,
|
||||
scheduledTime = scheduledDate
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -50,6 +50,7 @@ data class OutgoingMessage(
|
||||
val isEndSession: Boolean = false,
|
||||
val isIdentityVerified: Boolean = false,
|
||||
val isIdentityDefault: Boolean = false,
|
||||
val scheduledDate: Long = -1,
|
||||
) {
|
||||
|
||||
val isV2Group: Boolean = messageGroupContext != null && GroupV2UpdateMessageUtil.isGroupV2(messageGroupContext)
|
||||
@@ -78,7 +79,8 @@ data class OutgoingMessage(
|
||||
mismatches: Set<IdentityKeyMismatch> = emptySet(),
|
||||
giftBadge: GiftBadge? = null,
|
||||
isSecure: Boolean = false,
|
||||
bodyRanges: BodyRangeList? = null
|
||||
bodyRanges: BodyRangeList? = null,
|
||||
scheduledDate: Long = -1
|
||||
) : this(
|
||||
recipient = recipient,
|
||||
body = body ?: "",
|
||||
@@ -99,7 +101,8 @@ data class OutgoingMessage(
|
||||
identityKeyMismatches = mismatches,
|
||||
giftBadge = giftBadge,
|
||||
isSecure = isSecure,
|
||||
bodyRanges = bodyRanges
|
||||
bodyRanges = bodyRanges,
|
||||
scheduledDate = scheduledDate
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -153,6 +156,10 @@ data class OutgoingMessage(
|
||||
return messageGroupContext!!.requireGroupV2Properties()
|
||||
}
|
||||
|
||||
fun sendAt(scheduledDate: Long): OutgoingMessage {
|
||||
return copy(scheduledDate = scheduledDate)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package org.thoughtcrime.securesms.service
|
||||
|
||||
import android.app.Application
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.annotation.WorkerThread
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.jobs.IndividualSendJob
|
||||
import org.thoughtcrime.securesms.jobs.PushGroupSendJob
|
||||
|
||||
/**
|
||||
* Manages waking up and sending scheduled messages at the correct time
|
||||
*/
|
||||
class ScheduledMessageManager(
|
||||
val application: Application
|
||||
) : TimedEventManager<ScheduledMessageManager.Event>(application, "ScheduledMessagesManager") {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(ScheduledMessageManager::class.java)
|
||||
}
|
||||
|
||||
private val messagesTable = SignalDatabase.messages
|
||||
|
||||
init {
|
||||
scheduleIfNecessary()
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun getNextClosestEvent(): Event? {
|
||||
val oldestTimestamp = messagesTable.oldestScheduledSendTimestamp ?: return null
|
||||
|
||||
val delay = (oldestTimestamp - System.currentTimeMillis()).coerceAtLeast(0)
|
||||
Log.i(TAG, "The next scheduled message needs to be sent in $delay ms.")
|
||||
|
||||
return Event(delay)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun executeEvent(event: Event) {
|
||||
val scheduledMessagesToSend = messagesTable.getScheduledMessagesBefore(System.currentTimeMillis())
|
||||
for (record in scheduledMessagesToSend) {
|
||||
if (SignalDatabase.messages.clearScheduledStatus(record.threadId, record.id)) {
|
||||
if (record.recipient.isPushGroup) {
|
||||
PushGroupSendJob.enqueue(application, ApplicationDependencies.getJobManager(), record.id, record.recipient.id, emptySet())
|
||||
} else {
|
||||
IndividualSendJob.enqueue(application, ApplicationDependencies.getJobManager(), record.id, record.recipient)
|
||||
}
|
||||
} else {
|
||||
Log.i(TAG, "messageId=${record.id} was not a scheduled message, ignoring")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun getDelayForEvent(event: Event): Long = event.delay
|
||||
|
||||
@WorkerThread
|
||||
override fun scheduleAlarm(application: Application, delay: Long) {
|
||||
trySetExactAlarm(application, System.currentTimeMillis() + delay, ScheduledMessagesAlarm::class.java)
|
||||
}
|
||||
|
||||
data class Event(val delay: Long)
|
||||
|
||||
class ScheduledMessagesAlarm : BroadcastReceiver() {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(ScheduledMessagesAlarm::class.java)
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
Log.d(TAG, "onReceive()")
|
||||
ApplicationDependencies.getScheduledMessageManager().scheduleIfNecessary()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import android.app.Application;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Build;
|
||||
import android.os.Handler;
|
||||
import android.os.HandlerThread;
|
||||
|
||||
@@ -14,6 +15,7 @@ import androidx.annotation.Nullable;
|
||||
import androidx.annotation.WorkerThread;
|
||||
|
||||
import org.signal.core.util.PendingIntentFlags;
|
||||
import org.signal.core.util.logging.Log;
|
||||
import org.thoughtcrime.securesms.util.ServiceUtil;
|
||||
|
||||
/**
|
||||
@@ -21,6 +23,8 @@ import org.thoughtcrime.securesms.util.ServiceUtil;
|
||||
*/
|
||||
public abstract class TimedEventManager<E> {
|
||||
|
||||
private static final String TAG = Log.tag(TimedEventManager.class);
|
||||
|
||||
private final Application application;
|
||||
private final Handler handler;
|
||||
|
||||
@@ -91,4 +95,29 @@ public abstract class TimedEventManager<E> {
|
||||
alarmManager.cancel(pendingIntent);
|
||||
alarmManager.set(AlarmManager.RTC_WAKEUP, System.currentTimeMillis() + delay, pendingIntent);
|
||||
}
|
||||
|
||||
protected static void trySetExactAlarm(@NonNull Context context, long timestamp, @NonNull Class alarmClass) {
|
||||
Intent intent = new Intent(context, alarmClass);
|
||||
PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, intent, PendingIntentFlags.mutable());
|
||||
AlarmManager alarmManager = ServiceUtil.getAlarmManager(context);
|
||||
|
||||
alarmManager.cancel(pendingIntent);
|
||||
|
||||
boolean hasManagerPermission = Build.VERSION.SDK_INT < 31 || alarmManager.canScheduleExactAlarms();
|
||||
if (hasManagerPermission) {
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= 23) {
|
||||
alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, timestamp, pendingIntent);
|
||||
} else {
|
||||
alarmManager.setExact(AlarmManager.RTC_WAKEUP, timestamp, pendingIntent);
|
||||
}
|
||||
return;
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, e);
|
||||
}
|
||||
}
|
||||
|
||||
Log.w(TAG, "Unable to schedule exact alarm, falling back to inexact alarm, scheduling alarm for: " + timestamp);
|
||||
alarmManager.set(AlarmManager.RTC_WAKEUP, timestamp, pendingIntent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import org.thoughtcrime.securesms.R;
|
||||
import java.text.DateFormatSymbols;
|
||||
import java.text.ParseException;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.Calendar;
|
||||
import java.util.Date;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
@@ -44,6 +45,7 @@ public class DateUtils extends android.text.format.DateUtils {
|
||||
private static final ThreadLocal<SimpleDateFormat> DATE_FORMAT = new ThreadLocal<>();
|
||||
private static final ThreadLocal<SimpleDateFormat> BRIEF_EXACT_FORMAT = new ThreadLocal<>();
|
||||
private static final long MAX_RELATIVE_TIMESTAMP = TimeUnit.MINUTES.toMillis(3);
|
||||
private static final int HALF_A_YEAR_IN_DAYS = 182;
|
||||
|
||||
private static boolean isWithin(final long millis, final long span, final TimeUnit unit) {
|
||||
return System.currentTimeMillis() - millis <= unit.toMillis(span);
|
||||
@@ -110,11 +112,22 @@ public class DateUtils extends android.text.format.DateUtils {
|
||||
int mins = (int) TimeUnit.MINUTES.convert(System.currentTimeMillis() - timestamp, TimeUnit.MILLISECONDS);
|
||||
return context.getResources().getString(R.string.DateUtils_minutes_ago, mins);
|
||||
} else {
|
||||
String format = DateFormat.is24HourFormat(context) ? "HH:mm" : "hh:mm a";
|
||||
return getFormattedDateTime(timestamp, format, locale);
|
||||
return getOnlyTimeString(context, locale, timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a given timestamp as just the time.
|
||||
*
|
||||
* For example:
|
||||
* For 12 hour locale: 7:23 pm
|
||||
* For 24 hour locale: 19:23
|
||||
*/
|
||||
public static String getOnlyTimeString(final Context context, final Locale locale, final long timestamp) {
|
||||
String format = DateFormat.is24HourFormat(context) ? "HH:mm" : "hh:mm a";
|
||||
return getFormattedDateTime(timestamp, format, locale);
|
||||
}
|
||||
|
||||
public static String getTimeString(final Context c, final Locale locale, final long timestamp) {
|
||||
StringBuilder format = new StringBuilder();
|
||||
|
||||
@@ -129,6 +142,37 @@ public class DateUtils extends android.text.format.DateUtils {
|
||||
return getFormattedDateTime(timestamp, format.toString(), locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the passed timestamp based on the current time at a day precision.
|
||||
*
|
||||
* For example:
|
||||
* - Today
|
||||
* - Wed
|
||||
* - Mon
|
||||
* - Jan 31
|
||||
* - Feb 4
|
||||
* - Jan 12, 2033
|
||||
*/
|
||||
public static String getDayPrecisionTimeString(Context context, Locale locale, long timestamp) {
|
||||
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyyMMdd");
|
||||
|
||||
if (simpleDateFormat.format(System.currentTimeMillis()).equals(simpleDateFormat.format(timestamp))) {
|
||||
return context.getString(R.string.DeviceListItem_today);
|
||||
} else {
|
||||
String format;
|
||||
|
||||
if (isWithinAbs(timestamp, 6, TimeUnit.DAYS)) {
|
||||
format = "EEE ";
|
||||
} else if (isWithinAbs(timestamp, 365, TimeUnit.DAYS)) {
|
||||
format = "MMM d";
|
||||
} else {
|
||||
format = "MMM d, yyy";
|
||||
}
|
||||
|
||||
return getFormattedDateTime(timestamp, format, locale);
|
||||
}
|
||||
}
|
||||
|
||||
public static String getDayPrecisionTimeSpanString(Context context, Locale locale, long timestamp) {
|
||||
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyyMMdd");
|
||||
|
||||
@@ -165,13 +209,44 @@ public class DateUtils extends android.text.format.DateUtils {
|
||||
return context.getString(R.string.DateUtils_today);
|
||||
} else if (isYesterday(timestamp)) {
|
||||
return context.getString(R.string.DateUtils_yesterday);
|
||||
} else if (isWithin(timestamp, 182, TimeUnit.DAYS)) {
|
||||
} else if (isWithin(timestamp, HALF_A_YEAR_IN_DAYS, TimeUnit.DAYS)) {
|
||||
return formatDateWithDayOfWeek(locale, timestamp);
|
||||
} else {
|
||||
return formatDateWithYear(locale, timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
public static String getScheduledMessagesDateHeaderString(@NonNull Context context,
|
||||
@NonNull Locale locale,
|
||||
long timestamp)
|
||||
{
|
||||
if (isToday(timestamp)) {
|
||||
return context.getString(R.string.DateUtils_today);
|
||||
} else if (isWithinAbs(timestamp, HALF_A_YEAR_IN_DAYS, TimeUnit.DAYS)) {
|
||||
return formatDateWithDayOfWeek(locale, timestamp);
|
||||
} else {
|
||||
return formatDateWithYear(locale, timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
public static String getScheduledMessageDateString(@NonNull Context context, @NonNull Locale locale, long timestamp) {
|
||||
String dayModifier;
|
||||
if (isToday(timestamp)) {
|
||||
Calendar calendar = Calendar.getInstance(locale);
|
||||
if (calendar.get(Calendar.HOUR_OF_DAY) >= 19) {
|
||||
dayModifier = context.getString(R.string.DateUtils_tonight);
|
||||
} else {
|
||||
dayModifier = context.getString(R.string.DateUtils_today);
|
||||
}
|
||||
} else {
|
||||
dayModifier = context.getString(R.string.DateUtils_tomorrow);
|
||||
}
|
||||
String format = DateFormat.is24HourFormat(context) ? "HH:mm" : "hh:mm a";
|
||||
String time = getFormattedDateTime(timestamp, format, locale);
|
||||
|
||||
return context.getString(R.string.DateUtils_schedule_at, dayModifier, time);
|
||||
}
|
||||
|
||||
public static String formatDateWithDayOfWeek(@NonNull Locale locale, long timestamp) {
|
||||
return getFormattedDateTime(timestamp, "EEE, MMM d", locale);
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@ public final class FeatureFlags {
|
||||
private static final String PAYPAL_ONE_TIME_DONATIONS = "android.oneTimePayPalDonations.2";
|
||||
private static final String PAYPAL_RECURRING_DONATIONS = "android.recurringPayPalDonations.2";
|
||||
private static final String TEXT_FORMATTING = "android.textFormatting";
|
||||
private static final String SCHEDULED_MESSAGE_SENDS = "android.scheduledMessageSends";
|
||||
|
||||
/**
|
||||
* We will only store remote values for flags in this set. If you want a flag to be controllable
|
||||
@@ -160,7 +161,8 @@ public final class FeatureFlags {
|
||||
CHAT_FILTERS,
|
||||
PAYPAL_ONE_TIME_DONATIONS,
|
||||
PAYPAL_RECURRING_DONATIONS,
|
||||
TEXT_FORMATTING
|
||||
TEXT_FORMATTING,
|
||||
SCHEDULED_MESSAGE_SENDS
|
||||
);
|
||||
|
||||
@VisibleForTesting
|
||||
@@ -574,6 +576,13 @@ public final class FeatureFlags {
|
||||
return getBoolean(TEXT_FORMATTING, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether or not we allow the user to schedule message sends. This takes over the entry point for SMS message sends
|
||||
*/
|
||||
public static boolean scheduledMessageSends() {
|
||||
return getBoolean(SCHEDULED_MESSAGE_SENDS, false);
|
||||
}
|
||||
|
||||
/** Only for rendering debug info. */
|
||||
public static synchronized @NonNull Map<String, Object> getMemoryValues() {
|
||||
return new TreeMap<>(REMOTE_VALUES);
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user