mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-08-06 13:31:18 +01:00
Add scheduled send support to media-send module.
This commit is contained in:
@@ -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>
|
||||
@@ -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 & 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user