From ecb7e12c67ed51f62995b7d412de0ce409f4007d Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Fri, 31 Jul 2026 16:12:40 -0300 Subject: [PATCH] Add scheduled send support to media-send module. --- .../mediasend/v3/MediaSendV3Activity.kt | 30 ++- .../org/signal/core/ui/compose/IconButtons.kt | 8 +- .../org/signal/core/ui/compose/SignalIcons.kt | 3 + .../src/main/res/drawable/ic_daytime_24.xml | 9 + .../src/main/res/drawable/ic_nighttime_26.xml | 9 + .../main/res/drawable/symbol_calendar_24.xml | 13 ++ feature/media-send/build.gradle.kts | 1 + .../org/signal/mediasend/MediaSendEvent.kt | 13 ++ .../signal/mediasend/MediaSendViewModel.kt | 49 ++++- .../signal/mediasend/edit/AddAMessageRow.kt | 50 +++-- .../signal/mediasend/edit/MediaEditScreen.kt | 1 + .../mediasend/edit/MediaEditScreenEvent.kt | 1 + .../signal/mediasend/edit/ScheduleSendMenu.kt | 203 ++++++++++++++++++ .../org/signal/mediasend/test/TestTags.kt | 23 ++ .../src/main/res/values/strings.xml | 4 + .../mediasend/edit/AddAMessageRowTest.kt | 89 ++++++++ .../mediasend/edit/ScheduleSendMenuTest.kt | 115 ++++++++++ .../mediasend/edit/ScheduleSendOptionTest.kt | 133 ++++++++++++ 18 files changed, 726 insertions(+), 28 deletions(-) create mode 100644 core/ui/src/main/res/drawable/ic_daytime_24.xml create mode 100644 core/ui/src/main/res/drawable/ic_nighttime_26.xml create mode 100644 core/ui/src/main/res/drawable/symbol_calendar_24.xml create mode 100644 feature/media-send/src/main/java/org/signal/mediasend/edit/ScheduleSendMenu.kt create mode 100644 feature/media-send/src/main/java/org/signal/mediasend/test/TestTags.kt create mode 100644 feature/media-send/src/test/java/org/signal/mediasend/edit/AddAMessageRowTest.kt create mode 100644 feature/media-send/src/test/java/org/signal/mediasend/edit/ScheduleSendMenuTest.kt create mode 100644 feature/media-send/src/test/java/org/signal/mediasend/edit/ScheduleSendOptionTest.kt 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) + } +}