Merge pull request #5349 from vector-im/feature/mna/5005-save-image

#5005: Add save media icon in gallery
This commit is contained in:
Benoit Marty 2022-02-28 14:23:17 +01:00 committed by GitHub
commit 80bc3af5fa
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 524 additions and 96 deletions

1
changelog.d/5005.feature Normal file
View file

@ -0,0 +1 @@
Add possibility to save media from Gallery + reorder choices in message context menu

View file

@ -45,6 +45,8 @@ import kotlin.math.abs
abstract class AttachmentViewerActivity : AppCompatActivity(), AttachmentEventListener { abstract class AttachmentViewerActivity : AppCompatActivity(), AttachmentEventListener {
protected val rootView: View
get() = views.rootContainer
protected val pager2: ViewPager2 protected val pager2: ViewPager2
get() = views.attachmentPager get() = views.attachmentPager
protected val imageTransitionView: ImageView protected val imageTransitionView: ImageView
@ -301,7 +303,8 @@ abstract class AttachmentViewerActivity : AppCompatActivity(), AttachmentEventLi
swipeView = views.dismissContainer, swipeView = views.dismissContainer,
shouldAnimateDismiss = { shouldAnimateDismiss() }, shouldAnimateDismiss = { shouldAnimateDismiss() },
onDismiss = { animateClose() }, onDismiss = { animateClose() },
onSwipeViewMove = ::handleSwipeViewMove) onSwipeViewMove = ::handleSwipeViewMove
)
private fun createSwipeDirectionDetector() = private fun createSwipeDirectionDetector() =
SwipeDirectionDetector(this) { swipeDirection = it } SwipeDirectionDetector(this) { swipeDirection = it }

View file

@ -58,6 +58,7 @@ import im.vector.app.features.login.LoginViewModel
import im.vector.app.features.login2.LoginViewModel2 import im.vector.app.features.login2.LoginViewModel2
import im.vector.app.features.login2.created.AccountCreatedViewModel import im.vector.app.features.login2.created.AccountCreatedViewModel
import im.vector.app.features.matrixto.MatrixToBottomSheetViewModel 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.onboarding.OnboardingViewModel
import im.vector.app.features.poll.create.CreatePollViewModel import im.vector.app.features.poll.create.CreatePollViewModel
import im.vector.app.features.qrcode.QrCodeScannerViewModel import im.vector.app.features.qrcode.QrCodeScannerViewModel
@ -594,4 +595,9 @@ interface MavericksViewModelModule {
@IntoMap @IntoMap
@MavericksViewModelKey(LocationSharingViewModel::class) @MavericksViewModelKey(LocationSharingViewModel::class)
fun createLocationSharingViewModelFactory(factory: LocationSharingViewModel.Factory): MavericksAssistedViewModelFactory<*, *> fun createLocationSharingViewModelFactory(factory: LocationSharingViewModel.Factory): MavericksAssistedViewModelFactory<*, *>
@Binds
@IntoMap
@MavericksViewModelKey(VectorAttachmentViewerViewModel::class)
fun vectorAttachmentViewerViewModelFactory(factory: VectorAttachmentViewerViewModel.Factory): MavericksAssistedViewModelFactory<*, *>
} }

View file

@ -343,24 +343,6 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted
add(EventSharedAction.Edit(eventId, timelineEvent.root.getClearType())) 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)) { if (canCopy(msgType)) {
// TODO copy images? html? see ClipBoard // TODO copy images? html? see ClipBoard
add(EventSharedAction.Copy(messageContent!!.body)) add(EventSharedAction.Copy(messageContent!!.body))
@ -382,12 +364,30 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted
add(EventSharedAction.ViewEditHistory(informationData)) add(EventSharedAction.ViewEditHistory(informationData))
} }
if (canSave(msgType) && messageContent is MessageWithAttachmentContent) {
add(EventSharedAction.Save(timelineEvent.eventId, messageContent))
}
if (canShare(msgType)) { if (canShare(msgType)) {
add(EventSharedAction.Share(timelineEvent.eventId, messageContent!!)) add(EventSharedAction.Share(timelineEvent.eventId, messageContent!!))
} }
if (canSave(msgType) && messageContent is MessageWithAttachmentContent) { if (canRedact(timelineEvent, actionPermissions)) {
add(EventSharedAction.Save(timelineEvent.eventId, messageContent)) 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
))
}
} }
} }

