diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt index 7554339b2f..e4a694a119 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionViewModel.kt @@ -175,7 +175,7 @@ class MediaSelectionViewModel( return store.state.storySendRequirements } - private fun addMedia(media: Set) { + fun addMedia(media: Set) { val newSelectionList: List = linkedSetOf().apply { addAll(store.state.selectedMedia) addAll(media) @@ -269,9 +269,13 @@ class MediaSelectionViewModel( } fun removeMedia(media: Media) { + removeMedia(setOf(media)) + } + + fun removeMedia(media: Set) { val snapshot = store.state val newMediaList = snapshot.selectedMedia - media - val oldFocusIndex = snapshot.selectedMedia.indexOf(media) + val oldFocusIndex = snapshot.selectedMedia.indexOf(media.first()) val newFocus = when { newMediaList.isEmpty() -> null media == snapshot.focusedMedia -> newMediaList[Util.clamp(oldFocusIndex, 0, newMediaList.size - 1)] @@ -282,7 +286,7 @@ class MediaSelectionViewModel( it.copy( selectedMedia = newMediaList, focusedMedia = newFocus, - editorStateMap = it.editorStateMap - media.uri, + editorStateMap = it.editorStateMap - media.map { it.uri }, cameraFirstCapture = if (media == it.cameraFirstCapture) null else it.cameraFirstCapture ) } @@ -292,9 +296,9 @@ class MediaSelectionViewModel( } selectedMediaSubject.onNext(newMediaList) - repository.deleteBlobs(listOf(media)) + repository.deleteBlobs(media.toList()) - Log.d(TAG, "User removed ${media.uri} from message.") + Log.d(TAG, "User removed ${media.forEach { it.uri }} from message.") cancelUpload(media) } @@ -450,6 +454,10 @@ class MediaSelectionViewModel( } private fun cancelUpload(media: Media) { + cancelUpload(setOf(media)) + } + + private fun cancelUpload(media: Set) { repository.uploadRepository.cancelUpload(media) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/gallery/MediaGalleryFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/gallery/MediaGalleryFragment.kt index f44c86bc90..d31f332e2e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/gallery/MediaGalleryFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/gallery/MediaGalleryFragment.kt @@ -15,7 +15,9 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.map import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.ItemTouchHelper +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import org.signal.core.util.Stopwatch +import org.signal.core.util.concurrent.LifecycleDisposable import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.recyclerview.GridDividerDecoration import org.thoughtcrime.securesms.conversation.ManageContextMenu @@ -23,6 +25,8 @@ import org.thoughtcrime.securesms.databinding.V2MediaGalleryFragmentBinding import org.thoughtcrime.securesms.mediasend.Media import org.thoughtcrime.securesms.mediasend.MediaRepository import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil +import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel +import org.thoughtcrime.securesms.mediasend.v2.review.MediaGalleryGridItemTouchListener import org.thoughtcrime.securesms.permissions.PermissionCompat import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.util.Material3OnScrollHelper @@ -44,6 +48,10 @@ class MediaGalleryFragment : Fragment(R.layout.v2_media_gallery_fragment) { factoryProducer = { MediaGalleryViewModel.Factory(null, null, MediaGalleryRepository(requireContext(), MediaRepository())) } ) + private val sharedViewModel: MediaSelectionViewModel by viewModels( + ownerProducer = { requireActivity() } + ) + private lateinit var callbacks: Callbacks private var selectedMediaTouchHelper: ItemTouchHelper? = null @@ -54,6 +62,8 @@ class MediaGalleryFragment : Fragment(R.layout.v2_media_gallery_fragment) { private val viewStateLiveData = MutableLiveData(ViewState()) + private val lifecycleDisposable = LifecycleDisposable() + private val onBackPressedCallback: OnBackPressedCallback = object : OnBackPressedCallback(false) { override fun handleOnBackPressed() { onBack() @@ -64,6 +74,8 @@ class MediaGalleryFragment : Fragment(R.layout.v2_media_gallery_fragment) { callbacks = requireListener() val binding = V2MediaGalleryFragmentBinding.bind(view) + lifecycleDisposable.bindTo(this) + SystemWindowInsetsSetter.attach(view, viewLifecycleOwner, WindowInsetsCompat.Type.navigationBars()) binding.mediaGalleryToolbar.updateLayoutParams { @@ -130,8 +142,45 @@ class MediaGalleryFragment : Fragment(R.layout.v2_media_gallery_fragment) { binding.mediaGallerySelected.adapter = selectedAdapter selectedMediaTouchHelper?.attachToRecyclerView(binding.mediaGallerySelected) + val mediaGalleryGridItemTouchListener = MediaGalleryGridItemTouchListener() + val onDragSelectListener = object : MediaGalleryGridItemTouchListener.OnDragSelectListener { + override fun onSelectionStarted(start: Int) { + galleryAdapter.getModel(start).ifPresent { + val fileModel = it as MediaGallerySelectableItem.FileModel + val media = fileModel.media + if (fileModel.isSelected) { + callbacks.onMediaUnselected(media) + mediaGalleryGridItemTouchListener.setIsActive(false) + } else { + callbacks.onMediaSelected(media) + } + } + } + + override fun onSelectChange(start: Int, end: Int, shouldSelect: Boolean) { + val mediaSet = (start..end) + .mapNotNull { i -> + galleryAdapter.getModel(i).orElse(null) as? MediaGallerySelectableItem.FileModel + } + .map { fileModel -> + fileModel.media + } + .toSet() + + if (mediaSet.isNotEmpty()) { + if (shouldSelect) { + callbacks.onMediaSelected(mediaSet) + } else { + callbacks.onMediaUnselected(mediaSet) + } + } + } + } + mediaGalleryGridItemTouchListener.withSelectListener(onDragSelectListener) + MediaGallerySelectableItem.registerAdapter( mappingAdapter = galleryAdapter, + mediaGalleryGridItemTouchListener = mediaGalleryGridItemTouchListener, onMediaFolderClicked = { onBackPressedCallback.isEnabled = true viewModel.setMediaFolder(it) @@ -148,6 +197,7 @@ class MediaGalleryFragment : Fragment(R.layout.v2_media_gallery_fragment) { binding.mediaGalleryGrid.adapter = galleryAdapter binding.mediaGalleryGrid.addItemDecoration(GridDividerDecoration(4, ViewUtil.dpToPx(2))) + binding.mediaGalleryGrid.addOnItemTouchListener(mediaGalleryGridItemTouchListener) viewStateLiveData.observe(viewLifecycleOwner) { state -> binding.mediaGalleryBottomBarGroup.visible = state.selectedMedia.isNotEmpty() @@ -212,6 +262,13 @@ class MediaGalleryFragment : Fragment(R.layout.v2_media_gallery_fragment) { } requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, onBackPressedCallback) + + lifecycleDisposable += sharedViewModel.mediaErrors + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { + mediaGalleryGridItemTouchListener.stopAutoScroll() + mediaGalleryGridItemTouchListener.setIsActive(false) + } } override fun onResume() { @@ -285,6 +342,8 @@ class MediaGalleryFragment : Fragment(R.layout.v2_media_gallery_fragment) { fun isCameraEnabled(): Boolean = true fun isMultiselectEnabled(): Boolean = false fun onMediaSelected(media: Media) + fun onMediaSelected(media: Set) = Unit + fun onMediaUnselected(media: Set) = Unit fun onMediaUnselected(media: Media): Unit = throw UnsupportedOperationException() fun onSelectedMediaClicked(media: Media): Unit = throw UnsupportedOperationException() fun onNavigateToCamera(): Unit = throw UnsupportedOperationException() diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/gallery/MediaGallerySelectableItem.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/gallery/MediaGallerySelectableItem.kt index 14567500cd..1c18513221 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/gallery/MediaGallerySelectableItem.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/gallery/MediaGallerySelectableItem.kt @@ -19,6 +19,7 @@ import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.mediasend.Media import org.thoughtcrime.securesms.mediasend.MediaFolder +import org.thoughtcrime.securesms.mediasend.v2.review.MediaGalleryGridItemTouchListener import org.thoughtcrime.securesms.mms.DecryptableStreamUriLoader import org.thoughtcrime.securesms.mms.PartAuthority import org.thoughtcrime.securesms.util.MediaUtil @@ -40,12 +41,13 @@ object MediaGallerySelectableItem { fun registerAdapter( mappingAdapter: MappingAdapter, + mediaGalleryGridItemTouchListener: MediaGalleryGridItemTouchListener, onMediaFolderClicked: OnMediaFolderClicked, onMediaClicked: OnMediaClicked, isMultiselectEnabled: Boolean ) { mappingAdapter.registerFactory(FolderModel::class.java, LayoutFactory({ FolderViewHolder(it, onMediaFolderClicked) }, R.layout.v2_media_gallery_folder_item)) - mappingAdapter.registerFactory(FileModel::class.java, LayoutFactory({ FileViewHolder(it, onMediaClicked) }, if (isMultiselectEnabled) R.layout.v2_media_gallery_item else R.layout.v2_media_gallery_item_no_check)) + mappingAdapter.registerFactory(FileModel::class.java, LayoutFactory({ FileViewHolder(it, onMediaClicked, mediaGalleryGridItemTouchListener) }, if (isMultiselectEnabled) R.layout.v2_media_gallery_item else R.layout.v2_media_gallery_item_no_check)) mappingAdapter.registerFactory(PlaceholderModel::class.java, LayoutFactory({ PlaceholderViewHolder(it) }, R.layout.v2_media_gallery_placeholder_item)) } @@ -116,7 +118,7 @@ object MediaGallerySelectableItem { } } - class FileViewHolder(itemView: View, private val onMediaClicked: OnMediaClicked) : BaseViewHolder(itemView) { + class FileViewHolder(itemView: View, private val onMediaClicked: OnMediaClicked, private val mediaGalleryGridItemTouchListener: MediaGalleryGridItemTouchListener) : BaseViewHolder(itemView) { private val selectedPadding = DimensionUnit.DP.toPixels(12f) private val selectedRadius = DimensionUnit.DP.toPixels(12f) @@ -126,6 +128,10 @@ object MediaGallerySelectableItem { checkView?.visible = model.isSelected checkView?.text = "${model.selectionOneBasedIndex}" itemView.setOnClickListener { onMediaClicked(model.media, model.isSelected) } + itemView.setOnLongClickListener { + mediaGalleryGridItemTouchListener.startDragSelection(bindingAdapterPosition) + true + } playOverlay?.visible = MediaUtil.isVideo(model.media.contentType) && !model.media.isVideoGif title?.visible = false diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/gallery/MediaSelectionGalleryFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/gallery/MediaSelectionGalleryFragment.kt index 7cab9c0d22..a3cb746e7b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/gallery/MediaSelectionGalleryFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/gallery/MediaSelectionGalleryFragment.kt @@ -93,6 +93,14 @@ class MediaSelectionGalleryFragment : Fragment(R.layout.fragment_container), Med sharedViewModel.removeMedia(media) } + override fun onMediaSelected(media: Set) { + sharedViewModel.addMedia(media) + } + + override fun onMediaUnselected(media: Set) { + sharedViewModel.removeMedia(media) + } + override fun onSelectedMediaClicked(media: Media) { sharedViewModel.onPageChanged(media) navigator.goToReview(findNavController()) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaGalleryGridItemTouchListener.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaGalleryGridItemTouchListener.kt new file mode 100644 index 0000000000..f7accb903e --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/review/MediaGalleryGridItemTouchListener.kt @@ -0,0 +1,240 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.mediasend.v2.review + +import android.content.Context +import android.content.res.Resources +import android.view.MotionEvent +import android.view.animation.LinearInterpolator +import android.widget.OverScroller +import androidx.recyclerview.widget.RecyclerView + +class MediaGalleryGridItemTouchListener : RecyclerView.OnItemTouchListener { + private var isActive = false + private var start = RecyclerView.NO_POSITION + private var end = RecyclerView.NO_POSITION + private var inTopSpot = false + private var inBottomSpot = false + private var scrollDistance = 0 + private var lastX = Float.MIN_VALUE + private var lastY = Float.MIN_VALUE + private var lastStart = RecyclerView.NO_POSITION + private var lastEnd = RecyclerView.NO_POSITION + + private var selectListener: OnDragSelectListener? = null + private var recyclerView: RecyclerView? = null + private var scroller: OverScroller? = null + + private var topBoundFrom = 0 + private var topBoundTo = 0 + private var bottomBoundFrom = 0 + private var bottomBoundTo = 0 + private var maxScrollDistance = 16 + private var autoScrollDistance = (Resources.getSystem().displayMetrics.density * 56).toInt() + private var touchRegionTopOffset = 0 + private var touchRegionBottomOffset = 0 + private var scrollAboveTopRegion = true + private var scrollBelowTopRegion = true + + init { + reset() + } + + private fun reset() { + isActive = false + start = RecyclerView.NO_POSITION + end = RecyclerView.NO_POSITION + lastStart = RecyclerView.NO_POSITION + lastEnd = RecyclerView.NO_POSITION + inTopSpot = false + inBottomSpot = false + lastX = Float.MIN_VALUE + lastY = Float.MIN_VALUE + stopAutoScroll() + } + + fun stopAutoScroll() { + if (scroller != null && !scroller!!.isFinished) { + recyclerView?.removeCallbacks(scrollRunnable) + scroller?.abortAnimation() + } + } + + fun withSelectListener(selectListener: OnDragSelectListener): MediaGalleryGridItemTouchListener { + this.selectListener = selectListener + return this + } + + private val scrollRunnable = object : Runnable { + override fun run() { + if (scroller != null && scroller!!.computeScrollOffset()) { + scrollBy(scrollDistance) + recyclerView?.postOnAnimation(this) + } + } + } + + fun startDragSelection(position: Int) { + isActive = true + start = position + end = position + lastStart = position + lastEnd = position + selectListener?.onSelectionStarted(position) + } + + override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean { + if (!isActive || rv.adapter?.itemCount == 0) return false + + when (e.action) { + MotionEvent.ACTION_POINTER_DOWN, MotionEvent.ACTION_DOWN -> reset() + } + + recyclerView = rv + val height = rv.height + topBoundFrom = 0 + touchRegionTopOffset + topBoundTo = topBoundFrom + autoScrollDistance + bottomBoundFrom = height + touchRegionBottomOffset - autoScrollDistance + bottomBoundTo = height + touchRegionBottomOffset + return true + } + + fun setIsActive(isActive: Boolean) { + this.isActive = isActive + } + + override fun onTouchEvent(rv: RecyclerView, e: MotionEvent) { + if (!isActive) return + + when (e.action) { + MotionEvent.ACTION_DOWN -> updateSelectedRange(rv, e) + MotionEvent.ACTION_MOVE -> { + if (!inTopSpot && !inBottomSpot) updateSelectedRange(rv, e) + processAutoScroll(e) + } + MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP -> reset() + } + } + + private fun updateSelectedRange(rv: RecyclerView, e: MotionEvent) { + updateSelectedRange(rv, e.x, e.y) + } + + private fun processAutoScroll(event: MotionEvent) { + val y = event.y.toInt() + val scrollSpeedFactor: Float + when { + y in topBoundFrom..topBoundTo -> { + lastX = event.x + lastY = event.y + scrollSpeedFactor = (topBoundTo - topBoundFrom - (y - topBoundFrom)).toFloat() / (topBoundTo - topBoundFrom) + scrollDistance = (maxScrollDistance * scrollSpeedFactor * -1f).toInt() + if (!inTopSpot) { + inTopSpot = true + startAutoScroll() + } + } + scrollAboveTopRegion && y < topBoundFrom -> { + lastX = event.x + lastY = event.y + scrollDistance = -maxScrollDistance + if (!inTopSpot) { + inTopSpot = true + startAutoScroll() + } + } + y in bottomBoundFrom..bottomBoundTo -> { + lastX = event.x + lastY = event.y + scrollSpeedFactor = (y - bottomBoundFrom).toFloat() / (bottomBoundTo - bottomBoundFrom) + scrollDistance = (maxScrollDistance * scrollSpeedFactor).toInt() + if (!inBottomSpot) { + inBottomSpot = true + startAutoScroll() + } + } + scrollBelowTopRegion && y > bottomBoundTo -> { + lastX = event.x + lastY = event.y + scrollDistance = maxScrollDistance + if (!inTopSpot) { + inTopSpot = true + startAutoScroll() + } + } + else -> { + inBottomSpot = false + inTopSpot = false + lastX = Float.MIN_VALUE + lastY = Float.MIN_VALUE + stopAutoScroll() + } + } + } + + private fun updateSelectedRange(rv: RecyclerView, x: Float, y: Float) { + val child = rv.findChildViewUnder(x, y) + if (child != null) { + val position = rv.getChildAdapterPosition(child) + if (position != RecyclerView.NO_POSITION && end != position) { + end = position + notifySelectRangeChange() + } + } + } + + fun startAutoScroll() { + val context = recyclerView?.context ?: return + initScroller(context) + if (scroller?.isFinished == true) { + recyclerView?.removeCallbacks(scrollRunnable) + scroller?.startScroll(0, scroller!!.currY, 0, 5000, 100000) + recyclerView!!.postOnAnimation(scrollRunnable) + } + } + + private fun notifySelectRangeChange() { + if (selectListener == null || start == RecyclerView.NO_POSITION || end == RecyclerView.NO_POSITION) return + + val newStart = minOf(start, end) + val newEnd = maxOf(start, end) + when { + lastStart == RecyclerView.NO_POSITION || lastEnd == RecyclerView.NO_POSITION -> { + if (newEnd - newStart == 1) selectListener?.onSelectChange(newStart, newStart, true) + else selectListener?.onSelectChange(newStart, newEnd, true) + } + newStart > lastStart -> selectListener?.onSelectChange(lastStart, newStart - 1, false) + newStart < lastStart -> selectListener?.onSelectChange(newStart, lastStart - 1, true) + } + + when { + newEnd > lastEnd -> selectListener?.onSelectChange(lastEnd + 1, newEnd, true) + newEnd < lastEnd -> selectListener?.onSelectChange(newEnd + 1, lastEnd, false) + } + + lastStart = newStart + lastEnd = newEnd + } + + private fun initScroller(context: Context) { + if (scroller == null) scroller = OverScroller(context, LinearInterpolator()) + } + + override fun onRequestDisallowInterceptTouchEvent(disallowIntercept: Boolean) { } + + private fun scrollBy(distance: Int) { + val scrollDist = if (distance > 0) minOf(distance, maxScrollDistance) else maxOf(distance, -maxScrollDistance) + recyclerView?.scrollBy(0, scrollDist) + if (lastX != Float.MIN_VALUE && lastY != Float.MIN_VALUE) { + updateSelectedRange(recyclerView!!, lastX, lastY) + } + } + + interface OnDragSelectListener { + fun onSelectionStarted(start: Int) + fun onSelectChange(start: Int, end: Int, shouldSelect: Boolean) + } +}