Add scheduled send support to media-send module.

This commit is contained in:
Alex Hart
2026-07-31 16:12:40 -03:00
parent e597ee54c8
commit ecb7e12c67
18 changed files with 726 additions and 28 deletions
@@ -30,11 +30,15 @@ import org.signal.mediasend.MediaSendRecipient
import org.signal.mediasend.MediaSendScreen
import org.signal.mediasend.MediaSendViewModel
import org.signal.mediasend.edit.LocalAddAMessageRowTextField
import org.signal.mediasend.edit.LocalScheduledSendTimeFormatter
import org.thoughtcrime.securesms.PassphraseRequiredActivity
import org.thoughtcrime.securesms.components.emoji.EmojiEventListener
import org.thoughtcrime.securesms.components.emoji.EmojiTextView
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.conversation.ReenableScheduledMessagesDialogFragment
import org.thoughtcrime.securesms.conversation.ScheduleMessageDialogCallback
import org.thoughtcrime.securesms.conversation.ScheduleMessageTimePickerBottomSheet
import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardEvent
import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardEventViewModel
import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardPageFragment
@@ -50,6 +54,7 @@ import org.thoughtcrime.securesms.registration.olddevice.QuickTransferOldDeviceA
import org.thoughtcrime.securesms.safety.SafetyNumberBottomSheet
import org.thoughtcrime.securesms.scribbles.StickerSelectActivityContract
import org.thoughtcrime.securesms.util.CommunicationActions
import org.thoughtcrime.securesms.util.DateUtils
/**
* Encapsulates the media send flow for v3.
@@ -60,7 +65,9 @@ class MediaSendV3Activity :
TextStoryPostCreationFragment.Callback,
EmojiKeyboardPageFragment.Callback,
EmojiEventListener,
EmojiSearchFragment.Callback {
EmojiSearchFragment.Callback,
ScheduleMessageTimePickerBottomSheet.ScheduleCallback,
ScheduleMessageDialogCallback {
private val contractArgs: MediaSendActivityContract.Args by lazy { MediaSendActivityContract.Args.fromIntent(intent) }
@@ -113,6 +120,9 @@ class MediaSendV3Activity :
modifier = modifier
)
},
LocalScheduledSendTimeFormatter provides { time ->
DateUtils.getScheduledMessageDateString(context, time)
},
LocalDisplayNameProvider provides { id ->
rememberRecipientField(RecipientId.from(id)) {
if (isUnknown) {
@@ -151,6 +161,16 @@ class MediaSendV3Activity :
is HudCommand.SelectSticker -> stickerLauncher.launch(Unit)
is HudCommand.PickScheduledSendTime -> {
ScheduleMessageTimePickerBottomSheet.showSchedule(supportFragmentManager)
}
is HudCommand.ConfirmScheduledSend -> {
if (!ReenableScheduledMessagesDialogFragment.showIfNeeded(this, supportFragmentManager, null, it.scheduledTime)) {
viewModel.onScheduledSendConfirmed(it.scheduledTime)
}
}
is HudCommand.GoToConversation -> {
lifecycleScope.launch(Dispatchers.Default) {
val recipient = Recipient.resolved(RecipientId.from(it.recipientId.id))
@@ -224,6 +244,14 @@ class MediaSendV3Activity :
addMessageCommandViewModel.onEvent(EmojiKeyboardEvent.EmojiKeyEvent(keyEvent))
}
override fun onScheduleSend(scheduledTime: Long) {
viewModel.onScheduledSendTimeSelected(scheduledTime)
}
override fun onSchedulePermissionsGranted(metricId: String?, scheduledDate: Long) {
viewModel.onScheduledSendConfirmed(scheduledDate)
}
private fun finishWithResult(payload: Parcelable) {
setResult(RESULT_OK, Intent().putExtra(MediaSendActivityResult.EXTRA_RESULT, payload))
finish()
@@ -6,7 +6,7 @@
package org.signal.core.ui.compose
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
@@ -73,6 +73,8 @@ object IconButtons {
enabled: Boolean = true,
colors: IconButtonColors = iconButtonColors(),
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
onLongClick: (() -> Unit)? = null,
onLongClickLabel: String? = null,
content: @Composable () -> Unit
) {
Box(
@@ -81,8 +83,10 @@ object IconButtons {
.size(size)
.clip(shape)
.background(color = colors.containerColor(enabled).value)
.clickable(
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick,
onLongClickLabel = onLongClickLabel,
enabled = enabled,
role = Role.Button,
interactionSource = interactionSource,
@@ -38,6 +38,7 @@ enum class SignalIcons(private val icon: SignalIcon) : SignalIcon by icon {
Blur(icon(R.drawable.symbol_blur_24)),
BrushPen(icon(R.drawable.symbol_brush_pen_24)),
BrushHighlighter(icon(R.drawable.symbol_brush_highlighter_24)),
Calendar(icon(R.drawable.symbol_calendar_24)),
Camera(icon(R.drawable.symbol_camera_24)),
CameraSwitch(icon(R.drawable.symbol_switch_24)),
Check(icon(R.drawable.symbol_check_24)),
@@ -47,6 +48,7 @@ enum class SignalIcons(private val icon: SignalIcon) : SignalIcon by icon {
CropLock(icon(R.drawable.symbol_crop_lock_24)),
CropRotate(icon(R.drawable.symbol_crop_rotate_24)),
CropUnlock(icon(R.drawable.symbol_crop_unlock_24)),
Daytime(icon(R.drawable.ic_daytime_24)),
DevicePhone(icon(R.drawable.symbol_device_phone_24)),
Devices(icon(R.drawable.symbol_devices_24)),
Draw(icon(R.drawable.symbol_draw_24)),
@@ -69,6 +71,7 @@ enum class SignalIcons(private val icon: SignalIcon) : SignalIcon by icon {
Mic(icon(R.drawable.symbol_mic_24)),
MobileNextDisplay(icon(R.drawable.symbol_mobile_next_display_48)),
MoreVertical(icon(R.drawable.symbol_more_vertical_24)),
Nighttime(icon(R.drawable.ic_nighttime_26)),
PersonCircle(icon(R.drawable.symbol_person_circle_24)),
Phone(icon(R.drawable.symbol_phone_24)),
Plus(icon(R.drawable.symbol_plus_24)),
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="22"
android:viewportHeight="22">
<path
android:pathData="M12.174,1.203C12.174,0.734 11.783,0.344 11.314,0.344C10.856,0.344 10.465,0.734 10.465,1.203V3.254C10.465,3.713 10.856,4.104 11.314,4.104C11.783,4.104 12.174,3.713 12.174,3.254V1.203ZM16.139,4.924C15.816,5.256 15.816,5.793 16.139,6.125C16.471,6.447 17.018,6.457 17.35,6.125L18.805,4.67C19.137,4.338 19.137,3.781 18.805,3.459C18.482,3.127 17.935,3.127 17.613,3.459L16.139,4.924ZM5.279,6.125C5.611,6.447 6.158,6.447 6.48,6.125C6.813,5.813 6.813,5.246 6.49,4.924L5.035,3.459C4.723,3.137 4.166,3.127 3.834,3.459C3.512,3.781 3.512,4.338 3.824,4.66L5.279,6.125ZM11.314,15.93C14.029,15.93 16.285,13.674 16.285,10.949C16.285,8.215 14.029,5.959 11.314,5.959C8.59,5.959 6.334,8.215 6.334,10.949C6.334,13.674 8.59,15.93 11.314,15.93ZM11.314,14.436C9.4,14.436 7.818,12.854 7.818,10.949C7.818,9.035 9.4,7.453 11.314,7.453C13.219,7.453 14.801,9.035 14.801,10.949C14.801,12.854 13.219,14.436 11.314,14.436ZM21.031,11.799C21.5,11.799 21.891,11.408 21.891,10.949C21.891,10.49 21.5,10.1 21.031,10.1H18.99C18.531,10.1 18.141,10.49 18.141,10.949C18.141,11.408 18.531,11.799 18.99,11.799H21.031ZM1.598,10.1C1.139,10.1 0.748,10.49 0.748,10.949C0.748,11.408 1.139,11.799 1.598,11.799H3.639C4.107,11.799 4.498,11.408 4.498,10.949C4.498,10.49 4.107,10.1 3.639,10.1H1.598ZM17.34,15.783C17.018,15.451 16.471,15.451 16.139,15.783C15.816,16.105 15.816,16.652 16.139,16.984L17.613,18.449C17.935,18.771 18.482,18.762 18.805,18.44C19.137,18.107 19.137,17.56 18.805,17.238L17.34,15.783ZM3.824,17.229C3.502,17.551 3.502,18.098 3.814,18.43C4.137,18.752 4.693,18.762 5.025,18.44L6.48,16.984C6.813,16.662 6.813,16.115 6.49,15.783C6.168,15.461 5.611,15.461 5.279,15.783L3.824,17.229ZM12.174,18.645C12.174,18.176 11.783,17.785 11.314,17.785C10.856,17.785 10.465,18.176 10.465,18.645V20.695C10.465,21.154 10.856,21.545 11.314,21.545C11.783,21.545 12.174,21.154 12.174,20.695V18.645Z"
android:fillColor="#000000"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="26dp"
android:height="16dp"
android:viewportWidth="26"
android:viewportHeight="16">
<path
android:pathData="M13.01,4.178C13.439,4.168 13.801,3.807 13.801,3.299V0.896C13.801,0.398 13.439,0.027 13.01,0.027C12.57,0.027 12.209,0.398 12.209,0.896V3.299C12.209,3.807 12.57,4.188 13.01,4.178ZM18.039,6.268C18.352,6.561 18.869,6.561 19.221,6.209L20.92,4.51C21.291,4.148 21.271,3.65 20.969,3.338C20.666,3.025 20.158,3.016 19.797,3.377L18.098,5.076C17.736,5.438 17.727,5.965 18.039,6.268ZM7.971,6.268C8.273,5.955 8.273,5.438 7.912,5.076L6.213,3.377C5.852,3.016 5.354,3.035 5.041,3.338C4.729,3.641 4.719,4.148 5.09,4.51L6.789,6.209C7.141,6.561 7.668,6.57 7.971,6.268ZM1.34,15.965H24.67C25.129,15.965 25.52,15.613 25.52,15.184C25.52,14.754 25.129,14.393 24.67,14.393H17.326C17.971,13.523 18.342,12.459 18.342,11.297C18.342,8.377 15.92,5.945 13.01,5.945C10.09,5.945 7.668,8.377 7.668,11.297C7.668,12.459 8.039,13.523 8.684,14.393H1.34C0.881,14.393 0.49,14.754 0.49,15.184C0.49,15.613 0.881,15.965 1.34,15.965ZM9.23,11.297C9.23,9.227 10.939,7.527 13.01,7.527C15.07,7.527 16.779,9.227 16.779,11.297C16.779,12.586 16.105,13.729 15.109,14.393H10.9C9.904,13.729 9.23,12.586 9.23,11.297ZM2.609,11.844H5.012C5.52,11.844 5.9,11.482 5.891,11.053C5.881,10.613 5.51,10.252 5.012,10.252H2.609C2.102,10.252 1.74,10.613 1.74,11.053C1.74,11.482 2.102,11.844 2.609,11.844ZM20.998,11.844H23.4C23.908,11.844 24.27,11.482 24.27,11.053C24.27,10.613 23.908,10.252 23.4,10.252H20.998C20.5,10.252 20.109,10.613 20.119,11.053C20.129,11.482 20.5,11.844 20.998,11.844Z"
android:fillColor="#000000"/>
</vector>
@@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M7.5,11.25a1.375,1.375 0,1 0,0 2.75,1.375 1.375,0 0,0 0,-2.75ZM6.125,17.125a1.375,1.375 0,1 1,2.75 0,1.375 1.375,0 0,1 -2.75,0ZM16.5,11.25a1.375,1.375 0,1 0,0 2.75,1.375 1.375,0 0,0 0,-2.75ZM15.125,17.125a1.375,1.375 0,1 1,2.75 0,1.375 1.375,0 0,1 -2.75,0ZM12,11.25A1.375,1.375 0,1 0,12 14a1.375,1.375 0,0 0,0 -2.75ZM10.625,17.125a1.375,1.375 0,1 1,2.75 0,1.375 1.375,0 0,1 -2.75,0Z"
android:fillColor="#000"/>
<path
android:pathData="M7.5,0.75a1,1 0,0 1,1 1v1.875h7L15.5,1.75a1,1 0,1 1,2 0v1.877c0.362,0.003 0.683,0.012 0.963,0.035 0.471,0.038 0.908,0.121 1.32,0.33a3.38,3.38 0,0 1,1.474 1.476c0.21,0.411 0.293,0.848 0.331,1.319 0.037,0.452 0.037,1.008 0.037,1.677v8.572c0,0.67 0,1.224 -0.037,1.677 -0.038,0.471 -0.121,0.908 -0.33,1.32a3.374,3.374 0,0 1,-1.476 1.474c-0.411,0.21 -0.848,0.293 -1.319,0.331 -0.453,0.037 -1.008,0.037 -1.677,0.037L7.214,21.875c-0.67,0 -1.225,0 -1.677,-0.037 -0.471,-0.038 -0.908,-0.121 -1.32,-0.33a3.374,3.374 0,0 1,-1.474 -1.476c-0.21,-0.411 -0.293,-0.848 -0.331,-1.319 -0.037,-0.453 -0.037,-1.008 -0.037,-1.677L2.375,8.464c0,-0.67 0,-1.225 0.037,-1.677 0.038,-0.471 0.121,-0.908 0.33,-1.32a3.375,3.375 0,0 1,1.476 -1.474c0.411,-0.21 0.848,-0.293 1.319,-0.331 0.28,-0.023 0.6,-0.032 0.963,-0.035L6.5,1.75a1,1 0,0 1,1 -1ZM5.012,5.552c0.124,-0.063 0.304,-0.116 0.667,-0.146 0.373,-0.03 0.856,-0.031 1.571,-0.031h9.5c0.715,0 1.198,0 1.57,0.031 0.363,0.03 0.544,0.083 0.668,0.146 0.306,0.156 0.554,0.405 0.71,0.71 0.063,0.124 0.116,0.304 0.146,0.667 0.02,0.253 0.027,0.555 0.03,0.946L4.126,7.875c0.003,-0.39 0.01,-0.693 0.03,-0.946 0.03,-0.363 0.083,-0.543 0.146,-0.667 0.156,-0.305 0.405,-0.554 0.71,-0.71ZM4.125,9.625L4.125,17c0,0.715 0,1.198 0.031,1.57 0.03,0.363 0.083,0.544 0.146,0.668 0.156,0.306 0.405,0.554 0.71,0.71 0.124,0.063 0.304,0.116 0.667,0.146 0.373,0.03 0.856,0.031 1.571,0.031h9.5c0.715,0 1.198,0 1.57,-0.031 0.363,-0.03 0.544,-0.083 0.668,-0.146 0.306,-0.156 0.554,-0.404 0.71,-0.71 0.063,-0.124 0.116,-0.305 0.146,-0.667 0.03,-0.373 0.031,-0.857 0.031,-1.571L19.875,9.625L4.125,9.625Z"
android:fillColor="#000"
android:fillType="evenOdd"/>
</vector>
+1
View File
@@ -72,6 +72,7 @@ dependencies {
implementation(libs.androidx.camera.core)
// Testing
testImplementation(testFixtures(project(":core:ui")))
testImplementation(testLibs.junit.junit)
testImplementation(testLibs.mockk)
testImplementation(testLibs.assertk)
@@ -39,6 +39,19 @@ sealed interface HudCommand {
/** Show the app's sticker picker. The pick is handed back via [MediaSendViewModel.onStickerSelected]. */
data object SelectSticker : HudCommand
/**
* Show the app's date and time picker for a scheduled send. The pick is handed back via
* [MediaSendViewModel.onScheduledSendTimeSelected].
*/
data object PickScheduledSendTime : HudCommand
/**
* Clear whatever the app requires before a send can be scheduled for [scheduledTime], such as the scheduled messages
* intro sheet or the exact alarm permission. Once cleared, the app calls
* [MediaSendViewModel.onScheduledSendConfirmed] to let the send proceed.
*/
data class ConfirmScheduledSend(val scheduledTime: Long) : HudCommand
data class GoToConversation(val recipientId: MediaRecipientId) : HudCommand
data object GoToLinkedDevices : HudCommand
data class GoToQuickTransfer(val qrData: String) : HudCommand
@@ -55,6 +55,7 @@ import org.signal.mediasend.capture.CameraXScreenEvent
import org.signal.mediasend.capture.MediaCaptureScreenEvent
import org.signal.mediasend.edit.ImageController
import org.signal.mediasend.edit.MediaEditScreenEvent
import org.signal.mediasend.edit.ScheduleSendOption
import org.signal.mediasend.edit.image.BrushTool
import org.signal.mediasend.edit.image.BrushWidthsState
import org.signal.mediasend.edit.video.VideoTrimData
@@ -273,13 +274,8 @@ class MediaSendViewModel(
when (mediaEditScreenEvent) {
is MediaEditScreenEvent.FocusedMediaChanged -> setFocusedMedia(mediaEditScreenEvent.media)
is MediaEditScreenEvent.ReorderSelectedMedia -> reorderMedia(mediaEditScreenEvent.fromIndex, mediaEditScreenEvent.toIndex)
MediaEditScreenEvent.NextClick -> {
if (state.value.isContactSelectionRequired) {
backStack.goToSend()
} else {
performSend()
}
}
MediaEditScreenEvent.NextClick -> onNextClick()
is MediaEditScreenEvent.ScheduleSendClick -> onScheduleSendClick(mediaEditScreenEvent.option)
MediaEditScreenEvent.NavigateBack -> onPopFromEdit()
is MediaEditScreenEvent.VideoTrimChanged -> onEditVideoDuration(
totalDurationUs = mediaEditScreenEvent.videoTrimData.totalInputDurationUs,
@@ -1020,8 +1016,32 @@ class MediaSendViewModel(
updateState { copy(additionalRecipients = recipients) }
}
fun setScheduledTime(time: Long) {
updateState { copy(scheduledTime = time) }
//endregion
//region Scheduled Send
private fun onScheduleSendClick(option: ScheduleSendOption) {
when (option) {
is ScheduleSendOption.PresetTime -> sendHudCommand(HudCommand.ConfirmScheduledSend(option.timeMs))
ScheduleSendOption.PickTime -> sendHudCommand(HudCommand.PickScheduledSendTime)
}
}
/**
* A time chosen in the picker opened for [HudCommand.PickScheduledSendTime]. It still has to clear the app's
* scheduling prerequisites, just like a time picked straight from the menu.
*/
fun onScheduledSendTimeSelected(scheduledTime: Long) {
sendHudCommand(HudCommand.ConfirmScheduledSend(scheduledTime))
}
/**
* The app's scheduling prerequisites are cleared, so the flow can carry on with the send scheduled for
* [scheduledTime].
*/
fun onScheduledSendConfirmed(scheduledTime: Long) {
updateState { copy(scheduledTime = scheduledTime) }
onNextClick()
}
//endregion
@@ -1059,6 +1079,17 @@ class MediaSendViewModel(
//region Send
/**
* Leaves the editor, either on to destination selection or straight into the send.
*/
private fun onNextClick() {
if (state.value.isContactSelectionRequired) {
backStack.goToSend()
} else {
performSend()
}
}
/**
* Completes the flow for destinations that are already known, either by sending in place or by handing the
* payload back to whoever launched us. Safe to call again after the user resolves a safety number change.
@@ -8,6 +8,7 @@ package org.signal.mediasend.edit
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
@@ -19,16 +20,20 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.DropdownMenus
import org.signal.core.ui.compose.IconButtons
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalIcons
import org.signal.mediasend.R
import org.signal.mediasend.test.TestTags
/**
* Because we need to be able to support stuff like mentions, styled text, and custom emoji, we need to allow
@@ -51,7 +56,8 @@ fun AddAMessageRow(
onEvent: (MediaEditScreenEvent) -> Unit,
onNextClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true
enabled: Boolean = true,
canScheduleSend: Boolean = false
) {
Row(
horizontalArrangement = Arrangement.Center,
@@ -84,22 +90,34 @@ fun AddAMessageRow(
)
}
IconButtons.IconButton(
enabled = enabled,
onClick = onNextClick,
modifier = Modifier
.padding(start = 12.dp)
.background(
color = MaterialTheme.colorScheme.primaryContainer,
shape = CircleShape
)
) {
Icon(
painter = SignalIcons.ArrowEnd.painter,
contentDescription = stringResource(R.string.AddAMessageRow__next),
Box {
val scheduleSendMenuController = remember { DropdownMenus.MenuController() }
IconButtons.IconButton(
enabled = enabled,
onClick = onNextClick,
onLongClick = if (canScheduleSend) scheduleSendMenuController::show else null,
onLongClickLabel = stringResource(R.string.AddAMessageRow__schedule_send),
modifier = Modifier
.size(40.dp)
.padding(8.dp)
.testTag(TestTags.ADD_A_MESSAGE_NEXT_BUTTON)
.padding(start = 12.dp)
.background(
color = MaterialTheme.colorScheme.primaryContainer,
shape = CircleShape
)
) {
Icon(
painter = SignalIcons.ArrowEnd.painter,
contentDescription = stringResource(R.string.AddAMessageRow__next),
modifier = Modifier
.size(40.dp)
.padding(8.dp)
)
}
ScheduleSendMenu(
controller = scheduleSendMenuController,
onOptionClick = { onEvent(MediaEditScreenEvent.ScheduleSendClick(it)) }
)
}
}
@@ -316,6 +316,7 @@ internal fun MediaEditScreen(
) {
AddAMessageRow(
enabled = !isInteracting && !state.isSending,
canScheduleSend = !state.isStory,
message = state.message,
onEvent = onEvent,
onNextClick = { onEvent(MediaEditScreenEvent.NextClick) },
@@ -16,6 +16,7 @@ sealed interface MediaEditScreenEvent {
data class AddMessageClick(val startWithEmojiKeyboard: Boolean = false) : MediaEditScreenEvent
data object StickerClick : MediaEditScreenEvent
data object NextClick : MediaEditScreenEvent
data class ScheduleSendClick(val option: ScheduleSendOption) : MediaEditScreenEvent
data object NavigateBack : MediaEditScreenEvent
data object NavigateToGallery : MediaEditScreenEvent
data object ToggleMediaQuality : MediaEditScreenEvent
@@ -0,0 +1,203 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediasend.edit
import androidx.compose.foundation.layout.Arrangement.spacedBy
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.DropdownMenus
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.SignalIcon
import org.signal.core.ui.compose.SignalIcons
import org.signal.mediasend.R
import org.signal.mediasend.test.TestTags
import java.time.DayOfWeek
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.time.temporal.TemporalAdjusters
import java.util.concurrent.TimeUnit
/**
* Renders the label for a scheduled send time, e.g. "Tomorrow at 8:00 AM". Injected because localized date formatting
* lives with the host app.
*/
val LocalScheduledSendTimeFormatter = compositionLocalOf<(Long) -> String> {
{ timeMs ->
DateTimeFormatter
.ofLocalizedDateTime(FormatStyle.SHORT)
.format(Instant.ofEpochMilli(timeMs).atZone(ZoneId.systemDefault()))
}
}
/**
* A time the user can schedule a send for, offered by [ScheduleSendMenu].
*/
sealed interface ScheduleSendOption {
/**
* One of the suggested times, as a timestamp matching [System.currentTimeMillis].
*/
data class PresetTime(val timeMs: Long) : ScheduleSendOption
/**
* The user wants to choose a date and time themselves.
*/
data object PickTime : ScheduleSendOption
companion object {
private val PRESET_HOURS = intArrayOf(8, 12, 18, 21)
/**
* The options to offer at [currentTimeMs], ordered for a menu that opens above its trigger: the soonest suggestion
* sits closest to the send button and [PickTime] furthest from it.
*
* Suggestions are the next three [PRESET_HOURS] on the clock, plus Monday morning when the week is already over.
*/
fun forCurrentTime(currentTimeMs: Long, zoneId: ZoneId = ZoneId.systemDefault()): List<ScheduleSendOption> {
var dateTime: LocalDateTime = currentTimeMs.toLocalDateTime(zoneId)
var presetIndex = PRESET_HOURS.indexOfFirst { it > dateTime.hour }
if (presetIndex == -1) {
dateTime = dateTime.plusDays(1)
presetIndex = 0
}
dateTime = dateTime.withMinute(0).withSecond(0)
val times = ArrayList<Long>(5)
while (times.size < 3) {
dateTime = dateTime.withHour(PRESET_HOURS[presetIndex])
times += dateTime.toEpochMillis(zoneId)
presetIndex++
if (presetIndex >= PRESET_HOURS.size) {
presetIndex = 0
dateTime = dateTime.plusDays(1)
}
}
val now = currentTimeMs.toLocalDateTime(zoneId)
if (now.dayOfWeek == DayOfWeek.FRIDAY || now.dayOfWeek == DayOfWeek.SATURDAY) {
times += now
.with(TemporalAdjusters.next(DayOfWeek.MONDAY))
.withHour(8)
.withMinute(0)
.withSecond(0)
.toEpochMillis(zoneId)
}
return listOf(PickTime) + times.reversed().map { PresetTime(it) }
}
private fun Long.toLocalDateTime(zoneId: ZoneId): LocalDateTime = LocalDateTime.ofInstant(Instant.ofEpochMilli(this), zoneId)
private fun LocalDateTime.toEpochMillis(zoneId: ZoneId): Long = TimeUnit.SECONDS.toMillis(atZone(zoneId).toEpochSecond())
}
}
/**
* Menu of times the user can schedule their send for. Options are computed each time the menu is shown, so that a menu
* reopened after a preset hour has passed does not offer a time in the past.
*/
@Composable
fun ScheduleSendMenu(
controller: DropdownMenus.MenuController,
onOptionClick: (ScheduleSendOption) -> Unit,
modifier: Modifier = Modifier
) {
DropdownMenus.Menu(
controller = controller,
offsetX = 0.dp,
modifier = modifier
) {
ScheduleSendOptions(
options = remember(controller.isShown()) { ScheduleSendOption.forCurrentTime(System.currentTimeMillis()) },
onOptionClick = {
controller.hide()
onOptionClick(it)
}
)
}
}
@Composable
internal fun ScheduleSendOptions(
options: List<ScheduleSendOption>,
onOptionClick: (ScheduleSendOption) -> Unit
) {
options.forEach { option ->
ScheduleSendMenuItem(
option = option,
onClick = { onOptionClick(option) }
)
}
}
@Composable
private fun ScheduleSendMenuItem(
option: ScheduleSendOption,
onClick: () -> Unit
) {
val label = when (option) {
is ScheduleSendOption.PresetTime -> LocalScheduledSendTimeFormatter.current(option.timeMs)
ScheduleSendOption.PickTime -> stringResource(R.string.ScheduleSendMenu__pick_date_and_time)
}
val testTag = when (option) {
is ScheduleSendOption.PresetTime -> TestTags.scheduleSendPresetOption(option.timeMs)
ScheduleSendOption.PickTime -> TestTags.SCHEDULE_SEND_PICK_TIME_OPTION
}
DropdownMenus.Item(
modifier = Modifier.testTag(testTag),
text = {
Row(
horizontalArrangement = spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = option.icon.painter,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface
)
Text(text = label)
}
},
onClick = onClick
)
}
private val ScheduleSendOption.icon: SignalIcon
get() = when (this) {
is ScheduleSendOption.PresetTime -> if (LocalDateTime.ofInstant(Instant.ofEpochMilli(timeMs), ZoneId.systemDefault()).hour >= 18) SignalIcons.Nighttime else SignalIcons.Daytime
ScheduleSendOption.PickTime -> SignalIcons.Calendar
}
@DayNightPreviews
@Composable
private fun ScheduleSendMenuPreview() {
Previews.Preview {
ScheduleSendMenu(
controller = remember { DropdownMenus.MenuController().apply { show() } },
onOptionClick = {}
)
}
}
@@ -0,0 +1,23 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediasend.test
/**
* Test tags for Compose UI testing.
*/
object TestTags {
// Add A Message Row
const val ADD_A_MESSAGE_NEXT_BUTTON = "add_a_message_next_button"
// Schedule Send Menu
const val SCHEDULE_SEND_PICK_TIME_OPTION = "schedule_send_pick_time_option"
/**
* Tag for the suggested time at [timeMs], since the suggestions themselves depend on when the menu was opened.
*/
fun scheduleSendPresetOption(timeMs: Long): String = "schedule_send_preset_option_$timeMs"
}
@@ -24,6 +24,10 @@
<string name="AddAMessageRow__message">Message</string>
<!-- Accessibility description for the button that advances to the next step in the media send flow. -->
<string name="AddAMessageRow__next">Next</string>
<!-- Accessibility description for long-pressing the send button to schedule the send for later. -->
<string name="AddAMessageRow__schedule_send">Schedule send</string>
<!-- Menu option that opens a picker for choosing the exact date and time to send at. -->
<string name="ScheduleSendMenu__pick_date_and_time">Pick Date &amp; Time</string>
<!-- Setting option that can be selected to default media to be sent as high quality by default -->
<string name="SentMediaQuality__high">High</string>
<!-- Setting option that can be selected to default media to be sent as standard quality by default -->
@@ -0,0 +1,89 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediasend.edit
import android.app.Application
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.longClick
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput
import androidx.test.core.app.ApplicationProvider
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
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.ui.CoreUiDependenciesRule
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.mediasend.test.TestTags
/**
* Covers the send affordance's two gestures: a tap advances the flow, while a long press offers the schedule menu when
* the flow allows scheduling.
*/
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class)
class AddAMessageRowTest {
@get:Rule
val composeTestRule = createComposeRule()
@get:Rule
val coreUiDependenciesRule = CoreUiDependenciesRule(ApplicationProvider.getApplicationContext())
private val events = mutableListOf<MediaEditScreenEvent>()
private var nextClicks = 0
@Test
fun `Given scheduling is allowed, when the send button is tapped, then the flow advances without scheduling`() {
setContent(canScheduleSend = true)
composeTestRule.onNodeWithTag(TestTags.ADD_A_MESSAGE_NEXT_BUTTON).performClick()
assertEquals(1, nextClicks)
assertNull(events.filterIsInstance<MediaEditScreenEvent.ScheduleSendClick>().firstOrNull())
}
@Test
fun `Given scheduling is allowed, when the send button is long pressed, then a time can be scheduled`() {
setContent(canScheduleSend = true)
composeTestRule.onNodeWithTag(TestTags.ADD_A_MESSAGE_NEXT_BUTTON).performTouchInput { longClick() }
composeTestRule.onNodeWithTag(TestTags.SCHEDULE_SEND_PICK_TIME_OPTION).performClick()
assertEquals(
MediaEditScreenEvent.ScheduleSendClick(ScheduleSendOption.PickTime),
events.filterIsInstance<MediaEditScreenEvent.ScheduleSendClick>().single()
)
assertEquals(0, nextClicks)
}
@Test
fun `Given scheduling is not allowed, when the send button is long pressed, then nothing is offered`() {
setContent(canScheduleSend = false)
composeTestRule.onNodeWithTag(TestTags.ADD_A_MESSAGE_NEXT_BUTTON).performTouchInput { longClick() }
composeTestRule.onNodeWithTag(TestTags.SCHEDULE_SEND_PICK_TIME_OPTION).assertDoesNotExist()
assertEquals(emptyList<MediaEditScreenEvent>(), events)
}
private fun setContent(canScheduleSend: Boolean) {
composeTestRule.setContent {
SignalTheme {
AddAMessageRow(
message = null,
onEvent = { events += it },
onNextClick = { nextClicks++ },
canScheduleSend = canScheduleSend
)
}
}
}
}
@@ -0,0 +1,115 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediasend.edit
import android.app.Application
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.test.core.app.ApplicationProvider
import org.junit.Assert.assertEquals
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.ui.CoreUiDependenciesRule
import org.signal.core.ui.compose.theme.SignalTheme
import org.signal.mediasend.R
import org.signal.mediasend.test.TestTags
/**
* Covers what the schedule menu puts in front of the user: a row per suggested time labelled by the host app's date
* formatter, and the option each row reports back when tapped.
*/
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class)
class ScheduleSendMenuTest {
@get:Rule
val composeTestRule = createComposeRule()
@get:Rule
val coreUiDependenciesRule = CoreUiDependenciesRule(ApplicationProvider.getApplicationContext())
private val context: Application = ApplicationProvider.getApplicationContext()
private var clickedOption: ScheduleSendOption? = null
@Test
fun `Given suggested times, when displayed, then each is labelled by the injected formatter`() {
setContent()
composeTestRule.onNodeWithTag(TestTags.scheduleSendPresetOption(TONIGHT)).assertTextEquals("Scheduled for $TONIGHT")
composeTestRule.onNodeWithTag(TestTags.scheduleSendPresetOption(TOMORROW)).assertTextEquals("Scheduled for $TOMORROW")
}
@Test
fun `Given a pick a time option, when displayed, then it is labelled from the module's strings`() {
setContent()
composeTestRule
.onNodeWithTag(TestTags.SCHEDULE_SEND_PICK_TIME_OPTION)
.assertTextEquals(context.getString(R.string.ScheduleSendMenu__pick_date_and_time))
}
@Test
fun `Given suggested times, when one is clicked, then that time is reported`() {
setContent()
composeTestRule.onNodeWithTag(TestTags.scheduleSendPresetOption(TOMORROW)).performClick()
assertEquals(ScheduleSendOption.PresetTime(TOMORROW), clickedOption)
}
@Test
fun `Given a pick a time option, when it is clicked, then picking a time is reported`() {
setContent()
composeTestRule.onNodeWithTag(TestTags.SCHEDULE_SEND_PICK_TIME_OPTION).performClick()
assertEquals(ScheduleSendOption.PickTime, clickedOption)
}
@Test
fun `Given an evening and a morning suggestion, when displayed, then both are shown`() {
setContent()
composeTestRule.onNodeWithTag(TestTags.scheduleSendPresetOption(TONIGHT)).assertIsDisplayed()
composeTestRule.onNodeWithTag(TestTags.scheduleSendPresetOption(TOMORROW)).assertIsDisplayed()
}
private fun setContent() {
composeTestRule.setContent {
SignalTheme {
CompositionLocalProvider(LocalScheduledSendTimeFormatter provides { "Scheduled for $it" }) {
Column {
ScheduleSendOptions(
options = listOf(
ScheduleSendOption.PickTime,
ScheduleSendOption.PresetTime(TOMORROW),
ScheduleSendOption.PresetTime(TONIGHT)
),
onOptionClick = { clickedOption = it }
)
}
}
}
}
}
companion object {
/** 2026-07-05 21:00 UTC, an hour that renders with the night icon. */
private const val TONIGHT = 1783285200000L
/** 2026-07-06 08:00 UTC, an hour that renders with the day icon. */
private const val TOMORROW = 1783324800000L
}
}
@@ -0,0 +1,133 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediasend.edit
import org.junit.Assert.assertEquals
import org.junit.Test
import java.time.Instant
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.ZoneOffset
/**
* Covers the suggested times [ScheduleSendOption.forCurrentTime] offers, which are what the schedule menu is built
* from. Times are computed in a fixed zone so that the expectations hold wherever the tests run.
*/
class ScheduleSendOptionTest {
@Test
fun `Given a mid-morning weekday, when building options, then the rest of today is suggested soonest-last`() {
val options = optionsAt(WEDNESDAY.atTime(9, 30))
assertEquals(
listOf(
ScheduleSendOption.PickTime,
presetAt(WEDNESDAY.atTime(21, 0)),
presetAt(WEDNESDAY.atTime(18, 0)),
presetAt(WEDNESDAY.atTime(12, 0))
),
options
)
}
@Test
fun `Given a time past the last suggestion of the day, when building options, then tomorrow is suggested`() {
val options = optionsAt(WEDNESDAY.atTime(22, 15))
assertEquals(
listOf(
ScheduleSendOption.PickTime,
presetAt(THURSDAY.atTime(18, 0)),
presetAt(THURSDAY.atTime(12, 0)),
presetAt(THURSDAY.atTime(8, 0))
),
options
)
}
@Test
fun `Given the suggestions roll into the next day, when building options, then the following day's hours are used`() {
val options = optionsAt(WEDNESDAY.atTime(19, 0))
assertEquals(
listOf(
ScheduleSendOption.PickTime,
presetAt(THURSDAY.atTime(12, 0)),
presetAt(THURSDAY.atTime(8, 0)),
presetAt(WEDNESDAY.atTime(21, 0))
),
options
)
}
@Test
fun `Given a Friday, when building options, then Monday morning is also suggested`() {
val options = optionsAt(FRIDAY.atTime(9, 0))
assertEquals(
listOf(
ScheduleSendOption.PickTime,
presetAt(MONDAY.atTime(8, 0)),
presetAt(FRIDAY.atTime(21, 0)),
presetAt(FRIDAY.atTime(18, 0)),
presetAt(FRIDAY.atTime(12, 0))
),
options
)
}
@Test
fun `Given a Saturday, when building options, then Monday morning is also suggested`() {
val options = optionsAt(SATURDAY.atTime(9, 0))
assertEquals(
listOf(
ScheduleSendOption.PickTime,
presetAt(MONDAY.atTime(8, 0)),
presetAt(SATURDAY.atTime(21, 0)),
presetAt(SATURDAY.atTime(18, 0)),
presetAt(SATURDAY.atTime(12, 0))
),
options
)
}
@Test
fun `Given any time, when building options, then every suggestion is in the future and on the minute`() {
val now = WEDNESDAY.atTime(11, 47, 32)
val presets = optionsAt(now).filterIsInstance<ScheduleSendOption.PresetTime>()
assertEquals(3, presets.size)
presets.forEach { preset ->
val dateTime = LocalDateTime.ofInstant(Instant.ofEpochMilli(preset.timeMs), ZONE)
assertEquals(0, dateTime.minute)
assertEquals(0, dateTime.second)
assert(preset.timeMs > now.toEpochMillis()) { "$dateTime is not after $now" }
}
}
private fun optionsAt(dateTime: LocalDateTime): List<ScheduleSendOption> {
return ScheduleSendOption.forCurrentTime(dateTime.toEpochMillis(), ZONE)
}
private fun presetAt(dateTime: LocalDateTime): ScheduleSendOption.PresetTime {
return ScheduleSendOption.PresetTime(dateTime.toEpochMillis())
}
private fun LocalDateTime.toEpochMillis(): Long = toInstant(ZoneOffset.UTC).toEpochMilli()
companion object {
private val ZONE: ZoneId = ZoneOffset.UTC
private val WEDNESDAY = LocalDate.of(2026, 7, 1)
private val THURSDAY = LocalDate.of(2026, 7, 2)
private val FRIDAY = LocalDate.of(2026, 7, 3)
private val SATURDAY = LocalDate.of(2026, 7, 4)
private val MONDAY = LocalDate.of(2026, 7, 6)
}
}