Update MediaPreviewV2 to use thumbnail rail & menu items.

fork-5.53.8
Nicholas 2022-10-05 16:10:28 -04:00 zatwierdzone przez Greyson Parrelli
rodzic 2edb9eeb52
commit a9a64a3f60
7 zmienionych plików z 361 dodań i 26 usunięć

Wyświetl plik

@ -1,13 +1,22 @@
package org.thoughtcrime.securesms.mediapreview
import android.content.Context
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.fragment.app.commit
import org.thoughtcrime.securesms.R
class MediaPreviewV2Activity : AppCompatActivity(R.layout.activity_mediapreview_v2) {
override fun attachBaseContext(newBase: Context) {
delegate.localNightMode = AppCompatDelegate.MODE_NIGHT_YES
super.attachBaseContext(newBase)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setTheme(R.style.TextSecure_MediaPreview)
if (savedInstanceState == null) {
val bundle = Bundle()
val args = MediaIntentFactory.requireArguments(intent.extras!!)

Wyświetl plik

@ -1,23 +1,52 @@
package org.thoughtcrime.securesms.mediapreview
import android.Manifest
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.Menu
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.MarginLayoutParams
import android.widget.Toast
import androidx.core.app.ShareCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.LinearLayoutManager
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.animation.DepthPageTransformer
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.mediaoverview.MediaOverviewActivity
import org.thoughtcrime.securesms.mediasend.Media
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.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 java.util.Locale
import java.util.Optional
class MediaPreviewV2Fragment : Fragment(R.layout.fragment_media_preview_v2), MediaPreviewFragment.Events {
private val TAG = Log.tag(MediaPreviewV2Fragment::class.java)
@ -35,7 +64,11 @@ class MediaPreviewV2Fragment : Fragment(R.layout.fragment_media_preview_v2), Med
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
initializeViewModel()
val args = MediaIntentFactory.requireArguments(requireArguments())
initializeViewModel(args)
initializeToolbar(binding.toolbar, args)
binding.mediaPager.offscreenPageLimit = 1
binding.mediaPager.setPageTransformer(DepthPageTransformer())
val adapter = MediaPreviewV2Adapter(this)
@ -47,18 +80,59 @@ class MediaPreviewV2Fragment : Fragment(R.layout.fragment_media_preview_v2), Med
}
})
initializeFullScreenUi()
initializeAlbumRail()
anchorMarginsToBottomInsets(binding.mediaPreviewDetailsContainer)
lifecycleDisposable += viewModel.state.distinctUntilChanged().observeOn(AndroidSchedulers.mainThread()).subscribe {
bindCurrentState(it)
}
}
private fun initializeFullScreenUi() {
fullscreenHelper.configureToolbarLayout(binding.toolbarCutoutSpacer, binding.toolbar)
fullscreenHelper.hideSystemUI()
private fun initializeToolbar(toolbar: MaterialToolbar, args: MediaIntentFactory.MediaPreviewArgs) {
toolbar.setNavigationOnClickListener {
requireActivity().onBackPressed()
}
binding.toolbar.inflateMenu(R.menu.media_preview)
// Restricted to API26 because of MemoryFileUtil not supporting lower API levels well
binding.toolbar.menu.findItem(R.id.media_preview__share).isVisible = Build.VERSION.SDK_INT >= 26
if (args.hideAllMedia) {
binding.toolbar.menu.findItem(R.id.media_preview__overview).isVisible = false
}
}
private fun initializeViewModel() {
val args = MediaIntentFactory.requireArguments(requireArguments())
private fun initializeAlbumRail() {
binding.mediaPreviewAlbumRail.itemAnimator = null // Or can crash when set to INVISIBLE while animating by FullscreenHelper https://issuetracker.google.com/issues/148720682
binding.mediaPreviewAlbumRail.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
binding.mediaPreviewAlbumRail.adapter = MediaRailAdapter(
GlideApp.with(this),
object : MediaRailAdapter.RailItemListener {
override fun onRailItemClicked(distanceFromActive: Int) {
binding.mediaPager.currentItem += distanceFromActive
}
override fun onRailItemDeleteClicked(distanceFromActive: Int) {
throw UnsupportedOperationException("Callback unsupported.")
}
},
false
)
}
private fun initializeFullScreenUi() {
fullscreenHelper.configureToolbarLayout(binding.toolbarCutoutSpacer, binding.toolbar)
fullscreenHelper.showAndHideWithSystemUI(requireActivity().window, binding.toolbarLayout, binding.mediaPreviewDetailsContainer)
}
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.setShowThread(args.showThread)
val sorting = MediaDatabase.Sorting.values()[args.sorting]
viewModel.fetchAttachments(args.initialMediaUri, args.threadId, sorting)
@ -67,7 +141,11 @@ class MediaPreviewV2Fragment : Fragment(R.layout.fragment_media_preview_v2), Med
private fun bindCurrentState(currentState: MediaPreviewV2State) {
when (currentState.loadState) {
MediaPreviewV2State.LoadState.READY -> bindReadyState(currentState)
// INIT, else -> no-op
MediaPreviewV2State.LoadState.LOADED -> {
bindReadyState(currentState)
bindLoadedState(currentState)
}
else -> null
}
}
@ -76,6 +154,58 @@ class MediaPreviewV2Fragment : Fragment(R.layout.fragment_media_preview_v2), Med
val currentItem: MediaDatabase.MediaRecord = currentState.mediaRecords[currentState.position]
binding.toolbar.title = getTitleText(currentItem, currentState.showThread)
binding.toolbar.subtitle = getSubTitleText(currentItem)
val menu: Menu = binding.toolbar.menu
if (currentItem.threadId == MediaIntentFactory.NOT_IN_A_THREAD.toLong()) {
menu.findItem(R.id.media_preview__overview).isVisible = false
menu.findItem(R.id.delete).isVisible = false
}
binding.toolbar.setOnMenuItemClickListener {
when (it.itemId) {
R.id.media_preview__overview -> showOverview(currentItem.threadId)
R.id.media_preview__forward -> forward(currentItem)
R.id.media_preview__share -> share(currentItem)
R.id.save -> saveToDisk(currentItem)
R.id.delete -> deleteMedia(currentItem)
android.R.id.home -> requireActivity().finish()
else -> return@setOnMenuItemClickListener false
}
return@setOnMenuItemClickListener true
}
}
/**
* 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 bindLoadedState(currentState: MediaPreviewV2State) {
val currentItem: MediaDatabase.MediaRecord = currentState.mediaRecords[currentState.position]
val currentFragment: Fragment? = childFragmentManager.findFragmentByTag("f${currentState.position}")
val playbackControls = (currentFragment as? MediaPreviewFragment)?.playbackControls
val albumThumbnailMedia = currentState.mediaRecords.map { it.toMedia() }
val caption = currentItem.attachment?.caption
if (albumThumbnailMedia.isEmpty() && caption == null && playbackControls == null) {
binding.mediaPreviewDetailsContainer.visibility = View.GONE
} else {
binding.mediaPreviewDetailsContainer.visibility = View.VISIBLE
}
binding.mediaPreviewAlbumRail.visibility = if (albumThumbnailMedia.isEmpty()) View.GONE else View.VISIBLE
(binding.mediaPreviewAlbumRail.adapter as MediaRailAdapter).setMedia(albumThumbnailMedia, currentState.position)
binding.mediaPreviewAlbumRail.smoothScrollToPosition(currentState.position)
binding.mediaPreviewCaptionContainer.visibility = if (caption == null) View.GONE else View.VISIBLE
binding.mediaPreviewCaption.text = caption
if (playbackControls != null) {
val params = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
playbackControls.layoutParams = params
binding.mediaPreviewPlaybackControlsContainer.removeAllViews()
binding.mediaPreviewPlaybackControlsContainer.addView(playbackControls)
} else {
binding.mediaPreviewPlaybackControlsContainer.removeAllViews()
}
}
private fun getTitleText(mediaRecord: MediaDatabase.MediaRecord, showThread: Boolean): String {
@ -112,20 +242,148 @@ class MediaPreviewV2Fragment : Fragment(R.layout.fragment_media_preview_v2), Med
getString(R.string.MediaPreviewActivity_draft)
}
private fun anchorMarginsToBottomInsets(viewToAnchor: View) {
ViewCompat.setOnApplyWindowInsetsListener(viewToAnchor) { view: View, windowInsetsCompat: WindowInsetsCompat ->
val layoutParams = view.layoutParams as MarginLayoutParams
val systemBarInsets = windowInsetsCompat.getInsets(WindowInsetsCompat.Type.systemBars())
layoutParams.setMargins(
systemBarInsets.left,
layoutParams.topMargin,
systemBarInsets.right,
systemBarInsets.bottom
)
view.layoutParams = layoutParams
windowInsetsCompat
}
}
private fun MediaDatabase.MediaRecord.toMedia(): Media? {
val attachment = this.attachment
val uri = attachment?.uri
if (attachment == null || uri == null) {
return null
}
return Media(
uri,
this.contentType,
this.date,
attachment.width,
attachment.height,
attachment.size,
0,
attachment.isBorderless,
attachment.isVideoGif,
Optional.empty(),
Optional.ofNullable(attachment.caption),
Optional.empty()
)
}
override fun singleTapOnMedia(): Boolean {
Log.d(TAG, "singleTapOnMedia()")
fullscreenHelper.toggleUiVisibility()
return true
}
override fun mediaNotAvailable() {
Log.d(TAG, "mediaNotAvailable()")
Snackbar.make(binding.root, R.string.MediaPreviewActivity_media_no_longer_available, Snackbar.LENGTH_LONG)
.setAction(R.string.MediaPreviewActivity_dismiss_due_to_error) {
requireActivity().finish()
}.show()
}
override fun onMediaReady() {
Log.d(TAG, "onMediaReady()")
}
private fun showOverview(threadId: Long) {
val context = requireContext()
context.startActivity(MediaOverviewActivity.forThread(context, threadId))
}
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()
}
companion object {
val ARGS_KEY: String = "args"
const val ARGS_KEY: String = "args"
}
}

Wyświetl plik

@ -8,5 +8,5 @@ data class MediaPreviewV2State(
val position: Int = 0,
val showThread: Boolean = false
) {
enum class LoadState { INIT, READY, }
enum class LoadState { INIT, READY, LOADED }
}

Wyświetl plik

@ -1,13 +1,19 @@
package org.thoughtcrime.securesms.mediapreview
import android.content.Context
import android.net.Uri
import androidx.lifecycle.ViewModel
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.functions.Consumer
import io.reactivex.rxjava3.kotlin.plusAssign
import io.reactivex.rxjava3.schedulers.Schedulers
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.attachments.DatabaseAttachment
import org.thoughtcrime.securesms.database.MediaDatabase
import org.thoughtcrime.securesms.util.AttachmentUtil
import org.thoughtcrime.securesms.util.rx.RxStore
class MediaPreviewV2ViewModel : ViewModel() {
@ -19,7 +25,8 @@ class MediaPreviewV2ViewModel : ViewModel() {
val state: Flowable<MediaPreviewV2State> = store.stateFlowable.observeOn(AndroidSchedulers.mainThread())
fun fetchAttachments(startingUri: Uri, threadId: Long, sorting: MediaDatabase.Sorting) {
disposables += store.update(repository.getAttachments(startingUri, threadId, sorting)) { mediaRecords: List<MediaDatabase.MediaRecord>, oldState: MediaPreviewV2State ->
disposables += store.update(repository.getAttachments(startingUri, threadId, sorting)) {
mediaRecords: List<MediaDatabase.MediaRecord>, oldState: MediaPreviewV2State ->
oldState.copy(
mediaRecords = mediaRecords,
loadState = MediaPreviewV2State.LoadState.READY
@ -35,10 +42,17 @@ class MediaPreviewV2ViewModel : ViewModel() {
fun setCurrentPage(position: Int) {
store.update { oldState ->
oldState.copy(position = position)
oldState.copy(position = position, loadState = MediaPreviewV2State.LoadState.LOADED)
}
}
fun deleteItem(context: Context, attachment: DatabaseAttachment, onSuccess: Consumer<in Unit>, onError: Consumer<in Throwable>) {
disposables += Single.fromCallable { AttachmentUtil.deleteAttachment(context.applicationContext, attachment) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(onSuccess, onError)
}
override fun onCleared() {
disposables.dispose()
store.dispose()

Wyświetl plik

@ -3,4 +3,5 @@
xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/fragment_container_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />
android:layout_height="match_parent"
android:background="@color/signal_dark_colorNeutral"/>

Wyświetl plik

@ -1,11 +1,67 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/core_grey_95"
android:theme="@style/TextSecure.MediaPreview">
xmlns:tools="http://schemas.android.com/tools"
android:background="@color/signal_dark_colorNeutral">
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/media_pager"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clickable="true"
android:focusable="true" />
<LinearLayout
android:id="@+id/media_preview_details_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:animateLayoutChanges="true"
android:background="@drawable/image_preview_shade"
android:gravity="bottom"
android:orientation="vertical"
android:visibility="gone"
tools:visibility="visible">
<org.thoughtcrime.securesms.components.MaxHeightScrollView
android:id="@+id/media_preview_caption_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="32dp"
android:animateLayoutChanges="true"
app:scrollView_maxHeight="120dp">
<org.thoughtcrime.securesms.components.emoji.EmojiTextView
android:id="@+id/media_preview_caption"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingBottom="8dp"
style="@style/Signal.Text.Body"
android:textColor="@color/core_white"
android:gravity="bottom"
tools:text="With great power comes great responsibility." />
</org.thoughtcrime.securesms.components.MaxHeightScrollView>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/media_preview_album_rail"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginStart="12dp"
android:layout_marginEnd="12dp"
tools:layout_height="64dp"/>
<FrameLayout
android:id="@+id/media_preview_playback_controls_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:animateLayoutChanges="true"/>
</LinearLayout>
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/toolbar_layout"
android:layout_width="match_parent"
@ -19,18 +75,14 @@
android:layout_height="0dp"
android:visibility="gone" />
<androidx.appcompat.widget.Toolbar
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:theme="?actionBarStyle"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="@android:color/transparent" />
android:background="@android:color/transparent"
app:navigationIcon="@drawable/ic_arrow_left_white_24" />
</com.google.android.material.appbar.AppBarLayout>
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/media_pager"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clickable="true"
android:focusable="true" />
</FrameLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

Wyświetl plik

@ -1965,6 +1965,7 @@
<string name="MediaPreviewActivity_s_to_you">%1$s to you</string>
<string name="MediaPreviewActivity_media_no_longer_available">Media no longer available.</string>
<string name="MediaPreviewActivity_cant_find_an_app_able_to_share_this_media">Can\'t find an app able to share this media.</string>
<string name="MediaPreviewActivity_dismiss_due_to_error">Close</string>
<!-- MessageNotifier -->
<string name="MessageNotifier_d_new_messages_in_d_conversations">%1$d new messages in %2$d conversations</string>