From 83b4bfad96039923cae6caea86499778ad192766 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Tue, 21 Apr 2026 17:22:22 +0200 Subject: [PATCH] Move "Open with" action to bottom sheet --- .../impl/details/MediaDetailsBottomSheet.kt | 23 +++++++++++ .../impl/gallery/MediaGalleryEvents.kt | 1 + .../impl/gallery/MediaGalleryPresenter.kt | 17 +++++++++ .../impl/gallery/MediaGalleryView.kt | 3 ++ .../impl/viewer/MediaViewerView.kt | 27 +++---------- .../details/MediaDetailsBottomSheetTest.kt | 2 + .../impl/viewer/MediaViewerViewTest.kt | 38 +++++++++---------- 7 files changed, 70 insertions(+), 41 deletions(-) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDetailsBottomSheet.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDetailsBottomSheet.kt index 0e1e3a04c0..fc377e5cae 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDetailsBottomSheet.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDetailsBottomSheet.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.designsystem.colors.AvatarColorsProvider import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.AvatarData @@ -57,6 +58,7 @@ fun MediaDetailsBottomSheet( onShare: (EventId) -> Unit, onForward: (EventId) -> Unit, onDownload: (EventId) -> Unit, + onOpenWith: (EventId) -> Unit, onDelete: (EventId) -> Unit, onDismiss: () -> Unit, modifier: Modifier = Modifier, @@ -128,6 +130,26 @@ fun MediaDetailsBottomSheet( onDownload(state.eventId) } ) + val mimeType = state.mediaInfo.mimeType + val icon = when (mimeType) { + MimeTypes.Apk -> + ListItemContent.Icon(IconSource.Resource(R.drawable.ic_apk_install)) + else -> + ListItemContent.Icon(IconSource.Vector(CompoundIcons.PopOut())) + } + val wording = when (mimeType) { + MimeTypes.Apk -> stringResource(id = CommonStrings.common_install_apk_android) + else -> stringResource(id = CommonStrings.action_open_with) + } + HorizontalDivider() + ListItem( + leadingContent = icon, + headlineContent = { Text(wording) }, + style = ListItemStyle.Primary, + onClick = { + onOpenWith(state.eventId) + } + ) if (state.canDelete) { HorizontalDivider() ListItem( @@ -236,6 +258,7 @@ internal fun MediaDetailsBottomSheetPreview() = ElementPreview { onShare = {}, onForward = {}, onDownload = {}, + onOpenWith = {}, onDelete = {}, onDismiss = {}, ) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryEvents.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryEvents.kt index 2bf4f6b37d..767ca04c31 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryEvents.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryEvents.kt @@ -20,6 +20,7 @@ sealed interface MediaGalleryEvents { data class Share(val eventId: EventId) : MediaGalleryEvents data class Forward(val eventId: EventId) : MediaGalleryEvents data class SaveOnDisk(val eventId: EventId) : MediaGalleryEvents + data class OpenWith(val eventId: EventId) : MediaGalleryEvents data class OpenInfo(val mediaItem: MediaItem.Event) : MediaGalleryEvents data class ViewInTimeline(val eventId: EventId) : MediaGalleryEvents diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt index cc26e69c33..2e669fefb2 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt @@ -105,6 +105,12 @@ class MediaGalleryPresenter( saveOnDisk(it) } } + is MediaGalleryEvents.OpenWith -> coroutineScope.launch { + mediaBottomSheetState = MediaBottomSheetState.Hidden + groupedMediaItems.dataOrNull().find(event.eventId)?.let { + openWith(it) + } + } is MediaGalleryEvents.Share -> coroutineScope.launch { mediaBottomSheetState = MediaBottomSheetState.Hidden groupedMediaItems.dataOrNull().find(event.eventId)?.let { @@ -200,6 +206,17 @@ class MediaGalleryPresenter( } } + private suspend fun openWith(mediaItem: MediaItem.Event) { + downloadMedia(mediaItem) + .mapCatchingExceptions { localMedia -> + localMediaActions.open(localMedia) + } + .onFailure { + val snackbarMessage = SnackbarMessage(mediaActionsError(it)) + snackbarDispatcher.post(snackbarMessage) + } + } + private fun mediaActionsError(throwable: Throwable): Int { return if (throwable is ActivityNotFoundException) { R.string.error_no_compatible_app_found diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt index 6f7a201fdc..fe22376dee 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryView.kt @@ -173,6 +173,9 @@ fun MediaGalleryView( onDownload = { eventId -> state.eventSink(MediaGalleryEvents.SaveOnDisk(eventId)) }, + onOpenWith = { eventId -> + state.eventSink(MediaGalleryEvents.OpenWith(eventId)) + }, onDelete = { eventId -> state.eventSink( MediaGalleryEvents.ConfirmDelete( diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt index 6b62a04d0f..5119198ff4 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerView.kt @@ -65,7 +65,6 @@ import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.viewfolder.api.TextFileViewer import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.audio.api.AudioFocus -import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo import io.element.android.libraries.designsystem.components.async.AsyncFailure import io.element.android.libraries.designsystem.components.async.AsyncLoading @@ -85,7 +84,6 @@ import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.ui.media.MediaRequestData import io.element.android.libraries.mediaviewer.api.MediaInfo import io.element.android.libraries.mediaviewer.api.local.LocalMedia -import io.element.android.libraries.mediaviewer.impl.R import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState import io.element.android.libraries.mediaviewer.impl.details.MediaDeleteConfirmationBottomSheet import io.element.android.libraries.mediaviewer.impl.details.MediaDetailsBottomSheet @@ -226,7 +224,6 @@ fun MediaViewerView( onInfoClick = { state.eventSink(MediaViewerEvent.OpenInfo(currentData)) }, - eventSink = state.eventSink ) } else -> { @@ -275,6 +272,11 @@ fun MediaViewerView( state.eventSink(MediaViewerEvent.SaveOnDisk(currentData)) } }, + onOpenWith = { + (currentData as? MediaViewerPageData.MediaViewerData)?.let { + state.eventSink(MediaViewerEvent.OpenWith(currentData)) + } + }, onDelete = { eventId -> (currentData as? MediaViewerPageData.MediaViewerData)?.let { state.eventSink( @@ -469,11 +471,9 @@ private fun MediaViewerTopBar( onShareClick: () -> Unit, onSaveClick: () -> Unit, onInfoClick: () -> Unit, - eventSink: (MediaViewerEvent) -> Unit, ) { val downloadedMedia by data.downloadedMedia val actionsEnabled = downloadedMedia.isSuccess() - val mimeType = data.mediaInfo.mimeType val senderName = data.mediaInfo.senderName val dateSent = data.mediaInfo.dateSent TopAppBar( @@ -508,23 +508,6 @@ private fun MediaViewerTopBar( ), navigationIcon = { BackButton(onClick = onBackClick) }, actions = { - IconButton( - enabled = actionsEnabled, - onClick = { - eventSink(MediaViewerEvent.OpenWith(data)) - }, - ) { - when (mimeType) { - MimeTypes.Apk -> Icon( - resourceId = R.drawable.ic_apk_install, - contentDescription = stringResource(id = CommonStrings.common_install_apk_android) - ) - else -> Icon( - imageVector = CompoundIcons.PopOut(), - contentDescription = stringResource(id = CommonStrings.action_open_with) - ) - } - } IconButton( onClick = onShareClick, enabled = actionsEnabled, diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDetailsBottomSheetTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDetailsBottomSheetTest.kt index 74bc1bd648..b6b8b68466 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDetailsBottomSheetTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/details/MediaDetailsBottomSheetTest.kt @@ -116,6 +116,7 @@ private fun AndroidComposeTestRule.setMedia onShare: (EventId) -> Unit = EnsureNeverCalledWithParam(), onForward: (EventId) -> Unit = EnsureNeverCalledWithParam(), onDownload: (EventId) -> Unit = EnsureNeverCalledWithParam(), + onOpenWith: (EventId) -> Unit = EnsureNeverCalledWithParam(), onDelete: (EventId) -> Unit = EnsureNeverCalledWithParam(), onDismiss: () -> Unit = EnsureNeverCalled(), ) { @@ -126,6 +127,7 @@ private fun AndroidComposeTestRule.setMedia onShare = onShare, onForward = onForward, onDownload = onDownload, + onOpenWith = onOpenWith, onDelete = onDelete, onDismiss = onDismiss, ) diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerViewTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerViewTest.kt index d41841a9f0..e5eb07b871 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerViewTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerViewTest.kt @@ -10,6 +10,7 @@ package io.element.android.libraries.mediaviewer.impl.viewer import android.net.Uri import androidx.activity.ComponentActivity +import androidx.annotation.StringRes import androidx.compose.ui.test.assertHasClickAction import androidx.compose.ui.test.junit4.AndroidComposeTestRule import androidx.compose.ui.test.junit4.createAndroidComposeRule @@ -63,19 +64,7 @@ class MediaViewerViewTest { } @Test - fun `clicking on open emit expected Event`() { - val data = aMediaViewerPageData( - downloadedMedia = AsyncData.Success(aLocalMedia(uri = mockMediaUrl)), - ) - testMenuAction( - data, - CommonStrings.action_open_with, - MediaViewerEvent.OpenWith(data), - ) - } - - @Test - fun `clicking on info emit expected Event`() { + fun `clicking on info emits expected Event`() { val data = aMediaViewerPageData( downloadedMedia = AsyncData.Success(aLocalMedia(uri = mockMediaUrl)), ) @@ -112,7 +101,7 @@ class MediaViewerViewTest { private fun testMenuAction( data: MediaViewerPageData.MediaViewerData, - contentDescriptionRes: Int, + @StringRes contentDescriptionRes: Int, expectedEvent: MediaViewerEvent, ) { val eventsRecorder = EventsRecorder() @@ -135,7 +124,7 @@ class MediaViewerViewTest { @Test @Config(qualifiers = "h1024dp") - fun `clicking on download emit expected Event`() { + fun `clicking on download emits expected Event`() { val data = aMediaViewerPageData() testBottomSheetAction( data, @@ -146,7 +135,7 @@ class MediaViewerViewTest { @Test @Config(qualifiers = "h1024dp") - fun `clicking on share emit expected Event`() { + fun `clicking on share emits expected Event`() { val data = aMediaViewerPageData() testBottomSheetAction( data, @@ -155,9 +144,20 @@ class MediaViewerViewTest { ) } + @Config(qualifiers = "h1024dp") + @Test + fun `clicking on open in emits expected Event`() { + val data = aMediaViewerPageData() + testBottomSheetAction( + data, + CommonStrings.action_open_with, + MediaViewerEvent.OpenWith(data), + ) + } + private fun testBottomSheetAction( data: MediaViewerPageData.MediaViewerData, - contentDescriptionRes: Int, + @StringRes textRes: Int, expectedEvent: MediaViewerEvent, ) { val eventsRecorder = EventsRecorder() @@ -168,7 +168,7 @@ class MediaViewerViewTest { eventSink = eventsRecorder ), ) - rule.clickOn(contentDescriptionRes) + rule.clickOn(textRes) eventsRecorder.assertList( listOf( MediaViewerEvent.OnNavigateTo(0), @@ -188,7 +188,7 @@ class MediaViewerViewTest { state = state, ) // Ensure that the action are visible - val contentDescription = rule.activity.getString(CommonStrings.action_open_with) + val contentDescription = rule.activity.getString(CommonStrings.action_share) rule.onNodeWithContentDescription(contentDescription) .assertExists() .assertHasClickAction()