mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-24 02:39:55 +01:00
Add support for scheduled message sends.
This commit is contained in:
@@ -46,6 +46,7 @@ import org.thoughtcrime.securesms.R;
|
||||
import org.thoughtcrime.securesms.conversation.colors.Colorizable;
|
||||
import org.thoughtcrime.securesms.conversation.colors.Colorizer;
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart;
|
||||
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord;
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord;
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Playable;
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicyEnforcer;
|
||||
@@ -120,6 +121,7 @@ public class ConversationAdapter
|
||||
private Colorizer colorizer;
|
||||
private boolean isTypingViewEnabled;
|
||||
private boolean condensedMode;
|
||||
private boolean scheduledMessagesMode;
|
||||
private PulseRequest pulseRequest;
|
||||
|
||||
public ConversationAdapter(@NonNull Context context,
|
||||
@@ -261,6 +263,11 @@ public class ConversationAdapter
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
public void setScheduledMessagesMode(boolean scheduledMessagesMode) {
|
||||
this.scheduledMessagesMode = scheduledMessagesMode;
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
|
||||
switch (getItemViewType(position)) {
|
||||
@@ -331,7 +338,11 @@ public class ConversationAdapter
|
||||
|
||||
if (conversationMessage == null) return -1;
|
||||
|
||||
calendar.setTimeInMillis(conversationMessage.getMessageRecord().getDateSent());
|
||||
if (scheduledMessagesMode) {
|
||||
calendar.setTimeInMillis(((MediaMmsMessageRecord) conversationMessage.getMessageRecord()).getScheduledDate());
|
||||
} else {
|
||||
calendar.setTimeInMillis(conversationMessage.getMessageRecord().getDateSent());
|
||||
}
|
||||
return calendar.get(Calendar.YEAR) * 1000L + calendar.get(Calendar.DAY_OF_YEAR);
|
||||
}
|
||||
|
||||
@@ -345,7 +356,11 @@ public class ConversationAdapter
|
||||
Context context = viewHolder.itemView.getContext();
|
||||
ConversationMessage conversationMessage = Objects.requireNonNull(getItem(position));
|
||||
|
||||
viewHolder.setText(DateUtils.getConversationDateHeaderString(viewHolder.itemView.getContext(), locale, conversationMessage.getMessageRecord().getDateSent()));
|
||||
if (scheduledMessagesMode) {
|
||||
viewHolder.setText(DateUtils.getScheduledMessagesDateHeaderString(viewHolder.itemView.getContext(), locale, ((MediaMmsMessageRecord) conversationMessage.getMessageRecord()).getScheduledDate()));
|
||||
} else {
|
||||
viewHolder.setText(DateUtils.getConversationDateHeaderString(viewHolder.itemView.getContext(), locale, conversationMessage.getMessageRecord().getDateSent()));
|
||||
}
|
||||
|
||||
if (type == HEADER_TYPE_POPOVER_DATE) {
|
||||
if (hasWallpaper) {
|
||||
|
||||
@@ -186,6 +186,10 @@ public class ConversationDataSource implements PagedDataSource<MessageId, Conver
|
||||
return null;
|
||||
}
|
||||
|
||||
if (record instanceof MediaMmsMessageRecord && ((MediaMmsMessageRecord) record).getScheduledDate() != -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
stopwatch.split("message");
|
||||
|
||||
try {
|
||||
|
||||
@@ -1188,15 +1188,16 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||
Toast.LENGTH_LONG).show();
|
||||
}
|
||||
|
||||
public long stageOutgoingMessage(OutgoingMessage message) {
|
||||
public void stageOutgoingMessage(OutgoingMessage message) {
|
||||
if (message.getScheduledDate() != -1) {
|
||||
return;
|
||||
}
|
||||
MessageRecord messageRecord = MessageTable.readerFor(message, threadId).getCurrent();
|
||||
|
||||
if (getListAdapter() != null) {
|
||||
setLastSeen(0);
|
||||
list.post(() -> list.scrollToPosition(0));
|
||||
}
|
||||
|
||||
return messageRecord.getId();
|
||||
}
|
||||
|
||||
private void presentConversationMetadata(@NonNull ConversationData conversation) {
|
||||
@@ -2114,6 +2115,11 @@ public class ConversationFragment extends LoggingFragment implements Multiselect
|
||||
public void onSendPaymentClicked(@NonNull RecipientId recipientId) {
|
||||
AttachmentManager.selectPayment(ConversationFragment.this, recipient.get());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onScheduledIndicatorClicked(@NonNull View view, @NonNull MessageRecord messageRecord) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isUnopenedGift(View itemView, MessageRecord messageRecord) {
|
||||
|
||||
@@ -210,6 +210,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
private View storyReactionLabelWrapper;
|
||||
private TextView storyReactionLabel;
|
||||
protected View quotedIndicator;
|
||||
protected View scheduledIndicator;
|
||||
|
||||
private @NonNull Set<MultiselectPart> batchSelected = new HashSet<>();
|
||||
private @NonNull Outliner outliner = new Outliner();
|
||||
@@ -233,18 +234,19 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
private int measureCalls;
|
||||
private boolean updatingFooter;
|
||||
|
||||
private final PassthroughClickListener passthroughClickListener = new PassthroughClickListener();
|
||||
private final AttachmentDownloadClickListener downloadClickListener = new AttachmentDownloadClickListener();
|
||||
private final SlideClickPassthroughListener singleDownloadClickListener = new SlideClickPassthroughListener(downloadClickListener);
|
||||
private final SharedContactEventListener sharedContactEventListener = new SharedContactEventListener();
|
||||
private final SharedContactClickListener sharedContactClickListener = new SharedContactClickListener();
|
||||
private final LinkPreviewClickListener linkPreviewClickListener = new LinkPreviewClickListener();
|
||||
private final ViewOnceMessageClickListener revealableClickListener = new ViewOnceMessageClickListener();
|
||||
private final QuotedIndicatorClickListener quotedIndicatorClickListener = new QuotedIndicatorClickListener();
|
||||
private final UrlClickListener urlClickListener = new UrlClickListener();
|
||||
private final Rect thumbnailMaskingRect = new Rect();
|
||||
private final TouchDelegateChangedListener touchDelegateChangedListener = new TouchDelegateChangedListener();
|
||||
private final GiftMessageViewCallback giftMessageViewCallback = new GiftMessageViewCallback();
|
||||
private final PassthroughClickListener passthroughClickListener = new PassthroughClickListener();
|
||||
private final AttachmentDownloadClickListener downloadClickListener = new AttachmentDownloadClickListener();
|
||||
private final SlideClickPassthroughListener singleDownloadClickListener = new SlideClickPassthroughListener(downloadClickListener);
|
||||
private final SharedContactEventListener sharedContactEventListener = new SharedContactEventListener();
|
||||
private final SharedContactClickListener sharedContactClickListener = new SharedContactClickListener();
|
||||
private final LinkPreviewClickListener linkPreviewClickListener = new LinkPreviewClickListener();
|
||||
private final ViewOnceMessageClickListener revealableClickListener = new ViewOnceMessageClickListener();
|
||||
private final QuotedIndicatorClickListener quotedIndicatorClickListener = new QuotedIndicatorClickListener();
|
||||
private final ScheduledIndicatorClickListener scheduledIndicatorClickListener = new ScheduledIndicatorClickListener();
|
||||
private final UrlClickListener urlClickListener = new UrlClickListener();
|
||||
private final Rect thumbnailMaskingRect = new Rect();
|
||||
private final TouchDelegateChangedListener touchDelegateChangedListener = new TouchDelegateChangedListener();
|
||||
private final GiftMessageViewCallback giftMessageViewCallback = new GiftMessageViewCallback();
|
||||
|
||||
private final Context context;
|
||||
|
||||
@@ -278,6 +280,11 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
.scaleX(LONG_PRESS_SCALE_FACTOR)
|
||||
.scaleY(LONG_PRESS_SCALE_FACTOR);
|
||||
}
|
||||
if (scheduledIndicator != null) {
|
||||
scheduledIndicator.animate()
|
||||
.scaleX(LONG_PRESS_SCALE_FACTOR)
|
||||
.scaleY(LONG_PRESS_SCALE_FACTOR);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -328,6 +335,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
this.giftViewStub = new Stub<>(findViewById(R.id.gift_view_stub));
|
||||
this.quotedIndicator = findViewById(R.id.quoted_indicator);
|
||||
this.paymentViewStub = new Stub<>(findViewById(R.id.payment_view_stub));
|
||||
this.scheduledIndicator = findViewById(R.id.scheduled_indicator);
|
||||
|
||||
setOnClickListener(new ClickListener(null));
|
||||
|
||||
@@ -395,6 +403,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
setFooter(messageRecord, nextMessageRecord, locale, groupThread, hasWallpaper);
|
||||
setStoryReactionLabel(messageRecord);
|
||||
setHasBeenQuoted(conversationMessage);
|
||||
setHasBeenScheduled(conversationMessage);
|
||||
|
||||
if (audioViewStub.resolved()) {
|
||||
audioViewStub.get().setOnLongClickListener(passthroughClickListener);
|
||||
@@ -1035,7 +1044,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
boolean messageRequestAccepted,
|
||||
boolean allowedToPlayInline)
|
||||
{
|
||||
boolean showControls = !messageRecord.isFailed();
|
||||
boolean showControls = !messageRecord.isFailed() && !MessageRecordUtil.isScheduled(messageRecord);
|
||||
|
||||
ViewUtil.setTopMargin(bodyText, readDimen(R.dimen.message_bubble_top_padding));
|
||||
|
||||
@@ -1711,6 +1720,19 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
}
|
||||
}
|
||||
|
||||
private void setHasBeenScheduled(@NonNull ConversationMessage message) {
|
||||
if (scheduledIndicator == null) {
|
||||
return;
|
||||
}
|
||||
if (message.hasBeenScheduled()) {
|
||||
scheduledIndicator.setVisibility(View.VISIBLE);
|
||||
scheduledIndicator.setOnClickListener(scheduledIndicatorClickListener);
|
||||
} else {
|
||||
scheduledIndicator.setVisibility(View.GONE);
|
||||
scheduledIndicator.setOnClickListener(null);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean forceFooter(@NonNull MessageRecord messageRecord) {
|
||||
return hasAudio(messageRecord);
|
||||
}
|
||||
@@ -1872,21 +1894,23 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
private boolean isStartOfMessageCluster(@NonNull MessageRecord current, @NonNull Optional<MessageRecord> previous, boolean isGroupThread) {
|
||||
if (isGroupThread) {
|
||||
return !previous.isPresent() || previous.get().isUpdate() || !DateUtils.isSameDay(current.getTimestamp(), previous.get().getTimestamp()) ||
|
||||
!current.getRecipient().equals(previous.get().getRecipient()) || !isWithinClusteringTime(current, previous.get());
|
||||
!current.getRecipient().equals(previous.get().getRecipient()) || !isWithinClusteringTime(current, previous.get()) || MessageRecordUtil.isScheduled(current);
|
||||
} else {
|
||||
return !previous.isPresent() || previous.get().isUpdate() || !DateUtils.isSameDay(current.getTimestamp(), previous.get().getTimestamp()) ||
|
||||
current.isOutgoing() != previous.get().isOutgoing() || previous.get().isSecure() != current.isSecure() || !isWithinClusteringTime(current, previous.get());
|
||||
current.isOutgoing() != previous.get().isOutgoing() || previous.get().isSecure() != current.isSecure() || !isWithinClusteringTime(current, previous.get()) ||
|
||||
MessageRecordUtil.isScheduled(current);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isEndOfMessageCluster(@NonNull MessageRecord current, @NonNull Optional<MessageRecord> next, boolean isGroupThread) {
|
||||
if (isGroupThread) {
|
||||
return !next.isPresent() || next.get().isUpdate() || !DateUtils.isSameDay(current.getTimestamp(), next.get().getTimestamp()) ||
|
||||
!current.getRecipient().equals(next.get().getRecipient()) || !current.getReactions().isEmpty() || !isWithinClusteringTime(current, next.get());
|
||||
!current.getRecipient().equals(next.get().getRecipient()) || !current.getReactions().isEmpty() || !isWithinClusteringTime(current, next.get()) ||
|
||||
MessageRecordUtil.isScheduled(current);
|
||||
} else {
|
||||
return !next.isPresent() || next.get().isUpdate() || !DateUtils.isSameDay(current.getTimestamp(), next.get().getTimestamp()) ||
|
||||
current.isOutgoing() != next.get().isOutgoing() || !current.getReactions().isEmpty() || next.get().isSecure() != current.isSecure() ||
|
||||
!isWithinClusteringTime(current, next.get());
|
||||
!isWithinClusteringTime(current, next.get()) || MessageRecordUtil.isScheduled(current);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2279,6 +2303,16 @@ public final class ConversationItem extends RelativeLayout implements BindableCo
|
||||
}
|
||||
}
|
||||
|
||||
private class ScheduledIndicatorClickListener implements View.OnClickListener {
|
||||
public void onClick(final View view) {
|
||||
if (eventListener != null && batchSelected.isEmpty()) {
|
||||
eventListener.onScheduledIndicatorClicked(view, (messageRecord));
|
||||
} else {
|
||||
passthroughClickListener.onClick(view);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class AttachmentDownloadClickListener implements SlidesClickedListener {
|
||||
@Override
|
||||
public void onClick(View v, final List<Slide> slides) {
|
||||
|
||||
@@ -115,6 +115,10 @@ public class ConversationMessage {
|
||||
getBottomButton() == null;
|
||||
}
|
||||
|
||||
public boolean hasBeenScheduled() {
|
||||
return MessageRecordUtil.isScheduled(messageRecord);
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory providing multiple ways of creating {@link ConversationMessage}s.
|
||||
*/
|
||||
|
||||
@@ -136,6 +136,8 @@ import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel;
|
||||
import org.thoughtcrime.securesms.components.identity.UnverifiedBannerView;
|
||||
import org.thoughtcrime.securesms.components.location.SignalPlace;
|
||||
import org.thoughtcrime.securesms.components.mention.MentionAnnotation;
|
||||
import org.thoughtcrime.securesms.components.menu.ActionItem;
|
||||
import org.thoughtcrime.securesms.components.menu.SignalContextMenu;
|
||||
import org.thoughtcrime.securesms.components.reminder.BubbleOptOutReminder;
|
||||
import org.thoughtcrime.securesms.components.reminder.ExpiredBuildReminder;
|
||||
import org.thoughtcrime.securesms.components.reminder.GroupsV1MigrationSuggestionsReminder;
|
||||
@@ -284,6 +286,7 @@ import org.thoughtcrime.securesms.util.ContextUtil;
|
||||
import org.thoughtcrime.securesms.util.ConversationUtil;
|
||||
import org.thoughtcrime.securesms.util.Debouncer;
|
||||
import org.thoughtcrime.securesms.util.DrawableUtil;
|
||||
import org.thoughtcrime.securesms.util.FeatureFlags;
|
||||
import org.thoughtcrime.securesms.util.FullscreenHelper;
|
||||
import org.thoughtcrime.securesms.util.IdentityUtil;
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable;
|
||||
@@ -329,6 +332,7 @@ import io.reactivex.rxjava3.core.Flowable;
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
import io.reactivex.rxjava3.core.SingleObserver;
|
||||
import io.reactivex.rxjava3.disposables.Disposable;
|
||||
import kotlin.Unit;
|
||||
|
||||
import static android.content.res.Configuration.ORIENTATION_LANDSCAPE;
|
||||
|
||||
@@ -363,7 +367,9 @@ public class ConversationParentFragment extends Fragment
|
||||
EmojiSearchFragment.Callback,
|
||||
StickerKeyboardPageFragment.Callback,
|
||||
Material3OnScrollHelperBinder,
|
||||
MessageDetailsFragment.Callback
|
||||
MessageDetailsFragment.Callback,
|
||||
ScheduleMessageTimePickerBottomSheet.ScheduleCallback,
|
||||
ScheduledMessagesBottomSheet.Callback
|
||||
{
|
||||
|
||||
private static final int SHORTCUT_ICON_SIZE = Build.VERSION.SDK_INT >= 26 ? ViewUtil.dpToPx(72) : ViewUtil.dpToPx(48 + 16 * 2);
|
||||
@@ -431,6 +437,7 @@ public class ConversationParentFragment extends Fragment
|
||||
private View cancelJoinRequest;
|
||||
private Stub<View> releaseChannelUnmute;
|
||||
private Stub<View> mentionsSuggestions;
|
||||
private Stub<View> scheduledMessagesBarStub;
|
||||
private MaterialButton joinGroupCallButton;
|
||||
private boolean callingTooltipShown;
|
||||
private ImageView wallpaper;
|
||||
@@ -556,6 +563,7 @@ public class ConversationParentFragment extends Fragment
|
||||
initializeActionBar();
|
||||
|
||||
disposables.add(viewModel.getStoryViewState().subscribe(titleView::setStoryRingFromState));
|
||||
disposables.add(viewModel.getScheduledMessageCount().subscribe(this::updateScheduledMessagesBar));
|
||||
|
||||
backPressedCallback = new OnBackPressedCallback(true) {
|
||||
@Override
|
||||
@@ -783,7 +791,8 @@ public class ConversationParentFragment extends Fragment
|
||||
result.isViewOnce(),
|
||||
initiating,
|
||||
true,
|
||||
null).addListener(new AssertedSuccessListener<Void>() {
|
||||
null,
|
||||
result.getScheduledTime()).addListener(new AssertedSuccessListener<Void>() {
|
||||
@Override
|
||||
public void onSuccess(Void result) {
|
||||
AsyncTask.THREAD_POOL_EXECUTOR.execute(() -> {
|
||||
@@ -2039,6 +2048,7 @@ public class ConversationParentFragment extends Fragment
|
||||
wallpaperDim = view.findViewById(R.id.conversation_wallpaper_dim);
|
||||
voiceNotePlayerViewStub = ViewUtil.findStubById(view, R.id.voice_note_player_stub);
|
||||
navigationBarBackground = view.findViewById(R.id.navbar_background);
|
||||
scheduledMessagesBarStub = ViewUtil.findStubById(view, R.id.scheduled_messages_stub);
|
||||
|
||||
ImageButton quickCameraToggle = view.findViewById(R.id.quick_camera_toggle);
|
||||
ImageButton inlineAttachmentButton = view.findViewById(R.id.inline_attachment_button);
|
||||
@@ -2073,6 +2083,18 @@ public class ConversationParentFragment extends Fragment
|
||||
attachButton.setOnClickListener(new AttachButtonListener());
|
||||
attachButton.setOnLongClickListener(new AttachButtonLongClickListener());
|
||||
sendButton.setOnClickListener(sendButtonListener);
|
||||
if (FeatureFlags.scheduledMessageSends()) {
|
||||
sendButton.setScheduledSendListener(() -> {
|
||||
ScheduleMessageContextMenu.show(sendButton, (ViewGroup) requireView(), time -> {
|
||||
if (time == -1) {
|
||||
ScheduleMessageTimePickerBottomSheet.showSchedule(getChildFragmentManager());
|
||||
} else {
|
||||
sendMessage(null, time);
|
||||
}
|
||||
return Unit.INSTANCE;
|
||||
});
|
||||
});
|
||||
}
|
||||
sendButton.setEnabled(true);
|
||||
sendButton.addOnSelectionChangedListener((newMessageSendType, manuallySelected) -> {
|
||||
if (getContext() == null) {
|
||||
@@ -2916,6 +2938,13 @@ public class ConversationParentFragment extends Fragment
|
||||
}
|
||||
|
||||
private void sendMessage(@Nullable String metricId) {
|
||||
sendMessage(metricId, -1);
|
||||
}
|
||||
|
||||
private void sendMessage(@Nullable String metricId, long scheduledDate) {
|
||||
if (scheduledDate != -1 && !SignalStore.uiHints().hasSeenScheduledMessagesInfoSheet()) {
|
||||
ScheduleMessageFtuxBottomSheetDialog.show(getChildFragmentManager());
|
||||
}
|
||||
if (inputPanel.isRecordingInLockedMode()) {
|
||||
inputPanel.releaseRecordingLock();
|
||||
return;
|
||||
@@ -2925,7 +2954,7 @@ public class ConversationParentFragment extends Fragment
|
||||
if (voiceNote != null) {
|
||||
AudioSlide audioSlide = AudioSlide.createFromVoiceNoteDraft(requireContext(), voiceNote);
|
||||
|
||||
sendVoiceNote(Objects.requireNonNull(audioSlide.getUri()), audioSlide.getFileSize());
|
||||
sendVoiceNote(Objects.requireNonNull(audioSlide.getUri()), audioSlide.getFileSize(), scheduledDate);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2957,9 +2986,9 @@ public class ConversationParentFragment extends Fragment
|
||||
} else if (sendType.usesSignalTransport() && (identityRecords.isUnverified(true) || identityRecords.isUntrusted(true))) {
|
||||
handleRecentSafetyNumberChange();
|
||||
} else if (isMediaMessage) {
|
||||
sendMediaMessage(sendType, expiresIn, false, initiating, metricId);
|
||||
sendMediaMessage(sendType, expiresIn, false, initiating, metricId, scheduledDate);
|
||||
} else {
|
||||
sendTextMessage(sendType, expiresIn, initiating, metricId);
|
||||
sendTextMessage(sendType, expiresIn, initiating, metricId, scheduledDate);
|
||||
}
|
||||
} catch (RecipientFormattingException ex) {
|
||||
Toast.makeText(requireContext(),
|
||||
@@ -3002,7 +3031,8 @@ public class ConversationParentFragment extends Fragment
|
||||
Collections.emptySet(),
|
||||
null,
|
||||
true,
|
||||
result.getBodyRanges());
|
||||
result.getBodyRanges(),
|
||||
-1);
|
||||
|
||||
final Context context = requireContext().getApplicationContext();
|
||||
|
||||
@@ -3012,7 +3042,7 @@ public class ConversationParentFragment extends Fragment
|
||||
attachmentManager.clear(glideRequests, false);
|
||||
silentlySetComposeText("");
|
||||
|
||||
long id = fragment.stageOutgoingMessage(message);
|
||||
fragment.stageOutgoingMessage(message);
|
||||
|
||||
SimpleTask.run(() -> {
|
||||
long resultId = MessageSender.sendPushWithPreUploadedMedia(context, message, result.getPreUploadResults(), thread, null);
|
||||
@@ -3024,7 +3054,7 @@ public class ConversationParentFragment extends Fragment
|
||||
}, this::sendComplete);
|
||||
}
|
||||
|
||||
private void sendMediaMessage(@NonNull MessageSendType sendType, final long expiresIn, final boolean viewOnce, final boolean initiating, @Nullable String metricId)
|
||||
private void sendMediaMessage(@NonNull MessageSendType sendType, final long expiresIn, final boolean viewOnce, final boolean initiating, @Nullable String metricId, long scheduledDate)
|
||||
throws InvalidMessageException
|
||||
{
|
||||
Log.i(TAG, "Sending media message...");
|
||||
@@ -3042,7 +3072,8 @@ public class ConversationParentFragment extends Fragment
|
||||
viewOnce,
|
||||
initiating,
|
||||
true,
|
||||
metricId);
|
||||
metricId,
|
||||
scheduledDate);
|
||||
}
|
||||
|
||||
private ListenableFuture<Void> sendMediaMessage(@NonNull RecipientId recipientId,
|
||||
@@ -3059,6 +3090,25 @@ public class ConversationParentFragment extends Fragment
|
||||
final boolean initiating,
|
||||
final boolean clearComposeBox,
|
||||
final @Nullable String metricId)
|
||||
{
|
||||
return sendMediaMessage(recipientId, sendType, body, slideDeck, quote, contacts, previews, mentions, styling, expiresIn, viewOnce, initiating, clearComposeBox, metricId, -1);
|
||||
}
|
||||
|
||||
private ListenableFuture<Void> sendMediaMessage(@NonNull RecipientId recipientId,
|
||||
@NonNull MessageSendType sendType,
|
||||
@NonNull String body,
|
||||
SlideDeck slideDeck,
|
||||
QuoteModel quote,
|
||||
List<Contact> contacts,
|
||||
List<LinkPreview> previews,
|
||||
List<Mention> mentions,
|
||||
@Nullable BodyRangeList styling,
|
||||
final long expiresIn,
|
||||
final boolean viewOnce,
|
||||
final boolean initiating,
|
||||
final boolean clearComposeBox,
|
||||
final @Nullable String metricId,
|
||||
final long scheduledDate)
|
||||
{
|
||||
if (ExpiredBuildReminder.isEligible()) {
|
||||
showExpiredDialog();
|
||||
@@ -3101,7 +3151,8 @@ public class ConversationParentFragment extends Fragment
|
||||
Collections.emptySet(),
|
||||
null,
|
||||
false,
|
||||
styling);
|
||||
styling,
|
||||
scheduledDate);
|
||||
|
||||
final SettableFuture<Void> future = new SettableFuture<>();
|
||||
final Context context = requireContext().getApplicationContext();
|
||||
@@ -3126,7 +3177,7 @@ public class ConversationParentFragment extends Fragment
|
||||
silentlySetComposeText("");
|
||||
}
|
||||
|
||||
final long id = fragment.stageOutgoingMessage(outgoingMessage);
|
||||
fragment.stageOutgoingMessage(outgoingMessage);
|
||||
|
||||
SimpleTask.run(() -> {
|
||||
return MessageSender.send(context, outgoingMessage, thread, sendType.usesSmsTransport() ? SendType.MMS : SendType.SIGNAL, metricId, null);
|
||||
@@ -3141,7 +3192,7 @@ public class ConversationParentFragment extends Fragment
|
||||
return future;
|
||||
}
|
||||
|
||||
private void sendTextMessage(@NonNull MessageSendType sendType, final long expiresIn, final boolean initiating, final @Nullable String metricId)
|
||||
private void sendTextMessage(@NonNull MessageSendType sendType, final long expiresIn, final boolean initiating, final @Nullable String metricId, long scheduledDate)
|
||||
throws InvalidMessageException
|
||||
{
|
||||
if (ExpiredBuildReminder.isEligible()) {
|
||||
@@ -3159,10 +3210,14 @@ public class ConversationParentFragment extends Fragment
|
||||
final String messageBody = getMessage();
|
||||
final boolean sendPush = sendType.usesSignalTransport();
|
||||
|
||||
OutgoingMessage message;
|
||||
final OutgoingMessage message;
|
||||
|
||||
if (sendPush) {
|
||||
message = OutgoingMessage.text(recipient.get(), messageBody, expiresIn, System.currentTimeMillis(), null);
|
||||
if (scheduledDate > 0) {
|
||||
message = OutgoingMessage.text(recipient.get(), messageBody, expiresIn, System.currentTimeMillis(), null).sendAt(scheduledDate);
|
||||
} else {
|
||||
message = OutgoingMessage.text(recipient.get(), messageBody, expiresIn, System.currentTimeMillis(), null);
|
||||
}
|
||||
ApplicationDependencies.getTypingStatusSender().onTypingStopped(thread);
|
||||
} else {
|
||||
message = OutgoingMessage.sms(recipient.get(), messageBody, sendType.getSimSubscriptionIdOr(-1));
|
||||
@@ -3260,6 +3315,22 @@ public class ConversationParentFragment extends Fragment
|
||||
}
|
||||
}
|
||||
|
||||
private void updateScheduledMessagesBar(@NonNull Integer count) {
|
||||
if (count <= 0) {
|
||||
scheduledMessagesBarStub.setVisibility(View.GONE);
|
||||
} else {
|
||||
if (!scheduledMessagesBarStub.resolved()) {
|
||||
View scheduledMessagesBar = scheduledMessagesBarStub.get();
|
||||
scheduledMessagesBar.findViewById(R.id.scheduled_messages_show_all).setOnClickListener(v -> {
|
||||
ScheduledMessagesBottomSheet.show(getChildFragmentManager(), threadId, recipient.getId());
|
||||
});
|
||||
}
|
||||
scheduledMessagesBarStub.setVisibility(View.VISIBLE);
|
||||
TextView scheduledText = scheduledMessagesBarStub.get().findViewById(R.id.scheduled_messages_text);
|
||||
scheduledText.setText(getResources().getQuantityString(R.plurals.conversation_scheduled_messages_bar__number_of_messages, count, count));
|
||||
}
|
||||
}
|
||||
|
||||
private void recordTransportPreference(MessageSendType sendType) {
|
||||
new AsyncTask<Void, Void, Void>() {
|
||||
@Override
|
||||
@@ -3403,7 +3474,7 @@ public class ConversationParentFragment extends Fragment
|
||||
container.hideAttachedInput(true);
|
||||
}
|
||||
|
||||
private void sendVoiceNote(@NonNull Uri uri, long size) {
|
||||
private void sendVoiceNote(@NonNull Uri uri, long size, long scheduledDate) {
|
||||
boolean initiating = threadId == -1;
|
||||
long expiresIn = TimeUnit.SECONDS.toMillis(recipient.get().getExpiresInSeconds());
|
||||
AudioSlide audioSlide = new AudioSlide(requireContext(), uri, size, MediaUtil.AUDIO_AAC, true);
|
||||
@@ -3424,7 +3495,8 @@ public class ConversationParentFragment extends Fragment
|
||||
false,
|
||||
initiating,
|
||||
true,
|
||||
null);
|
||||
null,
|
||||
scheduledDate);
|
||||
}
|
||||
|
||||
private void sendSticker(@NonNull StickerRecord stickerRecord, boolean clearCompose) {
|
||||
@@ -3589,6 +3661,14 @@ public class ConversationParentFragment extends Fragment
|
||||
});
|
||||
}
|
||||
|
||||
public void onScheduleSend(long scheduledTime) {
|
||||
sendMessage(null, scheduledTime);
|
||||
}
|
||||
|
||||
public @NonNull ConversationAdapter.ItemClickListener getConversationAdapterListener() {
|
||||
return fragment.getConversationAdapterListener();
|
||||
}
|
||||
|
||||
// Listeners
|
||||
|
||||
private class RecordingSession implements SingleObserver<VoiceNoteDraft> {
|
||||
@@ -3607,7 +3687,7 @@ public class ConversationParentFragment extends Fragment
|
||||
@Override
|
||||
public void onSuccess(@NonNull VoiceNoteDraft draft) {
|
||||
if (shouldSend) {
|
||||
sendVoiceNote(draft.getUri(), draft.getSize());
|
||||
sendVoiceNote(draft.getUri(), draft.getSize(), -1);
|
||||
} else {
|
||||
if (!saveDraft) {
|
||||
draftViewModel.cancelEphemeralVoiceNoteDraft(draft.asDraft());
|
||||
@@ -3688,7 +3768,7 @@ public class ConversationParentFragment extends Fragment
|
||||
private class AttachButtonLongClickListener implements View.OnLongClickListener {
|
||||
@Override
|
||||
public boolean onLongClick(View v) {
|
||||
return sendButton.performLongClick();
|
||||
return sendButton.showSendTypeMenu();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ public class ConversationViewModel extends ViewModel {
|
||||
private final Application context;
|
||||
private final MediaRepository mediaRepository;
|
||||
private final ConversationRepository conversationRepository;
|
||||
private final ScheduledMessagesRepository scheduledMessagesRepository;
|
||||
private final MutableLiveData<List<Media>> recentMedia;
|
||||
private final BehaviorSubject<Long> threadId;
|
||||
private final Observable<MessageData> messageData;
|
||||
@@ -99,6 +100,7 @@ public class ConversationViewModel extends ViewModel {
|
||||
private final CompositeDisposable disposables;
|
||||
private final BehaviorSubject<Unit> conversationStateTick;
|
||||
private final PublishProcessor<Long> markReadRequestPublisher;
|
||||
private final Observable<Integer> scheduledMessageCount;
|
||||
|
||||
private ConversationIntents.Args args;
|
||||
private int jumpToPosition;
|
||||
@@ -107,6 +109,7 @@ public class ConversationViewModel extends ViewModel {
|
||||
this.context = ApplicationDependencies.getApplication();
|
||||
this.mediaRepository = new MediaRepository();
|
||||
this.conversationRepository = new ConversationRepository();
|
||||
this.scheduledMessagesRepository = new ScheduledMessagesRepository();
|
||||
this.recentMedia = new MutableLiveData<>();
|
||||
this.showScrollButtons = new MutableLiveData<>(false);
|
||||
this.hasUnreadMentions = new MutableLiveData<>(false);
|
||||
@@ -200,6 +203,10 @@ public class ConversationViewModel extends ViewModel {
|
||||
.withLatestFrom(conversationMetadata, (messages, metadata) -> new MessageData(metadata, messages))
|
||||
.doOnNext(a -> SignalLocalMetrics.ConversationOpen.onDataLoaded());
|
||||
|
||||
scheduledMessageCount = threadId
|
||||
.observeOn(Schedulers.io())
|
||||
.flatMap(scheduledMessagesRepository::getScheduledMessageCount);
|
||||
|
||||
Observable<Recipient> liveRecipient = recipientId.distinctUntilChanged().switchMap(id -> Recipient.live(id).asObservable());
|
||||
|
||||
canShowAsBubble = threadId.observeOn(Schedulers.io()).map(conversationRepository::canShowAsBubble);
|
||||
@@ -371,6 +378,10 @@ public class ConversationViewModel extends ViewModel {
|
||||
.observeOn(AndroidSchedulers.mainThread());
|
||||
}
|
||||
|
||||
@NonNull Observable<Integer> getScheduledMessageCount() {
|
||||
return scheduledMessageCount.observeOn(AndroidSchedulers.mainThread());
|
||||
}
|
||||
|
||||
void setHasUnreadMentions(boolean hasUnreadMentions) {
|
||||
this.hasUnreadMentions.setValue(hasUnreadMentions);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package org.thoughtcrime.securesms.conversation
|
||||
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.DrawableRes
|
||||
import org.signal.core.util.dp
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.menu.ActionItem
|
||||
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.toLocalDateTime
|
||||
import org.thoughtcrime.securesms.util.toMillis
|
||||
import java.util.Locale
|
||||
|
||||
class ScheduleMessageContextMenu {
|
||||
|
||||
companion object {
|
||||
|
||||
private val presetHours = arrayOf(8, 12, 18, 21)
|
||||
|
||||
@JvmStatic
|
||||
fun show(anchor: View, container: ViewGroup, action: (Long) -> Unit): SignalContextMenu {
|
||||
val currentTime = System.currentTimeMillis()
|
||||
val scheduledTimes = getNextScheduleTimes(currentTime)
|
||||
val actionItems = scheduledTimes.map {
|
||||
if (it > 0) {
|
||||
ActionItem(getIconForTime(it), DateUtils.getScheduledMessageDateString(anchor.context, Locale.getDefault(), it)) {
|
||||
action(it)
|
||||
}
|
||||
} else {
|
||||
ActionItem(0, anchor.context.getString(R.string.ScheduledMessages_pick_time)) {
|
||||
action(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return SignalContextMenu.Builder(anchor, container)
|
||||
.offsetX(12.dp)
|
||||
.offsetY(12.dp)
|
||||
.preferredVerticalPosition(SignalContextMenu.VerticalPosition.ABOVE)
|
||||
.show(actionItems)
|
||||
}
|
||||
|
||||
@DrawableRes
|
||||
private fun getIconForTime(timeMs: Long): Int {
|
||||
val dateTime = timeMs.toLocalDateTime()
|
||||
return if (dateTime.hour >= 18) {
|
||||
R.drawable.ic_nighttime_26
|
||||
} else {
|
||||
R.drawable.ic_daytime_24
|
||||
}
|
||||
}
|
||||
|
||||
private fun getNextScheduleTimes(currentTimeMs: Long): List<Long> {
|
||||
var currentDateTime = currentTimeMs.toLocalDateTime()
|
||||
|
||||
val timestampList = ArrayList<Long>(4)
|
||||
var presetIndex = presetHours.indexOfFirst { it > currentDateTime.hour }
|
||||
if (presetIndex == -1) {
|
||||
currentDateTime = currentDateTime.plusDays(1)
|
||||
presetIndex = 0
|
||||
}
|
||||
currentDateTime = currentDateTime.withMinute(0).withSecond(0)
|
||||
while (timestampList.size < 3) {
|
||||
currentDateTime = currentDateTime.withHour(presetHours[presetIndex])
|
||||
timestampList += currentDateTime.toMillis()
|
||||
presetIndex++
|
||||
if (presetIndex >= presetHours.size) {
|
||||
presetIndex = 0
|
||||
currentDateTime = currentDateTime.plusDays(1)
|
||||
}
|
||||
}
|
||||
timestampList += -1
|
||||
|
||||
return timestampList.reversed()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.thoughtcrime.securesms.conversation
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.components.ViewBinderDelegate
|
||||
import org.thoughtcrime.securesms.databinding.ScheduleMessageFtuxBottomSheetBinding
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||
|
||||
class ScheduleMessageFtuxBottomSheetDialog : FixedRoundedCornerBottomSheetDialogFragment() {
|
||||
override val peekHeightPercentage: Float = 0.66f
|
||||
override val themeResId: Int = R.style.Widget_Signal_FixedRoundedCorners_Messages
|
||||
|
||||
private val binding by ViewBinderDelegate(ScheduleMessageFtuxBottomSheetBinding::bind)
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
return inflater.inflate(R.layout.schedule_message_ftux_bottom_sheet, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
binding.okay.setOnClickListener {
|
||||
SignalStore.uiHints().markHasSeenScheduledMessagesInfoSheet()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun show(fragmentManager: FragmentManager) {
|
||||
val fragment = ScheduleMessageFtuxBottomSheetDialog()
|
||||
|
||||
fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package org.thoughtcrime.securesms.conversation
|
||||
|
||||
import android.os.Bundle
|
||||
import android.text.format.DateFormat
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import com.google.android.material.datepicker.CalendarConstraints
|
||||
import com.google.android.material.datepicker.DateValidatorPointForward
|
||||
import com.google.android.material.datepicker.MaterialDatePicker
|
||||
import com.google.android.material.timepicker.MaterialTimePicker
|
||||
import com.google.android.material.timepicker.TimeFormat
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.components.ViewBinderDelegate
|
||||
import org.thoughtcrime.securesms.databinding.ScheduleMessageTimePickerBottomSheetBinding
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||
import org.thoughtcrime.securesms.util.DateUtils
|
||||
import org.thoughtcrime.securesms.util.atMidnight
|
||||
import org.thoughtcrime.securesms.util.atUTC
|
||||
import org.thoughtcrime.securesms.util.formatHours
|
||||
import org.thoughtcrime.securesms.util.fragments.findListener
|
||||
import org.thoughtcrime.securesms.util.toLocalDateTime
|
||||
import org.thoughtcrime.securesms.util.toMillis
|
||||
import java.time.LocalDateTime
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZoneOffset
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Bottom sheet dialog that allows selecting a timestamp after the current time for
|
||||
* scheduling a message send.
|
||||
*
|
||||
* Will call [ScheduleCallback.onScheduleSend] with the selected time, if called with [showSchedule]
|
||||
* Will call [RescheduleCallback.onReschedule] with the selected time, if called with [showReschedule]
|
||||
*/
|
||||
class ScheduleMessageTimePickerBottomSheet : FixedRoundedCornerBottomSheetDialogFragment() {
|
||||
override val peekHeightPercentage: Float = 0.66f
|
||||
override val themeResId: Int = R.style.Widget_Signal_FixedRoundedCorners_Messages
|
||||
|
||||
private var scheduledDate: Long = 0
|
||||
private var scheduledHour: Int = 0
|
||||
private var scheduledMinute: Int = 0
|
||||
|
||||
private val binding by ViewBinderDelegate(ScheduleMessageTimePickerBottomSheetBinding::bind)
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
return inflater.inflate(R.layout.schedule_message_time_picker_bottom_sheet, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val initialTime = arguments?.getLong(KEY_INITIAL_TIME)
|
||||
scheduledDate = initialTime ?: System.currentTimeMillis()
|
||||
var scheduledLocalDateTime = scheduledDate.toLocalDateTime()
|
||||
if (initialTime == null) {
|
||||
scheduledLocalDateTime = scheduledLocalDateTime.plusMinutes(5L - (scheduledLocalDateTime.minute % 5))
|
||||
}
|
||||
|
||||
scheduledHour = scheduledLocalDateTime.hour
|
||||
scheduledMinute = scheduledLocalDateTime.minute
|
||||
|
||||
binding.scheduleSend.setOnClickListener {
|
||||
dismiss()
|
||||
val messageId = arguments?.getLong(KEY_MESSAGE_ID)
|
||||
if (messageId == null) {
|
||||
findListener<ScheduleCallback>()?.onScheduleSend(getSelectedTimestamp())
|
||||
} else {
|
||||
val selectedTime = getSelectedTimestamp()
|
||||
if (selectedTime != arguments?.getLong(KEY_INITIAL_TIME)) {
|
||||
findListener<RescheduleCallback>()?.onReschedule(selectedTime, messageId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val zoneOffsetFormatter = DateTimeFormatter.ofPattern("OOOO")
|
||||
val zoneNameFormatter = DateTimeFormatter.ofPattern("zzzz")
|
||||
val zonedDateTime = ZonedDateTime.now()
|
||||
binding.timezoneDisclaimer.apply {
|
||||
text = getString(
|
||||
R.string.ScheduleMessageTimePickerBottomSheet__timezone_disclaimer,
|
||||
zoneOffsetFormatter.format(zonedDateTime),
|
||||
zoneNameFormatter.format(zonedDateTime),
|
||||
)
|
||||
}
|
||||
|
||||
updateSelectedDate()
|
||||
updateSelectedTime()
|
||||
|
||||
setupDateSelector()
|
||||
setupTimeSelector()
|
||||
}
|
||||
|
||||
private fun setupDateSelector() {
|
||||
binding.daySelector.setOnClickListener {
|
||||
val local = LocalDateTime.now()
|
||||
.atMidnight()
|
||||
.atUTC()
|
||||
.toMillis()
|
||||
val datePicker =
|
||||
MaterialDatePicker.Builder.datePicker()
|
||||
.setTitleText(getString(R.string.ScheduleMessageTimePickerBottomSheet__select_date_title))
|
||||
.setSelection(scheduledDate)
|
||||
.setCalendarConstraints(CalendarConstraints.Builder().setStart(local).setValidator(DateValidatorPointForward.now()).build())
|
||||
.build()
|
||||
|
||||
datePicker.addOnDismissListener {
|
||||
datePicker.clearOnDismissListeners()
|
||||
datePicker.clearOnPositiveButtonClickListeners()
|
||||
}
|
||||
|
||||
datePicker.addOnPositiveButtonClickListener {
|
||||
it.let {
|
||||
scheduledDate = it.toLocalDateTime(ZoneOffset.UTC).atZone(ZoneId.systemDefault()).toMillis()
|
||||
updateSelectedDate()
|
||||
}
|
||||
}
|
||||
datePicker.show(childFragmentManager, "DATE_PICKER")
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupTimeSelector() {
|
||||
binding.timeSelector.setOnClickListener {
|
||||
val timeFormat = if (DateFormat.is24HourFormat(requireContext())) TimeFormat.CLOCK_24H else TimeFormat.CLOCK_12H
|
||||
val timePickerFragment = MaterialTimePicker.Builder()
|
||||
.setTimeFormat(timeFormat)
|
||||
.setHour(scheduledHour)
|
||||
.setMinute(scheduledMinute)
|
||||
.setTitleText(getString(R.string.ScheduleMessageTimePickerBottomSheet__select_time_title))
|
||||
.build()
|
||||
|
||||
timePickerFragment.addOnDismissListener {
|
||||
timePickerFragment.clearOnDismissListeners()
|
||||
timePickerFragment.clearOnPositiveButtonClickListeners()
|
||||
}
|
||||
|
||||
timePickerFragment.addOnPositiveButtonClickListener {
|
||||
scheduledHour = timePickerFragment.hour
|
||||
scheduledMinute = timePickerFragment.minute
|
||||
|
||||
updateSelectedTime()
|
||||
}
|
||||
|
||||
timePickerFragment.show(childFragmentManager, "TIME_PICKER")
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSelectedTimestamp(): Long {
|
||||
return scheduledDate.toLocalDateTime()
|
||||
.withMinute(scheduledMinute)
|
||||
.withHour(scheduledHour)
|
||||
.withSecond(0)
|
||||
.withNano(0)
|
||||
.toMillis()
|
||||
}
|
||||
|
||||
private fun updateSelectedDate() {
|
||||
binding.dateText.text = DateUtils.getDayPrecisionTimeString(requireContext(), Locale.getDefault(), scheduledDate)
|
||||
}
|
||||
|
||||
private fun updateSelectedTime() {
|
||||
val scheduledTime = LocalTime.of(scheduledHour, scheduledMinute)
|
||||
binding.timeText.text = scheduledTime.formatHours(requireContext())
|
||||
}
|
||||
|
||||
interface ScheduleCallback {
|
||||
fun onScheduleSend(scheduledTime: Long)
|
||||
}
|
||||
|
||||
interface RescheduleCallback {
|
||||
fun onReschedule(scheduledTime: Long, messageId: Long)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val KEY_MESSAGE_ID = "message_id"
|
||||
private const val KEY_INITIAL_TIME = "initial_time"
|
||||
|
||||
@JvmStatic
|
||||
fun showSchedule(fragmentManager: FragmentManager) {
|
||||
val fragment = ScheduleMessageTimePickerBottomSheet()
|
||||
|
||||
fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun showReschedule(fragmentManager: FragmentManager, messageId: Long, initialTime: Long) {
|
||||
val args = Bundle().apply {
|
||||
putLong(KEY_MESSAGE_ID, messageId)
|
||||
putLong(KEY_INITIAL_TIME, initialTime)
|
||||
}
|
||||
|
||||
val fragment = ScheduleMessageTimePickerBottomSheet().apply {
|
||||
arguments = args
|
||||
}
|
||||
|
||||
fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
package org.thoughtcrime.securesms.conversation
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.DialogInterface
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.view.doOnNextLayout
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import org.signal.core.util.StreamUtil
|
||||
import org.signal.core.util.concurrent.SimpleTask
|
||||
import org.signal.core.util.dp
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.FixedRoundedCornerBottomSheetDialogFragment
|
||||
import org.thoughtcrime.securesms.components.SignalProgressDialog
|
||||
import org.thoughtcrime.securesms.components.menu.ActionItem
|
||||
import org.thoughtcrime.securesms.components.menu.SignalContextMenu
|
||||
import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager
|
||||
import org.thoughtcrime.securesms.conversation.colors.Colorizer
|
||||
import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart
|
||||
import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart.Attachments
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.database.model.MmsMessageRecord
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ItemDecoration
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackController
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4PlaybackPolicy
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionPlayerHolder
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ProjectionRecycler
|
||||
import org.thoughtcrime.securesms.groups.GroupId
|
||||
import org.thoughtcrime.securesms.groups.GroupMigrationMembershipChange
|
||||
import org.thoughtcrime.securesms.linkpreview.LinkPreview
|
||||
import org.thoughtcrime.securesms.mms.GlideApp
|
||||
import org.thoughtcrime.securesms.mms.PartAuthority
|
||||
import org.thoughtcrime.securesms.mms.TextSlide
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil
|
||||
import org.thoughtcrime.securesms.util.BottomSheetUtil.requireCoordinatorLayout
|
||||
import org.thoughtcrime.securesms.util.LifecycleDisposable
|
||||
import org.thoughtcrime.securesms.util.StickyHeaderDecoration
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.thoughtcrime.securesms.util.fragments.requireListener
|
||||
import org.thoughtcrime.securesms.util.hasTextSlide
|
||||
import org.thoughtcrime.securesms.util.requireTextSlide
|
||||
import java.io.IOException
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Bottom sheet dialog to view all scheduled messages within a given thread.
|
||||
*/
|
||||
class ScheduledMessagesBottomSheet : FixedRoundedCornerBottomSheetDialogFragment(), ScheduleMessageTimePickerBottomSheet.RescheduleCallback {
|
||||
|
||||
override val peekHeightPercentage: Float = 0.66f
|
||||
override val themeResId: Int = R.style.Widget_Signal_FixedRoundedCorners_Messages
|
||||
|
||||
private var firstRender: Boolean = true
|
||||
|
||||
private lateinit var messageAdapter: ConversationAdapter
|
||||
private lateinit var callback: Callback
|
||||
|
||||
private val viewModel: ScheduledMessagesViewModel by viewModels(
|
||||
factoryProducer = {
|
||||
val threadId = requireArguments().getLong(KEY_THREAD_ID)
|
||||
ScheduledMessagesViewModel.Factory(threadId)
|
||||
}
|
||||
)
|
||||
|
||||
private val disposables: LifecycleDisposable = LifecycleDisposable()
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
val view = inflater.inflate(R.layout.scheduled_messages_bottom_sheet, container, false)
|
||||
disposables.bindTo(viewLifecycleOwner)
|
||||
return view
|
||||
}
|
||||
|
||||
@SuppressLint("WrongThread")
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val conversationRecipientId = RecipientId.from(arguments?.getString(KEY_CONVERSATION_RECIPIENT_ID, null) ?: throw IllegalArgumentException())
|
||||
val conversationRecipient = Recipient.resolved(conversationRecipientId)
|
||||
|
||||
callback = requireListener()
|
||||
|
||||
val colorizer = Colorizer()
|
||||
|
||||
messageAdapter = ConversationAdapter(requireContext(), viewLifecycleOwner, GlideApp.with(this), Locale.getDefault(), ConversationAdapterListener(), conversationRecipient, colorizer).apply {
|
||||
setCondensedMode(true)
|
||||
setScheduledMessagesMode(true)
|
||||
}
|
||||
|
||||
val list: RecyclerView = view.findViewById<RecyclerView>(R.id.scheduled_list).apply {
|
||||
layoutManager = SmoothScrollingLinearLayoutManager(requireContext(), true)
|
||||
adapter = messageAdapter
|
||||
itemAnimator = null
|
||||
|
||||
doOnNextLayout {
|
||||
// Adding this without waiting for a layout pass would result in an indeterminate amount of padding added to the top of the view
|
||||
addItemDecoration(StickyHeaderDecoration(messageAdapter, false, false, ConversationAdapter.HEADER_TYPE_INLINE_DATE))
|
||||
}
|
||||
}
|
||||
|
||||
val recyclerViewColorizer = RecyclerViewColorizer(list)
|
||||
|
||||
disposables += viewModel.getMessages(requireContext()).subscribe { messages ->
|
||||
if (messages.isEmpty()) {
|
||||
dismiss()
|
||||
}
|
||||
|
||||
messageAdapter.submitList(messages) {
|
||||
if (firstRender) {
|
||||
(list.layoutManager as LinearLayoutManager).scrollToPositionWithOffset(messages.size - 1, 100)
|
||||
|
||||
firstRender = false
|
||||
} else if (!list.canScrollVertically(1)) {
|
||||
list.layoutManager?.scrollToPosition(0)
|
||||
}
|
||||
}
|
||||
recyclerViewColorizer.setChatColors(conversationRecipient.chatColors)
|
||||
}
|
||||
|
||||
initializeGiphyMp4(view.findViewById(R.id.video_container) as ViewGroup, list)
|
||||
}
|
||||
|
||||
private fun initializeGiphyMp4(videoContainer: ViewGroup, list: RecyclerView): GiphyMp4ProjectionRecycler {
|
||||
val maxPlayback = GiphyMp4PlaybackPolicy.maxSimultaneousPlaybackInConversation()
|
||||
val holders = GiphyMp4ProjectionPlayerHolder.injectVideoViews(
|
||||
requireContext(),
|
||||
viewLifecycleOwner.lifecycle,
|
||||
videoContainer,
|
||||
maxPlayback
|
||||
)
|
||||
val callback = GiphyMp4ProjectionRecycler(holders)
|
||||
|
||||
GiphyMp4PlaybackController.attach(list, callback, maxPlayback)
|
||||
list.addItemDecoration(GiphyMp4ItemDecoration(callback) {}, 0)
|
||||
|
||||
return callback
|
||||
}
|
||||
|
||||
private fun showScheduledMessageContextMenu(view: View, messageRecord: MessageRecord) {
|
||||
SignalContextMenu.Builder(view, requireCoordinatorLayout())
|
||||
.offsetX(12.dp)
|
||||
.offsetY(12.dp)
|
||||
.preferredVerticalPosition(SignalContextMenu.VerticalPosition.ABOVE)
|
||||
.show(getMenuActionItems(messageRecord))
|
||||
}
|
||||
|
||||
private fun getMenuActionItems(messageRecord: MessageRecord): List<ActionItem> {
|
||||
val message = ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(requireContext(), messageRecord)
|
||||
val canCopy = message.multiselectCollection.toSet().any { it !is Attachments && messageRecord.body.isNotEmpty() }
|
||||
val items: MutableList<ActionItem> = ArrayList()
|
||||
items.add(ActionItem(R.drawable.ic_delete_tinted_24, resources.getString(R.string.conversation_selection__menu_delete), action = { handleDeleteMessage(messageRecord) }))
|
||||
if (canCopy) {
|
||||
items.add(ActionItem(R.drawable.ic_copy_24_tinted, resources.getString(R.string.conversation_selection__menu_copy), action = { handleCopyMessage(message) }))
|
||||
}
|
||||
items.add(ActionItem(R.drawable.ic_send_outline_24, resources.getString(R.string.ScheduledMessagesBottomSheet_menu_send_now), action = { handleSendMessageNow(messageRecord) }))
|
||||
items.add(ActionItem(R.drawable.ic_calendar_24, resources.getString(R.string.ScheduledMessagesBottomSheet_menu_reschedule), action = { handleRescheduleMessage(messageRecord) }))
|
||||
return items
|
||||
}
|
||||
|
||||
private fun handleRescheduleMessage(messageRecord: MessageRecord) {
|
||||
ScheduleMessageTimePickerBottomSheet.showReschedule(childFragmentManager, messageRecord.id, (messageRecord as MediaMmsMessageRecord).scheduledDate)
|
||||
}
|
||||
|
||||
private fun handleSendMessageNow(messageRecord: MessageRecord) {
|
||||
viewModel.rescheduleMessage(messageRecord.id, System.currentTimeMillis())
|
||||
}
|
||||
|
||||
private fun handleDeleteMessage(messageRecord: MessageRecord) {
|
||||
buildDeleteScheduledMessageConfirmationDialog(messageRecord).show()
|
||||
}
|
||||
|
||||
private fun handleCopyMessage(message: ConversationMessage) {
|
||||
SimpleTask.run(
|
||||
viewLifecycleOwner.lifecycle,
|
||||
{ getMessageText(message) },
|
||||
{ bodies: CharSequence? ->
|
||||
if (!Util.isEmpty(bodies)) {
|
||||
Util.copyToClipboard(requireContext(), bodies!!)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildDeleteScheduledMessageConfirmationDialog(messageRecord: MessageRecord): AlertDialog.Builder {
|
||||
return MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(resources.getString(R.string.ScheduledMessagesBottomSheet_delete_dialog_message))
|
||||
.setCancelable(true)
|
||||
.setPositiveButton(R.string.ScheduledMessagesBottomSheet_delete_dialog_action) { _: DialogInterface?, _: Int ->
|
||||
deleteMessage(messageRecord.id)
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
}
|
||||
|
||||
private fun getMessageText(message: ConversationMessage): CharSequence {
|
||||
if (message.messageRecord.hasTextSlide()) {
|
||||
val textSlide: TextSlide = message.messageRecord.requireTextSlide()
|
||||
if (textSlide.uri == null) {
|
||||
return message.getDisplayBody(requireContext())
|
||||
}
|
||||
try {
|
||||
PartAuthority.getAttachmentStream(requireContext(), textSlide.uri!!).use { stream ->
|
||||
val body = StreamUtil.readFullyAsString(stream)
|
||||
return ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(requireContext(), message.messageRecord, body)
|
||||
.getDisplayBody(requireContext())
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.w(TAG, "Failed to read text slide data.")
|
||||
}
|
||||
}
|
||||
return ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(requireContext(), message.messageRecord).getDisplayBody(requireContext())
|
||||
}
|
||||
|
||||
private fun deleteMessage(messageId: Long) {
|
||||
val progressDialog = SignalProgressDialog.show(
|
||||
context = requireContext(),
|
||||
message = resources.getString(R.string.ScheduledMessagesBottomSheet_deleting_progress_message),
|
||||
indeterminate = true
|
||||
)
|
||||
SimpleTask.run(viewLifecycleOwner.lifecycle, {
|
||||
SignalDatabase.messages.deleteScheduledMessage(messageId)
|
||||
}, {
|
||||
progressDialog.dismiss()
|
||||
})
|
||||
}
|
||||
|
||||
override fun onReschedule(scheduledTime: Long, messageId: Long) {
|
||||
viewModel.rescheduleMessage(messageId, scheduledTime)
|
||||
}
|
||||
|
||||
private fun getAdapterListener(): ConversationAdapter.ItemClickListener {
|
||||
return callback.getConversationAdapterListener()
|
||||
}
|
||||
|
||||
private inner class ConversationAdapterListener : ConversationAdapter.ItemClickListener by getAdapterListener() {
|
||||
override fun onItemClick(item: MultiselectPart) = Unit
|
||||
override fun onItemLongClick(itemView: View, item: MultiselectPart) = Unit
|
||||
override fun onQuoteClicked(messageRecord: MmsMessageRecord) = Unit
|
||||
override fun onLinkPreviewClicked(linkPreview: LinkPreview) = Unit
|
||||
override fun onQuotedIndicatorClicked(messageRecord: MessageRecord) = Unit
|
||||
override fun onReactionClicked(multiselectPart: MultiselectPart, messageId: Long, isMms: Boolean) = Unit
|
||||
override fun onGroupMemberClicked(recipientId: RecipientId, groupId: GroupId) = Unit
|
||||
override fun onMessageWithRecaptchaNeededClicked(messageRecord: MessageRecord) = Unit
|
||||
override fun onGroupMigrationLearnMoreClicked(membershipChange: GroupMigrationMembershipChange) = Unit
|
||||
override fun onChatSessionRefreshLearnMoreClicked() = Unit
|
||||
override fun onBadDecryptLearnMoreClicked(author: RecipientId) = Unit
|
||||
override fun onSafetyNumberLearnMoreClicked(recipient: Recipient) = Unit
|
||||
override fun onJoinGroupCallClicked() = Unit
|
||||
override fun onInviteFriendsToGroupClicked(groupId: GroupId.V2) = Unit
|
||||
override fun onEnableCallNotificationsClicked() = Unit
|
||||
override fun onCallToAction(action: String) = Unit
|
||||
override fun onDonateClicked() = Unit
|
||||
override fun onRecipientNameClicked(target: RecipientId) = Unit
|
||||
override fun onViewGiftBadgeClicked(messageRecord: MessageRecord) = Unit
|
||||
override fun onActivatePaymentsClicked() = Unit
|
||||
override fun onSendPaymentClicked(recipientId: RecipientId) = Unit
|
||||
override fun onScheduledIndicatorClicked(view: View, messageRecord: MessageRecord) {
|
||||
showScheduledMessageContextMenu(view, messageRecord)
|
||||
}
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
fun getConversationAdapterListener(): ConversationAdapter.ItemClickListener
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(ScheduledMessagesBottomSheet::class.java)
|
||||
|
||||
private const val KEY_THREAD_ID = "thread_id"
|
||||
private const val KEY_CONVERSATION_RECIPIENT_ID = "conversation_recipient_id"
|
||||
|
||||
@JvmStatic
|
||||
fun show(fragmentManager: FragmentManager, threadId: Long, conversationRecipientId: RecipientId) {
|
||||
val args = Bundle().apply {
|
||||
putLong(KEY_THREAD_ID, threadId)
|
||||
putString(KEY_CONVERSATION_RECIPIENT_ID, conversationRecipientId.serialize())
|
||||
}
|
||||
|
||||
val fragment = ScheduledMessagesBottomSheet().apply {
|
||||
arguments = args
|
||||
}
|
||||
|
||||
fragment.show(fragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package org.thoughtcrime.securesms.conversation
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.WorkerThread
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.thoughtcrime.securesms.database.DatabaseObserver
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.MessageRecord
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
|
||||
/**
|
||||
* Handles retrieving scheduled messages data to be shown in [ScheduledMessagesBottomSheet] and [ConversationParentFragment]
|
||||
*/
|
||||
class ScheduledMessagesRepository {
|
||||
|
||||
/**
|
||||
* Get all the scheduled messages for the specified thread, ordered by scheduled time
|
||||
*/
|
||||
fun getScheduledMessages(context: Context, threadId: Long): Observable<List<ConversationMessage>> {
|
||||
return Observable.create { emitter ->
|
||||
val databaseObserver: DatabaseObserver = ApplicationDependencies.getDatabaseObserver()
|
||||
val observer = DatabaseObserver.Observer { emitter.onNext(getScheduledMessagesSync(context, threadId)) }
|
||||
|
||||
databaseObserver.registerScheduledMessageObserver(threadId, observer)
|
||||
|
||||
emitter.setCancellable { databaseObserver.unregisterObserver(observer) }
|
||||
emitter.onNext(getScheduledMessagesSync(context, threadId))
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun getScheduledMessagesSync(context: Context, threadId: Long): List<ConversationMessage> {
|
||||
var scheduledMessages: List<MessageRecord> = SignalDatabase.messages.getScheduledMessagesInThread(threadId)
|
||||
|
||||
val attachmentHelper = ConversationDataSource.AttachmentHelper()
|
||||
|
||||
attachmentHelper.addAll(scheduledMessages)
|
||||
|
||||
attachmentHelper.fetchAttachments()
|
||||
|
||||
scheduledMessages = attachmentHelper.buildUpdatedModels(ApplicationDependencies.getApplication(), scheduledMessages)
|
||||
|
||||
val replies: List<ConversationMessage> = scheduledMessages
|
||||
.map { ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(context, it) }
|
||||
|
||||
return replies
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of scheduled messages for a given thread
|
||||
*/
|
||||
fun getScheduledMessageCount(threadId: Long): Observable<Int> {
|
||||
return Observable.create { emitter ->
|
||||
val databaseObserver: DatabaseObserver = ApplicationDependencies.getDatabaseObserver()
|
||||
val observer = DatabaseObserver.Observer { emitter.onNext(SignalDatabase.messages.getScheduledMessageCountForThread(threadId)) }
|
||||
|
||||
databaseObserver.registerScheduledMessageObserver(threadId, observer)
|
||||
|
||||
emitter.setCancellable { databaseObserver.unregisterObserver(observer) }
|
||||
emitter.onNext(SignalDatabase.messages.getScheduledMessageCountForThread(threadId))
|
||||
}.subscribeOn(Schedulers.io())
|
||||
}
|
||||
|
||||
fun rescheduleMessage(threadId: Long, messageId: Long, scheduleTime: Long) {
|
||||
SignalDatabase.messages.rescheduleMessage(threadId, messageId, scheduleTime)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package org.thoughtcrime.securesms.conversation
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Observable
|
||||
import org.signal.core.util.logging.Log
|
||||
|
||||
class ScheduledMessagesViewModel @JvmOverloads constructor(
|
||||
private val threadId: Long,
|
||||
private val repository: ScheduledMessagesRepository = ScheduledMessagesRepository()
|
||||
) : ViewModel() {
|
||||
|
||||
fun getMessages(context: Context): Observable<List<ConversationMessage>> {
|
||||
return repository.getScheduledMessages(context, threadId)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
}
|
||||
|
||||
fun rescheduleMessage(messageId: Long, scheduleTime: Long) {
|
||||
repository.rescheduleMessage(threadId, messageId, scheduleTime)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(ScheduledMessagesViewModel::class.java)
|
||||
}
|
||||
|
||||
class Factory(private val threadId: Long) : ViewModelProvider.NewInstanceFactory() {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return modelClass.cast(ScheduledMessagesViewModel(threadId)) as T
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user