diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java b/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java index f40ae84a9a..5caf78e7bc 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/ComposeText.java @@ -191,6 +191,11 @@ public class ComposeText extends EmojiEditText { setHintWithChecks(hint); } + public void setDraftText(@Nullable CharSequence draftText) { + setText(""); + append(draftText); + } + public void appendInvite(String invite) { if (getText() == null) { return; diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/LinkPreviewView.java b/app/src/main/java/org/thoughtcrime/securesms/components/LinkPreviewView.java index e2990df284..31049be219 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/LinkPreviewView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/LinkPreviewView.java @@ -205,7 +205,7 @@ public class LinkPreviewView extends FrameLayout { if (showThumbnail && linkPreview.getThumbnail().isPresent()) { thumbnail.setVisibility(VISIBLE); thumbnailState.applyState(thumbnail); - thumbnail.get().setImageResource(glideRequests, new ImageSlide(getContext(), linkPreview.getThumbnail().get()), type == TYPE_CONVERSATION, false); + thumbnail.get().setImageResource(glideRequests, new ImageSlide(linkPreview.getThumbnail().get()), type == TYPE_CONVERSATION, false); thumbnail.get().showDownloadText(false); } else { thumbnail.setVisibility(GONE); diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/TransferControlView.java b/app/src/main/java/org/thoughtcrime/securesms/components/TransferControlView.java index 9ba9d25484..6d66a26a38 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/TransferControlView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/components/TransferControlView.java @@ -201,7 +201,7 @@ public final class TransferControlView extends FrameLayout { private String getDownloadText(@NonNull List slides) { if (slides.size() == 1) { - return slides.get(0).getContentDescription(); + return slides.get(0).getContentDescription(getContext()); } else { int downloadCount = Stream.of(slides).reduce(0, (count, slide) -> slide.getTransferState() != AttachmentTable.TRANSFER_PROGRESS_DONE ? count + 1 : count); return getContext().getResources().getQuantityString(R.plurals.TransferControlView_n_items, downloadCount, downloadCount); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationIntents.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationIntents.java index 2abb2e50a6..ead6d8a3a1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationIntents.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationIntents.java @@ -17,6 +17,7 @@ import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.ThreadTable; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.mediasend.Media; +import org.thoughtcrime.securesms.mms.SlideFactory; import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.stickers.StickerLocator; @@ -105,6 +106,9 @@ public class ConversationIntents { private final RecipientId recipientId; private final long threadId; private final String draftText; + private final Uri draftMedia; + private final String draftContentType; + private final SlideFactory.MediaType draftMediaType; private final ArrayList media; private final StickerLocator stickerLocator; private final boolean isBorderless; @@ -124,6 +128,8 @@ public class ConversationIntents { null, null, null, + null, + null, false, ThreadTable.DistributionTypes.DEFAULT, -1, @@ -137,6 +143,8 @@ public class ConversationIntents { return new Args(RecipientId.from(Objects.requireNonNull(arguments.getString(EXTRA_RECIPIENT))), arguments.getLong(EXTRA_THREAD_ID, -1), arguments.getString(EXTRA_TEXT), + ConversationIntents.getIntentData(arguments), + ConversationIntents.getIntentType(arguments), arguments.getParcelableArrayList(EXTRA_MEDIA), arguments.getParcelable(EXTRA_STICKER), arguments.getBoolean(EXTRA_BORDERLESS, false), @@ -152,6 +160,8 @@ public class ConversationIntents { private Args(@NonNull RecipientId recipientId, long threadId, @Nullable String draftText, + @Nullable Uri draftMedia, + @Nullable String draftContentType, @Nullable ArrayList media, @Nullable StickerLocator stickerLocator, boolean isBorderless, @@ -166,6 +176,8 @@ public class ConversationIntents { this.recipientId = recipientId; this.threadId = threadId; this.draftText = draftText; + this.draftMedia = draftMedia; + this.draftContentType = draftContentType; this.media = media; this.stickerLocator = stickerLocator; this.isBorderless = isBorderless; @@ -176,6 +188,7 @@ public class ConversationIntents { this.giftBadge = giftBadge; this.shareDataTimestamp = shareDataTimestamp; this.conversationScreenType = conversationScreenType; + this.draftMediaType = SlideFactory.MediaType.from(draftContentType); } public @NonNull RecipientId getRecipientId() { @@ -190,6 +203,18 @@ public class ConversationIntents { return draftText; } + public @Nullable Uri getDraftMedia() { + return draftMedia; + } + + public @Nullable String getDraftContentType() { + return draftContentType; + } + + public @Nullable SlideFactory.MediaType getDraftMediaType() { + return draftMediaType; + } + public @Nullable ArrayList getMedia() { return media; } @@ -237,6 +262,10 @@ public class ConversationIntents { public @NonNull ConversationScreenType getConversationScreenType() { return conversationScreenType; } + + public boolean canInitializeFromDatabase() { + return draftText == null && (draftMedia == null || ConversationIntents.isBubbleIntentUri(draftMedia) || ConversationIntents.isNotificationIntentUri(draftMedia)) && draftMediaType == null; + } } public final static class Builder { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java index 30a2fe2d13..9328eaf64a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -1127,7 +1127,7 @@ public final class ConversationItem extends RelativeLayout implements BindableCo mediaThumbnailStub.require().setVisibility(VISIBLE); mediaThumbnailStub.require().setMinimumThumbnailWidth(readDimen(R.dimen.media_bubble_min_width_with_content)); mediaThumbnailStub.require().setMaximumThumbnailHeight(readDimen(R.dimen.media_bubble_max_height)); - mediaThumbnailStub.require().setImageResource(glideRequests, Collections.singletonList(new ImageSlide(context, linkPreview.getThumbnail().get())), showControls, false); + mediaThumbnailStub.require().setImageResource(glideRequests, Collections.singletonList(new ImageSlide(linkPreview.getThumbnail().get())), showControls, false); mediaThumbnailStub.require().setThumbnailClickListener(new LinkPreviewThumbnailClickListener()); mediaThumbnailStub.require().setDownloadClickListener(downloadClickListener); mediaThumbnailStub.require().setOnLongClickListener(passthroughClickListener); 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 56a9757ae4..1283dd6116 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 @@ -1,16 +1,22 @@ package org.thoughtcrime.securesms.conversation.drafts import android.content.Context +import android.graphics.Bitmap +import android.graphics.Color import android.net.Uri import android.text.Spannable import android.text.SpannableString +import androidx.annotation.WorkerThread +import com.bumptech.glide.load.engine.DiskCacheStrategy import io.reactivex.rxjava3.core.Maybe import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.schedulers.Schedulers import org.signal.core.util.StreamUtil import org.signal.core.util.concurrent.SignalExecutors import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.components.location.SignalPlace import org.thoughtcrime.securesms.components.mention.MentionAnnotation +import org.thoughtcrime.securesms.conversation.ConversationIntents import org.thoughtcrime.securesms.conversation.ConversationMessage import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory import org.thoughtcrime.securesms.conversation.MessageStyler @@ -27,28 +33,126 @@ import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.mediasend.Media +import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader.DecryptableUri +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.PartAuthority import org.thoughtcrime.securesms.mms.QuoteId +import org.thoughtcrime.securesms.mms.Slide +import org.thoughtcrime.securesms.mms.SlideFactory +import org.thoughtcrime.securesms.mms.StickerSlide import org.thoughtcrime.securesms.providers.BlobProvider import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.util.Base64 +import org.thoughtcrime.securesms.util.MediaUtil import org.thoughtcrime.securesms.util.concurrent.SerialMonoLifoExecutor import org.thoughtcrime.securesms.util.hasTextSlide import org.thoughtcrime.securesms.util.requireTextSlide import java.io.IOException +import java.util.concurrent.ExecutionException import java.util.concurrent.Executor +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException class DraftRepository( private val context: Context = ApplicationDependencies.getApplication(), private val threadTable: ThreadTable = SignalDatabase.threads, private val draftTable: DraftTable = SignalDatabase.drafts, - private val saveDraftsExecutor: Executor = SerialMonoLifoExecutor(SignalExecutors.BOUNDED) + private val saveDraftsExecutor: Executor = SerialMonoLifoExecutor(SignalExecutors.BOUNDED), + private val conversationArguments: ConversationIntents.Args? = null ) { companion object { val TAG = Log.tag(DraftRepository::class.java) } + fun getShareOrDraftData(): Maybe> { + return Maybe.fromCallable> { getShareOrDraftDataInternal() } + .observeOn(Schedulers.io()) + } + + private fun getShareOrDraftDataInternal(): Pair? { + val shareText = conversationArguments?.draftText + val shareMedia = conversationArguments?.draftMedia + val shareContentType = conversationArguments?.draftContentType + val shareMediaType = conversationArguments?.draftMediaType + val shareMediaList = conversationArguments?.media ?: emptyList() + val stickerLocator = conversationArguments?.stickerLocator + val borderless = conversationArguments?.isBorderless ?: false + + if (stickerLocator != null && shareMedia != null) { + val slide = StickerSlide(context, shareMedia, 0, stickerLocator, shareContentType!!) + return ShareOrDraftData.SendSticker(slide) to null + } + + if (shareMedia != null && shareContentType != null && borderless) { + val details = getKeyboardImageDetails(GlideApp.with(context), shareMedia) + + if (details == null || !details.hasTransparency) { + return ShareOrDraftData.SetMedia(shareMedia, shareMediaType!!, null) to null + } + + val slide: Slide? = if (MediaUtil.isGif(shareContentType)) { + GifSlide(context, shareMedia, 0, details.width, details.height, true, null) + } else if (MediaUtil.isImageType(shareContentType)) { + ImageSlide(context, shareMedia, shareContentType, 0, details.width, details.height, true, null, null) + } else { + Log.w(TAG, "Attempting to send unsupported non-image via keyboard share") + null + } + + return if (slide != null) ShareOrDraftData.SendKeyboardImage(slide) to null else null + } + + if (shareMediaList.isNotEmpty()) { + return ShareOrDraftData.StartSendMedia(shareMediaList, shareText) to null + } + + if (shareMedia != null && shareMediaType != null) { + return ShareOrDraftData.SetMedia(shareMedia, shareMediaType, shareText) to null + } + + if (shareText != null) { + return ShareOrDraftData.SetText(shareText) to null + } + + if (conversationArguments?.canInitializeFromDatabase() == true) { + val (drafts, updatedText) = loadDraftsInternal(conversationArguments.threadId) + + val draftText: CharSequence? = drafts.firstOrNull { it.type == DraftTable.Draft.TEXT }?.let { updatedText ?: it.value } + + val location: SignalPlace? = drafts.firstOrNull { it.type == DraftTable.Draft.LOCATION }?.let { SignalPlace.deserialize(it.value) } + if (location != null) { + return ShareOrDraftData.SetLocation(location, draftText) to drafts + } + + val audio: Uri? = drafts.firstOrNull { it.type == DraftTable.Draft.AUDIO }?.let { Uri.parse(it.value) } + if (audio != null) { + return ShareOrDraftData.SetMedia(audio, SlideFactory.MediaType.AUDIO, null) to drafts + } + + val quote: ConversationMessage? = drafts.firstOrNull { it.type == DraftTable.Draft.QUOTE }?.let { loadDraftQuoteInternal(it.value) } + if (quote != null) { + return ShareOrDraftData.SetQuote(quote, draftText) to drafts + } + + val messageEdit: ConversationMessage? = drafts.firstOrNull { it.type == DraftTable.Draft.MESSAGE_EDIT }?.let { loadDraftMessageEditInternal(it.value) } + if (messageEdit != null) { + return ShareOrDraftData.SetEditMessage(messageEdit) to drafts + } + + if (draftText != null) { + return ShareOrDraftData.SetText(draftText) to drafts + } + } + + // no share or draft + return null + } + fun deleteVoiceNoteDraftData(draft: DraftTable.Draft?) { if (draft != null) { SignalExecutors.BOUNDED.execute { @@ -57,11 +161,13 @@ class DraftRepository( } } - fun saveDrafts(recipient: Recipient, threadId: Long, distributionType: Int, drafts: Drafts) { + fun saveDrafts(recipient: Recipient?, threadId: Long, distributionType: Int, drafts: Drafts) { + require(threadId != -1L || recipient != null) + saveDraftsExecutor.execute { if (drafts.isNotEmpty()) { val actualThreadId = if (threadId == -1L) { - threadTable.getOrCreateThreadIdFor(recipient, distributionType) + threadTable.getOrCreateThreadIdFor(recipient!!, distributionType) } else { threadId } @@ -79,65 +185,109 @@ class DraftRepository( } } + @Deprecated("Not needed for CFv2") fun loadDrafts(threadId: Long): Single { return Single.fromCallable { - val drafts: Drafts = draftTable.getDrafts(threadId) - val bodyRangesDraft: DraftTable.Draft? = drafts.getDraftOfType(DraftTable.Draft.BODY_RANGES) - val textDraft: DraftTable.Draft? = drafts.getDraftOfType(DraftTable.Draft.TEXT) - var updatedText: Spannable? = null - - if (textDraft != null && bodyRangesDraft != null) { - val bodyRanges: BodyRangeList = BodyRangeList.parseFrom(Base64.decodeOrThrow(bodyRangesDraft.value)) - val mentions: List = MentionUtil.bodyRangeListToMentions(bodyRanges) - - val updated = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, textDraft.value, mentions) - - updatedText = SpannableString(updated.body) - MentionAnnotation.setMentionAnnotations(updatedText, updated.mentions) - MessageStyler.style(id = MessageStyler.DRAFT_ID, messageRanges = bodyRanges.adjustBodyRanges(updated.bodyAdjustments), span = updatedText, hideSpoilerText = false) - } - - DatabaseDraft(drafts, updatedText) + loadDraftsInternal(threadId) }.subscribeOn(Schedulers.io()) } - fun loadDraftQuote(serialized: String): Maybe { - return Maybe.fromCallable { - val quoteId: QuoteId = QuoteId.deserialize(context, serialized) ?: return@fromCallable null - val messageRecord: MessageRecord = SignalDatabase.messages.getMessageFor(quoteId.id, quoteId.author)?.let { - if (it is MediaMmsMessageRecord) { - it.withAttachments(context, SignalDatabase.attachments.getAttachmentsForMessage(it.id)) - } else { - it - } - } ?: return@fromCallable null + private fun loadDraftsInternal(threadId: Long): DatabaseDraft { + val drafts: Drafts = draftTable.getDrafts(threadId) + val bodyRangesDraft: DraftTable.Draft? = drafts.getDraftOfType(DraftTable.Draft.BODY_RANGES) + val textDraft: DraftTable.Draft? = drafts.getDraftOfType(DraftTable.Draft.TEXT) + var updatedText: Spannable? = null - val threadRecipient = requireNotNull(SignalDatabase.threads.getRecipientForThreadId(messageRecord.threadId)) - ConversationMessageFactory.createWithUnresolvedData(context, messageRecord, threadRecipient) + if (textDraft != null && bodyRangesDraft != null) { + val bodyRanges: BodyRangeList = BodyRangeList.parseFrom(Base64.decodeOrThrow(bodyRangesDraft.value)) + val mentions: List = MentionUtil.bodyRangeListToMentions(bodyRanges) + + val updated = MentionUtil.updateBodyAndMentionsWithDisplayNames(context, textDraft.value, mentions) + + updatedText = SpannableString(updated.body) + MentionAnnotation.setMentionAnnotations(updatedText, updated.mentions) + MessageStyler.style(id = MessageStyler.DRAFT_ID, messageRanges = bodyRanges.adjustBodyRanges(updated.bodyAdjustments), span = updatedText, hideSpoilerText = false) } + + return DatabaseDraft(drafts, updatedText) } + @Deprecated("Not needed for CFv2") + fun loadDraftQuote(serialized: String): Maybe { + return Maybe.fromCallable { loadDraftQuoteInternal(serialized) } + } + + private fun loadDraftQuoteInternal(serialized: String): ConversationMessage? { + val quoteId: QuoteId = QuoteId.deserialize(context, serialized) ?: return null + val messageRecord: MessageRecord = SignalDatabase.messages.getMessageFor(quoteId.id, quoteId.author)?.let { + if (it is MediaMmsMessageRecord) { + it.withAttachments(context, SignalDatabase.attachments.getAttachmentsForMessage(it.id)) + } else { + it + } + } ?: return null + + val threadRecipient = requireNotNull(SignalDatabase.threads.getRecipientForThreadId(messageRecord.threadId)) + return ConversationMessageFactory.createWithUnresolvedData(context, messageRecord, threadRecipient) + } + + @Deprecated("Not needed for CFv2") fun loadDraftMessageEdit(serialized: String): Maybe { - return Maybe.fromCallable { - val messageId = MessageId.deserialize(serialized) - val messageRecord: MessageRecord = SignalDatabase.messages.getMessageRecordOrNull(messageId.id) ?: return@fromCallable null - val threadRecipient: Recipient = requireNotNull(SignalDatabase.threads.getRecipientForThreadId(messageRecord.threadId)) - if (messageRecord.hasTextSlide()) { - val textSlide = messageRecord.requireTextSlide() - if (textSlide.uri != null) { - try { - PartAuthority.getAttachmentStream(context, textSlide.uri!!).use { stream -> - val body = StreamUtil.readFullyAsString(stream) - return@fromCallable ConversationMessageFactory.createWithUnresolvedData(context, messageRecord, body, threadRecipient) - } - } catch (e: IOException) { - Log.e(TAG, "Failed to load text slide", e) + return Maybe.fromCallable { loadDraftMessageEditInternal(serialized) } + } + + private fun loadDraftMessageEditInternal(serialized: String): ConversationMessage? { + val messageId = MessageId.deserialize(serialized) + val messageRecord: MessageRecord = SignalDatabase.messages.getMessageRecordOrNull(messageId.id) ?: return null + val threadRecipient: Recipient = requireNotNull(SignalDatabase.threads.getRecipientForThreadId(messageRecord.threadId)) + if (messageRecord.hasTextSlide()) { + val textSlide = messageRecord.requireTextSlide() + if (textSlide.uri != null) { + try { + PartAuthority.getAttachmentStream(context, textSlide.uri!!).use { stream -> + val body = StreamUtil.readFullyAsString(stream) + return ConversationMessageFactory.createWithUnresolvedData(context, messageRecord, body, threadRecipient) } + } catch (e: IOException) { + Log.e(TAG, "Failed to load text slide", e) } } - ConversationMessageFactory.createWithUnresolvedData(context, messageRecord, threadRecipient) + } + return ConversationMessageFactory.createWithUnresolvedData(context, messageRecord, threadRecipient) + } + + @WorkerThread + private fun getKeyboardImageDetails(glideRequests: GlideRequests, uri: Uri): KeyboardImageDetails? { + return try { + val bitmap: Bitmap = glideRequests.asBitmap() + .load(DecryptableUri(uri)) + .skipMemoryCache(true) + .diskCacheStrategy(DiskCacheStrategy.NONE) + .submit() + .get(1000, TimeUnit.MILLISECONDS) + val topLeft = bitmap.getPixel(0, 0) + KeyboardImageDetails(bitmap.width, bitmap.height, Color.alpha(topLeft) < 255) + } catch (e: InterruptedException) { + null + } catch (e: ExecutionException) { + null + } catch (e: TimeoutException) { + null } } data class DatabaseDraft(val drafts: Drafts, val updatedText: CharSequence?) + + sealed interface ShareOrDraftData { + data class SendSticker(val slide: Slide) : ShareOrDraftData + data class SendKeyboardImage(val slide: Slide) : ShareOrDraftData + data class StartSendMedia(val mediaList: List, val text: CharSequence?) : ShareOrDraftData + data class SetMedia(val media: Uri, val mediaType: SlideFactory.MediaType, val text: CharSequence?) : ShareOrDraftData + data class SetText(val text: CharSequence) : ShareOrDraftData + data class SetLocation(val location: SignalPlace, val draftText: CharSequence?) : ShareOrDraftData + data class SetQuote(val quote: ConversationMessage, val draftText: CharSequence?) : ShareOrDraftData + data class SetEditMessage(val messageEdit: ConversationMessage) : ShareOrDraftData + } + + data class KeyboardImageDetails(val width: Int, val height: Int, val hasTransparency: Boolean) } 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 b82213a5b1..4796dc3d42 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 @@ -10,8 +10,10 @@ import org.thoughtcrime.securesms.recipients.RecipientId * management pattern going forward for drafts. */ data class DraftState( - val recipientId: RecipientId = RecipientId.UNKNOWN, + @Deprecated("Not needed for CFv2") + val recipientId: RecipientId? = null, val threadId: Long = -1, + @Deprecated("Not needed for CFv2") val distributionType: Int = 0, val textDraft: DraftTable.Draft? = null, val bodyRangesDraft: DraftTable.Draft? = null, @@ -36,7 +38,7 @@ data class DraftState( } } - fun copyAndSetDrafts(threadId: Long, drafts: Drafts): DraftState { + fun copyAndSetDrafts(threadId: Long = this.threadId, drafts: Drafts): DraftState { return copy( threadId = threadId, textDraft = drafts.getDraftOfType(DraftTable.Draft.TEXT), 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 491e94dae3..63a5c605e1 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 @@ -25,10 +25,11 @@ import org.thoughtcrime.securesms.util.rx.RxStore * management pattern going forward for drafts. */ class DraftViewModel @JvmOverloads constructor( + threadId: Long = -1, private val repository: DraftRepository = DraftRepository() ) : ViewModel() { - private val store = RxStore(DraftState()) + private val store = RxStore(DraftState(threadId = threadId)) val state: Flowable = store.stateFlowable.observeOn(AndroidSchedulers.mainThread()) @@ -39,10 +40,12 @@ class DraftViewModel @JvmOverloads constructor( store.dispose() } + @Deprecated("Not needed for CFv2") fun setThreadId(threadId: Long) { store.update { it.copy(threadId = threadId) } } + @Deprecated("Not needed for CFv2") fun setDistributionType(distributionType: Int) { store.update { it.copy(distributionType = distributionType) } } @@ -64,6 +67,7 @@ class DraftViewModel @JvmOverloads constructor( } } + @Deprecated("Not needed for CFv2") fun onRecipientChanged(recipient: Recipient) { store.update { it.copy(recipientId = recipient.id) } } @@ -130,16 +134,17 @@ class DraftViewModel @JvmOverloads constructor( } } - fun onSendComplete(threadId: Long) { + fun onSendComplete(threadId: Long = store.state.threadId) { 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()) + repository.saveDrafts(state.recipientId?.let { Recipient.resolved(it) }, state.threadId, state.distributionType, state.toDrafts()) return state } + @Deprecated("Not needed for CFv2") fun loadDrafts(threadId: Long): Single { return repository .loadDrafts(threadId) @@ -149,17 +154,30 @@ class DraftViewModel @JvmOverloads constructor( .observeOn(AndroidSchedulers.mainThread()) } + @Deprecated("Not needed for CFv2") fun loadDraftQuote(serialized: String): Maybe { return repository.loadDraftQuote(serialized) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) } + @Deprecated("Not needed for CFv2") fun loadDraftEditMessage(serialized: String): Maybe { return repository.loadDraftMessageEdit(serialized) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) } + + fun loadShareOrDraftData(): Maybe { + return repository.getShareOrDraftData() + .doOnSuccess { (_, drafts) -> + if (drafts != null) { + store.update { saveDrafts(it.copyAndSetDrafts(drafts = drafts)) } + } + } + .map { (data, _) -> data } + .observeOn(AndroidSchedulers.mainThread()) + } } private fun String.toTextDraft(): Draft? { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityResultContracts.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityResultContracts.kt new file mode 100644 index 0000000000..403b61c46a --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityResultContracts.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2023 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation.v2 + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.activity.result.contract.ActivityResultContract +import androidx.core.content.IntentCompat +import androidx.fragment.app.Fragment +import org.thoughtcrime.securesms.contactshare.Contact +import org.thoughtcrime.securesms.contactshare.ContactShareEditActivity +import org.thoughtcrime.securesms.conversation.MessageSendType +import org.thoughtcrime.securesms.conversation.colors.ChatColors +import org.thoughtcrime.securesms.mediasend.Media +import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult +import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity +import org.thoughtcrime.securesms.recipients.RecipientId + +/** + * This encapsulates the logic for interacting with other activities used throughout a conversation. The gist + * is to use this to launch the various activities and use the [Callbacks] to provide strongly-typed results. + * It is intended to replace the need for [android.app.Activity.onActivityResult]. + * + * Note, not all activity results will live here but this should handle most of the basic cases. More advance + * usages like [AddToContactsContract] can be split out into their own [ActivityResultContract] implementations. + */ +class ConversationActivityResultContracts(fragment: Fragment, private val callbacks: Callbacks) { + + private val contactShareLauncher = fragment.registerForActivityResult(ContactShareEditor) { contacts -> callbacks.onSendContacts(contacts) } + private val mediaSelectionLauncher = fragment.registerForActivityResult(MediaSelection) { result -> callbacks.onMediaSend(result) } + + fun launchContactShareEditor(uri: Uri, chatColors: ChatColors) { + contactShareLauncher.launch(uri to chatColors) + } + + fun launchMediaEditor(mediaList: List, recipientId: RecipientId, text: CharSequence?) { + mediaSelectionLauncher.launch(MediaSelectionInput(mediaList, recipientId, text)) + } + + private object MediaSelection : ActivityResultContract() { + override fun createIntent(context: Context, input: MediaSelectionInput): Intent { + val (media, recipientId, text) = input + return MediaSelectionActivity.editor(context, MessageSendType.SignalMessageSendType, media, recipientId, text) + } + + override fun parseResult(resultCode: Int, intent: Intent?): MediaSendActivityResult { + return MediaSendActivityResult.fromData(intent!!) + } + } + + private object ContactShareEditor : ActivityResultContract, List>() { + override fun createIntent(context: Context, input: Pair): Intent { + val (uri, chatColors) = input + return ContactShareEditActivity.getIntent(context, listOf(uri), chatColors.asSingleColor()) + } + + override fun parseResult(resultCode: Int, intent: Intent?): List { + return intent?.let { IntentCompat.getParcelableArrayListExtra(intent, ContactShareEditActivity.KEY_CONTACTS, Contact::class.java) } ?: emptyList() + } + } + + private data class MediaSelectionInput(val media: List, val recipientId: RecipientId, val text: CharSequence?) + + interface Callbacks { + fun onSendContacts(contacts: List) + fun onMediaSend(result: MediaSendActivityResult) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index cc69e23caa..1eb363e0a6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -98,6 +98,7 @@ import org.thoughtcrime.securesms.components.ProgressCardDialogFragmentArgs import org.thoughtcrime.securesms.components.ScrollToPositionDelegate import org.thoughtcrime.securesms.components.SendButton import org.thoughtcrime.securesms.components.ViewBinderDelegate +import org.thoughtcrime.securesms.components.mention.MentionAnnotation import org.thoughtcrime.securesms.components.menu.ActionItem import org.thoughtcrime.securesms.components.menu.SignalBottomActionBar import org.thoughtcrime.securesms.components.recyclerview.SmoothScrollingLinearLayoutManager @@ -126,11 +127,15 @@ import org.thoughtcrime.securesms.conversation.ConversationReactionOverlay.OnHid import org.thoughtcrime.securesms.conversation.MarkReadHelper import org.thoughtcrime.securesms.conversation.MenuState import org.thoughtcrime.securesms.conversation.MessageSendType +import org.thoughtcrime.securesms.conversation.MessageStyler.getStyling import org.thoughtcrime.securesms.conversation.SelectedConversationModel import org.thoughtcrime.securesms.conversation.ShowAdminsBottomSheetDialog import org.thoughtcrime.securesms.conversation.colors.ChatColors import org.thoughtcrime.securesms.conversation.colors.Colorizer import org.thoughtcrime.securesms.conversation.colors.RecyclerViewColorizer +import org.thoughtcrime.securesms.conversation.drafts.DraftRepository +import org.thoughtcrime.securesms.conversation.drafts.DraftRepository.ShareOrDraftData +import org.thoughtcrime.securesms.conversation.drafts.DraftViewModel import org.thoughtcrime.securesms.conversation.mutiselect.ConversationItemAnimator import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectItemDecoration import org.thoughtcrime.securesms.conversation.mutiselect.MultiselectPart @@ -145,10 +150,13 @@ import org.thoughtcrime.securesms.conversation.v2.keyboard.AttachmentKeyboardFra import org.thoughtcrime.securesms.database.model.IdentityRecord import org.thoughtcrime.securesms.database.model.InMemoryMessageRecord import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord +import org.thoughtcrime.securesms.database.model.Mention import org.thoughtcrime.securesms.database.model.MessageId import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.database.model.Quote +import org.thoughtcrime.securesms.database.model.StickerRecord +import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList import org.thoughtcrime.securesms.databinding.V2ConversationFragmentBinding import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ItemDecoration @@ -177,13 +185,18 @@ import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory.create import org.thoughtcrime.securesms.mediapreview.MediaPreviewV2Activity import org.thoughtcrime.securesms.mediasend.Media +import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity import org.thoughtcrime.securesms.messagedetails.MessageDetailsFragment import org.thoughtcrime.securesms.messagerequests.MessageRequestRepository import org.thoughtcrime.securesms.messagerequests.MessageRequestState import org.thoughtcrime.securesms.mms.AttachmentManager import org.thoughtcrime.securesms.mms.GlideApp +import org.thoughtcrime.securesms.mms.MediaConstraints +import org.thoughtcrime.securesms.mms.QuoteModel +import org.thoughtcrime.securesms.mms.Slide import org.thoughtcrime.securesms.mms.SlideDeck +import org.thoughtcrime.securesms.mms.SlideFactory import org.thoughtcrime.securesms.notifications.v2.ConversationId import org.thoughtcrime.securesms.payments.preferences.PaymentsActivity import org.thoughtcrime.securesms.permissions.Permissions @@ -208,10 +221,12 @@ import org.thoughtcrime.securesms.util.BottomSheetUtil import org.thoughtcrime.securesms.util.BubbleUtil import org.thoughtcrime.securesms.util.CommunicationActions import org.thoughtcrime.securesms.util.ContextUtil +import org.thoughtcrime.securesms.util.Debouncer import org.thoughtcrime.securesms.util.DeleteDialog import org.thoughtcrime.securesms.util.DrawableUtil import org.thoughtcrime.securesms.util.FeatureFlags import org.thoughtcrime.securesms.util.FullscreenHelper +import org.thoughtcrime.securesms.util.MediaUtil import org.thoughtcrime.securesms.util.PlayStoreUtil import org.thoughtcrime.securesms.util.SaveAttachmentUtil import org.thoughtcrime.securesms.util.SignalLocalMetrics @@ -232,6 +247,7 @@ import org.thoughtcrime.securesms.verify.VerifyIdentityActivity import org.thoughtcrime.securesms.wallpaper.ChatWallpaper import org.thoughtcrime.securesms.wallpaper.ChatWallpaperDimLevelUtil import java.util.Locale +import java.util.Optional import java.util.concurrent.ExecutionException /** @@ -284,14 +300,20 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) MessageRequestViewModel(args.threadId, conversationRecipientRepository, messageRequestRepository) } + private val draftViewModel: DraftViewModel by viewModel { + DraftViewModel(threadId = args.threadId, repository = DraftRepository(conversationArguments = args)) + } + private val conversationTooltips = ConversationTooltips(this) private val colorizer = Colorizer() + private val textDraftSaveDebouncer = Debouncer(500) private lateinit var conversationOptionsMenuProvider: ConversationOptionsMenu.Provider private lateinit var layoutManager: LinearLayoutManager private lateinit var markReadHelper: MarkReadHelper private lateinit var giphyMp4ProjectionRecycler: GiphyMp4ProjectionRecycler private lateinit var addToContactsLauncher: ActivityResultLauncher + private lateinit var conversationActivityResultContracts: ConversationActivityResultContracts private lateinit var scrollToPositionDelegate: ScrollToPositionDelegate private lateinit var adapter: ConversationAdapterV2 private lateinit var recyclerViewColorizer: RecyclerViewColorizer @@ -483,8 +505,6 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) addTextChangedListener(composeTextEventsListener) setOnClickListener(composeTextEventsListener) onFocusChangeListener = composeTextEventsListener - - setMessageSendType(MessageSendType.SignalMessageSendType) } sendButton.apply { @@ -553,6 +573,30 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) SwipeAvailabilityProvider(), this::handleReplyToMessage ).attachToRecyclerView(binding.conversationItemRecycler) + + draftViewModel.loadShareOrDraftData() + .subscribeBy { + when (it) { + is ShareOrDraftData.SendKeyboardImage -> sendMessageWithoutComposeInput(slide = it.slide, clearCompose = false) + is ShareOrDraftData.SendSticker -> sendMessageWithoutComposeInput(slide = it.slide, clearCompose = true) + is ShareOrDraftData.SetEditMessage -> inputPanel.enterEditMessageMode(GlideApp.with(this), it.messageEdit, true) + is ShareOrDraftData.SetLocation -> attachmentManager.setLocation(it.location, MediaConstraints.getPushMediaConstraints()) + is ShareOrDraftData.SetMedia -> { + composeText.setDraftText(it.text) + setMedia(it.media, it.mediaType) + } + is ShareOrDraftData.SetQuote -> { + composeText.setDraftText(it.draftText) + handleReplyToMessage(it.quote) + } + is ShareOrDraftData.SetText -> composeText.setDraftText(it.text) + is ShareOrDraftData.StartSendMedia -> { + val recipientId = viewModel.recipientSnapshot?.id ?: return@subscribeBy + conversationActivityResultContracts.launchMediaEditor(it.mediaList, recipientId, it.text) + } + } + } + .addTo(disposables) } private fun presentInputReadyState(inputReadyState: InputReadyState) { @@ -577,6 +621,8 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) } else { disabledInputView.clear() } + + composeText.setMessageSendType(MessageSendType.SignalMessageSendType) } private fun presentIdentityRecordsState(identityRecordsState: IdentityRecordsState) { @@ -599,6 +645,33 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) } } + private fun setMedia(uri: Uri, mediaType: SlideFactory.MediaType, width: Int = 0, height: Int = 0, borderless: Boolean = false, videoGif: Boolean = false) { + val recipientId: RecipientId = viewModel.recipientSnapshot?.id ?: return + + if (mediaType == SlideFactory.MediaType.VCARD) { + conversationActivityResultContracts.launchContactShareEditor(uri, viewModel.recipientSnapshot!!.chatColors) + } else if (mediaType == SlideFactory.MediaType.IMAGE || mediaType == SlideFactory.MediaType.GIF || mediaType == SlideFactory.MediaType.VIDEO) { + val mimeType = MediaUtil.getMimeType(requireContext(), uri) ?: mediaType.toFallbackMimeType() + val media = Media( + uri, + mimeType, + 0, + width, + height, + 0, + 0, + borderless, + videoGif, + Optional.empty(), + Optional.empty(), + Optional.empty() + ) + conversationActivityResultContracts.launchMediaEditor(listOf(media), recipientId, composeText.textTrimmed) + } else { + attachmentManager.setMedia(GlideApp.with(this), uri, mediaType, MediaConstraints.getPushMediaConstraints(), width, height) + } + } + private fun calculateCharactersRemaining() { val messageBody: String = binding.conversationInputPanel.embeddedTextEditor.textTrimmed.toString() val charactersLeftView: TextView = binding.conversationInputSpaceLeft @@ -620,6 +693,7 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) private fun registerForResults() { addToContactsLauncher = registerForActivityResult(AddToContactsContract()) {} + conversationActivityResultContracts = ConversationActivityResultContracts(this, ActivityResultCallbacks()) } private fun onRecipientChanged(recipient: Recipient) { @@ -881,26 +955,54 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) } } - private fun sendMessage( - metricId: String? = null, - scheduledDate: Long = -1, - slideDeck: SlideDeck? = if (attachmentManager.isAttachmentPresent) attachmentManager.buildSlideDeck() else null + private fun sendMessageWithoutComposeInput( + slide: Slide? = null, + contacts: List = emptyList(), + clearCompose: Boolean = true ) { + sendMessage( + slideDeck = slide?.let { SlideDeck().apply { addSlide(slide) } }, + contacts = contacts, + clearCompose = clearCompose, + body = "", + mentions = emptyList(), + bodyRanges = null, + messageToEdit = null, + quote = null + ) + } + + private fun sendMessage( + body: String = composeText.editableText.toString(), + mentions: List = composeText.mentions, + bodyRanges: BodyRangeList? = composeText.styling, + messageToEdit: MessageId? = inputPanel.editMessageId, + quote: QuoteModel? = inputPanel.quote.orNull(), + scheduledDate: Long = -1, + slideDeck: SlideDeck? = if (attachmentManager.isAttachmentPresent) attachmentManager.buildSlideDeck() else null, + contacts: List = emptyList(), + clearCompose: Boolean = true + ) { + val metricId = viewModel.recipientSnapshot?.let { if (it.isGroup == true) SignalLocalMetrics.GroupMessageSend.start() else SignalLocalMetrics.IndividualMessageSend.start() } + val send: Completable = viewModel.sendMessage( metricId = metricId, - body = composeText.editableText.toString(), + body = body, slideDeck = slideDeck, scheduledDate = scheduledDate, - messageToEdit = inputPanel.editMessageId, - quote = inputPanel.quote.orNull(), - mentions = composeText.mentions, - bodyRanges = composeText.styling + messageToEdit = messageToEdit, + quote = quote, + mentions = mentions, + bodyRanges = bodyRanges, + contacts = contacts ) disposables += send .doOnSubscribe { - composeText.setText("") - inputPanel.clearQuote() + if (clearCompose) { + composeText.setText("") + inputPanel.clearQuote() + } } .subscribeBy( onError = { t -> @@ -929,7 +1031,7 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) // todo [cfv2] updateLinkPreviewState(); - // todo [cfv2] draftViewModel.onSendComplete(threadId); + draftViewModel.onSendComplete() inputPanel.exitEditMessageMode() } @@ -2067,6 +2169,23 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) } // endregion Conversation Callbacks + //region Activity Results Callbacks + + private inner class ActivityResultCallbacks : ConversationActivityResultContracts.Callbacks { + override fun onSendContacts(contacts: List) { + sendMessageWithoutComposeInput( + contacts = contacts, + clearCompose = false + ) + } + + override fun onMediaSend(result: MediaSendActivityResult) { + // TODO [cfv2] media send + } + } + + //endregion + private class LastSeenPositionUpdater( val adapter: ConversationAdapterV2, val layoutManager: LinearLayoutManager, @@ -2282,13 +2401,7 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) private inner class SendButtonListener : View.OnClickListener, OnEditorActionListener { override fun onClick(v: View) { - val metricId = if (viewModel.recipientSnapshot?.isGroup == true) { - SignalLocalMetrics.GroupMessageSend.start() - } else { - SignalLocalMetrics.IndividualMessageSend.start() - } - - sendMessage(metricId) + sendMessage() } override fun onEditorAction(v: TextView, actionId: Int, event: KeyEvent): Boolean { @@ -2312,6 +2425,9 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) ComposeText.CursorPositionChangedListener { private var beforeLength = 0 + private var previousText = "" + + var typingStatusEnabled = true override fun onKey(v: View, keyCode: Int, event: KeyEvent): Boolean { if (event.action == KeyEvent.ACTION_DOWN) { @@ -2352,11 +2468,114 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) // todo [cfv2] linkPreviewViewModel.onTextChanged(requireContext(), composeText.getTextTrimmed().toString(), start, end); } - override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) = Unit + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { + handleSaveDraftOnTextChange(composeText.textTrimmed) + handleTypingIndicatorOnTextChange(s.toString()) + } + + private fun handleSaveDraftOnTextChange(text: CharSequence) { + textDraftSaveDebouncer.publish { + if (inputPanel.inEditMessageMode()) { + draftViewModel.setMessageEditDraft(inputPanel.editMessageId!!, text.toString(), MentionAnnotation.getMentionsFromAnnotations(text), getStyling(text)) + } else { + draftViewModel.setTextDraft(text.toString(), MentionAnnotation.getMentionsFromAnnotations(text), getStyling(text)) + } + } + } + + private fun handleTypingIndicatorOnTextChange(text: String) { + val recipient = viewModel.recipientSnapshot + + if (recipient == null || !typingStatusEnabled || recipient.isBlocked || recipient.isSelf) { + return + } + + val typingStatusSender = ApplicationDependencies.getTypingStatusSender() + if (text.length == 0) { + typingStatusSender.onTypingStoppedWithNotify(args.threadId) + } else if (text.length < previousText.length && previousText.contains(text)) { + typingStatusSender.onTypingStopped(args.threadId) + } else { + typingStatusSender.onTypingStarted(args.threadId) + } + + previousText = text + } } //endregion Compose + Send Callbacks + //region Input Panel Callbacks + + private inner class InputPanelListener : InputPanel.Listener { + override fun onVoiceNoteDraftPlay(audioUri: Uri, progress: Double) { + // TODO [cfv2] Not yet implemented + } + + override fun onVoiceNoteDraftSeekTo(audioUri: Uri, progress: Double) { + // TODO [cfv2] Not yet implemented + } + + override fun onVoiceNoteDraftPause(audioUri: Uri) { + // TODO [cfv2] Not yet implemented + } + + override fun onVoiceNoteDraftDelete(audioUri: Uri) { + // TODO [cfv2] Not yet implemented + } + + override fun onRecorderStarted() { + // TODO [cfv2] Not yet implemented + } + + override fun onRecorderLocked() { + // TODO [cfv2] Not yet implemented + } + + override fun onRecorderFinished() { + // TODO [cfv2] Not yet implemented + } + + override fun onRecorderCanceled(byUser: Boolean) { + // TODO [cfv2] Not yet implemented + } + + override fun onRecorderPermissionRequired() { + // TODO [cfv2] Not yet implemented + } + + override fun onEmojiToggle() { + // TODO [cfv2] Not yet implemented + } + + override fun onLinkPreviewCanceled() { + // TODO [cfv2] Not yet implemented + } + + override fun onStickerSuggestionSelected(sticker: StickerRecord) { + // TODO [cfv2] Not yet implemented + } + + override fun onQuoteChanged(id: Long, author: RecipientId) { + draftViewModel.setQuoteDraft(id, author) + } + + override fun onQuoteCleared() { + draftViewModel.clearQuoteDraft() + } + + override fun onEnterEditMode() { + // TODO [cfv2] Not yet implemented + } + + override fun onExitEditMode() { + updateToggleButtonState() + draftViewModel.deleteMessageEditDraft() + } + } + + //endregion + //region Attachment + Media Keyboard private inner class AttachmentManagerListener : AttachmentManager.AttachmentListener { @@ -2365,7 +2584,7 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) } override fun onLocationRemoved() { - // TODO [cfv2] implement + draftViewModel.clearLocationDraft() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt index a8c2f7c870..85330beb89 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt @@ -169,7 +169,8 @@ class ConversationRepository( messageToEdit: MessageId?, quote: QuoteModel?, mentions: List, - bodyRanges: BodyRangeList? + bodyRanges: BodyRangeList?, + contacts: List ): Completable { val sendCompletable = Completable.create { emitter -> if (body.isEmpty() && slideDeck?.containsMediaSlide() != true) { @@ -193,7 +194,8 @@ class ConversationRepository( scheduledDate = scheduledDate, outgoingQuote = quote, messageToEdit = messageToEdit?.id ?: 0, - mentions = mentions + mentions = mentions, + sharedContacts = contacts ) MessageSender.send( diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 6f1221dafa..95bcf0ee32 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -28,6 +28,7 @@ import org.signal.core.util.concurrent.subscribeWithSubject import org.signal.core.util.orNull import org.signal.paging.ProxyPagingController import org.thoughtcrime.securesms.components.reminder.Reminder +import org.thoughtcrime.securesms.contactshare.Contact import org.thoughtcrime.securesms.conversation.ConversationMessage import org.thoughtcrime.securesms.conversation.colors.GroupAuthorNameColorHelper import org.thoughtcrime.securesms.conversation.colors.NameColor @@ -42,6 +43,7 @@ import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.database.model.Quote import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList import org.thoughtcrime.securesms.dependencies.ApplicationDependencies +import org.thoughtcrime.securesms.jobs.RetrieveProfileJob import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.messagerequests.MessageRequestRepository import org.thoughtcrime.securesms.messagerequests.MessageRequestState @@ -117,11 +119,6 @@ class ConversationViewModel( recipientSnapshot = it } - disposables += recipientRepository - .conversationRecipient - .skip(1) // We can safely skip the first emission since this is used for updating the header on future changes - .subscribeBy { pagingController.onDataItemChanged(ConversationElementKey.threadHeader) } - disposables += repository.getConversationThreadState(threadId, requestedStartingPosition) .subscribeBy(onSuccess = { pagingController.set(it.items.controller) @@ -153,6 +150,18 @@ class ConversationViewModel( } }.subscribeOn(Schedulers.io()).subscribe() + recipientRepository + .conversationRecipient + .filter { it.isRegistered } + .take(1) + .subscribeBy { RetrieveProfileJob.enqueue(it.id) } + .addTo(disposables) + + disposables += recipientRepository + .conversationRecipient + .skip(1) // We can safely skip the first emission since this is used for updating the header on future changes + .subscribeBy { pagingController.onDataItemChanged(ConversationElementKey.threadHeader) } + disposables += scrollButtonStateStore.update( repository.getMessageCounts(threadId) ) { counts, state -> @@ -250,7 +259,8 @@ class ConversationViewModel( messageToEdit: MessageId?, quote: QuoteModel?, mentions: List, - bodyRanges: BodyRangeList? + bodyRanges: BodyRangeList?, + contacts: List ): Completable { return repository.sendMessage( threadId = threadId, @@ -262,7 +272,8 @@ class ConversationViewModel( messageToEdit = messageToEdit, quote = quote, mentions = mentions, - bodyRanges = bodyRanges + bodyRanges = bodyRanges, + contacts = contacts ).observeOn(AndroidSchedulers.mainThread()) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/AudioSlide.java b/app/src/main/java/org/thoughtcrime/securesms/mms/AudioSlide.java index 5a4f579a60..631e7b6e06 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/AudioSlide.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/AudioSlide.java @@ -57,15 +57,15 @@ public class AudioSlide extends Slide { } public AudioSlide(Context context, Uri uri, long dataSize, boolean voiceNote) { - super(context, constructAttachmentFromUri(context, uri, MediaUtil.AUDIO_UNSPECIFIED, dataSize, 0, 0, false, null, null, null, null, null, voiceNote, false, false, false)); + super(constructAttachmentFromUri(context, uri, MediaUtil.AUDIO_UNSPECIFIED, dataSize, 0, 0, false, null, null, null, null, null, voiceNote, false, false, false)); } public AudioSlide(Context context, Uri uri, long dataSize, String contentType, boolean voiceNote) { - super(context, new UriAttachment(uri, contentType, AttachmentTable.TRANSFER_PROGRESS_STARTED, dataSize, 0, 0, null, null, voiceNote, false, false, false, null, null, null, null, null)); + super(new UriAttachment(uri, contentType, AttachmentTable.TRANSFER_PROGRESS_STARTED, dataSize, 0, 0, null, null, voiceNote, false, false, false, null, null, null, null, null)); } public AudioSlide(Context context, Attachment attachment) { - super(context, attachment); + super(attachment); } @Override @@ -85,7 +85,7 @@ public class AudioSlide extends Slide { @NonNull @Override - public String getContentDescription() { + public String getContentDescription(Context context) { return context.getString(R.string.Slide_audio); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/DocumentSlide.java b/app/src/main/java/org/thoughtcrime/securesms/mms/DocumentSlide.java index 424451d9e1..b82b696d6f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/DocumentSlide.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/DocumentSlide.java @@ -13,14 +13,14 @@ import org.thoughtcrime.securesms.util.StorageUtil; public class DocumentSlide extends Slide { public DocumentSlide(@NonNull Context context, @NonNull Attachment attachment) { - super(context, attachment); + super(attachment); } public DocumentSlide(@NonNull Context context, @NonNull Uri uri, @NonNull String contentType, long size, @Nullable String fileName) { - super(context, constructAttachmentFromUri(context, uri, contentType, size, 0, 0, true, StorageUtil.getCleanFileName(fileName), null, null, null, null, false, false, false, false)); + super(constructAttachmentFromUri(context, uri, contentType, size, 0, 0, true, StorageUtil.getCleanFileName(fileName), null, null, null, null, false, false, false, false)); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/GifSlide.java b/app/src/main/java/org/thoughtcrime/securesms/mms/GifSlide.java index 1f3b6cb12f..c328d7e632 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/GifSlide.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/GifSlide.java @@ -12,8 +12,8 @@ public class GifSlide extends ImageSlide { private final boolean borderless; - public GifSlide(Context context, Attachment attachment) { - super(context, attachment); + public GifSlide(Attachment attachment) { + super(attachment); this.borderless = attachment.isBorderless(); } @@ -22,22 +22,22 @@ public class GifSlide extends ImageSlide { } public GifSlide(Context context, Uri uri, long size, int width, int height, boolean borderless, @Nullable String caption) { - super(context, constructAttachmentFromUri(context, - uri, - MediaUtil.IMAGE_GIF, - size, - width, - height, - true, - null, - caption, - null, - null, - null, - false, - borderless, - true, - false)); + super(constructAttachmentFromUri(context, + uri, + MediaUtil.IMAGE_GIF, + size, + width, + height, + true, + null, + caption, + null, + null, + null, + false, + borderless, + true, + false)); this.borderless = borderless; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/ImageSlide.java b/app/src/main/java/org/thoughtcrime/securesms/mms/ImageSlide.java index 7e3c06103b..f4272f4b20 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/ImageSlide.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/ImageSlide.java @@ -38,8 +38,8 @@ public class ImageSlide extends Slide { @SuppressWarnings("unused") private static final String TAG = Log.tag(ImageSlide.class); - public ImageSlide(@NonNull Context context, @NonNull Attachment attachment) { - super(context, attachment); + public ImageSlide(@NonNull Attachment attachment) { + super(attachment); this.borderless = attachment.isBorderless(); } @@ -52,7 +52,7 @@ public class ImageSlide extends Slide { } public ImageSlide(Context context, Uri uri, String contentType, long size, int width, int height, boolean borderless, @Nullable String caption, @Nullable BlurHash blurHash, @Nullable TransformProperties transformProperties) { - super(context, constructAttachmentFromUri(context, uri, contentType, size, width, height, true, null, caption, null, blurHash, null, false, borderless, false, false, transformProperties)); + super(constructAttachmentFromUri(context, uri, contentType, size, width, height, true, null, caption, null, blurHash, null, false, borderless, false, false, transformProperties)); this.borderless = borderless; } @@ -78,7 +78,7 @@ public class ImageSlide extends Slide { @NonNull @Override - public String getContentDescription() { + public String getContentDescription(Context context) { return context.getString(R.string.Slide_image); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/MmsSlide.java b/app/src/main/java/org/thoughtcrime/securesms/mms/MmsSlide.java index 7eb51968c0..e8606e1da6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/MmsSlide.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/MmsSlide.java @@ -10,12 +10,12 @@ import org.thoughtcrime.securesms.attachments.Attachment; public class MmsSlide extends ImageSlide { public MmsSlide(@NonNull Context context, @NonNull Attachment attachment) { - super(context, attachment); + super(attachment); } @NonNull @Override - public String getContentDescription() { + public String getContentDescription(Context context) { return "MMS"; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/Slide.java b/app/src/main/java/org/thoughtcrime/securesms/mms/Slide.java index 3f5714010f..febdc66704 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/Slide.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/Slide.java @@ -40,10 +40,8 @@ import java.util.Optional; public abstract class Slide { protected final Attachment attachment; - protected final Context context; - public Slide(@NonNull Context context, @NonNull Attachment attachment) { - this.context = context; + public Slide(@NonNull Attachment attachment) { this.attachment = attachment; } @@ -122,7 +120,7 @@ public abstract class Slide { return hasVideo() && attachment.isVideoGif(); } - public @NonNull String getContentDescription() { return ""; } + public @NonNull String getContentDescription(@NonNull Context context) { return ""; } public @NonNull Attachment asAttachment() { return attachment; diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/StickerSlide.java b/app/src/main/java/org/thoughtcrime/securesms/mms/StickerSlide.java index 0c93aeaf8e..7efe11411d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/StickerSlide.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/StickerSlide.java @@ -21,13 +21,13 @@ public class StickerSlide extends Slide { private final StickerLocator stickerLocator; - public StickerSlide(@NonNull Context context, @NonNull Attachment attachment) { - super(context, attachment); + public StickerSlide(@NonNull Attachment attachment) { + super(attachment); this.stickerLocator = Objects.requireNonNull(attachment.getSticker()); } public StickerSlide(Context context, Uri uri, long size, @NonNull StickerLocator stickerLocator, @NonNull String contentType) { - super(context, constructAttachmentFromUri(context, uri, contentType, size, WIDTH, HEIGHT, true, null, null, stickerLocator, null, null, false, false, false, false)); + super(constructAttachmentFromUri(context, uri, contentType, size, WIDTH, HEIGHT, true, null, null, stickerLocator, null, null, false, false, false, false)); this.stickerLocator = Objects.requireNonNull(attachment.getSticker()); } @@ -47,7 +47,7 @@ public class StickerSlide extends Slide { } @Override - public @NonNull String getContentDescription() { + public @NonNull String getContentDescription(Context context) { return context.getString(R.string.Slide_sticker); } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/TextSlide.java b/app/src/main/java/org/thoughtcrime/securesms/mms/TextSlide.java index 75ea2cb099..ab7b186361 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/TextSlide.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/TextSlide.java @@ -13,10 +13,10 @@ import org.thoughtcrime.securesms.util.MediaUtil; public class TextSlide extends Slide { public TextSlide(@NonNull Context context, @NonNull Attachment attachment) { - super(context, attachment); + super(attachment); } public TextSlide(@NonNull Context context, @NonNull Uri uri, @Nullable String filename, long size) { - super(context, constructAttachmentFromUri(context, uri, MediaUtil.LONG_TEXT, size, 0, 0, true, filename, null, null, null, null, false, false, false, false)); + super(constructAttachmentFromUri(context, uri, MediaUtil.LONG_TEXT, size, 0, 0, true, filename, null, null, null, null, false, false, false, false)); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/VideoSlide.java b/app/src/main/java/org/thoughtcrime/securesms/mms/VideoSlide.java index 46f693ddc3..6e56e100f3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/VideoSlide.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/VideoSlide.java @@ -38,15 +38,15 @@ public class VideoSlide extends Slide { } public VideoSlide(Context context, Uri uri, long dataSize, boolean gif, @Nullable String caption, @Nullable AttachmentTable.TransformProperties transformProperties) { - super(context, constructAttachmentFromUri(context, uri, MediaUtil.VIDEO_UNSPECIFIED, dataSize, 0, 0, MediaUtil.hasVideoThumbnail(context, uri), null, caption, null, null, null, false, false, gif, false, transformProperties)); + super(constructAttachmentFromUri(context, uri, MediaUtil.VIDEO_UNSPECIFIED, dataSize, 0, 0, MediaUtil.hasVideoThumbnail(context, uri), null, caption, null, null, null, false, false, gif, false, transformProperties)); } public VideoSlide(Context context, Uri uri, long dataSize, boolean gif, int width, int height, @Nullable String caption, @Nullable AttachmentTable.TransformProperties transformProperties) { - super(context, constructAttachmentFromUri(context, uri, MediaUtil.VIDEO_UNSPECIFIED, dataSize, width, height, MediaUtil.hasVideoThumbnail(context, uri), null, caption, null, null, null, false, false, gif, false, transformProperties)); + super(constructAttachmentFromUri(context, uri, MediaUtil.VIDEO_UNSPECIFIED, dataSize, width, height, MediaUtil.hasVideoThumbnail(context, uri), null, caption, null, null, null, false, false, gif, false, transformProperties)); } public VideoSlide(Context context, Attachment attachment) { - super(context, attachment); + super(attachment); } @Override @@ -75,7 +75,7 @@ public class VideoSlide extends Slide { } @NonNull @Override - public String getContentDescription() { + public String getContentDescription(Context context) { return context.getString(R.string.Slide_video); } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/ViewOnceSlide.java b/app/src/main/java/org/thoughtcrime/securesms/mms/ViewOnceSlide.java index ec555a8956..cc3be09115 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/ViewOnceSlide.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/ViewOnceSlide.java @@ -16,7 +16,7 @@ import org.thoughtcrime.securesms.util.MediaUtil; public class ViewOnceSlide extends Slide { public ViewOnceSlide(@NonNull Context context, @NonNull Attachment attachment) { - super(context, attachment); + super(attachment); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/StoryLinkPreviewView.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/StoryLinkPreviewView.kt index 90829cb114..eaddcd2645 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/StoryLinkPreviewView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/StoryLinkPreviewView.kt @@ -116,7 +116,7 @@ class StoryLinkPreviewView @JvmOverloads constructor( notImage.visible = false - val imageSlide: Slide? = linkPreview.thumbnail.map { ImageSlide(context, it) }.orElse(null) + val imageSlide: Slide? = linkPreview.thumbnail.map { ImageSlide(it) }.orElse(null) if (imageSlide != null) { if (loadThumbnail) { future = image.setImageResource( diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MediaUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/MediaUtil.java index fafc873a86..b4a0e0675f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/MediaUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/MediaUtil.java @@ -92,12 +92,12 @@ public class MediaUtil { public static @NonNull Slide getSlideForAttachment(Context context, Attachment attachment) { if (attachment.isSticker()) { - return new StickerSlide(context, attachment); + return new StickerSlide(attachment); } switch (getSlideTypeFromContentType(attachment.getContentType())) { - case GIF : return new GifSlide(context, attachment); - case IMAGE : return new ImageSlide(context, attachment); + case GIF : return new GifSlide(attachment); + case IMAGE : return new ImageSlide(attachment); case VIDEO : return new VideoSlide(context, attachment); case AUDIO : return new AudioSlide(context, attachment); case MMS : return new MmsSlide(context, attachment); diff --git a/app/src/test/java/org/thoughtcrime/securesms/stories/dialogs/StoryContextMenuTest.kt b/app/src/test/java/org/thoughtcrime/securesms/stories/dialogs/StoryContextMenuTest.kt index 4a6cbd587a..887bdf1b3b 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/stories/dialogs/StoryContextMenuTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/stories/dialogs/StoryContextMenuTest.kt @@ -48,7 +48,6 @@ class StoryContextMenuTest { slideDeck = SlideDeck().apply { addSlide( ImageSlide( - context, FakeMessageRecords.buildDatabaseAttachment( attachmentId = attachmentId )