Move "Open with" action to bottom sheet

This commit is contained in:
Benoit Marty 2026-04-21 17:22:22 +02:00
parent a0632b216c
commit 83b4bfad96
7 changed files with 70 additions and 41 deletions

View file

@ -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 = {},
)

View file

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

View file

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

View file

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

View file

@ -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,

View file

@ -116,6 +116,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.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 <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMedia
onShare = onShare,
onForward = onForward,
onDownload = onDownload,
onOpenWith = onOpenWith,
onDelete = onDelete,
onDismiss = onDismiss,
)

View file

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