Signal-Android/app/src/main/java/org/thoughtcrime/securesms/mediapreview/MediaPreviewV2Fragment.kt

535 wiersze
21 KiB
Kotlin

package org.thoughtcrime.securesms.mediapreview
import android.Manifest
import android.annotation.SuppressLint
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.GONE
import android.view.ViewGroup.MarginLayoutParams
import android.view.ViewGroup.VISIBLE
import android.view.animation.PathInterpolator
import android.widget.Toast
import androidx.appcompat.view.menu.MenuBuilder
import androidx.core.app.ShareCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.LinearSmoothScroller
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.MarginPageTransformer
import androidx.viewpager2.widget.ViewPager2.OFFSCREEN_PAGE_LIMIT_DEFAULT
import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import org.signal.core.util.concurrent.SignalExecutors
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.components.ViewBinderDelegate
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragment
import org.thoughtcrime.securesms.conversation.mutiselect.forward.MultiselectForwardFragmentArgs
import org.thoughtcrime.securesms.database.MediaDatabase
import org.thoughtcrime.securesms.databinding.FragmentMediaPreviewV2Binding
import org.thoughtcrime.securesms.mediapreview.mediarail.CenterDecoration
import org.thoughtcrime.securesms.mediapreview.mediarail.MediaRailAdapter
import org.thoughtcrime.securesms.mediapreview.mediarail.MediaRailAdapter.ImageLoadingListener
import org.thoughtcrime.securesms.mediasend.Media
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
import org.thoughtcrime.securesms.mms.GlideApp
import org.thoughtcrime.securesms.mms.PartAuthority
import org.thoughtcrime.securesms.permissions.Permissions
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.Debouncer
import org.thoughtcrime.securesms.util.FullscreenHelper
import org.thoughtcrime.securesms.util.LifecycleDisposable
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.SaveAttachmentTask
import org.thoughtcrime.securesms.util.StorageUtil
import org.thoughtcrime.securesms.util.ViewUtil
import org.thoughtcrime.securesms.util.visible
import java.util.Locale
import java.util.concurrent.TimeUnit
class MediaPreviewV2Fragment : Fragment(R.layout.fragment_media_preview_v2), MediaPreviewFragment.Events {
private val TAG = Log.tag(MediaPreviewV2Fragment::class.java)
private val lifecycleDisposable = LifecycleDisposable()
private val binding by ViewBinderDelegate(FragmentMediaPreviewV2Binding::bind)
private val viewModel: MediaPreviewV2ViewModel by viewModels()
private val debouncer = Debouncer(2, TimeUnit.SECONDS)
private lateinit var fullscreenHelper: FullscreenHelper
private lateinit var albumRailAdapter: MediaRailAdapter
override fun onAttach(context: Context) {
super.onAttach(context)
fullscreenHelper = FullscreenHelper(requireActivity())
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
lifecycleDisposable.bindTo(viewLifecycleOwner)
return super.onCreateView(inflater, container, savedInstanceState)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val args = MediaIntentFactory.requireArguments(requireArguments())
initializeViewModel(args)
initializeToolbar(binding.toolbar)
initializeViewPager()
initializeAlbumRail()
initializeFullScreenUi()
anchorMarginsToBottomInsets(binding.mediaPreviewDetailsContainer)
lifecycleDisposable += viewModel.state.distinctUntilChanged().observeOn(AndroidSchedulers.mainThread()).subscribe {
bindCurrentState(it)
}
}
private fun initializeViewModel(args: MediaIntentFactory.MediaPreviewArgs) {
if (!MediaUtil.isImageType(args.initialMediaType) && !MediaUtil.isVideoType(args.initialMediaType)) {
Log.w(TAG, "Unsupported media type sent to MediaPreviewV2Fragment, finishing.")
Snackbar.make(binding.root, R.string.MediaPreviewActivity_unssuported_media_type, Snackbar.LENGTH_LONG)
.setAction(R.string.MediaPreviewActivity_dismiss_due_to_error) {
requireActivity().finish()
}.show()
}
viewModel.initialize(args.showThread, args.allMediaInRail, args.leftIsRecent)
val sorting = MediaDatabase.Sorting.deserialize(args.sorting.ordinal)
viewModel.fetchAttachments(PartAuthority.requireAttachmentId(args.initialMediaUri), args.threadId, sorting)
}
@SuppressLint("RestrictedApi")
private fun initializeToolbar(toolbar: MaterialToolbar) {
toolbar.setNavigationOnClickListener {
requireActivity().onBackPressed()
}
toolbar.setTitleTextAppearance(requireContext(), R.style.Signal_Text_TitleMedium)
toolbar.setSubtitleTextAppearance(requireContext(), R.style.Signal_Text_BodyMedium)
(binding.toolbar.menu as? MenuBuilder)?.setOptionalIconsVisible(true)
binding.toolbar.inflateMenu(R.menu.media_preview)
}
private fun initializeViewPager() {
binding.mediaPager.offscreenPageLimit = OFFSCREEN_PAGE_LIMIT_DEFAULT
binding.mediaPager.setPageTransformer(MarginPageTransformer(ViewUtil.dpToPx(24)))
val adapter = MediaPreviewV2Adapter(this)
binding.mediaPager.adapter = adapter
binding.mediaPager.registerOnPageChangeCallback(object : OnPageChangeCallback() {
override fun onPageSelected(position: Int) {
super.onPageSelected(position)
viewModel.setCurrentPage(position)
}
})
}
private fun initializeAlbumRail() {
binding.mediaPreviewPlaybackControls.recyclerView.apply {
layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
addItemDecoration(CenterDecoration(0))
albumRailAdapter = MediaRailAdapter(
GlideApp.with(this@MediaPreviewV2Fragment),
{ media -> jumpViewPagerToMedia(media) },
object : ImageLoadingListener() {
override fun onAllRequestsFinished() {
crossfadeViewIn(this@apply)
}
}
)
this.adapter = albumRailAdapter
}
}
private fun initializeFullScreenUi() {
fullscreenHelper.configureToolbarLayout(binding.toolbarCutoutSpacer, binding.toolbar)
fullscreenHelper.showAndHideWithSystemUI(requireActivity().window, binding.toolbarLayout, binding.mediaPreviewDetailsContainer)
}
private fun bindCurrentState(currentState: MediaPreviewV2State) {
if (currentState.position == -1 && currentState.mediaRecords.isEmpty()) {
onMediaNotAvailable()
return
}
when (currentState.loadState) {
MediaPreviewV2State.LoadState.DATA_LOADED -> bindDataLoadedState(currentState)
MediaPreviewV2State.LoadState.MEDIA_READY -> bindMediaReadyState(currentState)
else -> null
}
}
private fun bindDataLoadedState(currentState: MediaPreviewV2State) {
val currentPosition = currentState.position
val fragmentAdapter = binding.mediaPager.adapter as MediaPreviewV2Adapter
val backingItems = currentState.mediaRecords.mapNotNull { it.attachment }
if (backingItems.isEmpty()) {
onMediaNotAvailable()
return
}
fragmentAdapter.updateBackingItems(backingItems)
if (binding.mediaPager.currentItem != currentPosition) {
binding.mediaPager.setCurrentItem(currentPosition, false)
}
}
/**
* These are binding steps that need a reference to the actual fragment within the pager.
* This is not available until after a page has been chosen by the ViewPager, and we receive the
* {@link OnPageChangeCallback}.
*/
private fun bindMediaReadyState(currentState: MediaPreviewV2State) {
if (currentState.mediaRecords.isEmpty()) {
onMediaNotAvailable()
return
}
val currentPosition = currentState.position
val currentItem: MediaDatabase.MediaRecord = currentState.mediaRecords[currentPosition]
// pause all other fragments
childFragmentManager.fragments.map { fragment ->
if (fragment.tag != "f$currentPosition") {
(fragment as? MediaPreviewFragment)?.pause()
}
}
bindTextViews(currentItem, currentState.showThread)
bindMenuItems(currentItem)
bindMediaPreviewPlaybackControls(currentItem, getMediaPreviewFragmentFromChildFragmentManager(currentPosition))
val albumThumbnailMedia: List<Media> = if (currentState.allMediaInAlbumRail) {
currentState.mediaRecords.mapNotNull { it.toMedia() }
} else {
currentState.albums[currentItem.attachment?.mmsId] ?: emptyList()
}
bindAlbumRail(albumThumbnailMedia, currentItem)
crossfadeViewIn(binding.mediaPreviewDetailsContainer)
}
private fun bindTextViews(currentItem: MediaDatabase.MediaRecord, showThread: Boolean) {
binding.toolbar.title = getTitleText(currentItem, showThread)
binding.toolbar.subtitle = getSubTitleText(currentItem)
val caption = currentItem.attachment?.caption
binding.mediaPreviewCaption.text = caption
binding.mediaPreviewCaption.visible = caption != null
}
private fun bindMenuItems(currentItem: MediaDatabase.MediaRecord) {
val menu: Menu = binding.toolbar.menu
if (currentItem.threadId == MediaIntentFactory.NOT_IN_A_THREAD.toLong()) {
menu.findItem(R.id.delete).isVisible = false
}
binding.toolbar.setOnMenuItemClickListener {
when (it.itemId) {
R.id.edit -> editMediaItem(currentItem)
R.id.save -> saveToDisk(currentItem)
R.id.delete -> deleteMedia(currentItem)
android.R.id.home -> requireActivity().finish()
else -> return@setOnMenuItemClickListener false
}
return@setOnMenuItemClickListener true
}
}
private fun bindMediaPreviewPlaybackControls(currentItem: MediaDatabase.MediaRecord, currentFragment: MediaPreviewFragment?) {
val mediaType: MediaPreviewPlayerControlView.MediaMode = if (currentItem.attachment?.isVideoGif == true) {
MediaPreviewPlayerControlView.MediaMode.IMAGE
} else {
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)
}
currentFragment?.setBottomButtonControls(binding.mediaPreviewPlaybackControls)
}
private fun bindAlbumRail(albumThumbnailMedia: List<Media>, currentItem: MediaDatabase.MediaRecord) {
val albumRail: RecyclerView = binding.mediaPreviewPlaybackControls.recyclerView
if (albumThumbnailMedia.size > 1) {
val albumPosition = albumThumbnailMedia.indexOfFirst { it.uri == currentItem.attachment?.uri }
if (albumRail.visibility == GONE) {
albumRail.visibility = View.INVISIBLE
}
albumRailAdapter.currentItemPosition = albumPosition
albumRailAdapter.submitList(albumThumbnailMedia)
scrollAlbumRailToCurrentAdapterPosition()
} else {
albumRail.visibility = View.GONE
albumRailAdapter.submitList(emptyList())
albumRailAdapter.imageLoadingListener.reset()
}
}
private fun scrollAlbumRailToCurrentAdapterPosition() {
val currentItemPosition = albumRailAdapter.currentItemPosition
val currentList = albumRailAdapter.currentList
val albumRail: RecyclerView = binding.mediaPreviewPlaybackControls.recyclerView
var selectedItemWidth = -1
for (i in currentList.indices) {
val isSelected = i == currentItemPosition
val stableId = albumRailAdapter.getItemId(i)
val viewHolder = albumRail.findViewHolderForItemId(stableId) as? MediaRailAdapter.MediaRailViewHolder
if (viewHolder != null) {
viewHolder.setSelectedItem(isSelected)
if (isSelected) {
selectedItemWidth = viewHolder.itemView.width
}
}
}
val offsetFromStart = (albumRail.width - selectedItemWidth) / 2
val smoothScroller = OffsetSmoothScroller(requireContext(), offsetFromStart)
smoothScroller.targetPosition = currentItemPosition
val layoutManager = albumRail.layoutManager as LinearLayoutManager
layoutManager.startSmoothScroll(smoothScroller)
}
private fun crossfadeViewIn(view: View, duration: Long = 200) {
if (!view.isVisible && !fullscreenHelper.isSystemUiVisible) {
val viewPropertyAnimator = view.animate()
.alpha(1f)
.setDuration(duration)
.withStartAction {
view.visibility = VISIBLE
}
.withEndAction {
if (view == binding.mediaPreviewPlaybackControls.recyclerView) {
scrollAlbumRailToCurrentAdapterPosition()
}
}
if (Build.VERSION.SDK_INT >= 21) {
viewPropertyAnimator.interpolator = PathInterpolator(0.17f, 0.17f, 0f, 1f)
}
viewPropertyAnimator.start()
}
}
private fun getMediaPreviewFragmentFromChildFragmentManager(currentPosition: Int) = childFragmentManager.findFragmentByTag("f$currentPosition") as? MediaPreviewFragment
private fun jumpViewPagerToMedia(media: Media) {
val viewPagerAdapter = binding.mediaPager.adapter as MediaPreviewV2Adapter
val position = viewPagerAdapter.findItemPosition(media)
binding.mediaPager.setCurrentItem(position, true)
}
private fun getTitleText(mediaRecord: MediaDatabase.MediaRecord, showThread: Boolean): String {
val recipient: Recipient = Recipient.live(mediaRecord.recipientId).get()
val defaultFromString: String = if (mediaRecord.isOutgoing) {
getString(R.string.MediaPreviewActivity_you)
} else {
recipient.getDisplayName(requireContext())
}
if (!showThread) {
return defaultFromString
}
val threadRecipient = Recipient.live(mediaRecord.threadRecipientId).get()
return if (mediaRecord.isOutgoing) {
if (threadRecipient.isSelf) {
getString(R.string.note_to_self)
} else {
getString(R.string.MediaPreviewActivity_you_to_s, threadRecipient.getDisplayName(requireContext()))
}
} else {
if (threadRecipient.isGroup) {
getString(R.string.MediaPreviewActivity_s_to_s, defaultFromString, threadRecipient.getDisplayName(requireContext()))
} else {
getString(R.string.MediaPreviewActivity_s_to_you, defaultFromString)
}
}
}
private fun getSubTitleText(mediaRecord: MediaDatabase.MediaRecord): String =
if (mediaRecord.date > 0) {
DateUtils.getExtendedRelativeTimeSpanString(requireContext(), Locale.getDefault(), mediaRecord.date)
} else {
getString(R.string.MediaPreviewActivity_draft)
}
private fun anchorMarginsToBottomInsets(viewToAnchor: View) {
ViewCompat.setOnApplyWindowInsetsListener(viewToAnchor) { view: View, windowInsetsCompat: WindowInsetsCompat ->
val layoutParams = view.layoutParams as MarginLayoutParams
layoutParams.setMargins(
windowInsetsCompat.systemWindowInsetLeft,
layoutParams.topMargin,
windowInsetsCompat.systemWindowInsetRight,
windowInsetsCompat.systemWindowInsetBottom
)
view.layoutParams = layoutParams
windowInsetsCompat
}
}
override fun singleTapOnMedia(): Boolean {
fullscreenHelper.toggleUiVisibility()
return true
}
override fun onMediaNotAvailable() {
Toast.makeText(requireContext(), R.string.MediaPreviewActivity_media_no_longer_available, Toast.LENGTH_LONG).show()
requireActivity().finish()
}
override fun onMediaReady() {
viewModel.setMediaReady()
}
override fun onPlaying() {
debouncer.publish { fullscreenHelper.hideSystemUI() }
}
override fun onStopped() {
debouncer.clear()
}
private fun forward(mediaItem: MediaDatabase.MediaRecord) {
val attachment = mediaItem.attachment
val uri = attachment?.uri
if (attachment != null && uri != null) {
MultiselectForwardFragmentArgs.create(
requireContext(),
mediaItem.threadId,
uri,
attachment.contentType
) { args: MultiselectForwardFragmentArgs ->
MultiselectForwardFragment.showBottomSheet(childFragmentManager, args)
}
}
}
private fun share(mediaItem: MediaDatabase.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)
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()
}
}
}
private fun saveToDisk(mediaItem: MediaDatabase.MediaRecord) {
SaveAttachmentTask.showWarningDialog(requireContext()) { _: DialogInterface?, _: Int ->
if (StorageUtil.canWriteToMediaStore()) {
performSaveToDisk(mediaItem)
return@showWarningDialog
}
Permissions.with(this)
.request(Manifest.permission.WRITE_EXTERNAL_STORAGE)
.ifNecessary()
.withPermanentDenialDialog(getString(R.string.MediaPreviewActivity_signal_needs_the_storage_permission_in_order_to_write_to_external_storage_but_it_has_been_permanently_denied))
.onAnyDenied { Toast.makeText(requireContext(), R.string.MediaPreviewActivity_unable_to_write_to_external_storage_without_permission, Toast.LENGTH_LONG).show() }
.onAllGranted { performSaveToDisk(mediaItem) }
.execute()
}
}
fun performSaveToDisk(mediaItem: MediaDatabase.MediaRecord) {
val saveTask = SaveAttachmentTask(requireContext())
val saveDate = if (mediaItem.date > 0) mediaItem.date else System.currentTimeMillis()
val attachment = mediaItem.attachment
val uri = attachment?.uri
if (attachment != null && uri != null) {
saveTask.executeOnExecutor(SignalExecutors.BOUNDED_IO, SaveAttachmentTask.Attachment(uri, attachment.contentType, saveDate, null))
}
}
private fun deleteMedia(mediaItem: MediaDatabase.MediaRecord) {
val attachment: DatabaseAttachment = mediaItem.attachment ?: return
MaterialAlertDialogBuilder(requireContext())
.setIcon(R.drawable.ic_warning)
.setTitle(R.string.MediaPreviewActivity_media_delete_confirmation_title)
.setMessage(R.string.MediaPreviewActivity_media_delete_confirmation_message)
.setCancelable(true)
.setPositiveButton(R.string.delete) { _, _ ->
viewModel.deleteItem(requireContext(), attachment, onSuccess = {
requireActivity().finish()
}, onError = {
Log.e(TAG, "Delete failed!", it)
requireActivity().finish()
})
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun editMediaItem(currentItem: MediaDatabase.MediaRecord) {
val media = currentItem.toMedia()
if (media == null) {
val rootView = view
if (rootView != null) {
Snackbar.make(rootView, R.string.MediaPreviewFragment_edit_media_error, Snackbar.LENGTH_INDEFINITE).show()
} else {
Toast.makeText(requireContext(), R.string.MediaPreviewFragment_edit_media_error, Toast.LENGTH_LONG).show()
}
return
}
startActivity(MediaSelectionActivity.editor(context = requireContext(), media = listOf(media)))
}
override fun onPause() {
super.onPause()
getMediaPreviewFragmentFromChildFragmentManager(binding.mediaPager.currentItem)?.pause()
}
override fun onDestroyView() {
super.onDestroyView()
viewModel.onDestroyView()
}
private class OffsetSmoothScroller(context: Context, val offset: Int) : LinearSmoothScroller(context) {
override fun getHorizontalSnapPreference(): Int {
return SNAP_TO_START
}
override fun calculateDxToMakeVisible(view: View?, snapPreference: Int): Int {
return offset + super.calculateDxToMakeVisible(view, snapPreference)
}
}
companion object {
const val ARGS_KEY: String = "args"
@JvmStatic
fun isContentTypeSupported(contentType: String?): Boolean {
return MediaUtil.isImageType(contentType) || MediaUtil.isVideoType(contentType)
}
}
}