Display proper media toolbar buttons depending on EditorState.

This commit is contained in:
Alex Hart
2026-08-03 15:50:17 -03:00
parent c70443cc75
commit 3b3b697c8e
6 changed files with 307 additions and 22 deletions
@@ -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
)
}
}
@@ -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)
}
@@ -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 = {}
)
}
@@ -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"
@@ -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
}
)
}
}
@@ -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
)
}
}
}