View file

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

View file

@ -30,35 +30,33 @@ class AttachmentOverlayView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr), AttachmentEventListener { ) : ConstraintLayout(context, attrs, defStyleAttr), AttachmentEventListener {
var onShareCallback: (() -> Unit)? = null var interactionListener: AttachmentInteractionListener? = null
var onBack: (() -> Unit)? = null
var onPlayPause: ((play: Boolean) -> Unit)? = null
var videoSeekTo: ((progress: Int) -> Unit)? = null
val views: MergeImageAttachmentOverlayBinding val views: MergeImageAttachmentOverlayBinding
var isPlaying = false private var isPlaying = false
private var suspendSeekBarUpdate = false
var suspendSeekBarUpdate = false
init { init {
inflate(context, R.layout.merge_image_attachment_overlay, this) inflate(context, R.layout.merge_image_attachment_overlay, this)
views = MergeImageAttachmentOverlayBinding.bind(this) views = MergeImageAttachmentOverlayBinding.bind(this)
setBackgroundColor(Color.TRANSPARENT) setBackgroundColor(Color.TRANSPARENT)
views.overlayBackButton.setOnClickListener { views.overlayBackButton.setOnClickListener {
onBack?.invoke() interactionListener?.onDismiss()
} }
views.overlayShareButton.setOnClickListener { views.overlayShareButton.setOnClickListener {
onShareCallback?.invoke() interactionListener?.onShare()
}
views.overlayDownloadButton.setOnClickListener {
interactionListener?.onDownload()
} }
views.overlayPlayPauseButton.setOnClickListener { views.overlayPlayPauseButton.setOnClickListener {
onPlayPause?.invoke(!isPlaying) interactionListener?.onPlayPause(!isPlaying)
} }
views.overlaySeekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { views.overlaySeekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
if (fromUser) { if (fromUser) {
videoSeekTo?.invoke(progress) interactionListener?.videoSeekTo(progress)
} }
} }

View file

@ -49,14 +49,7 @@ abstract class BaseAttachmentProvider<Type>(
private val stringProvider: StringProvider private val stringProvider: StringProvider
) : AttachmentSourceProvider { ) : AttachmentSourceProvider {
interface InteractionListener { var interactionListener: AttachmentInteractionListener? = null
fun onDismissTapped()
fun onShareTapped()
fun onPlayPause(play: Boolean)
fun videoSeekTo(percent: Int)
}
var interactionListener: InteractionListener? = null
private var overlayView: AttachmentOverlayView? = null private var overlayView: AttachmentOverlayView? = null
@ -68,18 +61,7 @@ abstract class BaseAttachmentProvider<Type>(
if (position == -1) return null if (position == -1) return null
if (overlayView == null) { if (overlayView == null) {
overlayView = AttachmentOverlayView(context) overlayView = AttachmentOverlayView(context)
overlayView?.onBack = { overlayView?.interactionListener = interactionListener
interactionListener?.onDismissTapped()
}
overlayView?.onShareCallback = {
interactionListener?.onShareTapped()
}
overlayView?.onPlayPause = { play ->
interactionListener?.onPlayPause(play)
}
overlayView?.videoSeekTo = { percent ->
interactionListener?.videoSeekTo(percent)
}
} }
val timelineEvent = getTimelineEventAtPosition(position) val timelineEvent = getTimelineEventAtPosition(position)

View file

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

View file

@ -17,6 +17,7 @@ package im.vector.app.features.media
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Parcelable import android.os.Parcelable
import android.view.View import android.view.View
@ -30,16 +31,25 @@ import androidx.core.view.isInvisible
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.transition.Transition import androidx.transition.Transition
import com.airbnb.mvrx.viewModel
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.di.ActiveSessionHolder 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.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.core.utils.shareMedia
import im.vector.app.features.themes.ActivityOtherThemes import im.vector.app.features.themes.ActivityOtherThemes
import im.vector.app.features.themes.ThemeUtils import im.vector.app.features.themes.ThemeUtils
import im.vector.lib.attachmentviewer.AttachmentCommands import im.vector.lib.attachmentviewer.AttachmentCommands
import im.vector.lib.attachmentviewer.AttachmentViewerActivity import im.vector.lib.attachmentviewer.AttachmentViewerActivity
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ -47,7 +57,7 @@ import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class VectorAttachmentViewerActivity : AttachmentViewerActivity(), BaseAttachmentProvider.InteractionListener { class VectorAttachmentViewerActivity : AttachmentViewerActivity(), AttachmentInteractionListener {
@Parcelize @Parcelize
data class Args( data class Args(
@ -58,15 +68,28 @@ class VectorAttachmentViewerActivity : AttachmentViewerActivity(), BaseAttachmen
@Inject @Inject
lateinit var sessionHolder: ActiveSessionHolder lateinit var sessionHolder: ActiveSessionHolder
@Inject @Inject
lateinit var dataSourceFactory: AttachmentProviderFactory lateinit var dataSourceFactory: AttachmentProviderFactory
@Inject @Inject
lateinit var imageContentRenderer: ImageContentRenderer 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 initialIndex = 0
private var isAnimatingOut = false private var isAnimatingOut = false
private var currentSourceProvider: BaseAttachmentProvider<*>? = null 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -128,6 +151,8 @@ class VectorAttachmentViewerActivity : AttachmentViewerActivity(), BaseAttachmen
window.statusBarColor = ContextCompat.getColor(this, R.color.black_alpha) window.statusBarColor = ContextCompat.getColor(this, R.color.black_alpha)
window.navigationBarColor = ContextCompat.getColor(this, R.color.black_alpha) window.navigationBarColor = ContextCompat.getColor(this, R.color.black_alpha)
observeViewEvents()
} }
override fun onResume() { override fun onResume() {
@ -140,12 +165,6 @@ class VectorAttachmentViewerActivity : AttachmentViewerActivity(), BaseAttachmen
Timber.i("onPause Activity ${javaClass.simpleName}") Timber.i("onPause Activity ${javaClass.simpleName}")
} }
private fun getOtherThemes() = ActivityOtherThemes.VectorAttachmentsPreview
override fun shouldAnimateDismiss(): Boolean {
return currentPosition != initialIndex
}
override fun onBackPressed() { override fun onBackPressed() {
if (currentPosition == initialIndex) { if (currentPosition == initialIndex) {
// show back the transition view // show back the transition view
@ -156,6 +175,10 @@ class VectorAttachmentViewerActivity : AttachmentViewerActivity(), BaseAttachmen
super.onBackPressed() super.onBackPressed()
} }
override fun shouldAnimateDismiss(): Boolean {
return currentPosition != initialIndex
}
override fun animateClose() { override fun animateClose() {
if (currentPosition == initialIndex) { if (currentPosition == initialIndex) {
// show back the transition view // show back the transition view
@ -166,9 +189,7 @@ class VectorAttachmentViewerActivity : AttachmentViewerActivity(), BaseAttachmen
ActivityCompat.finishAfterTransition(this) ActivityCompat.finishAfterTransition(this)
} }
// ========================================================================================== private fun getOtherThemes() = ActivityOtherThemes.VectorAttachmentsPreview
// PRIVATE METHODS
// ==========================================================================================
/** /**
* Try and add a [Transition.TransitionListener] to the entering shared element * 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 { companion object {
const val EXTRA_ARGS = "EXTRA_ARGS" private const val EXTRA_ARGS = "EXTRA_ARGS"
const val EXTRA_IMAGE_DATA = "EXTRA_IMAGE_DATA" private const val EXTRA_IMAGE_DATA = "EXTRA_IMAGE_DATA"
const val EXTRA_IN_MEMORY_DATA = "EXTRA_IN_MEMORY_DATA" private const val EXTRA_IN_MEMORY_DATA = "EXTRA_IN_MEMORY_DATA"
fun newIntent(context: Context, fun newIntent(context: Context,
mediaData: AttachmentData, 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())
)
}
}
}
} }

View file

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

View file

@ -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<VectorDummyViewState, VectorAttachmentViewerAction, VectorAttachmentViewerViewEvents>(initialState) {
@AssistedFactory
interface Factory : MavericksAssistedViewModelFactory<VectorAttachmentViewerViewModel, VectorDummyViewState> {
override fun create(initialState: VectorDummyViewState): VectorAttachmentViewerViewModel
}
companion object : MavericksViewModelFactory<VectorAttachmentViewerViewModel, VectorDummyViewState> 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)) }
}
}
}

View file

@ -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<Unit> = withContext(session.coroutineDispatchers.io) {
runCatching {
saveMedia(
context = appContext,
file = input,
title = input.name,
mediaMimeType = getMimeTypeFromUri(appContext, input.toUri()),
notificationUtils = notificationUtils
)
}
}
}

View file

@ -67,6 +67,23 @@
app:layout_constraintTop_toBottomOf="@id/overlayCounterText" app:layout_constraintTop_toBottomOf="@id/overlayCounterText"
tools:text="Bill 29 Jun at 19:42" /> tools:text="Bill 29 Jun at 19:42" />
<ImageView
android:id="@+id/overlayDownloadButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:contentDescription="@string/action_download"
android:focusable="true"
android:padding="6dp"
app:layout_constraintBottom_toBottomOf="@id/overlayTopBackground"
app:layout_constraintEnd_toStartOf="@id/overlayShareButton"
app:layout_constraintTop_toTopOf="@id/overlayTopBackground"
app:srcCompat="@drawable/ic_material_save"
app:tint="?colorOnPrimary"
tools:ignore="MissingPrefix" />
<ImageView <ImageView
android:id="@+id/overlayShareButton" android:id="@+id/overlayShareButton"
android:layout_width="wrap_content" android:layout_width="wrap_content"

View file

@ -0,0 +1,135 @@
/*
* 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 android.net.Uri
import androidx.core.net.toUri
import com.airbnb.mvrx.test.MvRxTestRule
import im.vector.app.core.intent.getMimeTypeFromUri
import im.vector.app.core.utils.saveMedia
import im.vector.app.features.notifications.NotificationUtils
import im.vector.app.test.fakes.FakeFile
import im.vector.app.test.fakes.FakeSession
import io.mockk.MockKAnnotations
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.impl.annotations.OverrideMockKs
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.runs
import io.mockk.unmockkStatic
import io.mockk.verify
import io.mockk.verifyAll
import kotlinx.coroutines.test.runBlockingTest
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
class DownloadMediaUseCaseTest {
@get:Rule
val mvRxTestRule = MvRxTestRule()
@MockK
lateinit var appContext: Context
private val session = FakeSession()
@MockK
lateinit var notificationUtils: NotificationUtils
private val file = FakeFile()
@OverrideMockKs
lateinit var downloadMediaUseCase: DownloadMediaUseCase
@Before
fun setUp() {
MockKAnnotations.init(this)
mockkStatic("im.vector.app.core.utils.ExternalApplicationsUtilKt")
mockkStatic("im.vector.app.core.intent.VectorMimeTypeKt")
}
@After
fun tearDown() {
unmockkStatic("im.vector.app.core.utils.ExternalApplicationsUtilKt")
unmockkStatic("im.vector.app.core.intent.VectorMimeTypeKt")
file.tearDown()
}
@Test
fun `given a file when calling execute then save the file in local with success`() = runBlockingTest {
// Given
val uri = mockk<Uri>()
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<Uri>()
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)
}
}
}

View file

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