From 411a0198b4d9ad1bd8075a0d7df4ee7b18154856 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Mon, 15 Jun 2026 13:29:28 -0400 Subject: [PATCH] Show media preview controls immediately. --- .../conversation/ConversationItem.java | 6 + .../MediaOverviewPageFragment.java | 6 + .../mediapreview/MediaIntentFactory.kt | 15 ++ .../mediapreview/MediaPreviewRepository.kt | 17 +- .../mediapreview/MediaPreviewV2Fragment.kt | 175 ++++++++++++------ .../mediapreview/MediaPreviewV2ViewModel.kt | 59 +++--- .../securesms/mms/AttachmentManager.java | 6 + .../parcelers/NullableCharSequenceParceler.kt | 23 +++ 8 files changed, 215 insertions(+), 92 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/util/parcelers/NullableCharSequenceParceler.kt 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 c760cdb448..8e18790f70 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationItem.java @@ -2954,10 +2954,16 @@ public final class ConversationItem extends RelativeLayout implements BindableCo MediaIntentFactory.MediaPreviewArgs args = new MediaIntentFactory.MediaPreviewArgs( messageRecord.getThreadId(), messageRecord.getTimestamp(), + messageRecord.getId(), + messageRecord.getFromRecipient().getId(), + conversationRecipient.getId(), + messageRecord.isOutgoing(), mediaUri, + slide.getUri(), slide.getContentType(), slide.asAttachment().size, slide.getCaption().orElse(null), + conversationMessage.getDisplayBody(getContext()), false, false, false, diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaOverviewPageFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaOverviewPageFragment.java index 2723cebd99..f691f630e1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaOverviewPageFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediaoverview/MediaOverviewPageFragment.java @@ -338,10 +338,16 @@ public final class MediaOverviewPageFragment extends LoggingFragment MediaIntentFactory.MediaPreviewArgs args = new MediaIntentFactory.MediaPreviewArgs( threadId, mediaRecord.getDate(), + mediaRecord.getMessageId(), + mediaRecord.getRecipientId(), + mediaRecord.getThreadRecipientId(), + mediaRecord.isOutgoing(), Objects.requireNonNull(mediaRecord.getAttachment().getDisplayUri()), + mediaRecord.getAttachment().getUri(), mediaRecord.getContentType(), mediaRecord.getAttachment().size, mediaRecord.getAttachment().caption, + null, true, true, threadId == MediaTable.ALL_THREADS, diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaIntentFactory.kt b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaIntentFactory.kt index 3a945709ab..5f0e911266 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaIntentFactory.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaIntentFactory.kt @@ -6,11 +6,14 @@ import android.net.Uri import android.os.Bundle import android.os.Parcelable import kotlinx.parcelize.Parcelize +import kotlinx.parcelize.WriteWith import org.signal.core.util.dp import org.signal.core.util.getParcelableCompat import org.thoughtcrime.securesms.attachments.DatabaseAttachment import org.thoughtcrime.securesms.database.MediaTable import org.thoughtcrime.securesms.database.MediaTable.MediaRecord +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.util.parcelers.NullableCharSequenceParceler object MediaIntentFactory { private const val ARGS_KEY = "args" @@ -32,10 +35,16 @@ object MediaIntentFactory { data class MediaPreviewArgs( val threadId: Long, val date: Long, + val messageId: Long, + val fromRecipientId: RecipientId, + val threadRecipientId: RecipientId, + val outgoing: Boolean, val initialMediaUri: Uri, + val initialMediaDataUri: Uri?, val initialMediaType: String?, val initialMediaSize: Long, val initialCaption: String? = null, + val initialMessageBody: @WriteWith CharSequence? = null, val leftIsRecent: Boolean = false, val hideAllMedia: Boolean = false, val showThread: Boolean = false, @@ -70,10 +79,16 @@ object MediaIntentFactory { MediaPreviewArgs( threadId = mediaRecord.threadId, date = mediaRecord.date, + messageId = mediaRecord.messageId, + fromRecipientId = mediaRecord.recipientId, + threadRecipientId = mediaRecord.threadRecipientId, + outgoing = mediaRecord.isOutgoing, initialMediaUri = attachment.displayUri!!, + initialMediaDataUri = attachment.uri, initialMediaType = attachment.contentType, initialMediaSize = attachment.size, initialCaption = attachment.caption, + initialMessageBody = null, leftIsRecent = leftIsRecent, allMediaInRail = allMediaInRail, sorting = MediaTable.Sorting.Newest, diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewRepository.kt index 4565f9f095..a2492387d4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewRepository.kt @@ -85,16 +85,19 @@ class MediaPreviewRepository { } } - val messageIds = mediaRecords.mapNotNull { it.attachment?.mmsId }.toSet() - val messages: Map = SignalDatabase.messages.getMessages(messageIds).toList().withAttachments() - .map { it as MmsMessageRecord } - .associate { it.id to it.resolveBody(context).getDisplayBody(context) } - - Result(if (mediaRecords.isNotEmpty()) itemPosition.coerceIn(mediaRecords.indices) else itemPosition, mediaRecords, messages) + Result(if (mediaRecords.isNotEmpty()) itemPosition.coerceIn(mediaRecords.indices) else itemPosition, mediaRecords) } }.subscribeOn(Schedulers.io()).toFlowable() } + fun resolveMessageBodies(context: Context, messageIds: Set): Single> { + return Single.fromCallable { + SignalDatabase.messages.getMessages(messageIds).toList().withAttachments() + .filterIsInstance() + .associate { it.id to it.resolveBody(context).getDisplayBody(context) } + }.subscribeOn(Schedulers.io()) + } + fun localDelete(attachment: DatabaseAttachment): Completable { return Completable.fromRunnable { val deletedMessageRecord = AttachmentUtil.deleteAttachment(attachment) @@ -132,5 +135,5 @@ class MediaPreviewRepository { .observeOn(AndroidSchedulers.mainThread()) } - data class Result(val initialPosition: Int, val records: List, val messageBodies: Map) + data class Result(val initialPosition: Int, val records: List) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewV2Fragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewV2Fragment.kt index e81f9486c7..89a58d68a6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewV2Fragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewV2Fragment.kt @@ -7,6 +7,7 @@ import android.content.Context import android.content.Intent import android.graphics.PorterDuff import android.graphics.PorterDuffColorFilter +import android.net.Uri import android.os.Build import android.os.Bundle import android.text.Annotation @@ -71,6 +72,7 @@ import org.thoughtcrime.securesms.mediapreview.mediarail.MediaRailAdapter.ImageL import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity import org.thoughtcrime.securesms.mms.PartAuthority import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.sharing.v2.ShareActivity import org.thoughtcrime.securesms.util.DateUtils import org.thoughtcrime.securesms.util.FullscreenHelper @@ -96,6 +98,7 @@ class MediaPreviewV2Fragment : requireActivity() }) private val debouncer = Debouncer(2, TimeUnit.SECONDS) + private val args: MediaIntentFactory.MediaPreviewArgs by lazy { MediaIntentFactory.requireArguments(requireArguments()) } private lateinit var pagerAdapter: MediaPreviewV2Adapter private lateinit var albumRailAdapter: MediaRailAdapter @@ -118,9 +121,11 @@ class MediaPreviewV2Fragment : override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - val args = MediaIntentFactory.requireArguments(requireArguments()) initializeViewModel(args) initializeToolbar(binding.toolbar) + bindToolbar(args.fromRecipientId, args.threadRecipientId, args.outgoing, args.showThread, args.date, args.messageId) + bindInitialPlaybackControls(args) + bindInitialCaption(args) initializeViewPager() initializeAlbumRail() initializeFullScreenUi() @@ -244,6 +249,10 @@ class MediaPreviewV2Fragment : if (binding.mediaPager.currentItem != currentPosition) { binding.mediaPager.setCurrentItem(currentPosition, false) } + + val currentItem: MediaTable.MediaRecord = currentState.mediaRecords[currentPosition] + bindTextViews(currentItem, currentState.showThread, currentState.messageBodies) + bindMenuItems(currentItem) } /** @@ -282,13 +291,46 @@ class MediaPreviewV2Fragment : } private fun bindTextViews(currentItem: MediaTable.MediaRecord, showThread: Boolean, messageBodies: Map) { - val title = getTitleText(currentItem, showThread) - val (subtitle, subtitleContentDesc) = getSubTitleText(currentItem) + bindToolbar(currentItem.recipientId, currentItem.threadRecipientId, currentItem.isOutgoing, showThread, currentItem.date, currentItem.attachment?.mmsId) + + val caption = currentItem.attachment?.caption + val messageId = currentItem.attachment?.mmsId + if (caption != null) { + bindCaptionView(SpannableString(caption)) + } else { + bindCaptionView(messageBodies[messageId] ?: initialBodyForMessage(messageId)) + } + } + + /** + * The body passed in via arguments for the initially-opened message. Used as a fallback until the background + * body resolution populates [MediaPreviewV2State.messageBodies], so the caption never blanks out after the + * instant render. + */ + private fun initialBodyForMessage(messageId: Long?): SpannableString? { + return if (messageId != null && messageId == args.messageId) { + args.initialMessageBody?.let { SpannableString(it) } + } else { + null + } + } + + private fun bindInitialCaption(args: MediaIntentFactory.MediaPreviewArgs) { + val caption = args.initialCaption + if (caption != null) { + bindCaptionView(SpannableString(caption)) + } else { + bindCaptionView(args.initialMessageBody?.let { SpannableString(it) }) + } + } + + private fun bindToolbar(fromRecipientId: RecipientId, threadRecipientId: RecipientId, isOutgoing: Boolean, showThread: Boolean, date: Long, messageId: Long?) { + val title = getTitleText(fromRecipientId, threadRecipientId, isOutgoing, showThread) + val (subtitle, subtitleContentDesc) = getSubTitleText(date) binding.toolbar.title = title binding.toolbar.subtitle = subtitle binding.toolbar.contentDescription = "$title $subtitleContentDesc" - val messageId: Long? = currentItem.attachment?.mmsId - if (messageId != null) { + if (messageId != null && messageId > 0) { binding.toolbar.setOnClickListener { v -> lifecycleDisposable += viewModel.jumpToFragment(v.context, messageId).subscribeBy( onSuccess = { @@ -302,13 +344,6 @@ class MediaPreviewV2Fragment : ) } } - - val caption = currentItem.attachment?.caption - if (caption != null) { - bindCaptionView(SpannableString(caption)) - } else { - bindCaptionView(messageBodies[messageId]) - } } private fun bindCaptionView(displayBody: SpannableString?) { @@ -345,6 +380,20 @@ class MediaPreviewV2Fragment : } } + private fun bindInitialPlaybackControls(args: MediaIntentFactory.MediaPreviewArgs) { + if (!isContentTypeSupported(args.initialMediaType)) { + return + } + val mediaMode: MediaPreviewPlayerControlView.MediaMode = if (args.isVideoGif) { + MediaPreviewPlayerControlView.MediaMode.IMAGE + } else { + MediaPreviewPlayerControlView.MediaMode.fromString(args.initialMediaType) + } + binding.mediaPreviewPlaybackControls.setMediaMode(mediaMode) + bindShareAndForwardButtons(args.threadId, args.initialMediaDataUri, args.initialMediaType) + crossfadeViewIn(binding.mediaPreviewDetailsContainer) + } + private fun bindMediaPreviewPlaybackControls(currentItem: MediaTable.MediaRecord, currentFragment: MediaPreviewFragment?) { val mediaType: MediaPreviewPlayerControlView.MediaMode = if (currentItem.attachment?.videoGif == true) { MediaPreviewPlayerControlView.MediaMode.IMAGE @@ -352,19 +401,31 @@ class MediaPreviewV2Fragment : MediaPreviewPlayerControlView.MediaMode.fromString(currentItem.contentType) } binding.mediaPreviewPlaybackControls.setMediaMode(mediaType) - val videoMediaPreviewFragment: VideoMediaPreviewFragment? = currentFragment as? VideoMediaPreviewFragment - binding.mediaPreviewPlaybackControls.setShareButtonListener { - videoMediaPreviewFragment?.pause() - share(currentItem) - } - binding.mediaPreviewPlaybackControls.setForwardButtonListener { - videoMediaPreviewFragment?.pause() - forward(currentItem) - } + bindShareAndForwardButtons(currentItem.threadId, currentItem.attachment?.uri, currentItem.contentType) currentFragment?.setBottomButtonControls(binding.mediaPreviewPlaybackControls) currentFragment?.autoPlayIfNeeded() } + private fun bindShareAndForwardButtons(threadId: Long, uri: Uri?, contentType: String?) { + if (uri == null) { + binding.mediaPreviewPlaybackControls.setShareButtonListener(null) + binding.mediaPreviewPlaybackControls.setForwardButtonListener(null) + return + } + binding.mediaPreviewPlaybackControls.setShareButtonListener { + pauseCurrentMediaIfVideo() + share(uri, contentType) + } + binding.mediaPreviewPlaybackControls.setForwardButtonListener { + pauseCurrentMediaIfVideo() + forward(threadId, uri, contentType) + } + } + + private fun pauseCurrentMediaIfVideo() { + (getMediaPreviewFragmentFromChildFragmentManager(binding.mediaPager.currentItem) as? VideoMediaPreviewFragment)?.pause() + } + private fun tryBindMediaPreviewPlaybackControls( currentItem: MediaTable.MediaRecord, currentPosition: Int, @@ -453,9 +514,9 @@ class MediaPreviewV2Fragment : binding.mediaPager.setCurrentItem(position, true) } - private fun getTitleText(mediaRecord: MediaTable.MediaRecord, showThread: Boolean): String { - val recipient: Recipient = Recipient.live(mediaRecord.recipientId).get() - val defaultFromString: String = if (mediaRecord.isOutgoing) { + private fun getTitleText(fromRecipientId: RecipientId, threadRecipientId: RecipientId, isOutgoing: Boolean, showThread: Boolean): String { + val recipient: Recipient = Recipient.live(fromRecipientId).get() + val defaultFromString: String = if (isOutgoing) { getString(R.string.MediaPreviewActivity_you) } else { recipient.getDisplayName(requireContext()) @@ -464,8 +525,8 @@ class MediaPreviewV2Fragment : return defaultFromString } - val threadRecipient = Recipient.live(mediaRecord.threadRecipientId).get() - return if (mediaRecord.isOutgoing) { + val threadRecipient = Recipient.live(threadRecipientId).get() + return if (isOutgoing) { if (threadRecipient.isSelf) { getString(R.string.note_to_self) } else { @@ -480,9 +541,9 @@ class MediaPreviewV2Fragment : } } - private fun getSubTitleText(mediaRecord: MediaTable.MediaRecord): Pair { - val (text, contentDesc) = if (mediaRecord.date > 0) { - DateUtils.getExtendedRelativeTimeSpanString(requireContext(), Locale.getDefault(), mediaRecord.date) + private fun getSubTitleText(date: Long): Pair { + val (text, contentDesc) = if (date > 0) { + DateUtils.getExtendedRelativeTimeSpanString(requireContext(), Locale.getDefault(), date) } else { Pair(getString(R.string.MediaPreviewActivity_draft), getString(R.string.MediaPreviewActivity_draft)) } @@ -554,43 +615,35 @@ class MediaPreviewV2Fragment : activity?.finish() } - private fun forward(mediaItem: MediaTable.MediaRecord) { - val attachment = mediaItem.attachment - val uri = attachment?.uri - if (attachment != null && uri != null) { - MultiselectForwardFragmentArgs.create( - context = requireContext(), - threadId = mediaItem.threadId, - mediaUri = uri, - contentType = attachment.contentType - ) { args: MultiselectForwardFragmentArgs -> - MultiselectForwardFragment.showBottomSheet(childFragmentManager, args) - } + private fun forward(threadId: Long, uri: Uri, contentType: String?) { + MultiselectForwardFragmentArgs.create( + context = requireContext(), + threadId = threadId, + mediaUri = uri, + contentType = contentType + ) { args: MultiselectForwardFragmentArgs -> + MultiselectForwardFragment.showBottomSheet(childFragmentManager, args) } } - private fun share(mediaItem: MediaTable.MediaRecord) { - val attachment = mediaItem.attachment - val uri = attachment?.uri - if (attachment != null && uri != null) { - val publicUri = PartAuthority.getAttachmentPublicUri(uri) - val mimeType = Intent.normalizeMimeType(attachment.contentType) - val shareIntent = ShareCompat.IntentBuilder(requireActivity()) - .setStream(publicUri) - .setType(mimeType) - .createChooserIntent() - .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + private fun share(uri: Uri, contentType: String?) { + val publicUri = PartAuthority.getAttachmentPublicUri(uri) + val mimeType = Intent.normalizeMimeType(contentType) + val shareIntent = ShareCompat.IntentBuilder(requireActivity()) + .setStream(publicUri) + .setType(mimeType) + .createChooserIntent() + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - if (Build.VERSION.SDK_INT < 34) { - shareIntent.putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, arrayOf(ComponentName(requireContext(), ShareActivity::class.java))) - } + if (Build.VERSION.SDK_INT < 34) { + shareIntent.putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, arrayOf(ComponentName(requireContext(), ShareActivity::class.java))) + } - try { - startActivity(shareIntent) - } catch (e: ActivityNotFoundException) { - Log.w(TAG, "No activity existed to share the media.", e) - Toast.makeText(requireContext(), R.string.MediaPreviewActivity_cant_find_an_app_able_to_share_this_media, Toast.LENGTH_LONG).show() - } + try { + startActivity(shareIntent) + } catch (e: ActivityNotFoundException) { + Log.w(TAG, "No activity existed to share the media.", e) + Toast.makeText(requireContext(), R.string.MediaPreviewActivity_cant_find_an_app_able_to_share_this_media, Toast.LENGTH_LONG).show() } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewV2ViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewV2ViewModel.kt index 31e680310a..cded43c7be 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewV2ViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewV2ViewModel.kt @@ -59,36 +59,47 @@ class MediaPreviewV2ViewModel : ViewModel() { fun fetchAttachments(context: Context, startingAttachmentId: AttachmentId, threadId: Long, sorting: MediaTable.Sorting, forceRefresh: Boolean = false) { if (store.state.loadState == MediaPreviewV2State.LoadState.INIT || forceRefresh) { - disposables += store.update(repository.getAttachments(context, startingAttachmentId, threadId, sorting)) { result: MediaPreviewRepository.Result, oldState: MediaPreviewV2State -> - val albums = result.records.fold(mutableMapOf()) { acc: MutableMap>, mediaRecord: MediaTable.MediaRecord -> - val attachment = mediaRecord.attachment - if (attachment != null) { - val convertedMedia = mediaRecord.toMedia() ?: return@fold acc - acc.getOrPut(attachment.mmsId) { mutableListOf() }.add(convertedMedia) + disposables += repository.getAttachments(context, startingAttachmentId, threadId, sorting).subscribe { result -> + store.update { oldState -> + val albums = result.records.fold(mutableMapOf()) { acc: MutableMap>, mediaRecord: MediaTable.MediaRecord -> + val attachment = mediaRecord.attachment + if (attachment != null) { + val convertedMedia = mediaRecord.toMedia() ?: return@fold acc + acc.getOrPut(attachment.mmsId) { mutableListOf() }.add(convertedMedia) + } + acc + } + if (oldState.leftIsRecent) { + oldState.copy( + position = result.initialPosition, + mediaRecords = result.records, + albums = albums, + loadState = MediaPreviewV2State.LoadState.DATA_LOADED + ) + } else { + oldState.copy( + position = result.records.size - result.initialPosition - 1, + mediaRecords = result.records.reversed(), + albums = albums.mapValues { it.value.reversed() }, + loadState = MediaPreviewV2State.LoadState.DATA_LOADED + ) } - acc - } - if (oldState.leftIsRecent) { - oldState.copy( - position = result.initialPosition, - mediaRecords = result.records, - messageBodies = result.messageBodies, - albums = albums, - loadState = MediaPreviewV2State.LoadState.DATA_LOADED - ) - } else { - oldState.copy( - position = result.records.size - result.initialPosition - 1, - mediaRecords = result.records.reversed(), - messageBodies = result.messageBodies, - albums = albums.mapValues { it.value.reversed() }, - loadState = MediaPreviewV2State.LoadState.DATA_LOADED - ) } + fetchMessageBodies(context, result.records) } } } + private fun fetchMessageBodies(context: Context, records: List) { + val messageIds = records.mapNotNull { it.attachment?.mmsId }.toSet() + if (messageIds.isEmpty()) { + return + } + disposables += repository.resolveMessageBodies(context, messageIds).subscribe { bodies -> + store.update { oldState -> oldState.copy(messageBodies = oldState.messageBodies + bodies) } + } + } + fun refetchAttachments(context: Context, startingAttachmentId: AttachmentId, threadId: Long, sorting: MediaTable.Sorting) { val state = store.state val currentAttachmentId = if (state.position in state.mediaRecords.indices) { 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 6fc86c5a89..c234630901 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/AttachmentManager.java @@ -382,10 +382,16 @@ public class AttachmentManager { MediaIntentFactory.MediaPreviewArgs args = new MediaIntentFactory.MediaPreviewArgs( MediaIntentFactory.NOT_IN_A_THREAD, MediaIntentFactory.UNKNOWN_TIMESTAMP, + MediaIntentFactory.NOT_IN_A_THREAD, + RecipientId.UNKNOWN, + RecipientId.UNKNOWN, + true, + slide.getUri(), slide.getUri(), slide.getContentType(), slide.asAttachment().size, slide.getCaption().orElse(null), + null, false, false, false, diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/parcelers/NullableCharSequenceParceler.kt b/app/src/main/java/org/thoughtcrime/securesms/util/parcelers/NullableCharSequenceParceler.kt new file mode 100644 index 0000000000..34d38c795e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/parcelers/NullableCharSequenceParceler.kt @@ -0,0 +1,23 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.util.parcelers + +import android.os.Parcel +import android.text.TextUtils +import kotlinx.parcelize.Parceler + +/** + * Parceler for a nullable [CharSequence], preserving any spans (e.g. mention and styling annotations). + */ +object NullableCharSequenceParceler : Parceler { + override fun create(parcel: Parcel): CharSequence? { + return TextUtils.CHAR_SEQUENCE_CREATOR.createFromParcel(parcel) + } + + override fun CharSequence?.write(parcel: Parcel, flags: Int) { + TextUtils.writeToParcel(this, parcel, flags) + } +}