From 0a76eb81e6e61894adda398a46da43fe820465fb Mon Sep 17 00:00:00 2001 From: Cody Henthorne Date: Fri, 5 Aug 2022 17:00:11 -0400 Subject: [PATCH] Add save-as-you-compose drafts. --- .../securesms/components/InputPanel.java | 11 + .../contactshare/SimpleTextWatcher.java | 10 +- .../conversation/ConversationActivity.kt | 5 - .../ConversationParentFragment.java | 371 +++++++----------- .../ConversationPopupActivity.java | 33 -- .../conversation/drafts/DraftRepository.kt | 71 +++- .../conversation/drafts/DraftState.kt | 40 +- .../conversation/drafts/DraftViewModel.kt | 137 ++++--- .../securesms/database/DraftDatabase.java | 6 + .../securesms/mms/AttachmentManager.java | 7 + 10 files changed, 356 insertions(+), 335 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java b/app/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java index fdd34a138..75aa3a125 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/InputPanel.java @@ -50,6 +50,7 @@ import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.mms.QuoteModel; import org.thoughtcrime.securesms.mms.SlideDeck; import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.util.ViewUtil; import org.thoughtcrime.securesms.util.concurrent.AssertedSuccessListener; import org.thoughtcrime.securesms.util.concurrent.ListenableFuture; @@ -210,6 +211,10 @@ public class InputPanel extends LinearLayout int cornerRadius = readDimen(R.dimen.message_corner_collapse_radius); this.linkPreview.setCorners(cornerRadius, cornerRadius); } + + if (listener != null) { + listener.onQuoteChanged(id, author.getId()); + } } public void clearQuote() { @@ -230,6 +235,10 @@ public class InputPanel extends LinearLayout }); quoteAnimator.start(); + + if (listener != null) { + listener.onQuoteCleared(); + } } private static ValueAnimator createHeightAnimator(@NonNull View view, @@ -572,6 +581,8 @@ public class InputPanel extends LinearLayout void onEmojiToggle(); void onLinkPreviewCanceled(); void onStickerSuggestionSelected(@NonNull StickerRecord sticker); + void onQuoteChanged(long id, @NonNull RecipientId author); + void onQuoteCleared(); } private static class SlideToCancel { diff --git a/app/src/main/java/org/thoughtcrime/securesms/contactshare/SimpleTextWatcher.java b/app/src/main/java/org/thoughtcrime/securesms/contactshare/SimpleTextWatcher.java index b2448b8f8..8749a76e0 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contactshare/SimpleTextWatcher.java +++ b/app/src/main/java/org/thoughtcrime/securesms/contactshare/SimpleTextWatcher.java @@ -3,6 +3,8 @@ package org.thoughtcrime.securesms.contactshare; import android.text.Editable; import android.text.TextWatcher; +import androidx.annotation.NonNull; + public abstract class SimpleTextWatcher implements TextWatcher { @Override @@ -10,11 +12,15 @@ public abstract class SimpleTextWatcher implements TextWatcher { @Override public void onTextChanged(CharSequence s, int start, int before, int count) { - onTextChanged(s.toString()); + onTextChanged(s); } @Override public void afterTextChanged(Editable s) { } - public abstract void onTextChanged(String text); + public void onTextChanged(@NonNull CharSequence text) { + onTextChanged(text.toString()); + } + + public void onTextChanged(@NonNull String text) { } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.kt index c21996f80..2d9b92462 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.kt @@ -17,7 +17,6 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.DonationP import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.util.DynamicNoActionBarTheme import org.thoughtcrime.securesms.util.DynamicTheme -import org.thoughtcrime.securesms.util.concurrent.ListenableFuture import org.thoughtcrime.securesms.util.views.Stub open class ConversationActivity : PassphraseRequiredActivity(), ConversationParentFragment.Callback, DonationPaymentComponent { @@ -89,10 +88,6 @@ open class ConversationActivity : PassphraseRequiredActivity(), ConversationPare toolbar.setNavigationOnClickListener { finish() } } - fun saveDraft(): ListenableFuture { - return fragment.saveDraft() - } - fun getRecipient(): Recipient { return fragment.recipient } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java index 0b567bbfd..609edd870 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationParentFragment.java @@ -43,8 +43,6 @@ import android.provider.Browser; import android.provider.ContactsContract; import android.provider.Settings; import android.text.Editable; -import android.text.Spannable; -import android.text.SpannableString; import android.text.SpannableStringBuilder; import android.text.TextWatcher; import android.text.method.LinkMovementMethod; @@ -103,7 +101,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder; import org.greenrobot.eventbus.EventBus; import org.greenrobot.eventbus.Subscribe; import org.greenrobot.eventbus.ThreadMode; -import org.signal.core.util.DimensionUnit; +import org.signal.core.util.StringUtil; import org.signal.core.util.ThreadUtil; import org.signal.core.util.concurrent.SignalExecutors; import org.signal.core.util.concurrent.SimpleTask; @@ -138,8 +136,6 @@ 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; @@ -163,25 +159,19 @@ import org.thoughtcrime.securesms.contactshare.ContactUtil; import org.thoughtcrime.securesms.contactshare.SimpleTextWatcher; import org.thoughtcrime.securesms.conversation.ConversationGroupViewModel.GroupActiveState; import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory; -import org.thoughtcrime.securesms.conversation.drafts.DraftRepository; import org.thoughtcrime.securesms.conversation.drafts.DraftViewModel; import org.thoughtcrime.securesms.conversation.ui.groupcall.GroupCallViewModel; import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQuery; import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQueryChangedListener; import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQueryResultsController; -import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQueryResultsPopup; import org.thoughtcrime.securesms.conversation.ui.inlinequery.InlineQueryViewModel; import org.thoughtcrime.securesms.conversation.ui.mentions.MentionsPickerViewModel; import org.thoughtcrime.securesms.crypto.ReentrantSessionLock; import org.thoughtcrime.securesms.crypto.SecurityEvent; -import org.thoughtcrime.securesms.database.DraftDatabase; import org.thoughtcrime.securesms.database.DraftDatabase.Draft; import org.thoughtcrime.securesms.database.DraftDatabase.Drafts; import org.thoughtcrime.securesms.database.GroupDatabase; import org.thoughtcrime.securesms.database.IdentityDatabase.VerifiedStatus; -import org.thoughtcrime.securesms.database.MentionUtil; -import org.thoughtcrime.securesms.database.MentionUtil.UpdatedBodyAndMentions; -import org.thoughtcrime.securesms.database.MmsSmsColumns.Types; import org.thoughtcrime.securesms.database.RecipientDatabase; import org.thoughtcrime.securesms.database.RecipientDatabase.RegisteredState; import org.thoughtcrime.securesms.database.SignalDatabase; @@ -242,7 +232,6 @@ import org.thoughtcrime.securesms.mms.GifSlide; import org.thoughtcrime.securesms.mms.GlideApp; import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.mms.ImageSlide; -import org.thoughtcrime.securesms.mms.LocationSlide; import org.thoughtcrime.securesms.mms.MediaConstraints; import org.thoughtcrime.securesms.mms.OutgoingMediaMessage; import org.thoughtcrime.securesms.mms.OutgoingSecureMediaMessage; @@ -285,7 +274,6 @@ import org.thoughtcrime.securesms.stickers.StickerSearchRepository; import org.thoughtcrime.securesms.stories.StoryViewerArgs; import org.thoughtcrime.securesms.stories.viewer.StoryViewerActivity; import org.thoughtcrime.securesms.util.AsynchronousCallback; -import org.thoughtcrime.securesms.util.Base64; import org.thoughtcrime.securesms.util.BitmapUtil; import org.thoughtcrime.securesms.util.BubbleUtil; import org.thoughtcrime.securesms.util.CharacterCalculator.CharacterState; @@ -417,7 +405,7 @@ public class ConversationParentFragment extends Fragment protected Stub reminderView; private Stub unverifiedBannerView; private Stub reviewBanner; - private TypingStatusTextWatcher typingTextWatcher; + private ComposeTextWatcher typingTextWatcher; private ConversationSearchBottomBar searchNav; private MenuItem searchViewItem; private MessageRequestsBottomView messageRequestBottomView; @@ -466,11 +454,12 @@ public class ConversationParentFragment extends Fragment private LiveRecipient recipient; private long threadId; private int distributionType; - private int reactWithAnyEmojiStartPage = -1; - private boolean isSearchRequested = false; + private int reactWithAnyEmojiStartPage = -1; + private boolean isSearchRequested = false; - private final LifecycleDisposable disposables = new LifecycleDisposable(); - private final Debouncer optionsMenuDebouncer = new Debouncer(50); + private final LifecycleDisposable disposables = new LifecycleDisposable(); + private final Debouncer optionsMenuDebouncer = new Debouncer(50); + private final Debouncer textDraftSaveDebouncer = new Debouncer(500); private IdentityRecordList identityRecords = new IdentityRecordList(Collections.emptyList()); private Callback callback; @@ -640,7 +629,6 @@ public class ConversationParentFragment extends Fragment @Override public void onStop() { super.onStop(); - saveDraft(); EventBus.getDefault().unregister(this); } @@ -730,6 +718,7 @@ public class ConversationParentFragment extends Fragment case PICK_LOCATION: SignalPlace place = new SignalPlace(PlacePickerActivity.addressFromData(data)); attachmentManager.setLocation(place, getCurrentMediaConstraints()); + draftViewModel.setLocationDraft(place); break; case SMS_DEFAULT: viewModel.updateSecurityInfo(); @@ -1423,6 +1412,7 @@ public class ConversationParentFragment extends Fragment private void handleDistributionBroadcastEnabled(MenuItem item) { distributionType = ThreadDatabase.DistributionTypes.BROADCAST; + draftViewModel.setDistributionType(distributionType); item.setChecked(true); if (threadId != -1) { @@ -1438,6 +1428,7 @@ public class ConversationParentFragment extends Fragment private void handleDistributionConversationEnabled(MenuItem item) { distributionType = ThreadDatabase.DistributionTypes.CONVERSATION; + draftViewModel.setDistributionType(distributionType); item.setChecked(true); if (threadId != -1) { @@ -1772,90 +1763,68 @@ public class ConversationParentFragment extends Fragment private ListenableFuture initializeDraftFromDatabase() { SettableFuture future = new SettableFuture<>(); - final Context context = requireContext().getApplicationContext(); + Disposable disposable = draftViewModel + .loadDrafts(threadId) + .subscribe(databaseDrafts -> { + Drafts drafts = databaseDrafts.getDrafts(); + CharSequence updatedText = databaseDrafts.getUpdatedText(); - new AsyncTask>() { - @Override - protected Pair doInBackground(Void... params) { - DraftDatabase draftDatabase = SignalDatabase.drafts(); - Drafts results = draftDatabase.getDrafts(threadId); - Draft mentionsDraft = results.getDraftOfType(Draft.MENTION); - Spannable updatedText = null; + if (drafts.isEmpty()) { + future.set(false); + updateToggleButtonState(); + return; + } - if (mentionsDraft != null) { - String text = results.getDraftOfType(Draft.TEXT).getValue(); - List mentions = MentionUtil.bodyRangeListToMentions(context, Base64.decodeOrThrow(mentionsDraft.getValue())); - UpdatedBodyAndMentions updated = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, text, mentions); + AtomicInteger draftsRemaining = new AtomicInteger(drafts.size()); + AtomicBoolean success = new AtomicBoolean(false); + ListenableFuture.Listener listener = new AssertedSuccessListener() { + @Override + public void onSuccess(Boolean result) { + success.compareAndSet(false, result); - updatedText = new SpannableString(updated.getBody()); - MentionAnnotation.setMentionAnnotations(updatedText, updated.getMentions()); - } + if (draftsRemaining.decrementAndGet() <= 0) { + future.set(success.get()); + } + } + }; - draftDatabase.clearDrafts(threadId); + for (Draft draft : drafts) { + try { + switch (draft.getType()) { + case Draft.TEXT: + composeText.setText(updatedText == null ? draft.getValue() : updatedText); + listener.onSuccess(true); + break; + case Draft.LOCATION: + attachmentManager.setLocation(SignalPlace.deserialize(draft.getValue()), getCurrentMediaConstraints()).addListener(listener); + break; + case Draft.IMAGE: + setMedia(Uri.parse(draft.getValue()), MediaType.IMAGE).addListener(listener); + break; + case Draft.AUDIO: + setMedia(Uri.parse(draft.getValue()), MediaType.AUDIO).addListener(listener); + break; + case Draft.VIDEO: + setMedia(Uri.parse(draft.getValue()), MediaType.VIDEO).addListener(listener); + break; + case Draft.QUOTE: + SettableFuture quoteResult = new SettableFuture<>(); + new QuoteRestorationTask(draft.getValue(), quoteResult).execute(); + quoteResult.addListener(listener); + break; + case Draft.VOICE_NOTE: + listener.onSuccess(true); + break; + } + } catch (IOException e) { + Log.w(TAG, e); + } + } - return new Pair<>(results, updatedText); - } - - @Override - protected void onPostExecute(Pair draftsWithUpdatedMentions) { - Drafts drafts = Objects.requireNonNull(draftsWithUpdatedMentions.first()); - CharSequence updatedText = draftsWithUpdatedMentions.second(); - - if (drafts.isEmpty()) { - future.set(false); updateToggleButtonState(); - return; - } + }); - AtomicInteger draftsRemaining = new AtomicInteger(drafts.size()); - AtomicBoolean success = new AtomicBoolean(false); - ListenableFuture.Listener listener = new AssertedSuccessListener() { - @Override - public void onSuccess(Boolean result) { - success.compareAndSet(false, result); - - if (draftsRemaining.decrementAndGet() <= 0) { - future.set(success.get()); - } - } - }; - - for (Draft draft : drafts) { - try { - switch (draft.getType()) { - case Draft.TEXT: - composeText.setText(updatedText == null ? draft.getValue() : updatedText); - listener.onSuccess(true); - break; - case Draft.LOCATION: - attachmentManager.setLocation(SignalPlace.deserialize(draft.getValue()), getCurrentMediaConstraints()).addListener(listener); - break; - case Draft.IMAGE: - setMedia(Uri.parse(draft.getValue()), MediaType.IMAGE).addListener(listener); - break; - case Draft.AUDIO: - setMedia(Uri.parse(draft.getValue()), MediaType.AUDIO).addListener(listener); - break; - case Draft.VIDEO: - setMedia(Uri.parse(draft.getValue()), MediaType.VIDEO).addListener(listener); - break; - case Draft.QUOTE: - SettableFuture quoteResult = new SettableFuture<>(); - new QuoteRestorationTask(draft.getValue(), quoteResult).execute(); - quoteResult.addListener(listener); - break; - case Draft.VOICE_NOTE: - draftViewModel.setVoiceNoteDraft(recipient.getId(), draft); - break; - } - } catch (IOException e) { - Log.w(TAG, e); - } - } - - updateToggleButtonState(); - } - }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); + disposables.add(disposable); return future; } @@ -2062,7 +2031,7 @@ public class ConversationParentFragment extends Fragment attachmentManager = new AttachmentManager(requireActivity(), this); audioRecorder = new AudioRecorder(requireContext()); - typingTextWatcher = new TypingStatusTextWatcher(); + typingTextWatcher = new ComposeTextWatcher(); SendButtonListener sendButtonListener = new SendButtonListener(); ComposeKeyPressedListener composeKeyPressedListener = new ComposeKeyPressedListener(); @@ -2437,17 +2406,24 @@ public class ConversationParentFragment extends Fragment } public void initializeDraftViewModel() { - draftViewModel = new ViewModelProvider(this, new DraftViewModel.Factory(new DraftRepository(requireContext().getApplicationContext()))).get(DraftViewModel.class); + draftViewModel = new ViewModelProvider(this).get(DraftViewModel.class); recipient.observe(getViewLifecycleOwner(), r -> { draftViewModel.onRecipientChanged(r); }); - draftViewModel.getState().observe(getViewLifecycleOwner(), - state -> { - inputPanel.setVoiceNoteDraft(state.getVoiceNoteDraft()); - updateToggleButtonState(); - }); + draftViewModel.setThreadId(threadId); + draftViewModel.setDistributionType(distributionType); + + disposables.add( + draftViewModel + .getState() + .distinctUntilChanged(state -> state.getVoiceNoteDraft()) + .subscribe(state -> { + inputPanel.setVoiceNoteDraft(state.getVoiceNoteDraft()); + updateToggleButtonState(); + }) + ); } private void showGroupCallingTooltip() { @@ -2605,6 +2581,7 @@ public class ConversationParentFragment extends Fragment this.threadId = threadId; fragment.reload(recipient, this.threadId); setVisibleThread(this.threadId); + draftViewModel.setThreadId(this.threadId); } }); } @@ -2715,102 +2692,6 @@ public class ConversationParentFragment extends Fragment builder.show(); } - private Drafts getDraftsForCurrentState() { - Drafts drafts = new Drafts(); - - if (recipient.get().isGroup() && !recipient.get().isActiveGroup()) { - return drafts; - } - - if (!Util.isEmpty(composeText)) { - drafts.add(new Draft(Draft.TEXT, composeText.getTextTrimmed().toString())); - List draftMentions = composeText.getMentions(); - if (!draftMentions.isEmpty()) { - drafts.add(new Draft(Draft.MENTION, Base64.encodeBytes(MentionUtil.mentionsToBodyRangeList(draftMentions).toByteArray()))); - } - } - - for (Slide slide : attachmentManager.buildSlideDeck().getSlides()) { - if (slide.hasAudio() && slide.getUri() != null) drafts.add(new Draft(Draft.AUDIO, slide.getUri().toString())); - else if (slide.hasVideo() && slide.getUri() != null) drafts.add(new Draft(Draft.VIDEO, slide.getUri().toString())); - else if (slide.hasLocation()) drafts.add(new Draft(Draft.LOCATION, ((LocationSlide)slide).getPlace().serialize())); - else if (slide.hasImage() && slide.getUri() != null) drafts.add(new Draft(Draft.IMAGE, slide.getUri().toString())); - } - - Optional quote = inputPanel.getQuote(); - - if (quote.isPresent()) { - drafts.add(new Draft(Draft.QUOTE, new QuoteId(quote.get().getId(), quote.get().getAuthor()).serialize())); - } - - Draft voiceNoteDraft = draftViewModel.getVoiceNoteDraft(); - if (voiceNoteDraft != null) { - drafts.add(voiceNoteDraft); - } - - return drafts; - } - - protected ListenableFuture saveDraft() { - final SettableFuture future = new SettableFuture<>(); - - if (this.recipient == null) { - future.set(threadId); - return future; - } - - final Context context = requireContext().getApplicationContext(); - final Drafts drafts = getDraftsForCurrentState(); - final long thisThreadId = this.threadId; - final RecipientId recipientId = this.recipient.getId(); - final int thisDistributionType = this.distributionType; - final ListenableFuture voiceNoteDraftFuture = draftViewModel.consumeVoiceNoteDraftFuture(); - - new AsyncTask() { - @Override - protected Long doInBackground(Long... params) { - if (voiceNoteDraftFuture != null) { - try { - Draft voiceNoteDraft = voiceNoteDraftFuture.get().asDraft(); - draftViewModel.setVoiceNoteDraft(recipientId, voiceNoteDraft); - drafts.add(voiceNoteDraft); - } catch (ExecutionException | InterruptedException e) { - Log.w(TAG, "Could not extract voice note draft data.", e); - } - } - - ThreadDatabase threadDatabase = SignalDatabase.threads(); - DraftDatabase draftDatabase = SignalDatabase.drafts(); - long threadId = params[0]; - - if (drafts.size() > 0) { - if (threadId == -1) threadId = threadDatabase.getOrCreateThreadIdFor(getRecipient(), thisDistributionType); - - draftDatabase.replaceDrafts(threadId, drafts); - threadDatabase.updateSnippet(threadId, drafts.getSnippet(context), - drafts.getUriSnippet(), - System.currentTimeMillis(), Types.BASE_DRAFT_TYPE, true); - } else if (threadId > 0) { - threadDatabase.update(threadId, false); - } - - if (drafts.isEmpty()) { - draftDatabase.clearDrafts(threadId); - } - - return threadId; - } - - @Override - protected void onPostExecute(Long result) { - future.set(result); - } - - }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, thisThreadId); - - return future; - } - private void setBlockedUserState(Recipient recipient, @NonNull ConversationSecurityInfo conversationSecurityInfo) { if (!conversationSecurityInfo.isInitialized()) { Log.i(TAG, "Ignoring blocked state update for uninitialized security info."); @@ -2972,6 +2853,8 @@ public class ConversationParentFragment extends Fragment updateLinkPreviewState(); callback.onSendComplete(threadId); + + draftViewModel.onSendComplete(threadId); } private void sendMessage(@Nullable String metricId) { @@ -2985,7 +2868,6 @@ public class ConversationParentFragment extends Fragment AudioSlide audioSlide = AudioSlide.createFromVoiceNoteDraft(requireContext(), voiceNote); sendVoiceNote(Objects.requireNonNull(audioSlide.getUri()), audioSlide.getFileSize()); - draftViewModel.clearVoiceNoteDraft(); return; } @@ -3205,7 +3087,7 @@ public class ConversationParentFragment extends Fragment return; } - if (draftViewModel.hasVoiceNoteDraft()) { + if (draftViewModel.getVoiceNoteDraft() != null) { buttonToggle.display(sendButton); quickAttachmentToggle.hide(); inlineAttachmentToggle.hide(); @@ -3329,7 +3211,7 @@ public class ConversationParentFragment extends Fragment if (getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.RESUMED)) { future.addListener(new DeleteCanceledVoiceNoteListener()); } else { - draftViewModel.setVoiceNoteDraftFuture(future); + draftViewModel.saveEphemeralVoiceNoteDraft(future); } } @@ -3359,6 +3241,16 @@ public class ConversationParentFragment extends Fragment sendSticker(sticker, true); } + @Override + public void onQuoteChanged(long id, @NonNull RecipientId author) { + draftViewModel.setQuoteDraft(id, author); + } + + @Override + public void onQuoteCleared() { + draftViewModel.clearQuoteDraft(); + } + @Override public void onMediaSelected(@NonNull Uri uri, String contentType) { if (MediaUtil.isGif(contentType) || MediaUtil.isImageType(contentType)) { @@ -3389,32 +3281,26 @@ public class ConversationParentFragment extends Fragment } private void sendVoiceNote(@NonNull Uri uri, long size) { - boolean initiating = threadId == -1; - long expiresIn = TimeUnit.SECONDS.toMillis(recipient.get().getExpiresInSeconds()); - AudioSlide audioSlide = new AudioSlide(requireContext(), uri, size, MediaUtil.AUDIO_AAC, true); - SlideDeck slideDeck = new SlideDeck(); + boolean initiating = threadId == -1; + long expiresIn = TimeUnit.SECONDS.toMillis(recipient.get().getExpiresInSeconds()); + AudioSlide audioSlide = new AudioSlide(requireContext(), uri, size, MediaUtil.AUDIO_AAC, true); + SlideDeck slideDeck = new SlideDeck(); + slideDeck.addSlide(audioSlide); - ListenableFuture sendResult = sendMediaMessage(recipient.getId(), - sendButton.getSelectedSendType(), - "", - slideDeck, - inputPanel.getQuote().orElse(null), - Collections.emptyList(), - Collections.emptyList(), - composeText.getMentions(), - expiresIn, - false, - initiating, - true, - null); - - sendResult.addListener(new AssertedSuccessListener() { - @Override - public void onSuccess(Void nothing) { - draftViewModel.deleteBlob(uri); - } - }); + sendMediaMessage(recipient.getId(), + sendButton.getSelectedSendType(), + "", + slideDeck, + inputPanel.getQuote().orElse(null), + Collections.emptyList(), + Collections.emptyList(), + composeText.getMentions(), + expiresIn, + false, + initiating, + true, + null); } private void sendSticker(@NonNull StickerRecord stickerRecord, boolean clearCompose) { @@ -3446,9 +3332,9 @@ public class ConversationParentFragment extends Fragment } private void silentlySetComposeText(String text) { - typingTextWatcher.setEnabled(false); + typingTextWatcher.setTypingStatusEnabled(false); composeText.setText(text); - typingTextWatcher.setEnabled(true); + typingTextWatcher.setTypingStatusEnabled(true); } @Override @@ -3584,7 +3470,7 @@ public class ConversationParentFragment extends Fragment private final class DeleteCanceledVoiceNoteListener implements ListenableFuture.Listener { @Override public void onSuccess(final VoiceNoteDraft result) { - draftViewModel.deleteBlob(result.getUri()); + draftViewModel.cancelEphemeralVoiceNoteDraft(result.asDraft()); } @Override @@ -3691,15 +3577,24 @@ public class ConversationParentFragment extends Fragment } } - private class TypingStatusTextWatcher extends SimpleTextWatcher { + private class ComposeTextWatcher extends SimpleTextWatcher { - private boolean enabled = true; + private boolean typingStatusEnabled = true; private String previousText = ""; @Override - public void onTextChanged(String text) { - if (enabled && threadId > 0 && viewModel.isPushAvailable() && !isSmsForced() && !recipient.get().isBlocked() && !recipient.get().isSelf()) { + public void onTextChanged(@NonNull CharSequence text) { + handleSaveDraftOnTextChange(text); + handleTypingIndicatorOnTextChange(text.toString()); + } + + private void handleSaveDraftOnTextChange(@NonNull CharSequence text) { + textDraftSaveDebouncer.publish(() -> draftViewModel.setTextDraft(StringUtil.trimSequence(text).toString(), MentionAnnotation.getMentionsFromAnnotations(text))); + } + + private void handleTypingIndicatorOnTextChange(@NonNull String text) { + if (typingStatusEnabled && threadId > 0 && viewModel.isPushAvailable() && !isSmsForced() && !recipient.get().isBlocked() && !recipient.get().isSelf()) { TypingStatusSender typingStatusSender = ApplicationDependencies.getTypingStatusSender(); if (text.length() == 0) { @@ -3714,8 +3609,8 @@ public class ConversationParentFragment extends Fragment } } - public void setEnabled(boolean enabled) { - this.enabled = enabled; + public void setTypingStatusEnabled(boolean enabled) { + this.typingStatusEnabled = enabled; } } @@ -3931,6 +3826,7 @@ public class ConversationParentFragment extends Fragment @Override public void setThreadId(long threadId) { this.threadId = threadId; + draftViewModel.setThreadId(threadId); } @Override @@ -4028,6 +3924,11 @@ public class ConversationParentFragment extends Fragment updateLinkPreviewState(); } + @Override + public void onLocationRemoved() { + draftViewModel.clearLocationDraft(); + } + private void onMessageRequestDeleteClicked(@NonNull MessageRequestViewModel requestModel) { Recipient recipient = requestModel.getRecipient().getValue(); if (recipient == null) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationPopupActivity.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationPopupActivity.java index 464c15986..a21e70eae 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationPopupActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationPopupActivity.java @@ -1,23 +1,17 @@ package org.thoughtcrime.securesms.conversation; -import android.content.Intent; import android.os.Bundle; import android.view.Display; import android.view.Gravity; import android.view.Menu; import android.view.MenuInflater; -import android.view.MenuItem; import android.view.View; import android.view.WindowManager; import androidx.appcompat.widget.Toolbar; -import androidx.core.app.ActivityOptionsCompat; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.util.concurrent.ListenableFuture; - -import java.util.concurrent.ExecutionException; public class ConversationPopupActivity extends ConversationActivity { @@ -73,33 +67,6 @@ public class ConversationPopupActivity extends ConversationActivity { return true; } - @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case R.id.menu_expand: - saveDraft().addListener(new ListenableFuture.Listener() { - @Override - public void onSuccess(Long result) { - ActivityOptionsCompat transition = ActivityOptionsCompat.makeScaleUpAnimation(getWindow().getDecorView(), 0, 0, getWindow().getAttributes().width, getWindow().getAttributes().height); - Intent intent = ConversationIntents.createBuilder(ConversationPopupActivity.this, getRecipient().getId(), result) - .build(); - - startActivity(intent, transition.toBundle()); - - finish(); - } - - @Override - public void onFailure(ExecutionException e) { - Log.w(TAG, e); - } - }); - return true; - } - - return false; - } - @Override public void onInitializeToolbar(Toolbar toolbar) { } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftRepository.kt index 1aae8857e..96dc08f06 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftRepository.kt @@ -2,18 +2,75 @@ package org.thoughtcrime.securesms.conversation.drafts import android.content.Context import android.net.Uri +import android.text.Spannable +import android.text.SpannableString +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers import org.signal.core.util.concurrent.SignalExecutors +import org.thoughtcrime.securesms.components.mention.MentionAnnotation import org.thoughtcrime.securesms.database.DraftDatabase +import org.thoughtcrime.securesms.database.DraftDatabase.Drafts +import org.thoughtcrime.securesms.database.MentionUtil +import org.thoughtcrime.securesms.database.MmsSmsColumns +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.providers.BlobProvider +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.util.Base64 +import org.thoughtcrime.securesms.util.concurrent.SerialMonoLifoExecutor +import java.util.concurrent.Executor -class DraftRepository(private val context: Context) { - fun deleteVoiceNoteDraft(draft: DraftDatabase.Draft) { - deleteBlob(Uri.parse(draft.value).buildUpon().clearQuery().build()) - } +class DraftRepository( + private val context: Context = ApplicationDependencies.getApplication(), + private val threadDatabase: ThreadDatabase = SignalDatabase.threads, + private val draftDatabase: DraftDatabase = SignalDatabase.drafts, + private val saveDraftsExecutor: Executor = SerialMonoLifoExecutor(SignalExecutors.BOUNDED) +) { - fun deleteBlob(uri: Uri) { - SignalExecutors.BOUNDED.execute { - BlobProvider.getInstance().delete(context, uri) + fun deleteVoiceNoteDraftData(draft: DraftDatabase.Draft?) { + if (draft != null) { + SignalExecutors.BOUNDED.execute { + BlobProvider.getInstance().delete(context, Uri.parse(draft.value).buildUpon().clearQuery().build()) + } } } + + fun saveDrafts(recipient: Recipient, threadId: Long, distributionType: Int, drafts: Drafts) { + saveDraftsExecutor.execute { + if (drafts.isNotEmpty()) { + val actualThreadId = if (threadId == -1L) { + threadDatabase.getOrCreateThreadIdFor(recipient, distributionType) + } else { + threadId + } + + draftDatabase.replaceDrafts(actualThreadId, drafts) + threadDatabase.updateSnippet(actualThreadId, drafts.getSnippet(context), drafts.uriSnippet, System.currentTimeMillis(), MmsSmsColumns.Types.BASE_DRAFT_TYPE, true) + } else if (threadId > 0) { + threadDatabase.update(threadId, false) + draftDatabase.clearDrafts(threadId) + } + } + } + + fun loadDrafts(threadId: Long): Single { + return Single.fromCallable { + val drafts: Drafts = draftDatabase.getDrafts(threadId) + val mentionsDraft = drafts.getDraftOfType(DraftDatabase.Draft.MENTION) + var updatedText: Spannable? = null + + if (mentionsDraft != null) { + val text = drafts.getDraftOfType(DraftDatabase.Draft.TEXT)!!.value + val mentions = MentionUtil.bodyRangeListToMentions(context, Base64.decodeOrThrow(mentionsDraft.value)) + val updated = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, text, mentions) + updatedText = SpannableString(updated.body) + MentionAnnotation.setMentionAnnotations(updatedText, updated.mentions) + } + + DatabaseDraft(drafts, updatedText) + }.subscribeOn(Schedulers.io()) + } + + data class DatabaseDraft(val drafts: Drafts, val updatedText: CharSequence?) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftState.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftState.kt index fe8dc14ca..f9d8f3923 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftState.kt @@ -1,7 +1,7 @@ package org.thoughtcrime.securesms.conversation.drafts import org.thoughtcrime.securesms.database.DraftDatabase -import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.database.DraftDatabase.Drafts import org.thoughtcrime.securesms.recipients.RecipientId /** @@ -10,6 +10,38 @@ import org.thoughtcrime.securesms.recipients.RecipientId * management pattern going forward for drafts. */ data class DraftState( - val recipientId: RecipientId = Recipient.UNKNOWN.id, - val voiceNoteDraft: DraftDatabase.Draft? = null -) + val recipientId: RecipientId = RecipientId.UNKNOWN, + val threadId: Long = -1, + val distributionType: Int = 0, + val textDraft: DraftDatabase.Draft? = null, + val mentionsDraft: DraftDatabase.Draft? = null, + val quoteDraft: DraftDatabase.Draft? = null, + val locationDraft: DraftDatabase.Draft? = null, + val voiceNoteDraft: DraftDatabase.Draft? = null, +) { + + fun copyAndClearDrafts(threadId: Long = this.threadId): DraftState { + return DraftState(recipientId = recipientId, threadId = threadId, distributionType = distributionType) + } + + fun toDrafts(): Drafts { + return Drafts().apply { + addIfNotNull(textDraft) + addIfNotNull(mentionsDraft) + addIfNotNull(quoteDraft) + addIfNotNull(locationDraft) + addIfNotNull(voiceNoteDraft) + } + } + + fun copyAndSetDrafts(threadId: Long, drafts: Drafts): DraftState { + return copy( + threadId = threadId, + textDraft = drafts.getDraftOfType(DraftDatabase.Draft.TEXT), + mentionsDraft = drafts.getDraftOfType(DraftDatabase.Draft.MENTION), + quoteDraft = drafts.getDraftOfType(DraftDatabase.Draft.QUOTE), + locationDraft = drafts.getDraftOfType(DraftDatabase.Draft.LOCATION), + voiceNoteDraft = drafts.getDraftOfType(DraftDatabase.Draft.VOICE_NOTE), + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftViewModel.kt index e2cec0958..41ba3ee6f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/drafts/DraftViewModel.kt @@ -1,87 +1,126 @@ package org.thoughtcrime.securesms.conversation.drafts -import android.net.Uri -import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Flowable +import io.reactivex.rxjava3.core.Single +import org.thoughtcrime.securesms.components.location.SignalPlace import org.thoughtcrime.securesms.components.voice.VoiceNoteDraft -import org.thoughtcrime.securesms.database.DraftDatabase +import org.thoughtcrime.securesms.database.DraftDatabase.Draft +import org.thoughtcrime.securesms.database.MentionUtil +import org.thoughtcrime.securesms.database.model.Mention +import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList +import org.thoughtcrime.securesms.mms.QuoteId import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.util.Base64 import org.thoughtcrime.securesms.util.concurrent.ListenableFuture -import org.thoughtcrime.securesms.util.livedata.Store +import org.thoughtcrime.securesms.util.rx.RxStore /** * ViewModel responsible for holding Voice Note draft state. The intention is to allow * other pieces of draft state to be held here as well in the future, and to serve as a * management pattern going forward for drafts. */ -class DraftViewModel( - private val repository: DraftRepository +class DraftViewModel @JvmOverloads constructor( + private val repository: DraftRepository = DraftRepository() ) : ViewModel() { - private val store = Store(DraftState()) + private val store = RxStore(DraftState()) - val state: LiveData = store.stateLiveData + val state: Flowable = store.stateFlowable.observeOn(AndroidSchedulers.mainThread()) - private var voiceNoteDraftFuture: ListenableFuture? = null - - val voiceNoteDraft: DraftDatabase.Draft? + val voiceNoteDraft: Draft? get() = store.state.voiceNoteDraft - fun consumeVoiceNoteDraftFuture(): ListenableFuture? { - val future = voiceNoteDraftFuture - voiceNoteDraftFuture = null - - return future + fun setThreadId(threadId: Long) { + store.update { it.copy(threadId = threadId) } } - fun setVoiceNoteDraftFuture(voiceNoteDraftFuture: ListenableFuture) { - this.voiceNoteDraftFuture = voiceNoteDraftFuture + fun setDistributionType(distributionType: Int) { + store.update { it.copy(distributionType = distributionType) } } - fun setVoiceNoteDraft(recipientId: RecipientId, draft: DraftDatabase.Draft) { + fun saveEphemeralVoiceNoteDraft(voiceNoteDraftFuture: ListenableFuture) { store.update { - it.copy(recipientId = recipientId, voiceNoteDraft = draft) + saveDrafts(it.copy(voiceNoteDraft = voiceNoteDraftFuture.get().asDraft())) } } - @get:JvmName("hasVoiceNoteDraft") - val hasVoiceNoteDraft: Boolean - get() = store.state.voiceNoteDraft != null - - fun clearVoiceNoteDraft() { - store.update { - it.copy(voiceNoteDraft = null) - } + fun cancelEphemeralVoiceNoteDraft(draft: Draft) { + repository.deleteVoiceNoteDraftData(draft) } fun deleteVoiceNoteDraft() { - val draft = store.state.voiceNoteDraft - if (draft != null) { - clearVoiceNoteDraft() - repository.deleteVoiceNoteDraft(draft) + store.update { + repository.deleteVoiceNoteDraftData(it.voiceNoteDraft) + saveDrafts(it.copy(voiceNoteDraft = null)) } } fun onRecipientChanged(recipient: Recipient) { + store.update { it.copy(recipientId = recipient.id) } + } + + fun setTextDraft(text: String, mentions: List) { store.update { - if (recipient.id != it.recipientId) { - it.copy(recipientId = recipient.id, voiceNoteDraft = null) - } else { - it + saveDrafts(it.copy(textDraft = text.toTextDraft(), mentionsDraft = mentions.toMentionsDraft())) + } + } + + fun setLocationDraft(place: SignalPlace) { + store.update { + saveDrafts(it.copy(locationDraft = Draft(Draft.LOCATION, place.serialize()))) + } + } + + fun clearLocationDraft() { + store.update { + saveDrafts(it.copy(locationDraft = null)) + } + } + + fun setQuoteDraft(id: Long, author: RecipientId) { + store.update { + saveDrafts(it.copy(quoteDraft = Draft(Draft.QUOTE, QuoteId(id, author).serialize()))) + } + } + + fun clearQuoteDraft() { + store.update { + saveDrafts(it.copy(quoteDraft = null)) + } + } + + fun onSendComplete(threadId: Long) { + repository.deleteVoiceNoteDraftData(store.state.voiceNoteDraft) + store.update { saveDrafts(it.copyAndClearDrafts(threadId)) } + } + + private fun saveDrafts(state: DraftState): DraftState { + repository.saveDrafts(Recipient.resolved(state.recipientId), state.threadId, state.distributionType, state.toDrafts()) + return state + } + + fun loadDrafts(threadId: Long): Single { + return repository + .loadDrafts(threadId) + .doOnSuccess { drafts -> + store.update { it.copyAndSetDrafts(threadId, drafts.drafts) } } - } - } - - fun deleteBlob(uri: Uri) { - repository.deleteBlob(uri) - } - - class Factory(private val repository: DraftRepository) : ViewModelProvider.Factory { - - override fun create(modelClass: Class): T { - return requireNotNull(modelClass.cast(DraftViewModel(repository))) - } + .observeOn(AndroidSchedulers.mainThread()) + } +} + +private fun String.toTextDraft(): Draft? { + return if (isNotEmpty()) Draft(Draft.TEXT, this) else null +} + +private fun List.toMentionsDraft(): Draft? { + val mentions: BodyRangeList? = MentionUtil.mentionsToBodyRangeList(this) + return if (mentions != null) { + Draft(Draft.MENTION, Base64.encodeBytes(mentions.toByteArray())) + } else { + null } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/DraftDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/DraftDatabase.java index 22e1b653f..7f702499f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/DraftDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/DraftDatabase.java @@ -164,6 +164,12 @@ public class DraftDatabase extends Database { } public static class Drafts extends LinkedList { + public void addIfNotNull(@Nullable Draft draft) { + if (draft != null) { + add(draft); + } + } + public @Nullable Draft getDraftOfType(String type) { for (Draft draft : this) { if (type.equals(draft.getType())) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java b/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java index 2d0ca6b31..61caa9e94 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java @@ -489,6 +489,12 @@ public class AttachmentManager { private class RemoveButtonListener implements View.OnClickListener { @Override public void onClick(View v) { + slide.ifPresent(oldSlide -> { + if (oldSlide instanceof LocationSlide) { + attachmentListener.onLocationRemoved(); + } + }); + cleanup(); clear(GlideApp.with(context.getApplicationContext()), true); } @@ -496,6 +502,7 @@ public class AttachmentManager { public interface AttachmentListener { void onAttachmentChanged(); + void onLocationRemoved(); } }