mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-08-05 12:55:11 +01:00
Display proper media toolbar buttons depending on EditorState.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
+6
-2
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
+199
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user