diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt index f46da8c627..231b7c909a 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt @@ -259,7 +259,7 @@ internal fun MediaEditScreen( ) } - if (state.selectedMedia.size > 1) { + if (state.selectedMedia.size > 1 && isAddMediaVisible(state, focusedEditorState)) { MediaEditControl(visible = !isImageEditing, faded = isDragging) { ThumbnailRow( selectedMedia = state.selectedMedia, @@ -422,6 +422,11 @@ private fun MediaToolbar( return } + // An empty toolbar would still claim its slot in the surrounding stack, so bail before the control is composed. + if (focusedEditorState !is EditorState.Image && !hasSharedToolbarButtons(state, focusedEditorState)) { + return + } + MediaEditControl(faded = isDragging, modifier = modifier) { when (focusedEditorState) { is EditorState.Image -> { @@ -429,6 +434,7 @@ private fun MediaToolbar( ImageEditorToolbar( imageEditorController = it, state = state, + editorState = focusedEditorState, onEvent = onEvent, modifier = Modifier .navigationBarsPadding() @@ -441,8 +447,8 @@ private fun MediaToolbar( else -> MediaEditorToolbar { MediaEditorToolbarSharedButtons( state = state, - onEvent = onEvent, - canSave = focusedEditorState is EditorState.VideoTrim + editorState = focusedEditorState, + onEvent = onEvent ) } } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditorToolbar.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditorToolbar.kt index c504260282..b35ce6d9de 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditorToolbar.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditorToolbar.kt @@ -19,14 +19,17 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import org.signal.core.ui.WindowBreakpoint import org.signal.core.ui.compose.IconButtons import org.signal.core.ui.compose.SignalIcons import org.signal.core.ui.compose.copied.androidx.compose.material3.IconButtonColors import org.signal.core.ui.rememberWindowBreakpoint +import org.signal.mediasend.EditorState import org.signal.mediasend.MediaSendState import org.signal.mediasend.SentMediaQuality +import org.signal.mediasend.test.TestTags @Composable internal fun MediaEditorToolbar( @@ -73,12 +76,14 @@ internal fun MediaEditorToolbar( internal fun MediaEditorToolbarButton( imageVector: ImageVector, onClick: () -> Unit, + modifier: Modifier = Modifier, contentDescription: String? = null, colors: IconButtonColors = IconButtons.iconButtonColors() ) { IconButtons.IconButton( onClick = onClick, - colors = colors + colors = colors, + modifier = modifier ) { Icon(imageVector = imageVector, contentDescription = contentDescription, modifier = Modifier.size(24.dp)) } @@ -87,30 +92,58 @@ internal fun MediaEditorToolbarButton( @Composable internal fun MediaEditorToolbarSharedButtons( state: MediaSendState, - canSave: Boolean, + editorState: EditorState, onEvent: (MediaEditScreenEvent) -> Unit ) { - MediaEditorToolbarButton( - imageVector = if (state.sentMediaQuality == SentMediaQuality.HIGH) { - SignalIcons.QualityHigh.imageVector - } else { - SignalIcons.QualityHighSlash.imageVector - }, - onClick = { onEvent(MediaEditScreenEvent.ToggleMediaQuality) } - ) - - if (canSave) { + if (isQualityVisible(state, editorState)) { MediaEditorToolbarButton( - imageVector = SignalIcons.Save.imageVector, - onClick = { onEvent(MediaEditScreenEvent.SaveMedia) } + imageVector = if (state.sentMediaQuality == SentMediaQuality.HIGH) { + SignalIcons.QualityHigh.imageVector + } else { + SignalIcons.QualityHighSlash.imageVector + }, + onClick = { onEvent(MediaEditScreenEvent.ToggleMediaQuality) }, + modifier = Modifier.testTag(TestTags.MEDIA_EDITOR_TOOLBAR_QUALITY_BUTTON) ) } - // Adding a second attachment would silently drop view-once, so the entry point goes away while it is on. - if (!state.isViewOnceEnabled) { + if (isSaveVisible(editorState)) { + MediaEditorToolbarButton( + imageVector = SignalIcons.Save.imageVector, + onClick = { onEvent(MediaEditScreenEvent.SaveMedia) }, + modifier = Modifier.testTag(TestTags.MEDIA_EDITOR_TOOLBAR_SAVE_BUTTON) + ) + } + + if (isAddMediaVisible(state, editorState)) { MediaEditorToolbarButton( imageVector = SignalIcons.Plus.imageVector, // TODO [alex] - wrong art asset - onClick = { onEvent(MediaEditScreenEvent.NavigateToGallery) } + onClick = { onEvent(MediaEditScreenEvent.NavigateToGallery) }, + modifier = Modifier.testTag(TestTags.MEDIA_EDITOR_TOOLBAR_ADD_MEDIA_BUTTON) ) } } + +private fun isQualityVisible(state: MediaSendState, editorState: EditorState): Boolean { + return !state.isStory && editorState !is EditorState.Document +} + +private fun isSaveVisible(editorState: EditorState): Boolean { + return editorState is EditorState.Image || editorState is EditorState.Gif +} + +/** + * Adding a second attachment would silently drop view-once, so the entry point -- and the selection rail it belongs to + * -- goes away while it is on. + */ +internal fun isAddMediaVisible(state: MediaSendState, editorState: EditorState?): Boolean { + return !state.isViewOnceEnabled && editorState !is EditorState.Document +} + +/** + * Whether [MediaEditorToolbarSharedButtons] would render anything, so callers with no buttons of their own can skip the + * toolbar rather than leave an empty one behind. + */ +internal fun hasSharedToolbarButtons(state: MediaSendState, editorState: EditorState): Boolean { + return isQualityVisible(state, editorState) || isSaveVisible(editorState) || isAddMediaVisible(state, editorState) +} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorToolbar.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorToolbar.kt index 13f4325536..d7b6e894db 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorToolbar.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/image/ImageEditorToolbar.kt @@ -25,6 +25,7 @@ import org.signal.core.ui.compose.SignalIcons import org.signal.core.ui.compose.theme.SignalTheme import org.signal.core.util.next import org.signal.imageeditor.core.model.EditorModel +import org.signal.mediasend.EditorState import org.signal.mediasend.MediaSendState import org.signal.mediasend.edit.ImageController import org.signal.mediasend.edit.MediaEditScreenDialogs @@ -39,6 +40,7 @@ import java.util.EnumMap internal fun ImageEditorToolbar( imageEditorController: ImageController, state: MediaSendState, + editorState: EditorState.Image, onEvent: (MediaEditScreenEvent) -> Unit, modifier: Modifier = Modifier ) { @@ -50,7 +52,7 @@ internal fun ImageEditorToolbar( ) } imageEditorController.mode == ImageController.Mode.NONE -> { - ImageEditorNoneStateToolbar(imageEditorController, state, onEvent, modifier) + ImageEditorNoneStateToolbar(imageEditorController, state, editorState, onEvent, modifier) } imageEditorController.mode == ImageController.Mode.CROP -> { ImageEditorCropAndResizeToolbar(imageEditorController, modifier) @@ -68,6 +70,7 @@ internal fun ImageEditorToolbar( private fun ImageEditorNoneStateToolbar( imageEditorController: ImageController, state: MediaSendState, + editorState: EditorState.Image, onEvent: (MediaEditScreenEvent) -> Unit, modifier: Modifier = Modifier ) { @@ -84,7 +87,7 @@ private fun ImageEditorNoneStateToolbar( MediaEditorToolbarSharedButtons( state = state, - canSave = true, + editorState = editorState, onEvent = onEvent ) } @@ -274,6 +277,7 @@ private fun ImageEditorNoneStateToolbarPreview() { ImageController(EditorModel.create(0)) }, state = rememberPreviewState(), + editorState = remember { EditorState.Image(EditorModel.create(0)) }, onEvent = {} ) } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/test/TestTags.kt b/feature/media-send/src/main/java/org/signal/mediasend/test/TestTags.kt index 20915f0094..b4bcb07bbe 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/test/TestTags.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/test/TestTags.kt @@ -13,6 +13,11 @@ object TestTags { // Add A Message Row const val ADD_A_MESSAGE_NEXT_BUTTON = "add_a_message_next_button" + // Media Editor Toolbar + const val MEDIA_EDITOR_TOOLBAR_QUALITY_BUTTON = "media_editor_toolbar_quality_button" + const val MEDIA_EDITOR_TOOLBAR_SAVE_BUTTON = "media_editor_toolbar_save_button" + const val MEDIA_EDITOR_TOOLBAR_ADD_MEDIA_BUTTON = "media_editor_toolbar_add_media_button" + // Schedule Send Menu const val SCHEDULE_SEND_PICK_TIME_OPTION = "schedule_send_pick_time_option" diff --git a/feature/media-send/src/test/java/org/signal/mediasend/MediaSendDependenciesRule.kt b/feature/media-send/src/test/java/org/signal/mediasend/MediaSendDependenciesRule.kt new file mode 100644 index 0000000000..abe32e2146 --- /dev/null +++ b/feature/media-send/src/test/java/org/signal/mediasend/MediaSendDependenciesRule.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.mediasend + +import android.app.Application +import io.mockk.every +import io.mockk.mockk +import org.junit.rules.ExternalResource +import org.signal.camera.CameraDependencies +import org.signal.mediasend.edit.image.BrushWidths +import kotlin.time.Duration.Companion.seconds + +/** + * Stands up the module dependency graph that [MediaSendState] reaches into for its own defaults, so tests can build a + * state without an app around them. + */ +class MediaSendDependenciesRule(private val application: Application) : ExternalResource() { + + val mediaSendRepository: MediaSendRepository = mockk(relaxed = true) { + every { sentMediaQuality } returns SentMediaQuality.STANDARD + every { getMediaConstraints() } returns PreviewMediaConstraints + every { storyMaxVideoDuration } returns 30.seconds + every { brushWidths } returns BrushWidths() + } + + override fun before() { + CameraDependencies.init(application, mockk(relaxed = true)) + MediaSendDependencies.init( + application, + mockk(relaxed = true) { + every { provideMediaSendRepository() } returns mediaSendRepository + } + ) + } +} diff --git a/feature/media-send/src/test/java/org/signal/mediasend/edit/MediaEditorToolbarSharedButtonsTest.kt b/feature/media-send/src/test/java/org/signal/mediasend/edit/MediaEditorToolbarSharedButtonsTest.kt new file mode 100644 index 0000000000..a938fde6b9 --- /dev/null +++ b/feature/media-send/src/test/java/org/signal/mediasend/edit/MediaEditorToolbarSharedButtonsTest.kt @@ -0,0 +1,199 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.mediasend.edit + +import android.app.Application +import android.net.Uri +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.test.core.app.ApplicationProvider +import io.mockk.mockk +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.signal.core.models.media.Media +import org.signal.core.ui.CoreUiDependenciesRule +import org.signal.core.ui.compose.theme.SignalTheme +import org.signal.core.util.ContentTypeUtil +import org.signal.mediasend.EditorState +import org.signal.mediasend.MediaSendDependenciesRule +import org.signal.mediasend.MediaSendState +import org.signal.mediasend.test.TestTags + +/** + * Covers which of the shared toolbar actions each kind of media gets, which is the parity contract with the v2 review + * screen: send quality is meaningless for stories and documents, saving is for stills only, and adding media is off the + * table for documents and view-once sends. + */ +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class) +class MediaEditorToolbarSharedButtonsTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @get:Rule + val coreUiDependenciesRule = CoreUiDependenciesRule(ApplicationProvider.getApplicationContext()) + + @get:Rule + val mediaSendDependenciesRule = MediaSendDependenciesRule(ApplicationProvider.getApplicationContext()) + + @Test + fun `Given an image, when rendering the toolbar, then quality, save and add media are offered`() { + setContent(state(media = IMAGE), IMAGE_EDITOR_STATE) + + assertQuality(visible = true) + assertSave(visible = true) + assertAddMedia(visible = true) + } + + @Test + fun `Given a gif, when rendering the toolbar, then quality, save and add media are offered`() { + setContent(state(media = GIF), EditorState.Gif) + + assertQuality(visible = true) + assertSave(visible = true) + assertAddMedia(visible = true) + } + + @Test + fun `Given a video, when rendering the toolbar, then saving is not offered`() { + setContent(state(media = VIDEO), EditorState.VideoTrim.forVideo(durationUs = 1_000, maxDurationUs = 1_000)) + + assertQuality(visible = true) + assertSave(visible = false) + assertAddMedia(visible = true) + } + + @Test + fun `Given a video gif, when rendering the toolbar, then saving is not offered`() { + setContent(state(media = VIDEO_GIF), EditorState.VideoGif) + + assertQuality(visible = true) + assertSave(visible = false) + assertAddMedia(visible = true) + } + + @Test + fun `Given a document, when rendering the toolbar, then nothing is offered`() { + setContent(state(media = DOCUMENT), DOCUMENT_EDITOR_STATE) + + assertQuality(visible = false) + assertSave(visible = false) + assertAddMedia(visible = false) + } + + @Test + fun `Given a story, when rendering the toolbar, then quality is not offered`() { + setContent(state(media = IMAGE, isStory = true), IMAGE_EDITOR_STATE) + + assertQuality(visible = false) + assertSave(visible = true) + assertAddMedia(visible = true) + } + + @Test + fun `Given view once is on, when rendering the toolbar, then adding media is not offered`() { + setContent(state(media = IMAGE, viewOnce = true), IMAGE_EDITOR_STATE) + + assertQuality(visible = true) + assertSave(visible = true) + assertAddMedia(visible = false) + } + + /** + * The selection rail is the multi-item form of the add media button, so it follows the same rule. + */ + @Test + fun `Given a document or a view once send, then the selection rail is hidden`() { + assertFalse(isAddMediaVisible(state(media = DOCUMENT), DOCUMENT_EDITOR_STATE)) + assertFalse(isAddMediaVisible(state(media = IMAGE, viewOnce = true), IMAGE_EDITOR_STATE)) + assertTrue(isAddMediaVisible(state(media = IMAGE), IMAGE_EDITOR_STATE)) + } + + /** + * An empty toolbar still claims its slot on the edit screen, so the document page has to be able to tell that nothing + * would render. + */ + @Test + fun `Given a document, then the toolbar reports that it has nothing to show`() { + assertFalse(hasSharedToolbarButtons(state(media = DOCUMENT), DOCUMENT_EDITOR_STATE)) + assertTrue(hasSharedToolbarButtons(state(media = VIDEO, isStory = true), EditorState.VideoGif)) + } + + private fun assertQuality(visible: Boolean) = assertTag(TestTags.MEDIA_EDITOR_TOOLBAR_QUALITY_BUTTON, visible) + + private fun assertSave(visible: Boolean) = assertTag(TestTags.MEDIA_EDITOR_TOOLBAR_SAVE_BUTTON, visible) + + private fun assertAddMedia(visible: Boolean) = assertTag(TestTags.MEDIA_EDITOR_TOOLBAR_ADD_MEDIA_BUTTON, visible) + + private fun assertTag(tag: String, visible: Boolean) { + val node = composeTestRule.onNodeWithTag(tag) + if (visible) { + node.assertIsDisplayed() + } else { + node.assertDoesNotExist() + } + } + + private fun setContent(state: MediaSendState, editorState: EditorState) { + composeTestRule.setContent { + SignalTheme { + MediaEditorToolbar { + MediaEditorToolbarSharedButtons( + state = state, + editorState = editorState, + onEvent = {} + ) + } + } + } + } + + private fun state(media: Media, isStory: Boolean = false, viewOnce: Boolean = false): MediaSendState { + return MediaSendState( + selectedMedia = listOf(media), + focusedMedia = media, + isStory = isStory, + viewOnceToggleState = if (viewOnce) MediaSendState.ViewOnceToggleState.ONCE else MediaSendState.ViewOnceToggleState.OFF + ) + } + + companion object { + /** The editor model is never read by the toolbar, and a real one cannot be built under Robolectric's legacy graphics. */ + private val IMAGE_EDITOR_STATE = EditorState.Image(mockk(relaxed = true)) + private val DOCUMENT_EDITOR_STATE = EditorState.Document(fileName = "report.pdf", fileSize = 1, extension = "pdf") + + private val IMAGE = media(contentType = ContentTypeUtil.IMAGE_JPEG) + private val GIF = media(contentType = ContentTypeUtil.IMAGE_GIF) + private val VIDEO = media(contentType = ContentTypeUtil.VIDEO_MP4) + private val VIDEO_GIF = media(contentType = ContentTypeUtil.VIDEO_MP4, isVideoGif = true) + private val DOCUMENT = media(contentType = "application/pdf") + + private fun media(contentType: String, isVideoGif: Boolean = false): Media { + return Media( + uri = Uri.parse("content://media/$contentType"), + contentType = contentType, + date = 0, + width = 0, + height = 0, + size = 1, + duration = 0, + isBorderless = false, + isVideoGif = isVideoGif, + bucketId = Media.ALL_MEDIA_BUCKET_ID, + caption = null, + transformProperties = null, + fileName = null + ) + } + } +}