Use AttachmentSaver to save story images.

This commit is contained in:
Jeffrey Starke
2025-03-20 15:23:26 -04:00
committed by Cody Henthorne
parent 293012c219
commit c876c7847e
5 changed files with 95 additions and 40 deletions

View File

@@ -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()

View File

@@ -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) {

View File

@@ -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<out String>, grantResults: IntArray) {
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults)
}

View File

@@ -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<out String>, grantResults: IntArray) {
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults)
}
}

View File

@@ -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<out String>, 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)