diff --git a/changelog.d/5005.feature b/changelog.d/5005.feature new file mode 100644 index 0000000000..ce3b2ad1f9 --- /dev/null +++ b/changelog.d/5005.feature @@ -0,0 +1 @@ +Add possibility to save media from Gallery + reorder choices in message context menu diff --git a/library/attachment-viewer/src/main/java/im/vector/lib/attachmentviewer/AttachmentViewerActivity.kt b/library/attachment-viewer/src/main/java/im/vector/lib/attachmentviewer/AttachmentViewerActivity.kt index 573138bf5c..21af114c26 100644 --- a/library/attachment-viewer/src/main/java/im/vector/lib/attachmentviewer/AttachmentViewerActivity.kt +++ b/library/attachment-viewer/src/main/java/im/vector/lib/attachmentviewer/AttachmentViewerActivity.kt @@ -45,6 +45,8 @@ import kotlin.math.abs abstract class AttachmentViewerActivity : AppCompatActivity(), AttachmentEventListener { + protected val rootView: View + get() = views.rootContainer protected val pager2: ViewPager2 get() = views.attachmentPager protected val imageTransitionView: ImageView @@ -298,10 +300,11 @@ abstract class AttachmentViewerActivity : AppCompatActivity(), AttachmentEventLi private fun createSwipeToDismissHandler(): SwipeToDismissHandler = SwipeToDismissHandler( - swipeView = views.dismissContainer, - shouldAnimateDismiss = { shouldAnimateDismiss() }, - onDismiss = { animateClose() }, - onSwipeViewMove = ::handleSwipeViewMove) + swipeView = views.dismissContainer, + shouldAnimateDismiss = { shouldAnimateDismiss() }, + onDismiss = { animateClose() }, + onSwipeViewMove = ::handleSwipeViewMove + ) private fun createSwipeDirectionDetector() = SwipeDirectionDetector(this) { swipeDirection = it } diff --git a/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt b/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt index 2cd7136ffc..33afcf1dfb 100644 --- a/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt +++ b/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt @@ -58,6 +58,7 @@ import im.vector.app.features.login.LoginViewModel import im.vector.app.features.login2.LoginViewModel2 import im.vector.app.features.login2.created.AccountCreatedViewModel import im.vector.app.features.matrixto.MatrixToBottomSheetViewModel +import im.vector.app.features.media.VectorAttachmentViewerViewModel import im.vector.app.features.onboarding.OnboardingViewModel import im.vector.app.features.poll.create.CreatePollViewModel import im.vector.app.features.qrcode.QrCodeScannerViewModel @@ -594,4 +595,9 @@ interface MavericksViewModelModule { @IntoMap @MavericksViewModelKey(LocationSharingViewModel::class) fun createLocationSharingViewModelFactory(factory: LocationSharingViewModel.Factory): MavericksAssistedViewModelFactory<*, *> + + @Binds + @IntoMap + @MavericksViewModelKey(VectorAttachmentViewerViewModel::class) + fun vectorAttachmentViewerViewModelFactory(factory: VectorAttachmentViewerViewModel.Factory): MavericksAssistedViewModelFactory<*, *> } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt index 745cb0c731..5575d9b7f6 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt @@ -343,24 +343,6 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted add(EventSharedAction.Edit(eventId, timelineEvent.root.getClearType())) } - if (canRedact(timelineEvent, actionPermissions)) { - if (timelineEvent.root.getClearType() == EventType.POLL_START) { - add(EventSharedAction.Redact( - eventId, - askForReason = informationData.senderId != session.myUserId, - dialogTitleRes = R.string.delete_poll_dialog_title, - dialogDescriptionRes = R.string.delete_poll_dialog_content - )) - } else { - add(EventSharedAction.Redact( - eventId, - askForReason = informationData.senderId != session.myUserId, - dialogTitleRes = R.string.delete_event_dialog_title, - dialogDescriptionRes = R.string.delete_event_dialog_content - )) - } - } - if (canCopy(msgType)) { // TODO copy images? html? see ClipBoard add(EventSharedAction.Copy(messageContent!!.body)) @@ -382,12 +364,30 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted add(EventSharedAction.ViewEditHistory(informationData)) } + if (canSave(msgType) && messageContent is MessageWithAttachmentContent) { + add(EventSharedAction.Save(timelineEvent.eventId, messageContent)) + } + if (canShare(msgType)) { add(EventSharedAction.Share(timelineEvent.eventId, messageContent!!)) } - if (canSave(msgType) && messageContent is MessageWithAttachmentContent) { - add(EventSharedAction.Save(timelineEvent.eventId, messageContent)) + if (canRedact(timelineEvent, actionPermissions)) { + if (timelineEvent.root.getClearType() == EventType.POLL_START) { + add(EventSharedAction.Redact( + eventId, + askForReason = informationData.senderId != session.myUserId, + dialogTitleRes = R.string.delete_poll_dialog_title, + dialogDescriptionRes = R.string.delete_poll_dialog_content + )) + } else { + add(EventSharedAction.Redact( + eventId, + askForReason = informationData.senderId != session.myUserId, + dialogTitleRes = R.string.delete_event_dialog_title, + dialogDescriptionRes = R.string.delete_event_dialog_content + )) + } } } diff --git a/vector/src/main/java/im/vector/app/features/media/AttachmentInteractionListener.kt b/vector/src/main/java/im/vector/app/features/media/AttachmentInteractionListener.kt new file mode 100644 index 0000000000..b0cb913596 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/media/AttachmentInteractionListener.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.media + +interface AttachmentInteractionListener { + fun onDismiss() + fun onShare() + fun onDownload() + fun onPlayPause(play: Boolean) + fun videoSeekTo(percent: Int) +} diff --git a/vector/src/main/java/im/vector/app/features/media/AttachmentOverlayView.kt b/vector/src/main/java/im/vector/app/features/media/AttachmentOverlayView.kt index f79fb03898..58d10d2f2d 100644 --- a/vector/src/main/java/im/vector/app/features/media/AttachmentOverlayView.kt +++ b/vector/src/main/java/im/vector/app/features/media/AttachmentOverlayView.kt @@ -30,35 +30,33 @@ class AttachmentOverlayView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : ConstraintLayout(context, attrs, defStyleAttr), AttachmentEventListener { - var onShareCallback: (() -> Unit)? = null - var onBack: (() -> Unit)? = null - var onPlayPause: ((play: Boolean) -> Unit)? = null - var videoSeekTo: ((progress: Int) -> Unit)? = null - + var interactionListener: AttachmentInteractionListener? = null val views: MergeImageAttachmentOverlayBinding - var isPlaying = false - - var suspendSeekBarUpdate = false + private var isPlaying = false + private var suspendSeekBarUpdate = false init { inflate(context, R.layout.merge_image_attachment_overlay, this) views = MergeImageAttachmentOverlayBinding.bind(this) setBackgroundColor(Color.TRANSPARENT) views.overlayBackButton.setOnClickListener { - onBack?.invoke() + interactionListener?.onDismiss() } views.overlayShareButton.setOnClickListener { - onShareCallback?.invoke() + interactionListener?.onShare() + } + views.overlayDownloadButton.setOnClickListener { + interactionListener?.onDownload() } views.overlayPlayPauseButton.setOnClickListener { - onPlayPause?.invoke(!isPlaying) + interactionListener?.onPlayPause(!isPlaying) } views.overlaySeekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { if (fromUser) { - videoSeekTo?.invoke(progress) + interactionListener?.videoSeekTo(progress) } } diff --git a/vector/src/main/java/im/vector/app/features/media/BaseAttachmentProvider.kt b/vector/src/main/java/im/vector/app/features/media/BaseAttachmentProvider.kt index ca469bfbcb..4039ea112b 100644 --- a/vector/src/main/java/im/vector/app/features/media/BaseAttachmentProvider.kt +++ b/vector/src/main/java/im/vector/app/features/media/BaseAttachmentProvider.kt @@ -49,14 +49,7 @@ abstract class BaseAttachmentProvider( private val stringProvider: StringProvider ) : AttachmentSourceProvider { - interface InteractionListener { - fun onDismissTapped() - fun onShareTapped() - fun onPlayPause(play: Boolean) - fun videoSeekTo(percent: Int) - } - - var interactionListener: InteractionListener? = null + var interactionListener: AttachmentInteractionListener? = null private var overlayView: AttachmentOverlayView? = null @@ -68,18 +61,7 @@ abstract class BaseAttachmentProvider( if (position == -1) return null if (overlayView == null) { overlayView = AttachmentOverlayView(context) - overlayView?.onBack = { - interactionListener?.onDismissTapped() - } - overlayView?.onShareCallback = { - interactionListener?.onShareTapped() - } - overlayView?.onPlayPause = { play -> - interactionListener?.onPlayPause(play) - } - overlayView?.videoSeekTo = { percent -> - interactionListener?.videoSeekTo(percent) - } + overlayView?.interactionListener = interactionListener } val timelineEvent = getTimelineEventAtPosition(position) diff --git a/vector/src/main/java/im/vector/app/features/media/VectorAttachmentViewerAction.kt b/vector/src/main/java/im/vector/app/features/media/VectorAttachmentViewerAction.kt new file mode 100644 index 0000000000..5af3cd193a --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/media/VectorAttachmentViewerAction.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.media + +import im.vector.app.core.platform.VectorViewModelAction +import java.io.File + +sealed class VectorAttachmentViewerAction : VectorViewModelAction { + data class DownloadMedia(val file: File) : VectorAttachmentViewerAction() +} diff --git a/vector/src/main/java/im/vector/app/features/media/VectorAttachmentViewerActivity.kt b/vector/src/main/java/im/vector/app/features/media/VectorAttachmentViewerActivity.kt index 103511bad5..d8c2b83f9b 100644 --- a/vector/src/main/java/im/vector/app/features/media/VectorAttachmentViewerActivity.kt +++ b/vector/src/main/java/im/vector/app/features/media/VectorAttachmentViewerActivity.kt @@ -17,6 +17,7 @@ package im.vector.app.features.media import android.content.Context import android.content.Intent +import android.os.Build import android.os.Bundle import android.os.Parcelable import android.view.View @@ -30,16 +31,25 @@ import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope import androidx.transition.Transition +import com.airbnb.mvrx.viewModel import dagger.hilt.android.AndroidEntryPoint import im.vector.app.R import im.vector.app.core.di.ActiveSessionHolder +import im.vector.app.core.extensions.singletonEntryPoint import im.vector.app.core.intent.getMimeTypeFromUri +import im.vector.app.core.platform.showOptimizedSnackbar +import im.vector.app.core.utils.PERMISSIONS_FOR_WRITING_FILES +import im.vector.app.core.utils.checkPermissions +import im.vector.app.core.utils.onPermissionDeniedDialog +import im.vector.app.core.utils.registerForPermissionsResult import im.vector.app.core.utils.shareMedia import im.vector.app.features.themes.ActivityOtherThemes import im.vector.app.features.themes.ThemeUtils import im.vector.lib.attachmentviewer.AttachmentCommands import im.vector.lib.attachmentviewer.AttachmentViewerActivity import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.parcelize.Parcelize @@ -47,7 +57,7 @@ import timber.log.Timber import javax.inject.Inject @AndroidEntryPoint -class VectorAttachmentViewerActivity : AttachmentViewerActivity(), BaseAttachmentProvider.InteractionListener { +class VectorAttachmentViewerActivity : AttachmentViewerActivity(), AttachmentInteractionListener { @Parcelize data class Args( @@ -58,15 +68,28 @@ class VectorAttachmentViewerActivity : AttachmentViewerActivity(), BaseAttachmen @Inject lateinit var sessionHolder: ActiveSessionHolder + @Inject lateinit var dataSourceFactory: AttachmentProviderFactory + @Inject lateinit var imageContentRenderer: ImageContentRenderer + private val viewModel: VectorAttachmentViewerViewModel by viewModel() + private val errorFormatter by lazy(LazyThreadSafetyMode.NONE) { singletonEntryPoint().errorFormatter() } private var initialIndex = 0 private var isAnimatingOut = false - private var currentSourceProvider: BaseAttachmentProvider<*>? = null + private val downloadActionResultLauncher = registerForPermissionsResult { allGranted, deniedPermanently -> + if (allGranted) { + viewModel.pendingAction?.let { + viewModel.handle(it) + } + } else if (deniedPermanently) { + onPermissionDeniedDialog(R.string.denied_permission_generic) + } + viewModel.pendingAction = null + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -128,6 +151,8 @@ class VectorAttachmentViewerActivity : AttachmentViewerActivity(), BaseAttachmen window.statusBarColor = ContextCompat.getColor(this, R.color.black_alpha) window.navigationBarColor = ContextCompat.getColor(this, R.color.black_alpha) + + observeViewEvents() } override fun onResume() { @@ -140,12 +165,6 @@ class VectorAttachmentViewerActivity : AttachmentViewerActivity(), BaseAttachmen Timber.i("onPause Activity ${javaClass.simpleName}") } - private fun getOtherThemes() = ActivityOtherThemes.VectorAttachmentsPreview - - override fun shouldAnimateDismiss(): Boolean { - return currentPosition != initialIndex - } - override fun onBackPressed() { if (currentPosition == initialIndex) { // show back the transition view @@ -156,6 +175,10 @@ class VectorAttachmentViewerActivity : AttachmentViewerActivity(), BaseAttachmen super.onBackPressed() } + override fun shouldAnimateDismiss(): Boolean { + return currentPosition != initialIndex + } + override fun animateClose() { if (currentPosition == initialIndex) { // show back the transition view @@ -166,9 +189,7 @@ class VectorAttachmentViewerActivity : AttachmentViewerActivity(), BaseAttachmen ActivityCompat.finishAfterTransition(this) } - // ========================================================================================== - // PRIVATE METHODS - // ========================================================================================== + private fun getOtherThemes() = ActivityOtherThemes.VectorAttachmentsPreview /** * Try and add a [Transition.TransitionListener] to the entering shared element @@ -218,10 +239,72 @@ class VectorAttachmentViewerActivity : AttachmentViewerActivity(), BaseAttachmen }) } + private fun observeViewEvents() { + viewModel.viewEvents + .stream() + .onEach(::handleViewEvents) + .launchIn(lifecycleScope) + } + + private fun handleViewEvents(event: VectorAttachmentViewerViewEvents) { + when (event) { + is VectorAttachmentViewerViewEvents.ErrorDownloadingMedia -> showSnackBarError(event.error) + } + } + + private fun showSnackBarError(error: Throwable) { + rootView.showOptimizedSnackbar(errorFormatter.toHumanReadable(error)) + } + + private fun hasWritePermission() = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || + checkPermissions(PERMISSIONS_FOR_WRITING_FILES, this, downloadActionResultLauncher) + + override fun onDismiss() { + animateClose() + } + + override fun onPlayPause(play: Boolean) { + handle(if (play) AttachmentCommands.StartVideo else AttachmentCommands.PauseVideo) + } + + override fun videoSeekTo(percent: Int) { + handle(AttachmentCommands.SeekTo(percent)) + } + + override fun onShare() { + lifecycleScope.launch(Dispatchers.IO) { + val file = currentSourceProvider?.getFileForSharing(currentPosition) ?: return@launch + + withContext(Dispatchers.Main) { + shareMedia( + this@VectorAttachmentViewerActivity, + file, + getMimeTypeFromUri(this@VectorAttachmentViewerActivity, file.toUri()) + ) + } + } + } + + override fun onDownload() { + lifecycleScope.launch(Dispatchers.IO) { + val hasWritePermission = withContext(Dispatchers.Main) { + hasWritePermission() + } + + val file = currentSourceProvider?.getFileForSharing(currentPosition) ?: return@launch + if (hasWritePermission) { + viewModel.handle(VectorAttachmentViewerAction.DownloadMedia(file)) + } else { + viewModel.pendingAction = VectorAttachmentViewerAction.DownloadMedia(file) + } + } + } + companion object { - const val EXTRA_ARGS = "EXTRA_ARGS" - const val EXTRA_IMAGE_DATA = "EXTRA_IMAGE_DATA" - const val EXTRA_IN_MEMORY_DATA = "EXTRA_IN_MEMORY_DATA" + private const val EXTRA_ARGS = "EXTRA_ARGS" + private const val EXTRA_IMAGE_DATA = "EXTRA_IMAGE_DATA" + private const val EXTRA_IN_MEMORY_DATA = "EXTRA_IN_MEMORY_DATA" fun newIntent(context: Context, mediaData: AttachmentData, @@ -236,30 +319,4 @@ class VectorAttachmentViewerActivity : AttachmentViewerActivity(), BaseAttachmen } } } - - override fun onDismissTapped() { - animateClose() - } - - override fun onPlayPause(play: Boolean) { - handle(if (play) AttachmentCommands.StartVideo else AttachmentCommands.PauseVideo) - } - - override fun videoSeekTo(percent: Int) { - handle(AttachmentCommands.SeekTo(percent)) - } - - override fun onShareTapped() { - lifecycleScope.launch(Dispatchers.IO) { - val file = currentSourceProvider?.getFileForSharing(currentPosition) ?: return@launch - - withContext(Dispatchers.Main) { - shareMedia( - this@VectorAttachmentViewerActivity, - file, - getMimeTypeFromUri(this@VectorAttachmentViewerActivity, file.toUri()) - ) - } - } - } } diff --git a/vector/src/main/java/im/vector/app/features/media/VectorAttachmentViewerViewEvents.kt b/vector/src/main/java/im/vector/app/features/media/VectorAttachmentViewerViewEvents.kt new file mode 100644 index 0000000000..e46ee02155 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/media/VectorAttachmentViewerViewEvents.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.media + +import im.vector.app.core.platform.VectorViewEvents + +sealed class VectorAttachmentViewerViewEvents : VectorViewEvents { + data class ErrorDownloadingMedia(val error: Throwable) : VectorAttachmentViewerViewEvents() +} diff --git a/vector/src/main/java/im/vector/app/features/media/VectorAttachmentViewerViewModel.kt b/vector/src/main/java/im/vector/app/features/media/VectorAttachmentViewerViewModel.kt new file mode 100644 index 0000000000..807c69caff --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/media/VectorAttachmentViewerViewModel.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.media + +import com.airbnb.mvrx.MavericksViewModelFactory +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import im.vector.app.core.di.MavericksAssistedViewModelFactory +import im.vector.app.core.di.hiltMavericksViewModelFactory +import im.vector.app.core.platform.VectorDummyViewState +import im.vector.app.core.platform.VectorViewModel +import im.vector.app.features.media.domain.usecase.DownloadMediaUseCase +import im.vector.app.features.session.coroutineScope +import kotlinx.coroutines.launch +import org.matrix.android.sdk.api.session.Session + +class VectorAttachmentViewerViewModel @AssistedInject constructor( + @Assisted initialState: VectorDummyViewState, + private val session: Session, + private val downloadMediaUseCase: DownloadMediaUseCase +) : VectorViewModel(initialState) { + + @AssistedFactory + interface Factory : MavericksAssistedViewModelFactory { + override fun create(initialState: VectorDummyViewState): VectorAttachmentViewerViewModel + } + + companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() + + var pendingAction: VectorAttachmentViewerAction? = null + + override fun handle(action: VectorAttachmentViewerAction) { + when (action) { + is VectorAttachmentViewerAction.DownloadMedia -> handleDownloadAction(action) + } + } + + private fun handleDownloadAction(action: VectorAttachmentViewerAction.DownloadMedia) { + // launch in the coroutine scope session to avoid binding the coroutine to the lifecycle of the VM + session.coroutineScope.launch { + // Success event is handled via a notification inside the use case + downloadMediaUseCase.execute(action.file) + .onFailure { _viewEvents.post(VectorAttachmentViewerViewEvents.ErrorDownloadingMedia(it)) } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/media/domain/usecase/DownloadMediaUseCase.kt b/vector/src/main/java/im/vector/app/features/media/domain/usecase/DownloadMediaUseCase.kt new file mode 100644 index 0000000000..b0401ccd30 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/media/domain/usecase/DownloadMediaUseCase.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.media.domain.usecase + +import android.content.Context +import androidx.core.net.toUri +import dagger.hilt.android.qualifiers.ApplicationContext +import im.vector.app.core.intent.getMimeTypeFromUri +import im.vector.app.core.utils.saveMedia +import im.vector.app.features.notifications.NotificationUtils +import kotlinx.coroutines.withContext +import org.matrix.android.sdk.api.session.Session +import java.io.File +import javax.inject.Inject + +class DownloadMediaUseCase @Inject constructor( + @ApplicationContext private val appContext: Context, + private val session: Session, + private val notificationUtils: NotificationUtils +) { + + suspend fun execute(input: File): Result = withContext(session.coroutineDispatchers.io) { + runCatching { + saveMedia( + context = appContext, + file = input, + title = input.name, + mediaMimeType = getMimeTypeFromUri(appContext, input.toUri()), + notificationUtils = notificationUtils + ) + } + } +} diff --git a/vector/src/main/res/layout/merge_image_attachment_overlay.xml b/vector/src/main/res/layout/merge_image_attachment_overlay.xml index d8e2142f87..1a5c6d8bf4 100644 --- a/vector/src/main/res/layout/merge_image_attachment_overlay.xml +++ b/vector/src/main/res/layout/merge_image_attachment_overlay.xml @@ -67,6 +67,23 @@ app:layout_constraintTop_toBottomOf="@id/overlayCounterText" tools:text="Bill 29 Jun at 19:42" /> + + () + val mimeType = "mimeType" + val name = "filename" + every { getMimeTypeFromUri(appContext, uri) } returns mimeType + file.givenName(name) + file.givenUri(uri) + coEvery { saveMedia(any(), any(), any(), any(), any()) } just runs + + // When + val result = downloadMediaUseCase.execute(file.instance) + + // Then + assert(result.isSuccess) + verifyAll { + file.instance.name + file.instance.toUri() + } + verify { + getMimeTypeFromUri(appContext, uri) + } + coVerify { + saveMedia(appContext, file.instance, name, mimeType, notificationUtils) + } + } + + @Test + fun `given a file when calling execute then save the file in local with error`() = runBlockingTest { + // Given + val uri = mockk() + val mimeType = "mimeType" + val name = "filename" + val error = Throwable() + file.givenName(name) + file.givenUri(uri) + every { getMimeTypeFromUri(appContext, uri) } returns mimeType + coEvery { saveMedia(any(), any(), any(), any(), any()) } throws error + + // When + val result = downloadMediaUseCase.execute(file.instance) + + // Then + assert(result.isFailure && result.exceptionOrNull() == error) + verifyAll { + file.instance.name + file.instance.toUri() + } + verify { + getMimeTypeFromUri(appContext, uri) + } + coVerify { + saveMedia(appContext, file.instance, name, mimeType, notificationUtils) + } + } +} diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeFile.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeFile.kt new file mode 100644 index 0000000000..652d3f93fd --- /dev/null +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeFile.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2022 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.test.fakes + +import android.net.Uri +import androidx.core.net.toUri +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkStatic +import java.io.File + +class FakeFile { + + val instance = mockk() + + init { + mockkStatic(Uri::class) + } + + /** + * To be called after tests. + */ + fun tearDown() { + unmockkStatic(Uri::class) + } + + fun givenName(name: String) { + every { instance.name } returns name + } + + fun givenUri(uri: Uri) { + every { instance.toUri() } returns uri + } +}