mirror of
https://github.com/element-hq/element-android
synced 2024-11-28 04:52:00 +03:00
Merge pull request #5349 from vector-im/feature/mna/5005-save-image
#5005: Add save media icon in gallery
This commit is contained in:
commit
80bc3af5fa
15 changed files with 524 additions and 96 deletions
1
changelog.d/5005.feature
Normal file
1
changelog.d/5005.feature
Normal file
|
@ -0,0 +1 @@
|
|||
Add possibility to save media from Gallery + reorder choices in message context menu
|
|
@ -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
|
||||
|
@ -301,7 +303,8 @@ abstract class AttachmentViewerActivity : AppCompatActivity(), AttachmentEventLi
|
|||
swipeView = views.dismissContainer,
|
||||
shouldAnimateDismiss = { shouldAnimateDismiss() },
|
||||
onDismiss = { animateClose() },
|
||||
onSwipeViewMove = ::handleSwipeViewMove)
|
||||
onSwipeViewMove = ::handleSwipeViewMove
|
||||
)
|
||||
|
||||
private fun createSwipeDirectionDetector() =
|
||||
SwipeDirectionDetector(this) { swipeDirection = it }
|
||||
|
|
|
@ -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<*, *>
|
||||
}
|
||||
|
|
|
@ -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
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -49,14 +49,7 @@ abstract class BaseAttachmentProvider<Type>(
|
|||
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<Type>(
|
|||
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)
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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())
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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)) }
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -67,6 +67,23 @@
|
|||
app:layout_constraintTop_toBottomOf="@id/overlayCounterText"
|
||||
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
|
||||
android:id="@+id/overlayShareButton"
|
||||
android:layout_width="wrap_content"
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
49
vector/src/test/java/im/vector/app/test/fakes/FakeFile.kt
Normal file
49
vector/src/test/java/im/vector/app/test/fakes/FakeFile.kt
Normal 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
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue