Add support for scheduled message sends.

main
Clark 2023-01-26 10:37:08 -05:00 zatwierdzone przez Greyson Parrelli
rodzic df695f7611
commit f3e715e069
59 zmienionych plików z 1948 dodań i 90 usunięć

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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 &amp; 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>

Wyświetl plik

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

Wyświetl plik

@ -175,7 +175,8 @@ object FakeMessageRecords {
parentStoryId,
giftBadge,
payment,
call
call,
-1
)
}
}

Wyświetl plik

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