diff --git a/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageRepository.java b/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageRepository.java index cb7a6f9d4a..ba878b5d2c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageRepository.java +++ b/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageRepository.java @@ -2,23 +2,17 @@ package org.thoughtcrime.securesms.longmessage; import android.content.Context; import android.database.Cursor; -import android.net.Uri; import androidx.annotation.NonNull; import androidx.annotation.WorkerThread; -import org.signal.core.util.StreamUtil; import org.signal.core.util.concurrent.SignalExecutors; import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.conversation.ConversationMessage.ConversationMessageFactory; +import org.thoughtcrime.securesms.conversation.ConversationMessage; import org.thoughtcrime.securesms.database.MessageTable; import org.thoughtcrime.securesms.database.SignalDatabase; import org.thoughtcrime.securesms.database.model.MmsMessageRecord; -import org.thoughtcrime.securesms.mms.PartAuthority; -import org.thoughtcrime.securesms.mms.TextSlide; -import java.io.IOException; -import java.io.InputStream; import java.util.Optional; class LongMessageRepository { @@ -40,15 +34,9 @@ class LongMessageRepository { @WorkerThread private Optional getMmsLongMessage(@NonNull Context context, @NonNull MessageTable mmsDatabase, long messageId) { Optional record = getMmsMessage(mmsDatabase, messageId); - if (record.isPresent()) { - TextSlide textSlide = record.get().getSlideDeck().getTextSlide(); - - if (textSlide != null && textSlide.getUri() != null) { - return Optional.of(new LongMessage(ConversationMessageFactory.createWithUnresolvedData(context, record.get(), readFullBody(context, textSlide.getUri())))); - } else { - return Optional.of(new LongMessage(ConversationMessageFactory.createWithUnresolvedData(context, record.get()))); - } + final ConversationMessage resolvedMessage = LongMessageResolveerKt.resolveBody(record.get(), context); + return Optional.of(new LongMessage(resolvedMessage)); } else { return Optional.empty(); } @@ -61,14 +49,6 @@ class LongMessageRepository { } } - private @NonNull String readFullBody(@NonNull Context context, @NonNull Uri uri) { - try (InputStream stream = PartAuthority.getAttachmentStream(context, uri)) { - return StreamUtil.readFullyAsString(stream); - } catch (IOException e) { - Log.w(TAG, "Failed to read full text body.", e); - return ""; - } - } interface Callback { void onComplete(T result); diff --git a/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageResolveer.kt b/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageResolveer.kt new file mode 100644 index 0000000000..33da4876f6 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/longmessage/LongMessageResolveer.kt @@ -0,0 +1,31 @@ +package org.thoughtcrime.securesms.longmessage + +import android.content.Context +import android.net.Uri +import org.signal.core.util.StreamUtil +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.conversation.ConversationMessage +import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.mms.PartAuthority +import java.io.IOException + +const val TAG = "LongMessageResolver" + +fun readFullBody(context: Context, uri: Uri): String { + try { + PartAuthority.getAttachmentStream(context, uri).use { stream -> return StreamUtil.readFullyAsString(stream) } + } catch (e: IOException) { + Log.w(TAG, "Failed to read full text body.", e) + return "" + } +} + +fun MmsMessageRecord.resolveBody(context: Context): ConversationMessage { + val textSlide = slideDeck.textSlide + val textSlideUri = textSlide?.uri + return if (textSlide != null && textSlideUri != null) { + ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(context, this, readFullBody(context, textSlideUri)) + } else { + ConversationMessage.ConversationMessageFactory.createWithUnresolvedData(context, this) + } +} 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 3ae9230757..13f0e2533c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewRepository.kt @@ -2,6 +2,7 @@ package org.thoughtcrime.securesms.mediapreview import android.content.Context import android.content.Intent +import android.text.SpannableString import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Flowable @@ -19,6 +20,8 @@ import org.thoughtcrime.securesms.database.MediaTable.Sorting import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.database.SignalDatabase.Companion.media import org.thoughtcrime.securesms.database.model.MessageRecord +import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.longmessage.resolveBody import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.sms.MessageSender import org.thoughtcrime.securesms.util.AttachmentUtil @@ -38,7 +41,7 @@ class MediaPreviewRepository { * @param sorting the ordering of the results * @param limit the maximum quantity of the results */ - fun getAttachments(startingAttachmentId: AttachmentId, threadId: Long, sorting: Sorting, limit: Int = 500): Flowable { + fun getAttachments(context: Context, startingAttachmentId: AttachmentId, threadId: Long, sorting: Sorting, limit: Int = 500): Flowable { return Single.fromCallable { media.getGalleryMediaForThread(threadId, sorting).use { cursor -> val mediaRecords = mutableListOf() @@ -71,7 +74,12 @@ class MediaPreviewRepository { } } } - Result(itemPosition, mediaRecords.toList()) + val messageIds = mediaRecords.mapNotNull { it.attachment?.mmsId }.toSet() + val messages: Map = SignalDatabase.messages.getMessages(messageIds) + .map { it as MmsMessageRecord } + .associate { it.id to it.resolveBody(context).getDisplayBody(context) } + + Result(itemPosition, mediaRecords.toList(), messages) } }.subscribeOn(Schedulers.io()).toFlowable() } @@ -110,5 +118,5 @@ class MediaPreviewRepository { .observeOn(AndroidSchedulers.mainThread()) } - data class Result(val initialPosition: Int, val records: List) + data class Result(val initialPosition: Int, val records: List, val messageBodies: Map) } 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 cbeb94034d..2ddf8d4d14 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewV2Fragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewV2Fragment.kt @@ -9,6 +9,7 @@ import android.content.Intent import android.graphics.PorterDuff import android.graphics.PorterDuffColorFilter import android.os.Bundle +import android.text.SpannableString import android.text.SpannableStringBuilder import android.view.LayoutInflater import android.view.Menu @@ -48,6 +49,7 @@ import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectFor import org.thoughtcrime.securesms.database.MediaTable import org.thoughtcrime.securesms.database.SignalDatabase import org.thoughtcrime.securesms.databinding.FragmentMediaPreviewV2Binding +import org.thoughtcrime.securesms.mediapreview.caption.ExpandingCaptionView import org.thoughtcrime.securesms.mediapreview.mediarail.CenterDecoration import org.thoughtcrime.securesms.mediapreview.mediarail.MediaRailAdapter import org.thoughtcrime.securesms.mediapreview.mediarail.MediaRailAdapter.ImageLoadingListener @@ -127,7 +129,7 @@ class MediaPreviewV2Fragment : LoggingFragment(R.layout.fragment_media_preview_v } viewModel.initialize(args.showThread, args.allMediaInRail, args.leftIsRecent) val sorting = MediaTable.Sorting.deserialize(args.sorting.ordinal) - viewModel.fetchAttachments(PartAuthority.requireAttachmentId(args.initialMediaUri), args.threadId, sorting) + viewModel.fetchAttachments(requireContext(), PartAuthority.requireAttachmentId(args.initialMediaUri), args.threadId, sorting) } @SuppressLint("RestrictedApi") @@ -231,7 +233,7 @@ class MediaPreviewV2Fragment : LoggingFragment(R.layout.fragment_media_preview_v } } - bindTextViews(currentItem, currentState.showThread) + bindTextViews(currentItem, currentState.showThread, currentState.messageBodies) bindMenuItems(currentItem) bindMediaPreviewPlaybackControls(currentItem, getMediaPreviewFragmentFromChildFragmentManager(currentPosition)) @@ -245,7 +247,7 @@ class MediaPreviewV2Fragment : LoggingFragment(R.layout.fragment_media_preview_v crossfadeViewIn(binding.mediaPreviewDetailsContainer) } - private fun bindTextViews(currentItem: MediaTable.MediaRecord, showThread: Boolean) { + private fun bindTextViews(currentItem: MediaTable.MediaRecord, showThread: Boolean, messageBodies: Map) { binding.toolbar.title = getTitleText(currentItem, showThread) binding.toolbar.subtitle = getSubTitleText(currentItem) val messageId: Long? = currentItem.attachment?.mmsId @@ -262,8 +264,27 @@ class MediaPreviewV2Fragment : LoggingFragment(R.layout.fragment_media_preview_v } val caption = currentItem.attachment?.caption - binding.mediaPreviewCaption.text = caption - binding.mediaPreviewCaption.visible = caption != null + if (caption != null) { + bindCaptionView(caption) + } else { + bindCaptionView(messageBodies[messageId]) + } + } + + private fun bindCaptionView(displayBody: CharSequence?) { + val caption: ExpandingCaptionView = binding.mediaPreviewCaption + if (displayBody.isNullOrEmpty()) { + caption.visible = false + } else { + caption.expandedHeight = calculateExpandedHeight() + caption.fullCaptionText = displayBody + caption.visible = true + } + } + + private fun calculateExpandedHeight(): Int { + val height: Int = view?.height ?: return ViewUtil.dpToPx(requireContext(), EXPANDED_CAPTION_HEIGHT_FALLBACK_DP) + return ((height - binding.toolbar.height - binding.mediaPreviewPlaybackControls.height) * EXPANDED_CAPTION_HEIGHT_PERCENT).roundToInt() } private fun bindMenuItems(currentItem: MediaTable.MediaRecord) { @@ -602,6 +623,9 @@ class MediaPreviewV2Fragment : LoggingFragment(R.layout.fragment_media_preview_v } companion object { + private const val EXPANDED_CAPTION_HEIGHT_FALLBACK_DP = 400 + private const val EXPANDED_CAPTION_HEIGHT_PERCENT: Float = 0.7F + private val TAG = Log.tag(MediaPreviewV2Fragment::class.java) const val ARGS_KEY: String = "args" diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewV2State.kt b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewV2State.kt index 58a82047b0..56c3439fe9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewV2State.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewV2State.kt @@ -1,5 +1,6 @@ package org.thoughtcrime.securesms.mediapreview +import android.text.SpannableString import org.thoughtcrime.securesms.database.MediaTable import org.thoughtcrime.securesms.mediasend.Media @@ -11,6 +12,7 @@ data class MediaPreviewV2State( val allMediaInAlbumRail: Boolean = false, val leftIsRecent: Boolean = false, val albums: Map> = mapOf(), + val messageBodies: Map = mapOf(), ) { enum class LoadState { INIT, DATA_LOADED, MEDIA_READY } } 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 1c58ef058e..a75739eef1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewV2ViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewV2ViewModel.kt @@ -27,9 +27,9 @@ class MediaPreviewV2ViewModel : ViewModel() { val currentPosition: Int get() = store.state.position - fun fetchAttachments(startingAttachmentId: AttachmentId, threadId: Long, sorting: MediaTable.Sorting, forceRefresh: Boolean = false) { + 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(startingAttachmentId, threadId, sorting)) { result: MediaPreviewRepository.Result, oldState: MediaPreviewV2State -> + 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) { @@ -42,6 +42,7 @@ class MediaPreviewV2ViewModel : ViewModel() { oldState.copy( position = result.initialPosition, mediaRecords = result.records, + messageBodies = result.messageBodies, albums = albums, loadState = MediaPreviewV2State.LoadState.DATA_LOADED, ) @@ -49,6 +50,7 @@ class MediaPreviewV2ViewModel : ViewModel() { 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, ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediapreview/caption/ExpandingCaptionView.kt b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/caption/ExpandingCaptionView.kt new file mode 100644 index 0000000000..bdfbe822ea --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediapreview/caption/ExpandingCaptionView.kt @@ -0,0 +1,62 @@ +package org.thoughtcrime.securesms.mediapreview.caption + +import android.content.Context +import android.text.method.ScrollingMovementMethod +import android.util.AttributeSet +import android.view.ViewGroup +import androidx.core.view.updateLayoutParams +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.emoji.EmojiTextView + +class ExpandingCaptionView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : EmojiTextView(context, attrs, defStyleAttr) { + + var expandedHeight = 0 + + private var expanded: Boolean = false + + var fullCaptionText: CharSequence = "" + set(value) { + field = value + expanded = false + updateExpansionState() + } + + init { + setOnClickListener { toggleExpansion() } + } + + fun toggleExpansion() { + expanded = !expanded + updateExpansionState() + } + + private fun updateExpansionState() { + if (expanded) { + Log.d(TAG, "The view should be expanded now.") + text = fullCaptionText + movementMethod = ScrollingMovementMethod() + scrollTo(0, 0) + updateLayoutParams { height = expandedHeight } + } else { + Log.d(TAG, "The view should be collapsed now.") + text = if (fullCaptionText.length <= CHAR_LIMIT_MESSAGE_PREVIEW) { + fullCaptionText + } else { + context.getString(R.string.MediaPreviewFragment_see_more, fullCaptionText.substring(0, CHAR_LIMIT_MESSAGE_PREVIEW)) + } + movementMethod = null + updateLayoutParams { height = ViewGroup.LayoutParams.WRAP_CONTENT } + } + setOnClickListener { toggleExpansion() } + } + + companion object { + private val TAG = Log.tag(ExpandingCaptionView::class.java) + const val CHAR_LIMIT_MESSAGE_PREVIEW = 280 + } +} diff --git a/app/src/main/res/layout/fragment_media_preview_v2.xml b/app/src/main/res/layout/fragment_media_preview_v2.xml index ed693ebf98..a619a737c7 100644 --- a/app/src/main/res/layout/fragment_media_preview_v2.xml +++ b/app/src/main/res/layout/fragment_media_preview_v2.xml @@ -27,7 +27,7 @@ android:visibility="invisible" tools:visibility="visible"> - Media Error Error Deleting Message, Message May Still Exist + + %1$s… Read More %1$d new messages in %2$d conversations