diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Activity.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Activity.kt
index a5de0e31ad..3aa6cbe251 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Activity.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Activity.kt
@@ -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()
diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/IconButtons.kt b/core/ui/src/main/java/org/signal/core/ui/compose/IconButtons.kt
index cc54f2e45a..86922c198c 100644
--- a/core/ui/src/main/java/org/signal/core/ui/compose/IconButtons.kt
+++ b/core/ui/src/main/java/org/signal/core/ui/compose/IconButtons.kt
@@ -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,
diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt b/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt
index 09708272de..6a1416fb37 100644
--- a/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt
+++ b/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt
@@ -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)),
diff --git a/core/ui/src/main/res/drawable/ic_daytime_24.xml b/core/ui/src/main/res/drawable/ic_daytime_24.xml
new file mode 100644
index 0000000000..78d655d947
--- /dev/null
+++ b/core/ui/src/main/res/drawable/ic_daytime_24.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/core/ui/src/main/res/drawable/ic_nighttime_26.xml b/core/ui/src/main/res/drawable/ic_nighttime_26.xml
new file mode 100644
index 0000000000..e6f5932a29
--- /dev/null
+++ b/core/ui/src/main/res/drawable/ic_nighttime_26.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/core/ui/src/main/res/drawable/symbol_calendar_24.xml b/core/ui/src/main/res/drawable/symbol_calendar_24.xml
new file mode 100644
index 0000000000..d0b641bdbd
--- /dev/null
+++ b/core/ui/src/main/res/drawable/symbol_calendar_24.xml
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/feature/media-send/build.gradle.kts b/feature/media-send/build.gradle.kts
index f33c8615e4..492097b9eb 100644
--- a/feature/media-send/build.gradle.kts
+++ b/feature/media-send/build.gradle.kts
@@ -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)
diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendEvent.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendEvent.kt
index 4082b127f1..ece9b0c73e 100644
--- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendEvent.kt
+++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendEvent.kt
@@ -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
diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt
index d663814042..59a9863409 100644
--- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt
+++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt
@@ -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.
diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/AddAMessageRow.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/AddAMessageRow.kt
index 938694f5b9..a1741fe003 100644
--- a/feature/media-send/src/main/java/org/signal/mediasend/edit/AddAMessageRow.kt
+++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/AddAMessageRow.kt
@@ -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)) }
)
}
}
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 609351b245..036481d3d5 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
@@ -316,6 +316,7 @@ internal fun MediaEditScreen(
) {
AddAMessageRow(
enabled = !isInteracting && !state.isSending,
+ canScheduleSend = !state.isStory,
message = state.message,
onEvent = onEvent,
onNextClick = { onEvent(MediaEditScreenEvent.NextClick) },
diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreenEvent.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreenEvent.kt
index f7338c501c..cf1f994261 100644
--- a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreenEvent.kt
+++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreenEvent.kt
@@ -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
diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/ScheduleSendMenu.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/ScheduleSendMenu.kt
new file mode 100644
index 0000000000..a1dbd7a426
--- /dev/null
+++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/ScheduleSendMenu.kt
@@ -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 {
+ 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(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,
+ 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 = {}
+ )
+ }
+}
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
new file mode 100644
index 0000000000..20915f0094
--- /dev/null
+++ b/feature/media-send/src/main/java/org/signal/mediasend/test/TestTags.kt
@@ -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"
+}
diff --git a/feature/media-send/src/main/res/values/strings.xml b/feature/media-send/src/main/res/values/strings.xml
index 884620426e..6af09fa7fb 100644
--- a/feature/media-send/src/main/res/values/strings.xml
+++ b/feature/media-send/src/main/res/values/strings.xml
@@ -24,6 +24,10 @@
Message
Next
+
+ Schedule send
+
+ Pick Date & Time
High
diff --git a/feature/media-send/src/test/java/org/signal/mediasend/edit/AddAMessageRowTest.kt b/feature/media-send/src/test/java/org/signal/mediasend/edit/AddAMessageRowTest.kt
new file mode 100644
index 0000000000..5043c92403
--- /dev/null
+++ b/feature/media-send/src/test/java/org/signal/mediasend/edit/AddAMessageRowTest.kt
@@ -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()
+ 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().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().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(), events)
+ }
+
+ private fun setContent(canScheduleSend: Boolean) {
+ composeTestRule.setContent {
+ SignalTheme {
+ AddAMessageRow(
+ message = null,
+ onEvent = { events += it },
+ onNextClick = { nextClicks++ },
+ canScheduleSend = canScheduleSend
+ )
+ }
+ }
+ }
+}
diff --git a/feature/media-send/src/test/java/org/signal/mediasend/edit/ScheduleSendMenuTest.kt b/feature/media-send/src/test/java/org/signal/mediasend/edit/ScheduleSendMenuTest.kt
new file mode 100644
index 0000000000..58bdb99849
--- /dev/null
+++ b/feature/media-send/src/test/java/org/signal/mediasend/edit/ScheduleSendMenuTest.kt
@@ -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
+ }
+}
diff --git a/feature/media-send/src/test/java/org/signal/mediasend/edit/ScheduleSendOptionTest.kt b/feature/media-send/src/test/java/org/signal/mediasend/edit/ScheduleSendOptionTest.kt
new file mode 100644
index 0000000000..7c32f1b82d
--- /dev/null
+++ b/feature/media-send/src/test/java/org/signal/mediasend/edit/ScheduleSendOptionTest.kt
@@ -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()
+
+ 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 {
+ 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)
+ }
+}