kopia lustrzana https://github.com/ryukoposting/Signal-Android
Add support for scheduled message sends.
rodzic
df695f7611
commit
f3e715e069
|
@ -815,6 +815,8 @@
|
|||
|
||||
<receiver android:name=".revealable.ViewOnceMessageManager$ViewOnceAlarm" />
|
||||
|
||||
<receiver android:name=".service.ScheduledMessageManager$ScheduledMessagesAlarm" />
|
||||
|
||||
<receiver android:name=".service.PendingRetryReceiptManager$PendingRetryReceiptAlarm" />
|
||||
|
||||
<receiver android:name=".service.TrimThreadsByDateManager$TrimThreadsByDateAlarm" />
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M5.4,21.6C4.9,21.6 4.475,21.421 4.125,21.062C3.775,20.704 3.6,20.283 3.6,19.8V6.6C3.6,6.117 3.775,5.696 4.125,5.338C4.475,4.979 4.9,4.8 5.4,4.8H7.2V2.4H9V4.8H15V2.4H16.8V4.8H18.6C19.1,4.8 19.524,4.979 19.874,5.338C20.224,5.696 20.4,6.117 20.4,6.6V19.8C20.4,20.283 20.224,20.704 19.874,21.062C19.524,21.421 19.1,21.6 18.6,21.6H5.4ZM5.4,19.8H18.6V10.8H5.4V19.8ZM5.4,9H18.6V6.6H5.4V9ZM5.4,9V6.6V9ZM12,14.4C11.75,14.4 11.538,14.312 11.362,14.137C11.187,13.962 11.1,13.75 11.1,13.5C11.1,13.25 11.187,13.038 11.362,12.863C11.538,12.688 11.75,12.6 12,12.6C12.249,12.6 12.462,12.688 12.637,12.863C12.812,13.038 12.9,13.25 12.9,13.5C12.9,13.75 12.812,13.962 12.637,14.137C12.462,14.312 12.249,14.4 12,14.4ZM8.1,14.4C7.85,14.4 7.638,14.312 7.462,14.137C7.287,13.962 7.2,13.75 7.2,13.5C7.2,13.25 7.287,13.038 7.462,12.863C7.638,12.688 7.85,12.6 8.1,12.6C8.349,12.6 8.562,12.688 8.737,12.863C8.912,13.038 9,13.25 9,13.5C9,13.75 8.912,13.962 8.737,14.137C8.562,14.312 8.349,14.4 8.1,14.4ZM15.9,14.4C15.65,14.4 15.438,14.312 15.262,14.137C15.087,13.962 15,13.75 15,13.5C15,13.25 15.087,13.038 15.262,12.863C15.438,12.688 15.65,12.6 15.9,12.6C16.149,12.6 16.362,12.688 16.537,12.863C16.712,13.038 16.8,13.25 16.8,13.5C16.8,13.75 16.712,13.962 16.537,14.137C16.362,14.312 16.149,14.4 15.9,14.4ZM12,18C11.75,18 11.538,17.912 11.362,17.737C11.187,17.562 11.1,17.35 11.1,17.1C11.1,16.85 11.187,16.638 11.362,16.463C11.538,16.288 11.75,16.2 12,16.2C12.249,16.2 12.462,16.288 12.637,16.463C12.812,16.638 12.9,16.85 12.9,17.1C12.9,17.35 12.812,17.562 12.637,17.737C12.462,17.912 12.249,18 12,18ZM8.1,18C7.85,18 7.638,17.912 7.462,17.737C7.287,17.562 7.2,17.35 7.2,17.1C7.2,16.85 7.287,16.638 7.462,16.463C7.638,16.288 7.85,16.2 8.1,16.2C8.349,16.2 8.562,16.288 8.737,16.463C8.912,16.638 9,16.85 9,17.1C9,17.35 8.912,17.562 8.737,17.737C8.562,17.912 8.349,18 8.1,18ZM15.9,18C15.65,18 15.438,17.912 15.262,17.737C15.087,17.562 15,17.35 15,17.1C15,16.85 15.087,16.638 15.262,16.463C15.438,16.288 15.65,16.2 15.9,16.2C16.149,16.2 16.362,16.288 16.537,16.463C16.712,16.638 16.8,16.85 16.8,17.1C16.8,17.35 16.712,17.562 16.537,17.737C16.362,17.912 16.149,18 15.9,18Z"
|
||||
android:fillColor="#1B1B1D"/>
|
||||
</vector>
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="22"
|
||||
android:viewportHeight="22">
|
||||
<path
|
||||
android:pathData="M12.174,1.203C12.174,0.734 11.783,0.344 11.314,0.344C10.856,0.344 10.465,0.734 10.465,1.203V3.254C10.465,3.713 10.856,4.104 11.314,4.104C11.783,4.104 12.174,3.713 12.174,3.254V1.203ZM16.139,4.924C15.816,5.256 15.816,5.793 16.139,6.125C16.471,6.447 17.018,6.457 17.35,6.125L18.805,4.67C19.137,4.338 19.137,3.781 18.805,3.459C18.482,3.127 17.935,3.127 17.613,3.459L16.139,4.924ZM5.279,6.125C5.611,6.447 6.158,6.447 6.48,6.125C6.813,5.813 6.813,5.246 6.49,4.924L5.035,3.459C4.723,3.137 4.166,3.127 3.834,3.459C3.512,3.781 3.512,4.338 3.824,4.66L5.279,6.125ZM11.314,15.93C14.029,15.93 16.285,13.674 16.285,10.949C16.285,8.215 14.029,5.959 11.314,5.959C8.59,5.959 6.334,8.215 6.334,10.949C6.334,13.674 8.59,15.93 11.314,15.93ZM11.314,14.436C9.4,14.436 7.818,12.854 7.818,10.949C7.818,9.035 9.4,7.453 11.314,7.453C13.219,7.453 14.801,9.035 14.801,10.949C14.801,12.854 13.219,14.436 11.314,14.436ZM21.031,11.799C21.5,11.799 21.891,11.408 21.891,10.949C21.891,10.49 21.5,10.1 21.031,10.1H18.99C18.531,10.1 18.141,10.49 18.141,10.949C18.141,11.408 18.531,11.799 18.99,11.799H21.031ZM1.598,10.1C1.139,10.1 0.748,10.49 0.748,10.949C0.748,11.408 1.139,11.799 1.598,11.799H3.639C4.107,11.799 4.498,11.408 4.498,10.949C4.498,10.49 4.107,10.1 3.639,10.1H1.598ZM17.34,15.783C17.018,15.451 16.471,15.451 16.139,15.783C15.816,16.105 15.816,16.652 16.139,16.984L17.613,18.449C17.935,18.771 18.482,18.762 18.805,18.44C19.137,18.107 19.137,17.56 18.805,17.238L17.34,15.783ZM3.824,17.229C3.502,17.551 3.502,18.098 3.814,18.43C4.137,18.752 4.693,18.762 5.025,18.44L6.48,16.984C6.813,16.662 6.813,16.115 6.49,15.783C6.168,15.461 5.611,15.461 5.279,15.783L3.824,17.229ZM12.174,18.645C12.174,18.176 11.783,17.785 11.314,17.785C10.856,17.785 10.465,18.176 10.465,18.645V20.695C10.465,21.154 10.856,21.545 11.314,21.545C11.783,21.545 12.174,21.154 12.174,20.695V18.645Z"
|
||||
android:fillColor="#000000"/>
|
||||
</vector>
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M12,15L7,10H17L12,15Z"
|
||||
android:fillColor="@color/signal_colorOnSurface"/>
|
||||
</vector>
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="26dp"
|
||||
android:height="16dp"
|
||||
android:viewportWidth="26"
|
||||
android:viewportHeight="16">
|
||||
<path
|
||||
android:pathData="M13.01,4.178C13.439,4.168 13.801,3.807 13.801,3.299V0.896C13.801,0.398 13.439,0.027 13.01,0.027C12.57,0.027 12.209,0.398 12.209,0.896V3.299C12.209,3.807 12.57,4.188 13.01,4.178ZM18.039,6.268C18.352,6.561 18.869,6.561 19.221,6.209L20.92,4.51C21.291,4.148 21.271,3.65 20.969,3.338C20.666,3.025 20.158,3.016 19.797,3.377L18.098,5.076C17.736,5.438 17.727,5.965 18.039,6.268ZM7.971,6.268C8.273,5.955 8.273,5.438 7.912,5.076L6.213,3.377C5.852,3.016 5.354,3.035 5.041,3.338C4.729,3.641 4.719,4.148 5.09,4.51L6.789,6.209C7.141,6.561 7.668,6.57 7.971,6.268ZM1.34,15.965H24.67C25.129,15.965 25.52,15.613 25.52,15.184C25.52,14.754 25.129,14.393 24.67,14.393H17.326C17.971,13.523 18.342,12.459 18.342,11.297C18.342,8.377 15.92,5.945 13.01,5.945C10.09,5.945 7.668,8.377 7.668,11.297C7.668,12.459 8.039,13.523 8.684,14.393H1.34C0.881,14.393 0.49,14.754 0.49,15.184C0.49,15.613 0.881,15.965 1.34,15.965ZM9.23,11.297C9.23,9.227 10.939,7.527 13.01,7.527C15.07,7.527 16.779,9.227 16.779,11.297C16.779,12.586 16.105,13.729 15.109,14.393H10.9C9.904,13.729 9.23,12.586 9.23,11.297ZM2.609,11.844H5.012C5.52,11.844 5.9,11.482 5.891,11.053C5.881,10.613 5.51,10.252 5.012,10.252H2.609C2.102,10.252 1.74,10.613 1.74,11.053C1.74,11.482 2.102,11.844 2.609,11.844ZM20.998,11.844H23.4C23.908,11.844 24.27,11.482 24.27,11.053C24.27,10.613 23.908,10.252 23.4,10.252H20.998C20.5,10.252 20.109,10.613 20.119,11.053C20.129,11.482 20.5,11.844 20.998,11.844Z"
|
||||
android:fillColor="#000000"/>
|
||||
</vector>
|
|
@ -0,0 +1,13 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M22.1,10.915L5.286,1.306C5.081,1.188 4.846,1.132 4.609,1.142C4.373,1.153 4.144,1.231 3.95,1.366C3.756,1.502 3.604,1.69 3.513,1.908C3.421,2.127 3.394,2.367 3.433,2.6L4.69,10.138L10,12L4.69,13.862L3.433,21.4C3.394,21.633 3.422,21.873 3.514,22.091C3.606,22.309 3.757,22.496 3.952,22.631C4.146,22.767 4.374,22.844 4.61,22.854C4.846,22.865 5.081,22.808 5.286,22.691L22.1,13.085C22.291,12.976 22.45,12.818 22.561,12.627C22.671,12.437 22.729,12.22 22.729,12C22.729,11.78 22.671,11.563 22.561,11.373C22.45,11.182 22.291,11.024 22.1,10.915Z"/>
|
||||
<path
|
||||
android:pathData="M22.1,10.915L22.845,9.613L22.844,9.613L22.1,10.915ZM5.286,1.306L4.541,2.608L4.542,2.608L5.286,1.306ZM3.433,2.6L4.913,2.353L4.912,2.349L3.433,2.6ZM4.69,10.138L3.211,10.385C3.301,10.925 3.677,11.373 4.194,11.554L4.69,10.138ZM10,12L10.496,13.415L14.533,12L10.496,10.585L10,12ZM4.69,13.862L4.194,12.447C3.677,12.628 3.301,13.076 3.211,13.615L4.69,13.862ZM3.433,21.4L4.913,21.647L4.913,21.647L3.433,21.4ZM5.286,22.691L6.03,23.993L6.03,23.993L5.286,22.691ZM22.1,13.085L22.844,14.387L22.845,14.387L22.1,13.085ZM22.844,9.613L6.03,0.004L4.542,2.608L21.356,12.217L22.844,9.613ZM6.031,0.004C5.579,-0.255 5.062,-0.38 4.542,-0.356L4.677,2.641C4.629,2.643 4.583,2.632 4.541,2.608L6.031,0.004ZM4.542,-0.356C4.021,-0.333 3.518,-0.162 3.091,0.136L4.809,2.596C4.77,2.623 4.724,2.639 4.677,2.641L4.542,-0.356ZM3.091,0.136C2.664,0.435 2.33,0.848 2.129,1.329L4.896,2.488C4.878,2.531 4.847,2.569 4.809,2.596L3.091,0.136ZM2.129,1.329C1.928,1.809 1.867,2.337 1.954,2.851L4.912,2.349C4.92,2.396 4.914,2.444 4.896,2.488L2.129,1.329ZM1.954,2.847L3.211,10.385L6.17,9.891L4.913,2.353L1.954,2.847ZM4.194,11.554L9.504,13.415L10.496,10.585L5.186,8.723L4.194,11.554ZM9.504,10.585L4.194,12.447L5.186,15.278L10.496,13.415L9.504,10.585ZM3.211,13.615L1.954,21.153L4.913,21.647L6.17,14.109L3.211,13.615ZM1.954,21.153C1.868,21.667 1.93,22.193 2.132,22.673L4.896,21.509C4.915,21.552 4.92,21.6 4.913,21.647L1.954,21.153ZM2.132,22.673C2.333,23.152 2.667,23.565 3.094,23.862L4.809,21.4C4.848,21.428 4.878,21.465 4.896,21.509L2.132,22.673ZM3.094,23.862C3.521,24.159 4.023,24.33 4.543,24.353L4.677,21.356C4.725,21.358 4.77,21.374 4.809,21.4L3.094,23.862ZM4.543,24.353C5.063,24.376 5.578,24.252 6.03,23.993L4.542,21.389C4.583,21.365 4.63,21.354 4.677,21.356L4.543,24.353ZM6.03,23.993L22.844,14.387L21.356,11.783L4.542,21.389L6.03,23.993ZM22.845,14.387C23.266,14.146 23.615,13.799 23.858,13.38L21.263,11.875C21.285,11.837 21.317,11.805 21.355,11.783L22.845,14.387ZM23.858,13.38C24.101,12.961 24.229,12.485 24.229,12H21.229C21.229,11.956 21.241,11.913 21.263,11.875L23.858,13.38ZM24.229,12C24.229,11.516 24.101,11.04 23.858,10.62L21.263,12.125C21.241,12.087 21.229,12.044 21.229,12H24.229ZM23.858,10.62C23.615,10.201 23.266,9.854 22.845,9.613L21.355,12.217C21.317,12.195 21.285,12.164 21.263,12.125L23.858,10.62Z"
|
||||
android:fillColor="#000000"/>
|
||||
</group>
|
||||
</vector>
|
|
@ -0,0 +1,37 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="134dp"
|
||||
android:height="112dp"
|
||||
android:viewportWidth="134"
|
||||
android:viewportHeight="112">
|
||||
<path
|
||||
android:pathData="M67,56m56,0a56,56 0,1 0,-112 0a56,56 0,1 0,112 0"
|
||||
android:fillColor="#FBFCFE"/>
|
||||
<path
|
||||
android:pathData="M67,56m56,0a56,56 0,1 0,-112 0a56,56 0,1 0,112 0"
|
||||
android:fillColor="#50679F"
|
||||
android:fillAlpha="0.11"/>
|
||||
<path
|
||||
android:pathData="M119,32C125.63,32 131,37.37 131,44V68C131,74.63 125.63,80 119,80H115V93.17C115,94.95 112.85,95.85 111.59,94.59L97,80H15C8.37,80 3,74.63 3,68V44C3,37.37 8.37,32 15,32H119Z"
|
||||
android:fillColor="#ffffff"
|
||||
android:fillType="evenOdd"/>
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M53,42h28v28h-28z"/>
|
||||
<group>
|
||||
<clip-path
|
||||
android:pathData="M53,42h28v28h-28z"/>
|
||||
<path
|
||||
android:pathData="M73.3,56.7V54.6H68.05V49H65.95V56.7H73.3ZM67,67.2C65.46,67.2 64.01,66.91 62.65,66.32C61.29,65.74 60.1,64.94 59.08,63.92C58.06,62.9 57.26,61.71 56.67,60.35C56.09,58.98 55.8,57.54 55.8,56C55.8,54.44 56.09,52.99 56.67,51.63C57.26,50.26 58.06,49.08 59.08,48.07C60.1,47.06 61.29,46.26 62.65,45.67C64.01,45.09 65.46,44.8 67,44.8C68.56,44.8 70.01,45.09 71.37,45.67C72.74,46.26 73.92,47.06 74.93,48.07C75.94,49.08 76.74,50.26 77.32,51.63C77.91,52.99 78.2,54.44 78.2,56C78.2,57.54 77.91,58.98 77.32,60.35C76.74,61.71 75.94,62.9 74.93,63.92C73.92,64.94 72.74,65.74 71.37,66.32C70.01,66.91 68.56,67.2 67,67.2ZM67,65.1C69.53,65.1 71.68,64.21 73.45,62.43C75.21,60.65 76.1,58.51 76.1,56C76.1,53.47 75.21,51.32 73.45,49.55C71.68,47.78 69.53,46.9 67,46.9C64.49,46.9 62.35,47.78 60.57,49.55C58.79,51.32 57.9,53.47 57.9,56C57.9,58.51 58.79,60.65 60.57,62.43C62.35,64.21 64.49,65.1 67,65.1Z"
|
||||
android:fillColor="#2C58C3"/>
|
||||
<path
|
||||
android:strokeWidth="1"
|
||||
android:pathData="M78.85,62.7L78.85,62.7L70.02,57.65C70.02,57.65 70.02,57.65 70.02,57.65C69.83,57.54 69.62,57.49 69.4,57.5C69.18,57.51 68.97,57.58 68.79,57.71C68.61,57.83 68.47,58.01 68.38,58.21C68.3,58.41 68.27,58.63 68.31,58.85C68.31,58.85 68.31,58.85 68.31,58.85L68.97,62.8L69.03,63.15L69.36,63.21L71.8,63.7L69.36,64.19L69.03,64.25L68.97,64.6L68.31,68.55C68.27,68.77 68.3,68.99 68.38,69.19C68.47,69.39 68.61,69.57 68.79,69.69C68.97,69.82 69.18,69.89 69.4,69.9C69.62,69.91 69.83,69.86 70.02,69.75L70.02,69.75L78.85,64.7L78.85,64.7C79.03,64.6 79.17,64.46 79.28,64.28C79.38,64.1 79.43,63.9 79.43,63.7C79.43,63.5 79.38,63.3 79.28,63.12C79.17,62.94 79.03,62.8 78.85,62.7Z"
|
||||
android:fillColor="#2C58C3"
|
||||
android:strokeColor="#ffffff"/>
|
||||
</group>
|
||||
</group>
|
||||
<path
|
||||
android:pathData="M134,44C134,35.72 127.28,29 119,29H15C6.72,29 0,35.72 0,44V68C0,76.28 6.72,83 15,83H95.76L109.46,96.71C112.61,99.86 118,97.63 118,93.17V83H119C127.28,83 134,76.28 134,68V44ZM119,80C125.63,80 131,74.63 131,68V44C131,37.37 125.63,32 119,32H15C8.37,32 3,37.37 3,44V68C3,74.63 8.37,80 15,80H97L111.59,94.59C112.85,95.85 115,94.95 115,93.17V80H119Z"
|
||||
android:fillColor="#D6D9DF"
|
||||
android:fillType="evenOdd"/>
|
||||
</vector>
|
|
@ -62,6 +62,13 @@
|
|||
|
||||
</FrameLayout>
|
||||
|
||||
<ViewStub
|
||||
android:id="@+id/scheduled_messages_stub"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inflatedId="@+id/scheduled_messages"
|
||||
android:layout="@layout/conversation_activity_scheduled_messages_stub" />
|
||||
|
||||
<ViewStub
|
||||
android:id="@+id/attachment_editor_stub"
|
||||
android:layout_width="match_parent"
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:orientation="horizontal"
|
||||
android:background="@color/signal_colorSurface1">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/scheduled_messages_text"
|
||||
android:layout_width="0dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="17dp"
|
||||
tools:text="1 message scheduled"/>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/scheduled_messages_show_all"
|
||||
style="@style/Widget.Signal.Button.TextButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/conversation_scheduled_messages_bar__see_all" />
|
||||
|
||||
</LinearLayout>
|
|
@ -236,6 +236,17 @@
|
|||
android:tint="@color/signal_colorOnSurfaceVariant"
|
||||
app:srcCompat="@drawable/ic_replies_outline_20" />
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageView
|
||||
android:id="@+id/scheduled_indicator"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:background="@drawable/circle_tintable"
|
||||
android:backgroundTint="@color/signal_colorSurfaceVariant"
|
||||
android:padding="6dp"
|
||||
android:tint="@color/signal_colorOnSurfaceVariant"
|
||||
app:srcCompat="@drawable/ic_calendar_24" />
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
<org.thoughtcrime.securesms.reactions.ReactionsConversationView
|
||||
|
|
|
@ -109,6 +109,27 @@
|
|||
android:padding="8dp"
|
||||
android:orientation="vertical" />
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignStart="@id/body_bubble"
|
||||
android:layout_alignTop="@id/body_bubble"
|
||||
android:layout_alignBottom="@id/body_bubble"
|
||||
android:layout_marginStart="-42dp" >
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageView
|
||||
android:id="@+id/scheduled_indicator"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:background="@drawable/circle_tintable"
|
||||
android:backgroundTint="@color/signal_colorSurfaceVariant"
|
||||
android:padding="6dp"
|
||||
android:tint="@color/signal_colorOnSurfaceVariant"
|
||||
app:srcCompat="@drawable/ic_calendar_24" />
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
<org.thoughtcrime.securesms.reactions.ReactionsConversationView
|
||||
android:id="@+id/reactions_view"
|
||||
android:layout_width="wrap_content"
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.appcompat.widget.LinearLayoutCompat
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<View
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="2dp"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginTop="16dp"
|
||||
android:background="@color/signal_icon_tint_tab_unselected"/>
|
||||
|
||||
<ImageView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:src="@drawable/schedule_message_large"
|
||||
android:layout_marginTop="30dp"
|
||||
android:layout_gravity="center_horizontal"/>
|
||||
|
||||
<TextView
|
||||
style="@style/Signal.Text.TitleLarge"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:gravity="center"
|
||||
android:layout_marginTop="24dp"
|
||||
android:text="@string/ScheduleMessageFTUXBottomSheet__title"
|
||||
/>
|
||||
|
||||
<TextView
|
||||
style="@style/Signal.Text.BodyMedium"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:gravity="center"
|
||||
android:layout_marginTop="12dp"
|
||||
android:layout_marginHorizontal="24dp"
|
||||
android:text="@string/ScheduleMessageFTUXBottomSheet__disclaimer"
|
||||
/>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/okay"
|
||||
style="@style/Signal.Widget.Button.Medium.Tonal"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginTop="32dp"
|
||||
android:layout_marginBottom="32dp"
|
||||
android:text="@string/ScheduleMessageFTUXBottomSheet__okay"/>
|
||||
|
||||
</androidx.appcompat.widget.LinearLayoutCompat>
|
|
@ -0,0 +1,113 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<View
|
||||
android:id="@+id/anchor"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="2dp"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginTop="16dp"
|
||||
android:background="@color/signal_icon_tint_tab_unselected"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/title"
|
||||
style="@style/Signal.Text.TitleLarge"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintTop_toBottomOf="@id/anchor"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
android:gravity="center"
|
||||
android:layout_marginTop="30dp"
|
||||
android:text="@string/ScheduleMessageTimePickerBottomSheet__dialog_title"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/timezone_disclaimer"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
android:layout_marginStart="56dp"
|
||||
android:layout_marginEnd="56dp"
|
||||
android:gravity="start"
|
||||
android:textColor="@color/signal_colorOnSurfaceVariant_60"
|
||||
android:textAppearance="@style/Signal.Text.BodyMedium"
|
||||
tools:text="All times in (UTC -5:00) Eastern Time (US and Canada)"
|
||||
app:layout_constraintTop_toBottomOf="@id/title"/>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/day_selector"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingHorizontal="6dp"
|
||||
android:paddingVertical="8dp"
|
||||
android:layout_marginStart="18dp"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="@id/time_selector">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/date_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
tools:text="Today"
|
||||
android:drawablePadding="8dp"
|
||||
android:textAppearance="@style/Signal.Text.BodyLarge" />
|
||||
|
||||
<ImageView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:src="@drawable/ic_expand_down_24"
|
||||
android:background="?selectableItemBackgroundBorderless"/>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/time_selector"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingHorizontal="6dp"
|
||||
android:layout_marginEnd="18dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:paddingVertical="8dp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/timezone_disclaimer">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/time_text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
tools:text="1:00 p.m."
|
||||
android:drawablePadding="8dp"
|
||||
android:textAppearance="@style/Signal.Text.BodyLarge" />
|
||||
|
||||
<ImageView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:src="@drawable/ic_expand_down_24"
|
||||
android:background="?selectableItemBackgroundBorderless"/>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/schedule_send"
|
||||
style="@style/Signal.Widget.Button.Medium.Tonal"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="18dp"
|
||||
android:layout_marginTop="30dp"
|
||||
app:layout_constraintTop_toBottomOf="@id/day_selector"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
android:layout_marginBottom="24dp"
|
||||
android:text="@string/ScheduleMessageTimePickerBottomSheet__schedule_send"/>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
@ -0,0 +1,54 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
tools:viewBindingIgnore="true"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<View
|
||||
android:id="@+id/anchor"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="2dp"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginTop="16dp"
|
||||
android:background="@color/signal_icon_tint_tab_unselected"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/title"
|
||||
style="@style/Signal.Text.TitleMedium"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintTop_toBottomOf="@id/anchor"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
android:gravity="center"
|
||||
android:layout_marginTop="18dp"
|
||||
android:text="@string/ScheduledMessagesBottomSheet__schedules_messages"/>
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/video_container"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintBottom_toBottomOf="@id/scheduled_list"
|
||||
app:layout_constraintEnd_toEndOf="@id/scheduled_list"
|
||||
app:layout_constraintStart_toStartOf="@id/scheduled_list"
|
||||
app:layout_constraintTop_toTopOf="@id/scheduled_list" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/scheduled_list"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingBottom="24dp"
|
||||
android:clipToPadding="false"
|
||||
android:layout_marginTop="22dp"
|
||||
app:layout_constraintTop_toBottomOf="@id/title"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
@ -253,6 +253,31 @@
|
|||
<item name="android:textColor">@color/signal_colorOnSurfaceVariant</item>
|
||||
</style>
|
||||
|
||||
<style name="Signal.Widget.Calendar" parent="Widget.Material3.MaterialCalendar">
|
||||
<item name="shapeAppearance">@style/Signal.ShapeOverlay.Rounded</item>
|
||||
</style>
|
||||
|
||||
<style name="Signal.ThemeOverlay.Calendar" parent="@style/ThemeOverlay.Material3.MaterialCalendar">
|
||||
<item name="colorSurface">@color/signal_background_dialog</item>
|
||||
<item name="borderlessButtonStyle">@style/Signal.Widget.Button.Dialog</item>
|
||||
<item name="colorPrimary">@color/signal_colorPrimary</item>
|
||||
<item name="colorOnPrimary">@color/signal_colorOnPrimary</item>
|
||||
<item name="colorAccent">@color/signal_accent_primary</item>
|
||||
<item name="materialCalendarHeaderTitle">@style/MaterialCalendar.Signal.HeaderTitle</item>
|
||||
<item name="materialCalendarHeaderSelection">@style/MaterialCalendar.Signal.HeaderSelection</item>
|
||||
|
||||
<item name="buttonBarPositiveButtonStyle">@style/Signal.Widget.Button.Dialog</item>
|
||||
<item name="buttonBarNegativeButtonStyle">@style/Signal.Widget.Button.Dialog</item>
|
||||
</style>
|
||||
|
||||
<style name="MaterialCalendar.Signal.HeaderTitle" parent="Widget.Material3.MaterialCalendar.HeaderTitle">
|
||||
<item name="android:textAppearance">@style/Signal.Text.LabelMedium</item>
|
||||
</style>
|
||||
|
||||
<style name="MaterialCalendar.Signal.HeaderSelection" parent="Widget.Material3.MaterialCalendar.HeaderSelection">
|
||||
<item name="android:textAppearance">@style/Signal.Text.HeadlineLarge</item>
|
||||
</style>
|
||||
|
||||
<style name="Signal.Widget.TimePicker" parent="Widget.MaterialComponents.TimePicker">
|
||||
<item name="shapeAppearance">@style/Signal.ShapeOverlay.Rounded</item>
|
||||
</style>
|
||||
|
|
|
@ -630,6 +630,47 @@
|
|||
<string name="DateUtils_minutes_ago">%dm</string>
|
||||
<string name="DateUtils_today">Today</string>
|
||||
<string name="DateUtils_yesterday">Yesterday</string>
|
||||
<!-- When scheduling a message, %1$s replaced with either today, tonight, or tomorrow. %2$s replaced with the time. e.g. Tonight at 9:00pm -->
|
||||
<string name="DateUtils_schedule_at">%1$s at %2$s</string>
|
||||
<!-- Used when getting a time in the future. For example, Tomorrow at 9:00pm -->
|
||||
<string name="DateUtils_tomorrow">Tomorrow</string>
|
||||
<!-- Used in the context: Tonight at 9:00pm for example. Specifically this is after 7pm -->
|
||||
<string name="DateUtils_tonight">Tonight</string>
|
||||
|
||||
<!-- Scheduled Messages -->
|
||||
<!-- Title for dialog that shows all the users scheduled messages for a chat -->
|
||||
<string name="ScheduledMessagesBottomSheet__schedules_messages">Scheduled messages</string>
|
||||
<!-- Option when scheduling a message to select a specific date and time to send a message -->
|
||||
<string name="ScheduledMessages_pick_time">Pick Date & Time</string>
|
||||
<!-- Title for dialog explaining to users how the scheduled messages work -->
|
||||
<string name="ScheduleMessageFTUXBottomSheet__title">Scheduled messages</string>
|
||||
<!-- Disclaimer text for scheduled messages explaining to users that the scheduled messages will only send if connected to the internet -->
|
||||
<string name="ScheduleMessageFTUXBottomSheet__disclaimer">When you send a scheduled message, make sure your device will be on and connected to the internet at the time of sending. If not, your message will send when your device reconnects.</string>
|
||||
<!-- Confirmation button text acknowledging the user understands the disclaimer -->
|
||||
<string name="ScheduleMessageFTUXBottomSheet__okay">Okay</string>
|
||||
|
||||
<!-- Title of dialog with a calendar to select the date the user wants to schedule a message. -->
|
||||
<string name="ScheduleMessageTimePickerBottomSheet__select_date_title">Select date</string>
|
||||
<!-- Title of dialog with a clock to select the time at which the user wants to schedule a message. -->
|
||||
<string name="ScheduleMessageTimePickerBottomSheet__select_time_title">Select time</string>
|
||||
<!-- Title of dialog that allows user to set the time and day that their message will be sent -->
|
||||
<string name="ScheduleMessageTimePickerBottomSheet__dialog_title">Schedule message</string>
|
||||
<!-- Text for confirmation button when scheduling messages that allows the user to confirm and schedule the sending time -->
|
||||
<string name="ScheduleMessageTimePickerBottomSheet__schedule_send">Schedule send</string>
|
||||
|
||||
<!-- Disclaimer in message scheduling dialog. %1$s replaced with a GMT offset (e.g. GMT-05:00), and %2$s is replaced with the time zone name (e.g. Eastern Standard Time) -->
|
||||
<string name="ScheduleMessageTimePickerBottomSheet__timezone_disclaimer">All times in (%1$s) %2$s</string>
|
||||
|
||||
<!-- Context menu option to send a scheduled message now -->
|
||||
<string name="ScheduledMessagesBottomSheet_menu_send_now">Send now</string>
|
||||
<!-- Context menu option to reschedule a selected message -->
|
||||
<string name="ScheduledMessagesBottomSheet_menu_reschedule">Reschedule</string>
|
||||
<!-- Button in dialog asking user if they are sure they want to delete the selected scheduled message -->
|
||||
<string name="ScheduledMessagesBottomSheet_delete_dialog_action">Delete</string>
|
||||
<!-- Button in dialog asking user if they are sure they want to delete the selected scheduled message -->
|
||||
<string name="ScheduledMessagesBottomSheet_delete_dialog_message">Delete selected scheduled message?</string>
|
||||
<!-- Progress message shown while deleting selected scheduled message -->
|
||||
<string name="ScheduledMessagesBottomSheet_deleting_progress_message">Deleting scheduled message…</string>
|
||||
|
||||
<!-- DecryptionFailedDialog -->
|
||||
<string name="DecryptionFailedDialog_chat_session_refreshed">Chat session refreshed</string>
|
||||
|
@ -2212,6 +2253,8 @@
|
|||
<string name="conversation_activity__type_message_push">Signal message</string>
|
||||
<string name="conversation_activity__type_message_sms_insecure">Unsecured SMS</string>
|
||||
<string name="conversation_activity__type_message_mms_insecure">Unsecured MMS</string>
|
||||
<!-- Option in send button context menu to schedule the message instead of sending it directly -->
|
||||
<string name="conversation_activity__option_schedule_message">Schedule message</string>
|
||||
<string name="conversation_activity__from_sim_name">From %1$s</string>
|
||||
<string name="conversation_activity__sim_n">SIM %1$d</string>
|
||||
<string name="conversation_activity__send">Send</string>
|
||||
|
@ -3100,6 +3143,16 @@
|
|||
<!-- conversation_callable_insecure -->
|
||||
<string name="conversation_add_to_contacts__menu_add_to_contacts">Add to contacts</string>
|
||||
|
||||
<!-- conversation scheduled messages bar -->
|
||||
|
||||
<!-- Label for button in a banner to show all messages currently scheduled -->
|
||||
<string name="conversation_scheduled_messages_bar__see_all">See all</string>
|
||||
<!-- Body text for banner to show all scheduled messages for the chat that tells the user how many scheduled messages there are -->
|
||||
<plurals name="conversation_scheduled_messages_bar__number_of_messages">
|
||||
<item quantity="one">%1$d message scheduled</item>
|
||||
<item quantity="other">%1$d messages scheduled</item>
|
||||
</plurals>
|
||||
|
||||
<!-- conversation_group_options -->
|
||||
<string name="convesation_group_options__recipients_list">Recipients list</string>
|
||||
<string name="conversation_group_options__delivery">Delivery</string>
|
||||
|
|
|
@ -229,6 +229,9 @@
|
|||
<item name="materialAlertDialogTheme">@style/ThemeOverlay.Signal.MaterialAlertDialog</item>
|
||||
<item name="materialTimePickerTheme">@style/Signal.ThemeOverlay.TimePicker</item>
|
||||
<item name="materialTimePickerStyle">@style/Signal.Widget.TimePicker</item>
|
||||
|
||||
<item name="materialCalendarStyle">@style/Signal.Widget.Calendar</item>
|
||||
<item name="materialCalendarTheme">@style/Signal.ThemeOverlay.Calendar</item>
|
||||
</style>
|
||||
|
||||
<style name="TextSecure.DarkTheme" parent="@style/TextSecure.BaseDarkTheme">
|
||||
|
@ -315,6 +318,9 @@
|
|||
<item name="materialAlertDialogTheme">@style/ThemeOverlay.Signal.MaterialAlertDialog</item>
|
||||
<item name="materialTimePickerTheme">@style/Signal.ThemeOverlay.TimePicker</item>
|
||||
<item name="materialTimePickerStyle">@style/Signal.Widget.TimePicker</item>
|
||||
|
||||
<item name="materialCalendarStyle">@style/Signal.Widget.Calendar</item>
|
||||
<item name="materialCalendarTheme">@style/Signal.ThemeOverlay.Calendar</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.Signal.AlertDialog.Light.Cornered" parent="MaterialAlertDialog.Material3">
|
||||
|
|
|
@ -175,7 +175,8 @@ object FakeMessageRecords {
|
|||
parentStoryId,
|
||||
giftBadge,
|
||||
payment,
|
||||
call
|
||||
call,
|
||||
-1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,6 +24,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;
|
||||
|
@ -235,4 +236,9 @@ public class MockApplicationDependencyProvider implements ApplicationDependencie
|
|||
public @NonNull KeyBackupService provideKeyBackupService(@NonNull SignalServiceAccountManager signalServiceAccountManager, @NonNull KeyStore keyStore, @NonNull KbsEnclave enclave) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NonNull ScheduledMessageManager provideScheduledMessageManager() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
Ładowanie…
Reference in New Issue