From c876c7847e4af09e17141eb129fa281e7353d14a Mon Sep 17 00:00:00 2001 From: Jeffrey Starke Date: Thu, 20 Mar 2025 15:23:26 -0400 Subject: [PATCH] Use AttachmentSaver to save story images. --- .../securesms/attachments/AttachmentSaver.kt | 2 +- .../stories/dialogs/StoryContextMenu.kt | 69 +++++++++---------- .../stories/landing/StoriesLandingFragment.kt | 19 ++++- .../securesms/stories/my/MyStoriesFragment.kt | 16 ++++- .../viewer/page/StoryViewerPageFragment.kt | 29 +++++++- 5 files changed, 95 insertions(+), 40 deletions(-) diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentSaver.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentSaver.kt index 350db69abf..93409c6229 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentSaver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/AttachmentSaver.kt @@ -102,7 +102,7 @@ class AttachmentSaver(private val host: Host) { fun dismissSaveProgress() } - private data class FragmentHost(private val fragment: Fragment) : Host { + data class FragmentHost(private val fragment: Fragment) : Host { override fun showToast(getMessage: (Context) -> CharSequence) { Toast.makeText(fragment.requireContext(), getMessage(fragment.requireContext()), Toast.LENGTH_LONG).show() diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/dialogs/StoryContextMenu.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/dialogs/StoryContextMenu.kt index 1c3ea7cf31..66894fa059 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/dialogs/StoryContextMenu.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/dialogs/StoryContextMenu.kt @@ -4,7 +4,6 @@ import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.net.Uri -import android.os.AsyncTask import android.view.View import android.view.ViewGroup import android.widget.Toast @@ -12,12 +11,14 @@ import androidx.core.app.ShareCompat import androidx.fragment.app.Fragment import com.bumptech.glide.load.Options import io.reactivex.rxjava3.core.Single +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.signal.core.util.Base64 import org.signal.core.util.DimensionUnit -import org.signal.core.util.concurrent.SimpleTask import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.attachments.Attachment +import org.thoughtcrime.securesms.attachments.AttachmentSaver import org.thoughtcrime.securesms.components.menu.ActionItem import org.thoughtcrime.securesms.components.menu.SignalContextMenu import org.thoughtcrime.securesms.database.model.MessageRecord @@ -31,7 +32,7 @@ import org.thoughtcrime.securesms.stories.viewer.page.StoryViewerPageState import org.thoughtcrime.securesms.util.BitmapUtil import org.thoughtcrime.securesms.util.DeleteDialog import org.thoughtcrime.securesms.util.MediaUtil -import org.thoughtcrime.securesms.util.SaveAttachmentTask +import org.thoughtcrime.securesms.util.SaveAttachmentUtil import java.io.ByteArrayInputStream object StoryContextMenu { @@ -48,48 +49,46 @@ object StoryContextMenu { ).map { (_, deletedThread) -> deletedThread } } - fun save(context: Context, messageRecord: MessageRecord) { + suspend fun save(host: AttachmentSaver.Host, messageRecord: MessageRecord) { val mediaMessageRecord = messageRecord as? MmsMessageRecord val uri: Uri? = mediaMessageRecord?.slideDeck?.firstSlide?.uri val contentType: String? = mediaMessageRecord?.slideDeck?.firstSlide?.contentType - if (mediaMessageRecord?.storyType?.isTextStory == true) { - SimpleTask.run({ - val model = StoryTextPostModel.parseFrom(messageRecord) - val decoder = StoryTextPostModel.Decoder() - val bitmap = decoder.decode(model, 1080, 1920, Options()).get() - val jpeg: ByteArrayInputStream = BitmapUtil.toCompressedJpeg(bitmap) + when { + mediaMessageRecord?.storyType?.isTextStory == true -> saveTextStory(host, mediaMessageRecord) + uri == null || contentType == null -> showErrorCantSaveStory(host, uri, contentType) + else -> saveMediaStory(host, uri, contentType, mediaMessageRecord) + } + } - bitmap.recycle() + private suspend fun saveTextStory(host: AttachmentSaver.Host, messageRecord: MmsMessageRecord) { + val saveAttachment = withContext(Dispatchers.Main) { + val model = StoryTextPostModel.parseFrom(messageRecord) + val decoder = StoryTextPostModel.Decoder() + val bitmap = decoder.decode(model, 1080, 1920, Options()).get() + val jpeg: ByteArrayInputStream = BitmapUtil.toCompressedJpeg(bitmap) - SaveAttachmentTask.Attachment( - BlobProvider.getInstance().forData(jpeg.readBytes()).createForSingleUseInMemory(), - MediaUtil.IMAGE_JPEG, - mediaMessageRecord.dateSent, - null - ) - }, { saveAttachment -> - SaveAttachmentTask(context) - .executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, saveAttachment) - }) - return + bitmap.recycle() + + SaveAttachmentUtil.SaveAttachment( + uri = BlobProvider.getInstance().forData(jpeg.readBytes()).createForSingleUseInMemory(), + contentType = MediaUtil.IMAGE_JPEG, + date = messageRecord.dateSent, + fileName = null + ) } - if (uri == null || contentType == null) { - Log.w(TAG, "Unable to save story media uri: $uri contentType: $contentType") - Toast.makeText(context, R.string.MyStories__unable_to_save, Toast.LENGTH_SHORT).show() - return - } + AttachmentSaver(host).saveAttachments(setOf(saveAttachment)) + } - val saveAttachment = SaveAttachmentTask.Attachment( - uri, - contentType, - mediaMessageRecord.dateSent, - null - ) + private suspend fun saveMediaStory(host: AttachmentSaver.Host, uri: Uri, contentType: String, mediaMessageRecord: MmsMessageRecord) { + val saveAttachment = SaveAttachmentUtil.SaveAttachment(uri = uri, contentType = contentType, date = mediaMessageRecord.dateSent, fileName = null) + AttachmentSaver(host).saveAttachments(setOf(saveAttachment)) + } - SaveAttachmentTask(context) - .executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, saveAttachment) + private fun showErrorCantSaveStory(host: AttachmentSaver.Host, uri: Uri?, contentType: String?) { + Log.w(TAG, "Unable to save story media uri: $uri contentType: $contentType") + host.showToast { context -> context.getString(R.string.MyStories__unable_to_save) } } fun share(fragment: Fragment, messageRecord: MmsMessageRecord) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingFragment.kt index ff39172d63..9bd0f8de0a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/landing/StoriesLandingFragment.kt @@ -15,6 +15,7 @@ import androidx.core.app.ActivityOptionsCompat import androidx.core.app.SharedElementCallback import androidx.core.view.ViewCompat import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver import androidx.transition.TransitionInflater @@ -23,9 +24,11 @@ import com.google.android.material.snackbar.Snackbar import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.kotlin.subscribeBy +import kotlinx.coroutines.launch import org.signal.core.util.concurrent.LifecycleDisposable import org.thoughtcrime.securesms.MainActivity import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.attachments.AttachmentSaver import org.thoughtcrime.securesms.banner.BannerManager import org.thoughtcrime.securesms.banner.banners.DeprecatedBuildBanner import org.thoughtcrime.securesms.banner.banners.UnauthorizedBanner @@ -200,7 +203,13 @@ class StoriesLandingFragment : DSLSettingsFragment(layoutId = R.layout.stories_l .ifNecessary() .onAllGranted { startActivityIfAble(MediaSelectionActivity.camera(requireContext(), isStory = true)) } .withRationaleDialog(getString(R.string.CameraXFragment_allow_access_camera), getString(R.string.CameraXFragment_to_capture_photos_and_video_allow_camera), R.drawable.symbol_camera_24) - .withPermanentDenialDialog(getString(R.string.CameraXFragment_signal_needs_camera_access_capture_photos), null, R.string.CameraXFragment_allow_access_camera, R.string.CameraXFragment_to_capture_photos_videos, getParentFragmentManager()) + .withPermanentDenialDialog( + getString(R.string.CameraXFragment_signal_needs_camera_access_capture_photos), + null, + R.string.CameraXFragment_allow_access_camera, + R.string.CameraXFragment_to_capture_photos_videos, + getParentFragmentManager() + ) .onAnyDenied { Toast.makeText(requireContext(), R.string.CameraXFragment_signal_needs_camera_access_capture_photos, Toast.LENGTH_LONG).show() } .execute() } @@ -320,7 +329,12 @@ class StoriesLandingFragment : DSLSettingsFragment(layoutId = R.layout.stories_l StoryContextMenu.share(this@StoriesLandingFragment, it.data.primaryStory.messageRecord as MmsMessageRecord) }, onSave = { - StoryContextMenu.save(requireContext(), it.data.primaryStory.messageRecord) + lifecycleScope.launch { + StoryContextMenu.save( + host = AttachmentSaver.FragmentHost(this@StoriesLandingFragment), + messageRecord = it.data.primaryStory.messageRecord + ) + } }, onDeleteStory = { handleDeleteStory(it) @@ -406,6 +420,7 @@ class StoriesLandingFragment : DSLSettingsFragment(layoutId = R.layout.stories_l } } + @Suppress("OVERRIDE_DEPRECATION") override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesFragment.kt index c7e5c3612f..8eff6560bd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/my/MyStoriesFragment.kt @@ -6,8 +6,11 @@ import androidx.activity.OnBackPressedCallback import androidx.core.app.ActivityOptionsCompat import androidx.core.view.ViewCompat import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch import org.signal.core.util.concurrent.LifecycleDisposable import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.attachments.AttachmentSaver import org.thoughtcrime.securesms.components.settings.DSLConfiguration import org.thoughtcrime.securesms.components.settings.DSLSettingsFragment import org.thoughtcrime.securesms.components.settings.DSLSettingsText @@ -15,6 +18,7 @@ import org.thoughtcrime.securesms.components.settings.configure import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs import org.thoughtcrime.securesms.database.model.MmsMessageRecord +import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet import org.thoughtcrime.securesms.stories.StoryTextPostModel @@ -78,7 +82,12 @@ class MyStoriesFragment : DSLSettingsFragment( openStoryViewer(it, preview, false) }, onSaveClick = { - StoryContextMenu.save(requireContext(), it.distributionStory.messageRecord) + lifecycleScope.launch { + StoryContextMenu.save( + host = AttachmentSaver.FragmentHost(this@MyStoriesFragment), + messageRecord = it.distributionStory.messageRecord + ) + } }, onDeleteClick = this@MyStoriesFragment::handleDeleteClick, onForwardClick = { item -> @@ -155,4 +164,9 @@ class MyStoriesFragment : DSLSettingsFragment( private fun handleDeleteClick(model: MyStoriesItem.Model) { lifecycleDisposable += StoryContextMenu.delete(requireContext(), setOf(model.distributionStory.messageRecord)).subscribe() } + + @Suppress("OVERRIDE_DEPRECATION") + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults) + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt index 53af6bde7f..24451894f6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/stories/viewer/page/StoryViewerPageFragment.kt @@ -31,6 +31,7 @@ import androidx.core.view.animation.PathInterpolatorCompat import androidx.fragment.app.DialogFragment import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope import com.bumptech.glide.Glide import com.google.android.material.button.MaterialButton import com.google.android.material.card.MaterialCardView @@ -40,6 +41,7 @@ import com.google.android.material.snackbar.Snackbar import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.kotlin.subscribeBy +import kotlinx.coroutines.launch import org.signal.core.util.DimensionUnit import org.signal.core.util.concurrent.LifecycleDisposable import org.signal.core.util.dp @@ -47,6 +49,7 @@ import org.signal.core.util.getParcelableCompat import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.animation.AnimationCompleteListener +import org.thoughtcrime.securesms.attachments.AttachmentSaver import org.thoughtcrime.securesms.components.AvatarImageView import org.thoughtcrime.securesms.components.emoji.EmojiTextView import org.thoughtcrime.securesms.components.segmentedprogressbar.SegmentedProgressBar @@ -63,6 +66,7 @@ import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList import org.thoughtcrime.securesms.mediapreview.MediaPreviewFragment import org.thoughtcrime.securesms.mediapreview.VideoControlsDelegate +import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.recipients.ui.bottomsheet.RecipientBottomSheetDialogFragment @@ -476,9 +480,11 @@ class StoryViewerPageFragment : state.hideChromeImmediate -> { hideChromeImmediate() } + state.hideChrome -> { hideChrome() } + else -> { showChrome() } @@ -493,6 +499,7 @@ class StoryViewerPageFragment : is StoryViewerDialog.GroupDirectReply -> { onStartDirectReply(sheet.storyId, sheet.recipientId) } + StoryViewerDialog.Delete, StoryViewerDialog.Forward -> Unit } @@ -539,6 +546,11 @@ class StoryViewerPageFragment : viewModel.setIsDisplayingForwardDialog(false) } + @Suppress("OVERRIDE_DEPRECATION") + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults) + } + private fun checkEventIntersectsClickableSpan(cardWrapper: ViewGroup, event: MotionEvent): Boolean { if (viewModel.getPost()?.content?.isText() != true) { textStoryIntersectProcessingEvents = false @@ -712,12 +724,14 @@ class StoryViewerPageFragment : constraintSet.connect(viewsAndReplies.id, ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM) card.radius = DimensionUnit.DP.toPixels(18f) } + StoryDisplay.MEDIUM -> { constraintSet.setDimensionRatio(cardWrapper.id, "9:16") constraintSet.clear(viewsAndReplies.id, ConstraintSet.TOP) constraintSet.connect(viewsAndReplies.id, ConstraintSet.BOTTOM, cardWrapper.id, ConstraintSet.BOTTOM) card.radius = DimensionUnit.DP.toPixels(18f) } + StoryDisplay.SMALL -> { constraintSet.setDimensionRatio(cardWrapper.id, null) constraintSet.clear(viewsAndReplies.id, ConstraintSet.TOP) @@ -757,6 +771,7 @@ class StoryViewerPageFragment : isFromNotification, groupReplyStartPosition ) + StoryViewerPageState.ReplyState.PRIVATE -> StoryDirectReplyDialogFragment.create(storyPostId) StoryViewerPageState.ReplyState.GROUP_SELF -> StoryViewsAndRepliesDialogFragment.create( storyPostId, @@ -765,14 +780,17 @@ class StoryViewerPageFragment : isFromNotification, groupReplyStartPosition ) + StoryViewerPageState.ReplyState.PARTIAL_SEND -> { handleResend(storyPost) return } + StoryViewerPageState.ReplyState.SEND_FAILURE -> { handleResend(storyPost) return } + StoryViewerPageState.ReplyState.SENDING -> return } @@ -854,24 +872,28 @@ class StoryViewerPageFragment : viewModel.setIsDisplayingSlate(false) markViewedIfAble() } + AttachmentTable.TRANSFER_PROGRESS_PENDING -> { Log.d(TAG, "Story content download is pending.") storySlate.moveToState(StorySlateView.State.LOADING, post.id) sharedViewModel.setContentIsReady() viewModel.setIsDisplayingSlate(true) } + AttachmentTable.TRANSFER_PROGRESS_STARTED -> { Log.d(TAG, "Story content download is in progress.") storySlate.moveToState(StorySlateView.State.LOADING, post.id) sharedViewModel.setContentIsReady() viewModel.setIsDisplayingSlate(true) } + AttachmentTable.TRANSFER_PROGRESS_FAILED -> { Log.d(TAG, "Story content download has failed temporarily.") storySlate.moveToState(StorySlateView.State.ERROR, post.id) sharedViewModel.setContentIsReady() viewModel.setIsDisplayingSlate(true) } + AttachmentTable.TRANSFER_PROGRESS_PERMANENT_FAILURE -> { Log.d(TAG, "Story content download has failed permanently.") storySlate.moveToState(StorySlateView.State.FAILED, post.id, post.sender) @@ -1175,7 +1197,12 @@ class StoryViewerPageFragment : StoryContextMenu.share(this, it.conversationMessage.messageRecord as MmsMessageRecord) }, onSave = { - StoryContextMenu.save(requireContext(), it.conversationMessage.messageRecord) + lifecycleScope.launch { + StoryContextMenu.save( + host = AttachmentSaver.FragmentHost(this@StoryViewerPageFragment), + messageRecord = it.conversationMessage.messageRecord + ) + } }, onDelete = { viewModel.setIsDisplayingDeleteDialog(true)