diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 190a3d5be1..58cba5c602 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -639,6 +639,7 @@ dependencies {
implementation(libs.kotlinx.serialization.json)
implementation(project(":lib:billing"))
+ implementation(project(":feature:media-send"))
"spinnerImplementation"(project(":lib:spinner"))
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index c10ce5ab39..c9f2ba6395 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -474,6 +474,15 @@
android:theme="@style/TextSecure.DarkNoActionBar"
android:windowSoftInputMode="stateAlwaysHidden|adjustNothing" />
+
+
= PublishSubject.create()
+ private lateinit var mediaActivityLauncher: ActivityResultLauncher
+
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
return motionEventRelay.offer(ev) || super.dispatchTouchEvent(ev)
}
@@ -276,6 +280,8 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
super.onCreate(savedInstanceState, ready)
navigator = MainNavigator(this, mainNavigationViewModel)
+ mediaActivityLauncher = registerForActivityResult(MediaSendV3ActivityContract()) { }
+
AppForegroundObserver.addListener(object : AppForegroundObserver.Listener {
override fun onForeground() {
mainNavigationViewModel.getNextMegaphone()
@@ -1088,15 +1094,23 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
private fun onCameraClick(destination: MainNavigationListLocation, isForQuickRestore: Boolean) {
val onGranted = {
- val intent = if (isForQuickRestore) {
- MediaSelectionActivity.cameraForQuickRestore(context = this@MainActivity)
+ if (isForQuickRestore) {
+ startActivity(MediaSelectionActivity.cameraForQuickRestore(context = this@MainActivity))
+ } else if (SignalStore.internal.useNewMediaActivity) {
+ mediaActivityLauncher.launch(
+ MediaSendActivityContract.Args(
+ isCameraFirst = false,
+ isStory = destination == MainNavigationListLocation.STORIES
+ )
+ )
} else {
- MediaSelectionActivity.camera(
- context = this@MainActivity,
- isStory = destination == MainNavigationListLocation.STORIES
+ startActivity(
+ MediaSelectionActivity.camera(
+ context = this@MainActivity,
+ isStory = destination == MainNavigationListLocation.STORIES
+ )
)
}
- startActivity(intent)
}
if (CameraXUtil.isSupported()) {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt
index 795aa41327..5549f92d0c 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsFragment.kt
@@ -913,6 +913,14 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
viewModel.setUseConversationItemV2Media(!state.useConversationItemV2ForMedia)
}
)
+
+ switchPref(
+ title = DSLSettingsText.from("Use new media activity"),
+ isChecked = state.useNewMediaActivity,
+ onClick = {
+ viewModel.setUseNewMediaActivity(!state.useNewMediaActivity)
+ }
+ )
}
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsState.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsState.kt
index f32edeffab..1e3e990d52 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsState.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsState.kt
@@ -30,5 +30,6 @@ data class InternalSettingsState(
val useConversationItemV2ForMedia: Boolean,
val hasPendingOneTimeDonation: Boolean,
val hevcEncoding: Boolean,
- val forceSplitPane: Boolean
+ val forceSplitPane: Boolean,
+ val useNewMediaActivity: Boolean
)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt
index 46e3f75011..69521fd22c 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/internal/InternalSettingsViewModel.kt
@@ -144,6 +144,11 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
refresh()
}
+ fun setUseNewMediaActivity(enabled: Boolean) {
+ SignalStore.internal.useNewMediaActivity = enabled
+ refresh()
+ }
+
fun setHevcEncoding(enabled: Boolean) {
SignalStore.internal.hevcEncoding = enabled
refresh()
@@ -196,7 +201,8 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
useConversationItemV2ForMedia = SignalStore.internal.useConversationItemV2Media,
hasPendingOneTimeDonation = SignalStore.inAppPayments.getPendingOneTimeDonation() != null,
hevcEncoding = SignalStore.internal.hevcEncoding,
- forceSplitPane = SignalStore.internal.forceSplitPane
+ forceSplitPane = SignalStore.internal.forceSplitPane,
+ useNewMediaActivity = SignalStore.internal.useNewMediaActivity
)
fun onClearOnboardingState() {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/TransformPropertiesUtil.kt b/app/src/main/java/org/thoughtcrime/securesms/database/TransformPropertiesUtil.kt
index f57cc0e1b8..42ceb5215e 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/TransformPropertiesUtil.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/TransformPropertiesUtil.kt
@@ -5,6 +5,7 @@
package org.thoughtcrime.securesms.database
+import kotlinx.serialization.json.Json
import org.signal.core.models.media.TransformProperties
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.mms.SentMediaQuality
@@ -18,7 +19,7 @@ private val TAG = Log.tag(TransformProperties::class.java)
* Serializes the TransformProperties to a JSON string using Jackson.
*/
fun TransformProperties.serialize(): String {
- return JsonUtil.toJson(this)
+ return Json.encodeToString(this)
}
/**
diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.kt b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.kt
index ff5e59bac5..f8393e7b25 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.kt
+++ b/app/src/main/java/org/thoughtcrime/securesms/keyvalue/InternalValues.kt
@@ -35,6 +35,7 @@ class InternalValues internal constructor(store: KeyValueStore) : SignalStoreVal
const val SHOW_ARCHIVE_STATE_HINT: String = "internal.show_archive_state_hint"
const val INCLUDE_DEBUGLOG_IN_BACKUP: String = "internal.include_debuglog_in_backup"
const val IMPORTED_BACKUP_DEBUG_INFO: String = "internal.imported_backup_debug_info"
+ const val USE_NEW_MEDIA_ACTIVITY: String = "internal.use_new_media_activity"
}
public override fun onFirstEverAppLaunch() = Unit
@@ -46,6 +47,8 @@ class InternalValues internal constructor(store: KeyValueStore) : SignalStoreVal
*/
var forceSplitPane by booleanValue(FORCE_SPLIT_PANE_ON_COMPACT_LANDSCAPE, false).falseForExternalUsers()
+ var useNewMediaActivity by booleanValue(USE_NEW_MEDIA_ACTIVITY, false).falseForExternalUsers()
+
/**
* Members will not be added directly to a GV2 even if they could be.
*/
diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraFragment.java
index b480f939d4..8119c641db 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraFragment.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraFragment.java
@@ -31,6 +31,14 @@ public interface CameraFragment {
}
}
+ static Class extends Fragment> getFragmentClass() {
+ if (CameraXUtil.isSupported()) {
+ return CameraXFragment.class;
+ } else {
+ return Camera1Fragment.class;
+ }
+ }
+
@SuppressLint({ "RestrictedApi", "UnsafeOptInUsageError" })
static Fragment newInstanceForAvatarCapture() {
if (CameraXUtil.isSupported()) {
diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaRepository.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaRepository.java
index 0102e01bbe..33bd454ab8 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaRepository.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/MediaRepository.java
@@ -24,7 +24,6 @@ import org.thoughtcrime.securesms.R;
import org.signal.core.models.media.Media;
import org.signal.core.models.media.MediaFolder;
import org.signal.core.models.media.TransformProperties;
-import org.thoughtcrime.securesms.database.AttachmentTable;
import org.thoughtcrime.securesms.dependencies.AppDependencies;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.mms.PartAuthority;
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
new file mode 100644
index 0000000000..5e1a67c0b9
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Activity.kt
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.mediasend.v3
+
+import androidx.compose.runtime.Composable
+import io.reactivex.rxjava3.core.Flowable
+import org.signal.core.models.media.Media
+import org.signal.mediasend.MediaSendActivity
+import org.thoughtcrime.securesms.mediasend.CameraFragment
+import org.thoughtcrime.securesms.mms.MediaConstraints
+import java.io.FileDescriptor
+import java.util.Optional
+
+/**
+ * App-layer implementation of the feature module media send activity.
+ */
+class MediaSendV3Activity : MediaSendActivity(), CameraFragment.Controller {
+
+ override val preUploadCallback = MediaSendV3PreUploadCallback()
+ override val repository by lazy { MediaSendV3Repository(applicationContext) }
+
+ @Composable
+ override fun CameraSlot() {
+ MediaSendV3CameraSlot()
+ }
+
+ @Composable
+ override fun TextStoryEditorSlot() {
+ MediaSendV3PlaceholderScreen(text = "Text Story Editor")
+ }
+
+ @Composable
+ override fun VideoEditorSlot() {
+ MediaSendV3PlaceholderScreen(text = "Video Editor")
+ }
+
+ @Composable
+ override fun SendSlot() {
+ MediaSendV3PlaceholderScreen(text = "Send Review")
+ }
+
+ // region Camera Callbacks
+
+ override fun onCameraError() {
+ error("Not yet implemented")
+ }
+
+ override fun onImageCaptured(data: ByteArray, width: Int, height: Int) {
+ error("Not yet implemented")
+ }
+
+ override fun onVideoCaptured(fd: FileDescriptor) {
+ error("Not yet implemented")
+ }
+
+ override fun onVideoCaptureError() {
+ error("Not yet implemented")
+ }
+
+ override fun onGalleryClicked() {
+ error("Not yet implemented")
+ }
+
+ override fun onCameraCountButtonClicked() {
+ error("Not yet implemented")
+ }
+
+ override fun onQrCodeFound(data: String) {
+ error("Not yet implemented")
+ }
+
+ override fun getMostRecentMediaItem(): Flowable> {
+ error("Not yet implemented")
+ }
+
+ override fun getMediaConstraints(): MediaConstraints {
+ return MediaConstraints.getPushMediaConstraints()
+ }
+
+ override fun getMaxVideoDuration(): Int {
+ error("Not yet implemented")
+ }
+ // endregion
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3ActivityContract.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3ActivityContract.kt
new file mode 100644
index 0000000000..1cf245bba9
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3ActivityContract.kt
@@ -0,0 +1,13 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.mediasend.v3
+
+import org.signal.mediasend.MediaSendActivityContract
+
+/**
+ * Activity result contract bound to [MediaSendV3Activity].
+ */
+class MediaSendV3ActivityContract : MediaSendActivityContract(MediaSendV3Activity::class.java)
diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3CameraSlot.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3CameraSlot.kt
new file mode 100644
index 0000000000..965e323126
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3CameraSlot.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.mediasend.v3
+
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.fragment.compose.AndroidFragment
+import org.thoughtcrime.securesms.mediasend.CameraFragment
+
+/**
+ * Displays the proper camera capture ui
+ */
+@Composable
+fun MediaSendV3CameraSlot() {
+ val fragmentClass = remember {
+ CameraFragment.getFragmentClass()
+ }
+
+ AndroidFragment(
+ clazz = fragmentClass,
+ modifier = Modifier.fillMaxSize()
+ )
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3PreUploadCallback.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3PreUploadCallback.kt
new file mode 100644
index 0000000000..c9584fdc82
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3PreUploadCallback.kt
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.mediasend.v3
+
+import android.content.Context
+import androidx.annotation.WorkerThread
+import org.signal.core.models.media.Media
+import org.signal.mediasend.MediaRecipientId
+import org.signal.mediasend.preupload.PreUploadManager
+import org.signal.mediasend.preupload.PreUploadResult
+import org.thoughtcrime.securesms.attachments.AttachmentId
+import org.thoughtcrime.securesms.database.SignalDatabase
+import org.thoughtcrime.securesms.dependencies.AppDependencies
+import org.thoughtcrime.securesms.mediasend.MediaUploadRepository
+import org.thoughtcrime.securesms.recipients.Recipient
+import org.thoughtcrime.securesms.recipients.RecipientId
+import org.thoughtcrime.securesms.sms.MessageSender
+
+class MediaSendV3PreUploadCallback : PreUploadManager.Callback {
+
+ @WorkerThread
+ override fun preUpload(context: Context, media: Media, recipientId: MediaRecipientId?): PreUploadResult? {
+ val attachment = MediaUploadRepository.asAttachment(context, media)
+ val recipient = recipientId?.let { Recipient.resolved(RecipientId.from(it.id)) }
+ val legacyResult = MessageSender.preUploadPushAttachment(context, attachment, recipient, media) ?: return null
+ return PreUploadResult(
+ legacyResult.media,
+ legacyResult.attachmentId.id,
+ legacyResult.jobIds.toMutableList()
+ )
+ }
+
+ @WorkerThread
+ override fun cancelJobs(context: Context, jobIds: List) {
+ val jobManager = AppDependencies.jobManager
+ jobIds.forEach(jobManager::cancel)
+ }
+
+ @WorkerThread
+ override fun deleteAttachment(context: Context, attachmentId: Long) {
+ SignalDatabase.attachments.deleteAttachment(AttachmentId(attachmentId))
+ }
+
+ @WorkerThread
+ override fun updateAttachmentCaption(context: Context, attachmentId: Long, caption: String?) {
+ SignalDatabase.attachments.updateAttachmentCaption(AttachmentId(attachmentId), caption)
+ }
+
+ @WorkerThread
+ override fun updateDisplayOrder(context: Context, orderMap: Map) {
+ val mapped = orderMap.mapKeys { AttachmentId(it.key) }
+ SignalDatabase.attachments.updateDisplayOrder(mapped)
+ }
+
+ @WorkerThread
+ override fun deleteAbandonedPreuploadedAttachments(context: Context): Int {
+ return SignalDatabase.attachments.deleteAbandonedPreuploadedAttachments()
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Repository.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Repository.kt
new file mode 100644
index 0000000000..e92052098e
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Repository.kt
@@ -0,0 +1,267 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.mediasend.v3
+
+import android.content.Context
+import android.net.Uri
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.rx3.asFlow
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withContext
+import org.signal.core.models.media.Media
+import org.signal.core.models.media.MediaFolder
+import org.signal.mediasend.EditorState
+import org.signal.mediasend.MediaFilterError
+import org.signal.mediasend.MediaFilterResult
+import org.signal.mediasend.MediaRecipientId
+import org.signal.mediasend.MediaSendRepository
+import org.signal.mediasend.SendRequest
+import org.signal.mediasend.SendResult
+import org.signal.mediasend.StorySendRequirements
+import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
+import org.thoughtcrime.securesms.conversation.MessageSendType
+import org.thoughtcrime.securesms.database.SignalDatabase
+import org.thoughtcrime.securesms.database.model.IdentityRecord
+import org.thoughtcrime.securesms.dependencies.AppDependencies
+import org.thoughtcrime.securesms.mediasend.MediaRepository
+import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionRepository
+import org.thoughtcrime.securesms.mediasend.v2.MediaValidator
+import org.thoughtcrime.securesms.mediasend.v2.videos.VideoTrimData
+import org.thoughtcrime.securesms.mms.MediaConstraints
+import org.thoughtcrime.securesms.mms.SentMediaQuality
+import org.thoughtcrime.securesms.providers.BlobProvider
+import org.thoughtcrime.securesms.recipients.Recipient
+import org.thoughtcrime.securesms.recipients.RecipientId
+import org.thoughtcrime.securesms.scribbles.ImageEditorFragment
+import org.thoughtcrime.securesms.stories.Stories
+import org.thoughtcrime.securesms.util.MediaUtil
+import java.util.concurrent.TimeUnit
+import kotlin.coroutines.resume
+import kotlin.time.Duration.Companion.seconds
+
+/**
+ * App-layer implementation of [MediaSendRepository] that bridges to legacy v2 infrastructure.
+ */
+class MediaSendV3Repository(
+ context: Context
+) : MediaSendRepository {
+
+ private val appContext = context.applicationContext
+ private val legacyRepository = MediaSelectionRepository(appContext)
+ private val mediaRepository = MediaRepository()
+
+ override suspend fun getFolders(): List = suspendCancellableCoroutine { continuation ->
+ mediaRepository.getFolders(appContext) { folders ->
+ continuation.resume(folders)
+ }
+ }
+
+ override suspend fun getMedia(bucketId: String): List = suspendCancellableCoroutine { continuation ->
+ mediaRepository.getMediaInBucket(appContext, bucketId) { media ->
+ continuation.resume(media)
+ }
+ }
+
+ override suspend fun validateAndFilterMedia(
+ media: List,
+ maxSelection: Int,
+ isStory: Boolean
+ ): MediaFilterResult = withContext(Dispatchers.IO) {
+ val populated = MediaRepository().getPopulatedMedia(appContext, media)
+ val constraints = MediaConstraints.getPushMediaConstraints()
+ val result = MediaValidator.filterMedia(appContext, populated, constraints, maxSelection, isStory)
+
+ val error = mapFilterError(result.filterError, populated, constraints, maxSelection, isStory)
+ MediaFilterResult(result.filteredMedia, error)
+ }
+
+ override suspend fun deleteBlobs(media: List) {
+ media
+ .map(Media::uri)
+ .filter(BlobProvider::isAuthority)
+ .forEach { BlobProvider.getInstance().delete(appContext, it) }
+ }
+
+ override suspend fun send(request: SendRequest): SendResult = withContext(Dispatchers.IO) {
+ val recipients = buildRecipients(request)
+ if (recipients.isEmpty()) {
+ return@withContext SendResult.Error("No recipients provided.")
+ }
+
+ val singleContact = if (recipients.size == 1) recipients.first() else null
+ val contacts = if (recipients.size > 1) recipients else emptyList()
+
+ val legacyEditorStateMap = mapLegacyEditorState(request.editorStateMap)
+ val quality = SentMediaQuality.fromCode(request.quality)
+
+ return@withContext try {
+ legacyRepository.send(
+ selectedMedia = request.selectedMedia,
+ stateMap = legacyEditorStateMap,
+ quality = quality,
+ message = request.message,
+ isViewOnce = request.isViewOnce,
+ singleContact = singleContact,
+ contacts = contacts,
+ mentions = emptyList(),
+ bodyRanges = null,
+ sendType = resolveSendType(request.sendType),
+ scheduledTime = request.scheduledTime
+ ).blockingGet()
+ SendResult.Success
+ } catch (exception: Exception) {
+ SendResult.Error(exception.message ?: "Failed to send media.")
+ }
+ }
+
+ override fun getMaxVideoDurationUs(quality: Int, maxFileSizeBytes: Long): Long {
+ val preset = MediaConstraints.getPushMediaConstraints(SentMediaQuality.fromCode(quality)).videoTranscodingSettings
+ return preset.calculateMaxVideoUploadDurationInSeconds(maxFileSizeBytes).seconds.inWholeMicroseconds
+ }
+
+ override fun getVideoMaxSizeBytes(): Long {
+ return MediaConstraints.getPushMediaConstraints().videoMaxSize
+ }
+
+ override fun isVideoTranscodeAvailable(): Boolean {
+ return MediaConstraints.isVideoTranscodeAvailable()
+ }
+
+ override suspend fun getStorySendRequirements(media: List): StorySendRequirements = withContext(Dispatchers.IO) {
+ when (Stories.MediaTransform.getSendRequirements(media)) {
+ Stories.MediaTransform.SendRequirements.VALID_DURATION -> StorySendRequirements.CAN_SEND
+ Stories.MediaTransform.SendRequirements.REQUIRES_CLIP -> StorySendRequirements.REQUIRES_CROP
+ Stories.MediaTransform.SendRequirements.CAN_NOT_SEND -> StorySendRequirements.CAN_NOT_SEND
+ }
+ }
+
+ override suspend fun checkUntrustedIdentities(contactIds: Set, since: Long): List = withContext(Dispatchers.IO) {
+ if (contactIds.isEmpty()) return@withContext emptyList()
+
+ val recipients: List = contactIds
+ .map { Recipient.resolved(RecipientId.from(it)) }
+ .map { recipient ->
+ when {
+ recipient.isGroup -> Recipient.resolvedList(recipient.participantIds)
+ recipient.isDistributionList -> Recipient.resolvedList(SignalDatabase.distributionLists.getMembers(recipient.distributionListId.get()))
+ else -> listOf(recipient)
+ }
+ }
+ .flatten()
+
+ val calculatedWindow = System.currentTimeMillis() - since
+ val identityRecords = AppDependencies
+ .protocolStore
+ .aci()
+ .identities()
+ .getIdentityRecords(recipients)
+
+ val untrusted = identityRecords.getUntrustedRecords(
+ calculatedWindow.coerceIn(TimeUnit.SECONDS.toMillis(5)..TimeUnit.HOURS.toMillis(1))
+ )
+
+ (untrusted + identityRecords.unverifiedRecords)
+ .distinctBy(IdentityRecord::recipientId)
+ .map { it.recipientId.toLong() }
+ }
+
+ override fun observeRecipientValid(recipientId: MediaRecipientId): Flow {
+ return Recipient.observable(RecipientId.from(recipientId.id))
+ .asFlow()
+ .map { recipient ->
+ recipient.isGroup || recipient.isDistributionList || recipient.isRegistered
+ }
+ .distinctUntilChanged()
+ }
+
+ private fun resolveSendType(sendType: Int): MessageSendType {
+ return when (sendType) {
+ else -> MessageSendType.SignalMessageSendType
+ }
+ }
+
+ private fun buildRecipients(request: SendRequest): List {
+ return buildList {
+ request.singleRecipientId?.let { add(it) }
+ addAll(request.recipientIds)
+ }.distinctBy(MediaRecipientId::id).map {
+ ContactSearchKey.RecipientSearchKey(RecipientId.from(it.id), request.isStory)
+ }
+ }
+
+ private fun mapLegacyEditorState(editorStateMap: Map): Map {
+ return editorStateMap.mapNotNull { (uri, state) ->
+ val legacyState: Any = when (state) {
+ is EditorState.Image -> ImageEditorFragment.Data().apply { writeModel(state.model) }
+ is EditorState.VideoTrim -> VideoTrimData(
+ isDurationEdited = state.isDurationEdited,
+ totalInputDurationUs = state.totalInputDurationUs,
+ startTimeUs = state.startTimeUs,
+ endTimeUs = state.endTimeUs
+ )
+ }
+ uri to legacyState
+ }.toMap()
+ }
+
+ private fun mapFilterError(
+ error: MediaValidator.FilterError?,
+ media: List,
+ constraints: MediaConstraints,
+ maxSelection: Int,
+ isStory: Boolean
+ ): MediaFilterError? {
+ return when (error) {
+ is MediaValidator.FilterError.NoItems -> MediaFilterError.NoItems
+ is MediaValidator.FilterError.TooManyItems -> MediaFilterError.TooManyItems(maxSelection)
+ is MediaValidator.FilterError.ItemInvalidType -> {
+ findFirstInvalidType(media)?.let { MediaFilterError.ItemInvalidType(it) }
+ ?: MediaFilterError.Other("One or more items have an invalid type.")
+ }
+ is MediaValidator.FilterError.ItemTooLarge -> {
+ findFirstTooLarge(media, constraints, isStory)?.let { MediaFilterError.ItemTooLarge(it) }
+ ?: MediaFilterError.Other("One or more items are too large.")
+ }
+ MediaValidator.FilterError.None, null -> null
+ }
+ }
+
+ private fun findFirstInvalidType(media: List): Media? {
+ return media.firstOrNull { item ->
+ val contentType = item.contentType ?: return@firstOrNull true
+ !MediaUtil.isGif(contentType) &&
+ !MediaUtil.isImageType(contentType) &&
+ !MediaUtil.isVideoType(contentType) &&
+ !MediaUtil.isDocumentType(contentType)
+ }
+ }
+
+ private fun findFirstTooLarge(
+ media: List,
+ constraints: MediaConstraints,
+ isStory: Boolean
+ ): Media? {
+ return media.firstOrNull { item ->
+ val contentType = item.contentType ?: return@firstOrNull true
+ val size = item.size
+
+ val isTooLarge = when {
+ MediaUtil.isGif(contentType) -> size > constraints.getGifMaxSize(appContext)
+ MediaUtil.isVideoType(contentType) -> size > constraints.getUncompressedVideoMaxSize(appContext)
+ MediaUtil.isImageType(contentType) -> size > constraints.getImageMaxSize(appContext)
+ MediaUtil.isDocumentType(contentType) -> size > constraints.getDocumentMaxSize(appContext)
+ else -> true
+ }
+
+ val isStoryInvalid = isStory && Stories.MediaTransform.getSendRequirements(item) == Stories.MediaTransform.SendRequirements.CAN_NOT_SEND
+
+ isTooLarge || isStoryInvalid
+ }
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Slots.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Slots.kt
new file mode 100644
index 0000000000..ccf6019891
--- /dev/null
+++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Slots.kt
@@ -0,0 +1,20 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.thoughtcrime.securesms.mediasend.v3
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+
+@Composable
+internal fun MediaSendV3PlaceholderScreen(text: String, modifier: Modifier = Modifier) {
+ Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text(text = text)
+ }
+}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/PartAuthority.java b/app/src/main/java/org/thoughtcrime/securesms/mms/PartAuthority.java
index 823bb4f916..263bdae911 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/mms/PartAuthority.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/mms/PartAuthority.java
@@ -17,13 +17,11 @@ import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.attachments.AttachmentId;
import org.thoughtcrime.securesms.avatar.AvatarPickerStorage;
import org.signal.core.models.media.TransformProperties;
-import org.thoughtcrime.securesms.database.AttachmentTable;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.emoji.EmojiFiles;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.providers.DeprecatedPersistentBlobProvider;
import org.thoughtcrime.securesms.providers.PartProvider;
-import org.thoughtcrime.securesms.wallpaper.WallpaperStorage;
import java.io.FileNotFoundException;
import java.io.IOException;
diff --git a/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java b/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java
index 439e5d8532..94825ba999 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/scribbles/ImageEditorFragment.java
@@ -129,7 +129,7 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
this(new Bundle());
}
- void writeModel(@NonNull EditorModel model) {
+ public void writeModel(@NonNull EditorModel model) {
byte[] bytes = ParcelUtil.serialize(model);
bundle.putByteArray("MODEL", bytes);
}
diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MediaUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/MediaUtil.java
index c8101fcd0e..10564e891a 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/util/MediaUtil.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/util/MediaUtil.java
@@ -26,6 +26,7 @@ import com.bumptech.glide.Glide;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.bumptech.glide.load.resource.gif.GifDrawable;
+import org.signal.core.util.ContentTypeUtil;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.attachments.AttachmentId;
@@ -56,23 +57,23 @@ public class MediaUtil {
private static final String TAG = Log.tag(MediaUtil.class);
- public static final String IMAGE_PNG = "image/png";
- public static final String IMAGE_JPEG = "image/jpeg";
- public static final String IMAGE_HEIC = "image/heic";
- public static final String IMAGE_HEIF = "image/heif";
- public static final String IMAGE_AVIF = "image/avif";
- public static final String IMAGE_WEBP = "image/webp";
- public static final String IMAGE_GIF = "image/gif";
- public static final String AUDIO_AAC = "audio/aac";
- public static final String AUDIO_MP4 = "audio/mp4";
- public static final String AUDIO_UNSPECIFIED = "audio/*";
- public static final String VIDEO_MP4 = "video/mp4";
- public static final String VIDEO_UNSPECIFIED = "video/*";
- public static final String VCARD = "text/x-vcard";
- public static final String LONG_TEXT = "text/x-signal-plain";
- public static final String VIEW_ONCE = "application/x-signal-view-once";
- public static final String UNKNOWN = "*/*";
- public static final String OCTET = "application/octet-stream";
+ public static final String IMAGE_PNG = ContentTypeUtil.IMAGE_PNG;
+ public static final String IMAGE_JPEG = ContentTypeUtil.IMAGE_JPEG;
+ public static final String IMAGE_HEIC = ContentTypeUtil.IMAGE_HEIC;
+ public static final String IMAGE_HEIF = ContentTypeUtil.IMAGE_HEIF;
+ public static final String IMAGE_AVIF = ContentTypeUtil.IMAGE_AVIF;
+ public static final String IMAGE_WEBP = ContentTypeUtil.IMAGE_WEBP;
+ public static final String IMAGE_GIF = ContentTypeUtil.IMAGE_GIF;
+ public static final String AUDIO_AAC = ContentTypeUtil.AUDIO_AAC;
+ public static final String AUDIO_MP4 = ContentTypeUtil.AUDIO_MP4;
+ public static final String AUDIO_UNSPECIFIED = ContentTypeUtil.AUDIO_UNSPECIFIED;
+ public static final String VIDEO_MP4 = ContentTypeUtil.VIDEO_MP4;
+ public static final String VIDEO_UNSPECIFIED = ContentTypeUtil.VIDEO_UNSPECIFIED;
+ public static final String VCARD = ContentTypeUtil.VCARD;
+ public static final String LONG_TEXT = ContentTypeUtil.LONG_TEXT;
+ public static final String VIEW_ONCE = ContentTypeUtil.VIEW_ONCE;
+ public static final String UNKNOWN = ContentTypeUtil.UNKNOWN;
+ public static final String OCTET = ContentTypeUtil.OCTET;
public static @NonNull SlideType getSlideTypeFromContentType(@Nullable String contentType) {
if (isGif(contentType)) {
@@ -286,7 +287,7 @@ public class MediaUtil {
}
public static boolean isMms(String contentType) {
- return !TextUtils.isEmpty(contentType) && contentType.trim().equals("application/mms");
+ return ContentTypeUtil.isMms(contentType);
}
public static boolean isGif(Attachment attachment) {
@@ -318,39 +319,39 @@ public class MediaUtil {
}
public static boolean isVideo(String contentType) {
- return !TextUtils.isEmpty(contentType) && contentType.trim().startsWith("video/");
+ return ContentTypeUtil.isVideo(contentType);
}
public static boolean isVcard(String contentType) {
- return !TextUtils.isEmpty(contentType) && contentType.trim().equals(VCARD);
+ return ContentTypeUtil.isVcard(contentType);
}
public static boolean isGif(String contentType) {
- return !TextUtils.isEmpty(contentType) && contentType.trim().equals("image/gif");
+ return ContentTypeUtil.isGif(contentType);
}
public static boolean isJpegType(String contentType) {
- return !TextUtils.isEmpty(contentType) && contentType.trim().equals(IMAGE_JPEG);
+ return ContentTypeUtil.isJpegType(contentType);
}
public static boolean isHeicType(String contentType) {
- return !TextUtils.isEmpty(contentType) && contentType.trim().equals(IMAGE_HEIC);
+ return ContentTypeUtil.isHeicType(contentType);
}
public static boolean isHeifType(String contentType) {
- return !TextUtils.isEmpty(contentType) && contentType.trim().equals(IMAGE_HEIF);
+ return ContentTypeUtil.isHeifType(contentType);
}
public static boolean isAvifType(String contentType) {
- return !TextUtils.isEmpty(contentType) && contentType.trim().equals(IMAGE_AVIF);
+ return ContentTypeUtil.isAvifType(contentType);
}
public static boolean isWebpType(String contentType) {
- return !TextUtils.isEmpty(contentType) && contentType.trim().equals(IMAGE_WEBP);
+ return ContentTypeUtil.isWebpType(contentType);
}
public static boolean isPngType(String contentType) {
- return !TextUtils.isEmpty(contentType) && contentType.trim().equals(IMAGE_PNG);
+ return ContentTypeUtil.isPngType(contentType);
}
public static boolean isFile(Attachment attachment) {
@@ -358,7 +359,7 @@ public class MediaUtil {
}
public static boolean isTextType(String contentType) {
- return (null != contentType) && contentType.startsWith("text/");
+ return ContentTypeUtil.isTextType(contentType);
}
public static boolean isNonGifVideo(Media media) {
@@ -366,62 +367,47 @@ public class MediaUtil {
}
public static boolean isImageType(String contentType) {
- if (contentType == null) {
- return false;
- }
-
- return (contentType.startsWith("image/") && !contentType.equals("image/svg+xml")) ||
- contentType.equals(MediaStore.Images.Media.CONTENT_TYPE);
+ return ContentTypeUtil.isImageType(contentType);
}
public static boolean isAudioType(String contentType) {
- if (contentType == null) {
- return false;
- }
-
- return contentType.startsWith("audio/") ||
- contentType.equals(MediaStore.Audio.Media.CONTENT_TYPE);
+ return ContentTypeUtil.isAudioType(contentType);
}
public static boolean isVideoType(String contentType) {
- if (contentType == null) {
- return false;
- }
-
- return contentType.startsWith("video/") ||
- contentType.equals(MediaStore.Video.Media.CONTENT_TYPE);
+ return ContentTypeUtil.isVideoType(contentType);
}
public static boolean isImageOrVideoType(String contentType) {
- return isImageType(contentType) || isVideoType(contentType);
+ return ContentTypeUtil.isImageOrVideoType(contentType);
}
public static boolean isStorySupportedType(String contentType) {
- return isImageOrVideoType(contentType) && !isGif(contentType);
+ return ContentTypeUtil.isStorySupportedType(contentType);
}
public static boolean isImageVideoOrAudioType(String contentType) {
- return isImageOrVideoType(contentType) || isAudioType(contentType);
+ return ContentTypeUtil.isImageVideoOrAudioType(contentType);
}
public static boolean isImageAndNotGif(@NonNull String contentType) {
- return isImageType(contentType) && !isGif(contentType);
+ return ContentTypeUtil.isImageAndNotGif(contentType);
}
public static boolean isLongTextType(String contentType) {
- return (null != contentType) && contentType.equals(LONG_TEXT);
+ return ContentTypeUtil.isLongTextType(contentType);
}
public static boolean isViewOnceType(String contentType) {
- return (null != contentType) && contentType.equals(VIEW_ONCE);
+ return ContentTypeUtil.isViewOnceType(contentType);
}
public static boolean isOctetStream(@Nullable String contentType) {
- return OCTET.equals(contentType);
+ return ContentTypeUtil.isOctetStream(contentType);
}
public static boolean isDocumentType(String contentType) {
- return !isImageOrVideoType(contentType) && !isGif(contentType) && !isLongTextType(contentType) && !isViewOnceType(contentType);
+ return ContentTypeUtil.isDocumentType(contentType);
}
public static boolean hasVideoThumbnail(@NonNull Context context, @Nullable Uri uri) {
diff --git a/core/models/build.gradle.kts b/core/models/build.gradle.kts
index 8f44920779..f30337c658 100644
--- a/core/models/build.gradle.kts
+++ b/core/models/build.gradle.kts
@@ -16,4 +16,5 @@ android {
dependencies {
implementation(libs.kotlinx.serialization.json)
implementation(libs.jackson.core)
+ implementation(libs.jackson.module.kotlin)
}
diff --git a/core/models/src/main/java/org/signal/core/models/media/MediaFolder.kt b/core/models/src/main/java/org/signal/core/models/media/MediaFolder.kt
index f760a066e5..0327545108 100644
--- a/core/models/src/main/java/org/signal/core/models/media/MediaFolder.kt
+++ b/core/models/src/main/java/org/signal/core/models/media/MediaFolder.kt
@@ -6,17 +6,20 @@
package org.signal.core.models.media
import android.net.Uri
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
/**
* Represents a folder that's shown in a media selector, containing [Media] items.
*/
+@Parcelize
data class MediaFolder(
val thumbnailUri: Uri,
val title: String,
val itemCount: Int,
val bucketId: String,
val folderType: FolderType
-) {
+) : Parcelable {
enum class FolderType {
NORMAL, CAMERA
}
diff --git a/core/models/src/main/java/org/signal/core/models/media/TransformProperties.kt b/core/models/src/main/java/org/signal/core/models/media/TransformProperties.kt
index f14830dd2b..51e3569e45 100644
--- a/core/models/src/main/java/org/signal/core/models/media/TransformProperties.kt
+++ b/core/models/src/main/java/org/signal/core/models/media/TransformProperties.kt
@@ -9,35 +9,51 @@ import android.os.Parcelable
import com.fasterxml.jackson.annotation.JsonProperty
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
+import kotlinx.serialization.EncodeDefault
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Properties that describe transformations to be applied to media before sending.
*/
+@OptIn(ExperimentalSerializationApi::class)
@Serializable
@Parcelize
data class TransformProperties(
+ @EncodeDefault(EncodeDefault.Mode.ALWAYS)
@JsonProperty("skipTransform")
+ @SerialName("skipTransform")
@JvmField
val skipTransform: Boolean = false,
+ @EncodeDefault(EncodeDefault.Mode.ALWAYS)
@JsonProperty("videoTrim")
+ @SerialName("videoTrim")
@JvmField
val videoTrim: Boolean = false,
+ @EncodeDefault(EncodeDefault.Mode.ALWAYS)
@JsonProperty("videoTrimStartTimeUs")
+ @SerialName("videoTrimStartTimeUs")
@JvmField
val videoTrimStartTimeUs: Long = 0,
+ @EncodeDefault(EncodeDefault.Mode.ALWAYS)
@JsonProperty("videoTrimEndTimeUs")
+ @SerialName("videoTrimEndTimeUs")
@JvmField
val videoTrimEndTimeUs: Long = 0,
+ @EncodeDefault(EncodeDefault.Mode.ALWAYS)
@JsonProperty("sentMediaQuality")
+ @SerialName("sentMediaQuality")
@JvmField
val sentMediaQuality: Int = DEFAULT_MEDIA_QUALITY,
+ @EncodeDefault(EncodeDefault.Mode.ALWAYS)
@JsonProperty("mp4Faststart")
+ @SerialName("mp4Faststart")
@JvmField
val mp4FastStart: Boolean = false
) : Parcelable {
@@ -46,7 +62,9 @@ data class TransformProperties(
}
@IgnoredOnParcel
+ @EncodeDefault(EncodeDefault.Mode.ALWAYS)
@JsonProperty("videoEdited")
+ @SerialName("videoEdited")
val videoEdited: Boolean = videoTrim
fun withSkipTransform(): TransformProperties {
diff --git a/core/models/src/test/java/org/signal/core/models/media/TransformPropertiesTest.kt b/core/models/src/test/java/org/signal/core/models/media/TransformPropertiesTest.kt
new file mode 100644
index 0000000000..733a58c567
--- /dev/null
+++ b/core/models/src/test/java/org/signal/core/models/media/TransformPropertiesTest.kt
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.core.models.media
+
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.fasterxml.jackson.module.kotlin.registerKotlinModule
+import kotlinx.serialization.json.Json
+import org.junit.Assert
+import org.junit.Test
+
+class TransformPropertiesTest {
+
+ @Test
+ fun `kotlinx serialize matches legacy json keys`() {
+ val properties = TransformProperties.empty()
+
+ Assert.assertEquals(
+ "{\"skipTransform\":false,\"videoTrim\":false,\"videoTrimStartTimeUs\":0,\"videoTrimEndTimeUs\":0,\"sentMediaQuality\":0,\"mp4Faststart\":false,\"videoEdited\":false}",
+ Json.encodeToString(properties)
+ )
+ }
+
+ @Test
+ fun `kotlinx parse tolerates legacy mp4Faststart key`() {
+ val json = "{\"skipTransform\":false,\"videoTrim\":false,\"videoTrimStartTimeUs\":0,\"videoTrimEndTimeUs\":0,\"videoEdited\":false,\"mp4Faststart\":true}"
+
+ val parsed = Json.decodeFromString(json)
+
+ Assert.assertEquals(true, parsed.mp4FastStart)
+ Assert.assertEquals(false, parsed.videoTrim)
+ Assert.assertEquals(false, parsed.videoEdited)
+ }
+
+ @Test
+ fun `jackson serialize matches legacy json keys`() {
+ val properties = TransformProperties.empty()
+
+ val objectMapper = ObjectMapper().registerKotlinModule()
+ val encoded = objectMapper.writeValueAsString(properties)
+
+ Assert.assertEquals(
+ "{\"skipTransform\":false,\"videoTrim\":false,\"videoTrimStartTimeUs\":0,\"videoTrimEndTimeUs\":0,\"sentMediaQuality\":0,\"mp4Faststart\":false,\"videoEdited\":false}",
+ encoded
+ )
+ }
+
+ @Test
+ fun `jackson parse tolerates legacy mp4Faststart key`() {
+ val json = "{\"skipTransform\":false,\"videoTrim\":false,\"videoTrimStartTimeUs\":0,\"videoTrimEndTimeUs\":0,\"videoEdited\":false,\"mp4Faststart\":true}"
+
+ val objectMapper = ObjectMapper().registerKotlinModule()
+ val parsed = objectMapper.readValue(json, TransformProperties::class.java)
+
+ Assert.assertEquals(true, parsed.mp4FastStart)
+ Assert.assertEquals(false, parsed.videoTrim)
+ Assert.assertEquals(false, parsed.videoEdited)
+ }
+}
\ No newline at end of file
diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/ModifierExtensions.kt b/core/ui/src/main/java/org/signal/core/ui/compose/ModifierExtensions.kt
index 750b5a6ba9..c070f64c84 100644
--- a/core/ui/src/main/java/org/signal/core/ui/compose/ModifierExtensions.kt
+++ b/core/ui/src/main/java/org/signal/core/ui/compose/ModifierExtensions.kt
@@ -13,6 +13,7 @@ import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.layout
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
@@ -60,3 +61,13 @@ fun Modifier.clickableContainer(
Modifier
}
)
+
+fun Modifier.ensureWidthIsAtLeastHeight(): Modifier {
+ return this.layout { measurable, constraints ->
+ val placeable = measurable.measure(constraints)
+ val size = maxOf(placeable.width, placeable.height)
+ layout(size, size) {
+ placeable.placeRelative((size - placeable.width) / 2, (size - placeable.height) / 2)
+ }
+ }
+}
\ No newline at end of file
diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/Previews.kt b/core/ui/src/main/java/org/signal/core/ui/compose/Previews.kt
index 3b96657918..cea36f0740 100644
--- a/core/ui/src/main/java/org/signal/core/ui/compose/Previews.kt
+++ b/core/ui/src/main/java/org/signal/core/ui/compose/Previews.kt
@@ -15,10 +15,13 @@ import androidx.compose.material3.SheetValue
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.LayoutDirection
import org.signal.core.ui.compose.theme.SignalTheme
+import kotlin.random.Random
object Previews {
/**
@@ -88,4 +91,11 @@ object Previews {
}
}
}
+
+ @Composable
+ fun rememberRandomColor(): Color {
+ return remember {
+ Color(Random.nextFloat(), Random.nextFloat(), Random.nextFloat(), 1f)
+ }
+ }
}
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 a2d42fcc79..e0024c8ec6 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
@@ -32,6 +32,7 @@ import org.signal.core.ui.R
*/
enum class SignalIcons(private val icon: SignalIcon) : SignalIcon by icon {
ArrowStart(icon(R.drawable.symbol_arrow_start_24)),
+ ArrowEnd(icon(R.drawable.symbol_arrow_end_24)),
At(icon(R.drawable.symbol_at_24)),
Backup(icon(R.drawable.symbol_backup_24)),
Camera(icon(R.drawable.symbol_camera_24)),
@@ -40,6 +41,7 @@ enum class SignalIcons(private val icon: SignalIcon) : SignalIcon by icon {
ChevronRight(icon(R.drawable.symbol_chevron_right_24)),
Copy(icon(R.drawable.symbol_copy_android_24)),
Edit(icon(R.drawable.symbol_edit_24)),
+ Emoji(icon(R.drawable.symbol_emoji_24)),
ErrorCircle(icon(R.drawable.symbol_error_circle_fill_24)),
FlashAuto(icon(R.drawable.symbol_flash_auto_24)),
FlashOff(icon(R.drawable.symbol_flash_slash_24)),
diff --git a/core/ui/src/main/res/drawable/symbol_arrow_end_24.xml b/core/ui/src/main/res/drawable/symbol_arrow_end_24.xml
new file mode 100644
index 0000000000..ddd0cf63a9
--- /dev/null
+++ b/core/ui/src/main/res/drawable/symbol_arrow_end_24.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/core/ui/src/main/res/drawable/symbol_emoji_24.xml b/core/ui/src/main/res/drawable/symbol_emoji_24.xml
new file mode 100644
index 0000000000..11611ab3b0
--- /dev/null
+++ b/core/ui/src/main/res/drawable/symbol_emoji_24.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
diff --git a/core/util/src/main/java/org/signal/core/util/ContentTypeUtil.java b/core/util/src/main/java/org/signal/core/util/ContentTypeUtil.java
new file mode 100644
index 0000000000..8d3f11fbe2
--- /dev/null
+++ b/core/util/src/main/java/org/signal/core/util/ContentTypeUtil.java
@@ -0,0 +1,143 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.core.util;
+
+import android.provider.MediaStore;
+import android.text.TextUtils;
+
+import androidx.annotation.Nullable;
+
+/**
+ * Utility methods for working with media content types.
+ *
+ * Intended to live in core util so feature modules can reuse common
+ * MIME/type predicates without depending on app-layer utilities.
+ */
+public final class ContentTypeUtil {
+
+ private ContentTypeUtil() {}
+
+ public static final String IMAGE_PNG = "image/png";
+ public static final String IMAGE_JPEG = "image/jpeg";
+ public static final String IMAGE_HEIC = "image/heic";
+ public static final String IMAGE_HEIF = "image/heif";
+ public static final String IMAGE_AVIF = "image/avif";
+ public static final String IMAGE_WEBP = "image/webp";
+ public static final String IMAGE_GIF = "image/gif";
+ public static final String AUDIO_AAC = "audio/aac";
+ public static final String AUDIO_MP4 = "audio/mp4";
+ public static final String AUDIO_UNSPECIFIED = "audio/*";
+ public static final String VIDEO_MP4 = "video/mp4";
+ public static final String VIDEO_UNSPECIFIED = "video/*";
+ public static final String VCARD = "text/x-vcard";
+ public static final String LONG_TEXT = "text/x-signal-plain";
+ public static final String VIEW_ONCE = "application/x-signal-view-once";
+ public static final String UNKNOWN = "*/*";
+ public static final String OCTET = "application/octet-stream";
+
+ public static boolean isMms(@Nullable String contentType) {
+ return !TextUtils.isEmpty(contentType) && contentType.trim().equals("application/mms");
+ }
+
+ public static boolean isVideo(@Nullable String contentType) {
+ return !TextUtils.isEmpty(contentType) && contentType.trim().startsWith("video/");
+ }
+
+ public static boolean isVcard(@Nullable String contentType) {
+ return !TextUtils.isEmpty(contentType) && contentType.trim().equals(VCARD);
+ }
+
+ public static boolean isGif(@Nullable String contentType) {
+ return !TextUtils.isEmpty(contentType) && contentType.trim().equals(IMAGE_GIF);
+ }
+
+ public static boolean isJpegType(@Nullable String contentType) {
+ return !TextUtils.isEmpty(contentType) && contentType.trim().equals(IMAGE_JPEG);
+ }
+
+ public static boolean isHeicType(@Nullable String contentType) {
+ return !TextUtils.isEmpty(contentType) && contentType.trim().equals(IMAGE_HEIC);
+ }
+
+ public static boolean isHeifType(@Nullable String contentType) {
+ return !TextUtils.isEmpty(contentType) && contentType.trim().equals(IMAGE_HEIF);
+ }
+
+ public static boolean isAvifType(@Nullable String contentType) {
+ return !TextUtils.isEmpty(contentType) && contentType.trim().equals(IMAGE_AVIF);
+ }
+
+ public static boolean isWebpType(@Nullable String contentType) {
+ return !TextUtils.isEmpty(contentType) && contentType.trim().equals(IMAGE_WEBP);
+ }
+
+ public static boolean isPngType(@Nullable String contentType) {
+ return !TextUtils.isEmpty(contentType) && contentType.trim().equals(IMAGE_PNG);
+ }
+
+ public static boolean isTextType(@Nullable String contentType) {
+ return contentType != null && contentType.startsWith("text/");
+ }
+
+ public static boolean isImageType(@Nullable String contentType) {
+ if (contentType == null) {
+ return false;
+ }
+
+ return (contentType.startsWith("image/") && !contentType.equals("image/svg+xml")) ||
+ contentType.equals(MediaStore.Images.Media.CONTENT_TYPE);
+ }
+
+ public static boolean isAudioType(@Nullable String contentType) {
+ if (contentType == null) {
+ return false;
+ }
+
+ return contentType.startsWith("audio/") ||
+ contentType.equals(MediaStore.Audio.Media.CONTENT_TYPE);
+ }
+
+ public static boolean isVideoType(@Nullable String contentType) {
+ if (contentType == null) {
+ return false;
+ }
+
+ return contentType.startsWith("video/") ||
+ contentType.equals(MediaStore.Video.Media.CONTENT_TYPE);
+ }
+
+ public static boolean isImageOrVideoType(@Nullable String contentType) {
+ return isImageType(contentType) || isVideoType(contentType);
+ }
+
+ public static boolean isStorySupportedType(@Nullable String contentType) {
+ return isImageOrVideoType(contentType) && !isGif(contentType);
+ }
+
+ public static boolean isImageVideoOrAudioType(@Nullable String contentType) {
+ return isImageOrVideoType(contentType) || isAudioType(contentType);
+ }
+
+ public static boolean isImageAndNotGif(@Nullable String contentType) {
+ return isImageType(contentType) && !isGif(contentType);
+ }
+
+ public static boolean isLongTextType(@Nullable String contentType) {
+ return contentType != null && contentType.equals(LONG_TEXT);
+ }
+
+ public static boolean isViewOnceType(@Nullable String contentType) {
+ return contentType != null && contentType.equals(VIEW_ONCE);
+ }
+
+ public static boolean isOctetStream(@Nullable String contentType) {
+ return OCTET.equals(contentType);
+ }
+
+ public static boolean isDocumentType(@Nullable String contentType) {
+ return !isImageOrVideoType(contentType) && !isGif(contentType) && !isLongTextType(contentType) && !isViewOnceType(contentType);
+ }
+}
\ No newline at end of file
diff --git a/feature/media-send/build.gradle.kts b/feature/media-send/build.gradle.kts
new file mode 100644
index 0000000000..8a5ea0f7d8
--- /dev/null
+++ b/feature/media-send/build.gradle.kts
@@ -0,0 +1,77 @@
+plugins {
+ id("signal-library")
+ id("kotlin-parcelize")
+ alias(libs.plugins.ktlint)
+ alias(libs.plugins.compose.compiler)
+ alias(libs.plugins.kotlinx.serialization)
+}
+
+ktlint {
+ version.set("1.5.0")
+}
+
+android {
+ namespace = "org.signal.mediasend"
+
+ buildFeatures {
+ compose = true
+ }
+
+ testOptions {
+ unitTests {
+ isIncludeAndroidResources = true
+ }
+ }
+}
+
+dependencies {
+ lintChecks(project(":lintchecks"))
+ ktlintRuleset(libs.ktlint.twitter.compose)
+
+ // Project dependencies
+ implementation(libs.androidx.ui.test.junit4)
+ implementation(project(":core:ui"))
+ implementation(project(":core:util"))
+ implementation(project(":core:models"))
+ implementation(project(":lib:image-editor"))
+ implementation(project(":lib:glide"))
+
+ // Compose BOM
+ platform(libs.androidx.compose.bom).let { composeBom ->
+ implementation(composeBom)
+ androidTestImplementation(composeBom)
+ }
+
+ // Compose dependencies
+ implementation(libs.androidx.activity.compose)
+ implementation(libs.androidx.compose.material3)
+ implementation(libs.androidx.compose.ui.tooling.preview)
+ debugImplementation(libs.androidx.compose.ui.tooling.core)
+
+ // Navigation 3
+ implementation(libs.androidx.navigation3.runtime)
+ implementation(libs.androidx.navigation3.ui)
+
+ // Kotlinx Serialization
+ implementation(libs.kotlinx.serialization.json)
+
+ // Lifecycle
+ implementation(libs.androidx.lifecycle.runtime.compose)
+ implementation(libs.androidx.lifecycle.viewmodel.ktx)
+ implementation(libs.androidx.lifecycle.viewmodel.navigation3)
+
+ // Permissions
+ implementation(libs.accompanist.permissions)
+
+ // Testing
+ testImplementation(testLibs.junit.junit)
+ testImplementation(testLibs.mockk)
+ testImplementation(testLibs.assertk)
+ testImplementation(testLibs.kotlinx.coroutines.test)
+ testImplementation(testLibs.robolectric.robolectric)
+ testImplementation(libs.androidx.compose.ui.test.junit4)
+ androidTestImplementation(testLibs.androidx.test.ext.junit)
+ androidTestImplementation(libs.androidx.compose.ui.test.junit4)
+ implementation(libs.androidx.compose.ui.test.manifest)
+ debugImplementation(libs.androidx.compose.ui.test.manifest)
+}
diff --git a/feature/media-send/src/main/AndroidManifest.xml b/feature/media-send/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..2cf11af26b
--- /dev/null
+++ b/feature/media-send/src/main/AndroidManifest.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
diff --git a/feature/media-send/src/main/java/org/signal/mediasend/EditorState.kt b/feature/media-send/src/main/java/org/signal/mediasend/EditorState.kt
new file mode 100644
index 0000000000..8118e30fc5
--- /dev/null
+++ b/feature/media-send/src/main/java/org/signal/mediasend/EditorState.kt
@@ -0,0 +1,113 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.mediasend
+
+import android.os.Bundle
+import android.os.Parcelable
+import androidx.core.os.bundleOf
+import kotlinx.parcelize.Parcelize
+import org.signal.core.util.getParcelableCompat
+import org.signal.imageeditor.core.model.EditorModel
+
+/**
+ * Sealed interface for per-media editor state. All subtypes are [Parcelable] so the
+ * entire editor state map can be persisted in [SavedStateHandle].
+ */
+sealed interface EditorState : Parcelable {
+
+ /**
+ * Video trim/duration editing state.
+ */
+ @Parcelize
+ data class VideoTrim(
+ val isDurationEdited: Boolean = false,
+ val totalInputDurationUs: Long = 0,
+ val startTimeUs: Long = 0,
+ val endTimeUs: Long = 0
+ ) : EditorState {
+
+ val clipDurationUs: Long get() = endTimeUs - startTimeUs
+
+ /**
+ * Clamps this trim data to the maximum allowed clip duration.
+ *
+ * @param maxDurationUs Maximum allowed duration in microseconds.
+ * @param preserveStartTime If true, keeps start time and adjusts end; otherwise adjusts start.
+ * @return Clamped VideoTrim, or this if already within limits.
+ */
+ fun clampToMaxDuration(maxDurationUs: Long, preserveStartTime: Boolean): VideoTrim {
+ if (clipDurationUs <= maxDurationUs) {
+ return this
+ }
+
+ return copy(
+ isDurationEdited = true,
+ startTimeUs = if (!preserveStartTime) endTimeUs - maxDurationUs else startTimeUs,
+ endTimeUs = if (preserveStartTime) startTimeUs + maxDurationUs else endTimeUs
+ )
+ }
+
+ companion object {
+ private const val KEY_IS_DURATION_EDITED = "isDurationEdited"
+ private const val KEY_TOTAL_INPUT_DURATION_US = "totalInputDurationUs"
+ private const val KEY_START_TIME_US = "startTimeUs"
+ private const val KEY_END_TIME_US = "endTimeUs"
+
+ fun fromBundle(bundle: Bundle): VideoTrim {
+ return VideoTrim(
+ isDurationEdited = bundle.getBoolean(KEY_IS_DURATION_EDITED, false),
+ totalInputDurationUs = bundle.getLong(KEY_TOTAL_INPUT_DURATION_US, 0),
+ startTimeUs = bundle.getLong(KEY_START_TIME_US, 0),
+ endTimeUs = bundle.getLong(KEY_END_TIME_US, 0)
+ )
+ }
+
+ /**
+ * Creates initial trim data for a video, clamping to max duration if needed.
+ */
+ fun forVideo(durationUs: Long, maxDurationUs: Long): VideoTrim {
+ return if (durationUs <= maxDurationUs) {
+ VideoTrim(
+ isDurationEdited = false,
+ totalInputDurationUs = durationUs,
+ startTimeUs = 0,
+ endTimeUs = durationUs
+ )
+ } else {
+ VideoTrim(
+ isDurationEdited = true,
+ totalInputDurationUs = durationUs,
+ startTimeUs = 0,
+ endTimeUs = maxDurationUs
+ )
+ }
+ }
+ }
+
+ fun toBundle(): Bundle = Bundle().apply {
+ putBoolean(KEY_IS_DURATION_EDITED, isDurationEdited)
+ putLong(KEY_TOTAL_INPUT_DURATION_US, totalInputDurationUs)
+ putLong(KEY_START_TIME_US, startTimeUs)
+ putLong(KEY_END_TIME_US, endTimeUs)
+ }
+ }
+
+ /**
+ * Image editor state.
+ */
+ @Parcelize
+ data class Image(
+ val model: EditorModel
+ ) : EditorState {
+ companion object {
+ private const val KEY_MODEL = "model"
+
+ fun fromBundle(bundle: Bundle): Image = Image(bundle.getParcelableCompat(KEY_MODEL, EditorModel::class.java)!!)
+ }
+
+ fun toBundle(): Bundle = bundleOf(KEY_MODEL to model)
+ }
+}
diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaCaptureScreen.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaCaptureScreen.kt
new file mode 100644
index 0000000000..580a12499b
--- /dev/null
+++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaCaptureScreen.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.mediasend
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.navigation3.runtime.NavBackStack
+import androidx.navigation3.runtime.NavKey
+import org.signal.core.ui.compose.Buttons
+
+/**
+ * Screen that allows user to capture the media they will send using a camera or text story
+ */
+@Composable
+fun MediaCaptureScreen(
+ backStack: NavBackStack,
+ cameraSlot: @Composable () -> Unit,
+ textStoryEditorSlot: @Composable () -> Unit
+) {
+ Box(modifier = Modifier.fillMaxSize()) {
+ val top = backStack.last()
+
+ when (top) {
+ is MediaSendNavKey.Capture.Camera -> cameraSlot()
+ is MediaSendNavKey.Capture.TextStory -> textStoryEditorSlot()
+ }
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .align(Alignment.BottomCenter)
+ ) {
+ Buttons.Small(onClick = {
+ if (top == MediaSendNavKey.Capture.TextStory) {
+ backStack.remove(top)
+ }
+ }) {
+ Text(text = "Camera")
+ }
+
+ Buttons.Small(onClick = {
+ if (top == MediaSendNavKey.Capture.Camera) {
+ backStack.add(MediaSendNavKey.Capture.TextStory)
+ }
+ }) {
+ Text(text = "Text Story")
+ }
+ }
+ }
+}
diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaRecipientId.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaRecipientId.kt
new file mode 100644
index 0000000000..1b205fe4ca
--- /dev/null
+++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaRecipientId.kt
@@ -0,0 +1,21 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.mediasend
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+/**
+ * Feature-module value type representing a recipient identifier.
+ *
+ * This avoids coupling to the app-layer `RecipientId` while making it clear we're passing
+ * a recipient reference rather than an arbitrary Long.
+ */
+@Parcelize
+@JvmInline
+value class MediaRecipientId(val id: Long) : Parcelable {
+ override fun toString(): String = "MediaRecipientId($id)"
+}
diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendActivity.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendActivity.kt
new file mode 100644
index 0000000000..48103ed7be
--- /dev/null
+++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendActivity.kt
@@ -0,0 +1,131 @@
+package org.signal.mediasend
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.activity.viewModels
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.fragment.app.FragmentActivity
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.navigation3.runtime.rememberNavBackStack
+import org.signal.core.ui.compose.theme.SignalTheme
+import org.signal.mediasend.preupload.PreUploadManager
+import org.signal.mediasend.select.MediaSelectScreen
+
+/**
+ * Abstract base activity for the media sending flow.
+ *
+ * App-layer implementations must extend this class and provide:
+ * - [preUploadCallback] — For pre-upload job management
+ * - [repository] — For media validation, sending, and other app-layer operations
+ * - UI slots: [CameraSlot], [TextStoryEditorSlot], [MediaSelectSlot], [ImageEditorSlot], [VideoEditorSlot], [SendSlot]
+ *
+ * The concrete implementation should be registered in the app's manifest.
+ */
+abstract class MediaSendActivity : FragmentActivity() {
+
+ /** Pre-upload callback implementation for job management. */
+ protected abstract val preUploadCallback: PreUploadManager.Callback
+
+ /** Repository implementation for app-layer operations. */
+ protected abstract val repository: MediaSendRepository
+
+ /** Contract args extracted from intent. Available after super.onCreate(). */
+ protected lateinit var contractArgs: MediaSendActivityContract.Args
+ private set
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ enableEdgeToEdge()
+ super.onCreate(savedInstanceState)
+
+ contractArgs = MediaSendActivityContract.Args.fromIntent(intent)
+
+ setContent {
+ val viewModel by viewModels(factoryProducer = {
+ MediaSendViewModel.Factory(
+ context = applicationContext,
+ args = contractArgs,
+ isMeteredFlow = MeteredConnectivity.isMetered(applicationContext),
+ repository = repository,
+ preUploadCallback = preUploadCallback
+ )
+ })
+
+ val state by viewModel.state.collectAsStateWithLifecycle()
+ val backStack = rememberNavBackStack(
+ if (state.isCameraFirst) MediaSendNavKey.Capture.Camera else MediaSendNavKey.Select
+ )
+
+ Theme {
+ Surface {
+ MediaSendNavDisplay(
+ state = state,
+ backStack = backStack,
+ callback = viewModel,
+ modifier = Modifier.fillMaxSize(),
+ cameraSlot = { CameraSlot() },
+ textStoryEditorSlot = { TextStoryEditorSlot() },
+ mediaSelectSlot = {
+ MediaSelectScreen(
+ state = state,
+ backStack = backStack,
+ callback = viewModel
+ )
+ },
+ videoEditorSlot = { VideoEditorSlot() },
+ sendSlot = { SendSlot() }
+ )
+ }
+ }
+ }
+ }
+
+ /** Theme wrapper */
+ @Composable
+ protected open fun Theme(content: @Composable () -> Unit) {
+ SignalTheme(incognitoKeyboardEnabled = false) {
+ content()
+ }
+ }
+
+ /** Camera capture UI slot. */
+ @Composable
+ protected abstract fun CameraSlot()
+
+ /** Text story editor UI slot. */
+ @Composable
+ protected abstract fun TextStoryEditorSlot()
+
+ /** Video editor UI slot. */
+ @Composable
+ protected abstract fun VideoEditorSlot()
+
+ /** Send/review UI slot. */
+ @Composable
+ protected abstract fun SendSlot()
+
+ companion object {
+ /**
+ * Creates an intent for a concrete [MediaSendActivity] subclass.
+ *
+ * @param context The context.
+ * @param activityClass The concrete activity class to launch.
+ * @param args The activity arguments.
+ */
+ fun createIntent(
+ context: Context,
+ activityClass: Class,
+ args: MediaSendActivityContract.Args = MediaSendActivityContract.Args()
+ ): Intent {
+ return Intent(context, activityClass).apply {
+ putExtra(MediaSendActivityContract.EXTRA_ARGS, args)
+ }
+ }
+ }
+}
diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendActivityContract.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendActivityContract.kt
new file mode 100644
index 0000000000..fea61bc475
--- /dev/null
+++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendActivityContract.kt
@@ -0,0 +1,224 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.mediasend
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Parcelable
+import androidx.activity.result.contract.ActivityResultContract
+import kotlinx.parcelize.Parcelize
+import org.signal.core.models.media.Media
+
+/**
+ * Well-defined entry/exit contract for the media sending flow.
+ *
+ * This intentionally supports a "multi-tier" outcome:
+ * - Return a payload to be sent by the caller (single-recipient / conversation-owned send pipeline).
+ * - Or send immediately in the flow and return an acknowledgement (multi-recipient / broadcast paths).
+ *
+ * Since [MediaSendActivity] is abstract, app-layer implementations should either:
+ * 1. Extend this contract and override [createIntent] to use their concrete activity class
+ * 2. Use the constructor that takes an activity class parameter
+ *
+ * Example:
+ * ```kotlin
+ * class MyMediaSendContract : MediaSendActivityContract(MyMediaSendActivity::class.java)
+ * ```
+ */
+open class MediaSendActivityContract(
+ private val activityClass: Class? = null
+) : ActivityResultContract() {
+
+ /**
+ * Creates the intent to launch the media send activity.
+ *
+ * Subclasses should override this if not using the constructor parameter.
+ */
+ override fun createIntent(context: Context, input: Args): Intent {
+ val clazz = activityClass
+ ?: throw IllegalStateException(
+ "MediaSendActivityContract requires either a concrete activity class in the constructor " +
+ "or an overridden createIntent() method. MediaSendActivity is abstract and cannot be launched directly."
+ )
+ return MediaSendActivity.createIntent(context, clazz, input)
+ }
+
+ override fun parseResult(resultCode: Int, intent: Intent?): Result? {
+ if (resultCode != Activity.RESULT_OK || intent == null) return null
+ return intent.getParcelableExtra(EXTRA_RESULT)
+ }
+
+ @Parcelize
+ data class Args(
+ val isCameraFirst: Boolean = false,
+ /**
+ * Optional recipient identifier for single-recipient flows.
+ */
+ val recipientId: MediaRecipientId? = null,
+ val mode: Mode = Mode.SingleRecipient,
+ /**
+ * Initial media to populate the selection.
+ * For gallery/editor flows, this is the pre-selected media.
+ * For camera-first flows, this is typically empty.
+ */
+ val initialMedia: List = emptyList(),
+ /**
+ * Initial message/caption text.
+ */
+ val initialMessage: String? = null,
+ /**
+ * Whether this is a reply flow (affects UI/constraints).
+ */
+ val isReply: Boolean = false,
+ /**
+ * Whether this is a story send flow.
+ */
+ val isStory: Boolean = false,
+ /**
+ * Whether this is specifically the "add to group story" flow.
+ */
+ val isAddToGroupStoryFlow: Boolean = false,
+ /**
+ * Maximum number of media items that can be selected.
+ */
+ val maxSelection: Int = 32,
+ /**
+ * Send type identifier (app-layer enum ordinal).
+ */
+ val sendType: Int = 0
+ ) : Parcelable {
+ companion object {
+ fun fromIntent(intent: Intent): Args {
+ return intent.getParcelableExtra(EXTRA_ARGS) ?: Args()
+ }
+ }
+ }
+
+ /**
+ * High-level mode of operation for the flow.
+ *
+ * Note: We keep this free of app/database types. Callers can wrap their own identifiers as needed.
+ */
+ sealed interface Mode : Parcelable {
+ /** Single known recipient — returns result for caller to send. */
+ @Parcelize
+ data object SingleRecipient : Mode
+
+ /** Multiple known recipients — sends immediately, returns [Result.Sent]. */
+ @Parcelize
+ data object MultiRecipient : Mode
+
+ /** User will select contacts during the flow — sends immediately, returns [Result.Sent]. */
+ @Parcelize
+ data object ChooseAfterMediaSelection : Mode
+ }
+
+ /**
+ * Result returned when the flow completes successfully.
+ *
+ * - [ReadyToSend] mirrors the historical "return a result to Conversation to send" pattern.
+ * - [Sent] mirrors the historical "broadcast paths send immediately" behavior.
+ */
+ sealed interface Result : Parcelable {
+
+ /**
+ * Caller should send via its canonical pipeline (e.g., conversation-owned send path).
+ */
+ @Parcelize
+ data class ReadyToSend(
+ val recipientId: MediaRecipientId,
+ val body: String,
+ val isViewOnce: Boolean,
+ val scheduledTime: Long = -1,
+ val payload: Payload
+ ) : Result
+
+ /**
+ * The flow already performed the send (e.g., multi-recipient broadcast), so there is no payload.
+ */
+ @Parcelize
+ data object Sent : Result
+ }
+
+ sealed interface Payload : Parcelable {
+ /**
+ * Pre-upload handles + dependencies. This is intentionally "handle-only" so the feature module
+ * does not need to understand DB details; the app layer can interpret these primitives.
+ */
+ @Parcelize
+ data class PreUploaded(
+ val items: List
+ ) : Payload
+
+ /**
+ * Local media that the caller should upload/send as part of the normal pipeline.
+ */
+ @Parcelize
+ data class LocalMedia(
+ val items: List
+ ) : Payload
+ }
+
+ /**
+ * Mirrors the minimum data the legacy send pipeline needs for pre-upload sends:
+ * - an attachment row identifier
+ * - job dependency ids that the send jobs should wait on
+ *
+ * The feature module does not create or interpret these; it only transports them.
+ */
+ @Parcelize
+ data class PreUploadHandle(
+ val attachmentId: Long,
+ val jobIds: List,
+ val mediaUri: Uri
+ ) : Parcelable
+
+ /**
+ * Feature-level representation of selected media when the caller is responsible for upload/send.
+ * This intentionally avoids coupling to app-layer `Media` / `TransformProperties`.
+ */
+ @Parcelize
+ data class SelectedMedia(
+ val uri: Uri,
+ val contentType: String?,
+ val width: Int = 0,
+ val height: Int = 0,
+ val size: Long = 0,
+ val duration: Long = 0,
+ val isBorderless: Boolean = false,
+ val isVideoGif: Boolean = false,
+ val caption: String? = null,
+ val fileName: String? = null,
+ val transform: Transform? = null
+ ) : Parcelable
+
+ /**
+ * Feature-level transform model for media edits/quality.
+ * Kept Parcelable for the Activity result boundary; a pure JVM equivalent can live in `core-models`.
+ */
+ @Parcelize
+ data class Transform(
+ val skipTransform: Boolean = false,
+ val videoTrim: Boolean = false,
+ val videoTrimStartTimeUs: Long = 0,
+ val videoTrimEndTimeUs: Long = 0,
+ val sentMediaQuality: Int = 0,
+ val mp4FastStart: Boolean = false
+ ) : Parcelable
+
+ companion object {
+ const val EXTRA_ARGS = "org.signal.mediasend.args"
+ const val EXTRA_RESULT = "result"
+
+ fun toResultIntent(result: Result): Intent {
+ return Intent().apply {
+ putExtra(EXTRA_RESULT, result)
+ }
+ }
+ }
+}
diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendCallback.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendCallback.kt
new file mode 100644
index 0000000000..2704a59f22
--- /dev/null
+++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendCallback.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.mediasend
+
+import android.net.Uri
+import org.signal.core.models.media.Media
+import org.signal.mediasend.edit.MediaEditScreenCallback
+import org.signal.mediasend.select.MediaSelectScreenCallback
+
+/**
+ * Interface for communicating user intent back up to the view-model.
+ */
+interface MediaSendCallback : MediaEditScreenCallback, MediaSelectScreenCallback {
+
+ /** Called when the user navigates to a different position. */
+ fun onPageChanged(position: Int) {}
+
+ /** Called when the user edits video trim data. */
+ fun onVideoEdited(uri: Uri, isEdited: Boolean) {}
+
+ /** Called when message text changes. */
+ fun onMessageChanged(text: CharSequence?) {}
+
+ object Empty : MediaSendCallback, MediaEditScreenCallback by MediaEditScreenCallback.Empty, MediaSelectScreenCallback by MediaSelectScreenCallback.Empty {
+ override fun setFocusedMedia(media: Media) = Unit
+ }
+}
+
+/**
+ * Commands sent from the ViewModel to the UI layer (HUD).
+ *
+ * These are one-shot events that don't belong in persistent state.
+ */
+sealed interface HudCommand {
+ /** Start camera capture flow. */
+ data object StartCamera : HudCommand
+
+ /** Open the media selector/gallery. */
+ data object OpenGallery : HudCommand
+
+ /** Resume previously paused video. */
+ data object ResumeVideo : HudCommand
+
+ /** Show a transient error message. */
+ data class ShowError(val message: String) : HudCommand
+}
diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendMetrics.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendMetrics.kt
new file mode 100644
index 0000000000..b382bbcd5a
--- /dev/null
+++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendMetrics.kt
@@ -0,0 +1,13 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.mediasend
+
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+
+object MediaSendMetrics {
+ val SelectedMediaPreviewSize = DpSize(44.dp, 44.dp)
+}
diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendNavKey.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendNavKey.kt
new file mode 100644
index 0000000000..9eb1081420
--- /dev/null
+++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendNavKey.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.mediasend
+
+import androidx.navigation3.runtime.NavKey
+import kotlinx.serialization.Serializable
+
+/**
+ * Nav3 keys
+ */
+@Serializable
+sealed interface MediaSendNavKey : NavKey {
+ @Serializable
+ data object Select : MediaSendNavKey
+
+ @Serializable
+ sealed interface Capture : MediaSendNavKey {
+ @Serializable
+ data object Chrome : Capture
+
+ @Serializable
+ data object Camera : Capture
+
+ @Serializable
+ data object TextStory : Capture
+ }
+
+ @Serializable
+ data object Edit : MediaSendNavKey
+
+ @Serializable
+ data object Send : MediaSendNavKey
+}
diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendRepository.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendRepository.kt
new file mode 100644
index 0000000000..32046a5814
--- /dev/null
+++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendRepository.kt
@@ -0,0 +1,160 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.mediasend
+
+import android.net.Uri
+import kotlinx.coroutines.flow.Flow
+import org.signal.core.models.media.Media
+import org.signal.core.models.media.MediaFolder
+
+/**
+ * Repository interface for media send operations that require app-layer implementation.
+ *
+ * This allows the feature module to remain decoupled from database, recipient,
+ * and constraint implementations while still supporting full functionality.
+ */
+interface MediaSendRepository {
+
+ /**
+ * Retrieves the top-level folders which contain media
+ */
+ suspend fun getFolders(): List
+
+ /**
+ * Retrieves media for a given bucketId (folder)
+ */
+ suspend fun getMedia(bucketId: String): List
+
+ /**
+ * Validates and filters media against constraints.
+ *
+ * @param media The media items to validate.
+ * @param maxSelection Maximum number of items allowed.
+ * @param isStory Whether this is for a story (may have different constraints).
+ * @return Result containing filtered media and any validation errors.
+ */
+ suspend fun validateAndFilterMedia(
+ media: List,
+ maxSelection: Int,
+ isStory: Boolean
+ ): MediaFilterResult
+
+ /**
+ * Deletes temporary blob files for the given media.
+ */
+ suspend fun deleteBlobs(media: List)
+
+ /**
+ * Sends the media with the given parameters.
+ *
+ * @return Result indicating success or containing a send result.
+ */
+ suspend fun send(request: SendRequest): SendResult
+
+ /**
+ * Gets the maximum video duration in microseconds based on quality and file size limits.
+ *
+ * @param quality The sent media quality code.
+ * @param maxFileSizeBytes Maximum file size in bytes.
+ * @return Maximum duration in microseconds.
+ */
+ fun getMaxVideoDurationUs(quality: Int, maxFileSizeBytes: Long): Long
+
+ /**
+ * Gets the maximum video file size in bytes.
+ */
+ fun getVideoMaxSizeBytes(): Long
+
+ /**
+ * Checks if video transcoding is available on this device.
+ */
+ fun isVideoTranscodeAvailable(): Boolean
+
+ /**
+ * Gets story send requirements for the given media.
+ */
+ suspend fun getStorySendRequirements(media: List): StorySendRequirements
+
+ /**
+ * Checks for untrusted identity records among the given contacts.
+ *
+ * @param contactIds Contact identifiers to check.
+ * @param since Timestamp to check identity changes since.
+ * @return List of contacts with bad identity records, empty if all trusted.
+ */
+ suspend fun checkUntrustedIdentities(
+ contactIds: Set,
+ since: Long
+ ): List
+
+ /**
+ * Provides a flow of recipient "exists" state for determining pre-upload eligibility.
+ * Emits true if the recipient is valid and can receive pre-uploads.
+ *
+ * @param recipientId The recipient to observe.
+ * @return Flow that emits whenever recipient validity changes.
+ */
+ fun observeRecipientValid(recipientId: MediaRecipientId): Flow
+}
+
+/**
+ * Result of media validation/filtering.
+ */
+data class MediaFilterResult(
+ val filteredMedia: List,
+ val error: MediaFilterError?
+)
+
+/**
+ * Errors that can occur during media filtering.
+ */
+sealed interface MediaFilterError {
+ data object NoItems : MediaFilterError
+ data class ItemTooLarge(val media: Media) : MediaFilterError
+ data class ItemInvalidType(val media: Media) : MediaFilterError
+ data class TooManyItems(val max: Int) : MediaFilterError
+ data class CannotMixMediaTypes(val message: String) : MediaFilterError
+ data class Other(val message: String) : MediaFilterError
+}
+
+/**
+ * Request parameters for sending media.
+ */
+data class SendRequest(
+ val selectedMedia: List,
+ val editorStateMap: Map,
+ val quality: Int,
+ val message: String?,
+ val isViewOnce: Boolean,
+ val singleRecipientId: MediaRecipientId?,
+ val recipientIds: List,
+ val scheduledTime: Long,
+ val sendType: Int,
+ val isStory: Boolean
+)
+
+/**
+ * Result of a send operation.
+ */
+sealed interface SendResult {
+ data object Success : SendResult
+ data class Error(val message: String) : SendResult
+ data class UntrustedIdentity(val recipientIds: List) : SendResult
+}
+
+/**
+ * Story send requirements based on media content.
+ */
+enum class StorySendRequirements {
+ /** Can send to stories. */
+ CAN_SEND,
+
+ /** Cannot send to stories. */
+ CAN_NOT_SEND,
+
+ /** Requires cropping before sending to stories. */
+ REQUIRES_CROP
+}
diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendScreen.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendScreen.kt
new file mode 100644
index 0000000000..adb97b4e5e
--- /dev/null
+++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendScreen.kt
@@ -0,0 +1,98 @@
+package org.signal.mediasend
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.navigation3.runtime.NavBackStack
+import androidx.navigation3.runtime.NavEntry
+import androidx.navigation3.runtime.NavKey
+import androidx.navigation3.runtime.rememberNavBackStack
+import androidx.navigation3.ui.NavDisplay
+import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner
+import androidx.navigationevent.compose.rememberNavigationEventDispatcherOwner
+import org.signal.core.ui.compose.AllDevicePreviews
+import org.signal.core.ui.compose.Previews
+import org.signal.mediasend.edit.MediaEditScreen
+
+/**
+ * Enforces the following flow of:
+ *
+ * Capture -> Edit -> Send
+ * Select -> Edit -> Send
+ */
+@Composable
+fun MediaSendNavDisplay(
+ state: MediaSendState,
+ backStack: NavBackStack,
+ callback: MediaSendCallback,
+ modifier: Modifier = Modifier,
+ cameraSlot: @Composable () -> Unit = {},
+ textStoryEditorSlot: @Composable () -> Unit = {},
+ mediaSelectSlot: @Composable () -> Unit = {},
+ videoEditorSlot: @Composable () -> Unit = {},
+ sendSlot: @Composable () -> Unit = {}
+) {
+ NavDisplay(
+ backStack = backStack,
+ modifier = modifier.fillMaxSize()
+ ) { key ->
+ when (key) {
+ is MediaSendNavKey.Capture -> NavEntry(MediaSendNavKey.Capture.Chrome) {
+ MediaCaptureScreen(
+ backStack = backStack,
+ cameraSlot = cameraSlot,
+ textStoryEditorSlot = textStoryEditorSlot
+ )
+ }
+
+ MediaSendNavKey.Select -> NavEntry(key) {
+ mediaSelectSlot()
+ }
+
+ is MediaSendNavKey.Edit -> NavEntry(MediaSendNavKey.Edit) {
+ MediaEditScreen(
+ state = state,
+ backStack = backStack,
+ videoEditorSlot = videoEditorSlot,
+ callback = callback
+ )
+ }
+
+ is MediaSendNavKey.Send -> NavEntry(key) {
+ sendSlot()
+ }
+
+ else -> error("Unknown key: $key")
+ }
+ }
+}
+
+@AllDevicePreviews
+@Composable
+private fun MediaSendNavDisplayPreview() {
+ Previews.Preview {
+ CompositionLocalProvider(LocalNavigationEventDispatcherOwner provides rememberNavigationEventDispatcherOwner(parent = null)) {
+ MediaSendNavDisplay(
+ state = MediaSendState(isCameraFirst = true),
+ backStack = rememberNavBackStack(MediaSendNavKey.Edit),
+ callback = MediaSendCallback.Empty,
+ cameraSlot = { BoxWithText("Camera Slot") },
+ textStoryEditorSlot = { BoxWithText("Text Story Editor Slot") },
+ mediaSelectSlot = { BoxWithText("Media Select Slot") },
+ videoEditorSlot = { BoxWithText("Video Editor Slot") },
+ sendSlot = { BoxWithText("Send Slot") }
+ )
+ }
+ }
+}
+
+@Composable
+private fun BoxWithText(text: String, modifier: Modifier = Modifier) {
+ Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Text(text = text)
+ }
+}
diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendState.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendState.kt
new file mode 100644
index 0000000000..79142c0e60
--- /dev/null
+++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendState.kt
@@ -0,0 +1,140 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.mediasend
+
+import android.net.Uri
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+import org.signal.core.models.media.Media
+import org.signal.core.models.media.MediaFolder
+
+/**
+ * The collective state of the media send flow.
+ *
+ * Fully [Parcelable] for [SavedStateHandle] persistence — no separate serialization needed.
+ */
+@Parcelize
+data class MediaSendState(
+ val isCameraFirst: Boolean = false,
+ /**
+ * Optional recipient identifier for single-recipient flows.
+ */
+ val recipientId: MediaRecipientId? = null,
+ /**
+ * Mode of operation — determines whether we return a result or send immediately.
+ */
+ val mode: MediaSendActivityContract.Mode = MediaSendActivityContract.Mode.SingleRecipient,
+ val selectedMedia: List = emptyList(),
+ /**
+ * The currently focused/visible media item in the pager.
+ */
+ val focusedMedia: Media? = null,
+ val isMeteredConnection: Boolean = false,
+ val isPreUploadEnabled: Boolean = false,
+ /**
+ * Int code to avoid depending on app-layer enums. Conventionally 0 == STANDARD.
+ */
+ val sentMediaQuality: Int = 0,
+ /**
+ * Per-media editor state keyed by URI (video trim data, image editor data, etc.).
+ */
+ val editorStateMap: Map = emptyMap(),
+ /**
+ * View-once toggle state. Cycles: OFF -> ONCE -> OFF.
+ */
+ val viewOnceToggleState: ViewOnceToggleState = ViewOnceToggleState.OFF,
+ /**
+ * Optional message/caption text to accompany the media.
+ */
+ val message: String? = null,
+ /**
+ * If non-null, this media was the first capture from the camera and may be
+ * removed if the user backs out of camera-first flow.
+ */
+ val cameraFirstCapture: Media? = null,
+ /**
+ * Whether touch interactions are enabled (disabled during animations/transitions).
+ */
+ val isTouchEnabled: Boolean = true,
+ /**
+ * When true, suppresses the "no items" error when selection becomes empty.
+ * Used during camera-first flow exit.
+ */
+ val suppressEmptyError: Boolean = false,
+ /**
+ * Whether the media has been sent (prevents duplicate sends).
+ */
+ val isSent: Boolean = false,
+ /**
+ * Whether this is a story send flow.
+ */
+ val isStory: Boolean = false,
+ /**
+ * Send type code (SMS vs Signal). Conventionally 0 == SignalMessageSendType.
+ */
+ val sendType: Int = 0,
+ /**
+ * Story send requirements based on current media selection.
+ */
+ val storySendRequirements: StorySendRequirements = StorySendRequirements.CAN_NOT_SEND,
+ /**
+ * Maximum number of media items that can be selected.
+ */
+ val maxSelection: Int = 32,
+ /**
+ * Whether contact selection is required (for choose-after-media flows).
+ */
+ val isContactSelectionRequired: Boolean = false,
+ /**
+ * Whether this is a reply to an existing message.
+ */
+ val isReply: Boolean = false,
+ /**
+ * Whether this is the "add to group story" flow.
+ */
+ val isAddToGroupStoryFlow: Boolean = false,
+ /**
+ * Additional recipient IDs for multi-recipient sends.
+ */
+ val additionalRecipientIds: List = emptyList(),
+ /**
+ * Scheduled send time (-1 for immediate).
+ */
+ val scheduledTime: Long = -1,
+
+ /**
+ * The [MediaFolder] list available on the system
+ */
+ val mediaFolders: List = emptyList(),
+
+ /**
+ * The selected [MediaFolder] for which to display content in the Select screen
+ */
+ val selectedMediaFolder: MediaFolder? = null,
+
+ /**
+ * The media content for a given selected [MediaFolder]
+ */
+ val selectedMediaFolderItems: List = emptyList()
+) : Parcelable {
+
+ /**
+ * View-once toggle state.
+ */
+ enum class ViewOnceToggleState(val code: Int) {
+ OFF(0),
+ ONCE(1);
+
+ fun next(): ViewOnceToggleState = when (this) {
+ OFF -> ONCE
+ ONCE -> OFF
+ }
+
+ companion object {
+ fun fromCode(code: Int): ViewOnceToggleState = entries.firstOrNull { it.code == code } ?: OFF
+ }
+ }
+}
diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendToScreen.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendToScreen.kt
new file mode 100644
index 0000000000..c864223f52
--- /dev/null
+++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendToScreen.kt
@@ -0,0 +1,15 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.mediasend
+
+import androidx.compose.runtime.Composable
+
+/**
+ * Screen allowing the user to select who they wish to send a piece of content to.
+ */
+@Composable
+fun MediaSendToScreen() {
+}
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
new file mode 100644
index 0000000000..f7c7a67a7c
--- /dev/null
+++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt
@@ -0,0 +1,748 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.mediasend
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.net.Uri
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.createSavedStateHandle
+import androidx.lifecycle.viewModelScope
+import androidx.lifecycle.viewmodel.CreationExtras
+import com.bumptech.glide.load.DataSource
+import com.bumptech.glide.load.engine.GlideException
+import com.bumptech.glide.request.RequestListener
+import com.bumptech.glide.request.target.Target
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import org.signal.core.models.media.Media
+import org.signal.core.models.media.MediaFolder
+import org.signal.core.util.ContentTypeUtil
+import org.signal.core.util.StringUtil
+import org.signal.imageeditor.core.model.EditorElement
+import org.signal.imageeditor.core.model.EditorModel
+import org.signal.imageeditor.core.renderers.UriGlideRenderer
+import org.signal.mediasend.preupload.PreUploadManager
+import java.util.Collections
+import kotlin.time.Duration.Companion.milliseconds
+
+/**
+ * Configuration-survivable state manager for the media send flow.
+ *
+ * Uses [SavedStateHandle] for automatic state persistence across process death.
+ * [MediaSendState] is fully [Parcelable] and saved directly as a single key.
+ */
+class MediaSendViewModel(
+ private val args: MediaSendActivityContract.Args,
+ private val identityChangesSince: Long,
+ isMeteredFlow: Flow,
+ private val savedStateHandle: SavedStateHandle,
+ private val repository: MediaSendRepository,
+ private val preUploadManager: PreUploadManager
+) : ViewModel(), MediaSendCallback {
+
+ private val defaultState = MediaSendState(
+ isCameraFirst = args.isCameraFirst,
+ recipientId = args.recipientId,
+ mode = args.mode,
+ isStory = args.isStory,
+ isReply = args.isReply,
+ isAddToGroupStoryFlow = args.isAddToGroupStoryFlow,
+ maxSelection = args.maxSelection,
+ message = args.initialMessage,
+ isContactSelectionRequired = args.mode == MediaSendActivityContract.Mode.ChooseAfterMediaSelection,
+ sendType = args.sendType
+ )
+
+ /**
+ * Main UI state. Backed by [SavedStateHandle] for automatic process death survival.
+ * Writes to this flow are automatically persisted.
+ */
+ private val internalState: MutableStateFlow = savedStateHandle.getMutableStateFlow(KEY_STATE, defaultState)
+ val state: StateFlow = internalState.asStateFlow()
+
+ private val editedVideoUris: MutableSet = mutableSetOf().apply {
+ addAll(savedStateHandle[KEY_EDITED_VIDEO_URIS] ?: emptyList())
+ }
+
+ /** One-shot HUD commands exposed as a Flow. */
+ private val hudCommandChannel = Channel(Channel.BUFFERED)
+ val hudCommands: Flow = hudCommandChannel.receiveAsFlow()
+
+ /** Media filter errors. */
+ private val _mediaErrors = MutableSharedFlow(replay = 1)
+ val mediaErrors: SharedFlow = _mediaErrors.asSharedFlow()
+
+ /** Character count for the message field. */
+ val messageCharacterCount: Flow = state
+ .map { it.message?.let { msg -> StringUtil.getGraphemeCount(msg) } ?: 0 }
+ .distinctUntilChanged()
+
+ /** Tracks drag state for media reordering. */
+ private var lastMediaDrag: Pair = Pair(0, 0)
+
+ init {
+ // Matches legacy behavior: VM subscribes to connectivity updates and derives
+ // isPreUploadEnabled from metered state.
+ viewModelScope.launch {
+ isMeteredFlow.collect { metered ->
+ updateState { copy(isMeteredConnection = metered, isPreUploadEnabled = shouldPreUpload(metered)) }
+ }
+ }
+
+ // Observe recipient validity for pre-upload eligibility
+ args.recipientId?.let { recipientId ->
+ viewModelScope.launch {
+ repository.observeRecipientValid(recipientId).collect { isValid ->
+ if (isValid) {
+ updateState { copy(isPreUploadEnabled = shouldPreUpload(isMeteredConnection)) }
+ }
+ }
+ }
+ }
+
+ // Add initial media if provided
+ if (args.initialMedia.isNotEmpty()) {
+ addMedia(args.initialMedia.toSet())
+ }
+
+ refreshMediaFolders()
+ }
+
+ /** Updates state atomically — automatically persisted via SavedStateHandle-backed MutableStateFlow. */
+ private inline fun updateState(crossinline transform: MediaSendState.() -> MediaSendState) {
+ internalState.update { it.transform() }
+ }
+
+ //region Media Selection
+
+ fun refreshMediaFolders() {
+ viewModelScope.launch {
+ val folders = repository.getFolders()
+ internalState.update {
+ it.copy(
+ mediaFolders = folders,
+ selectedMediaFolder = if (it.selectedMediaFolder in folders) it.selectedMediaFolder else null,
+ selectedMedia = if (it.selectedMediaFolder in folders) it.selectedMediaFolderItems else emptyList()
+ )
+ }
+ }
+ }
+
+ override fun onFolderClick(mediaFolder: MediaFolder?) {
+ viewModelScope.launch {
+ if (mediaFolder != null) {
+ val media = repository.getMedia(mediaFolder.bucketId)
+ internalState.update { it.copy(selectedMediaFolder = mediaFolder, selectedMediaFolderItems = media) }
+ } else {
+ internalState.update { it.copy(selectedMediaFolder = null, selectedMediaFolderItems = emptyList()) }
+ }
+ }
+ }
+
+ override fun onMediaClick(media: Media) {
+ if (media.uri in internalState.value.selectedMedia.map { it.uri }) {
+ removeMedia(media)
+ } else {
+ addMedia(media)
+ }
+ }
+
+ /**
+ * Adds [media] to the selection, preserving insertion order and uniqueness by equality.
+ *
+ * Validates against constraints and starts pre-uploads for newly added items.
+ *
+ * @param media Media items to add.
+ */
+ fun addMedia(media: Set) {
+ viewModelScope.launch {
+ val snapshot = state.value
+ val newSelectionList: List = linkedSetOf().apply {
+ addAll(snapshot.selectedMedia)
+ addAll(media)
+ }.toList()
+
+ // Validate and filter through repository
+ val filterResult = repository.validateAndFilterMedia(
+ media = newSelectionList,
+ maxSelection = snapshot.maxSelection,
+ isStory = snapshot.isStory
+ )
+
+ if (filterResult.filteredMedia.isNotEmpty()) {
+ // Initialize video trim states for new videos
+ val maxVideoDurationUs = getMaxVideoDurationUs()
+ val initializedVideoEditorStates = filterResult.filteredMedia
+ .filterNot { snapshot.editorStateMap.containsKey(it.uri) }
+ .filter { isNonGifVideo(it) }
+ .associate { video ->
+ val durationUs = video.duration.milliseconds.inWholeMicroseconds
+ video.uri to EditorState.VideoTrim.forVideo(durationUs, maxVideoDurationUs)
+ }
+
+ val initializedImageEditorStates = filterResult.filteredMedia
+ .filterNot { snapshot.editorStateMap.containsKey(it.uri) }
+ .filter { ContentTypeUtil.isImageType(it.contentType) }
+ .associate { image ->
+ // TODO - this should likely be in a repository?
+ val editorModel = EditorModel.create(0x0)
+ val element = EditorElement(
+ UriGlideRenderer(
+ image.uri,
+ true,
+ 0,
+ 0,
+ UriGlideRenderer.STRONG_BLUR,
+ object : RequestListener {
+ override fun onResourceReady(resource: Bitmap?, model: Any?, target: Target?, dataSource: DataSource?, isFirstResource: Boolean): Boolean {
+ return false
+ }
+
+ override fun onLoadFailed(e: GlideException?, model: Any?, target: Target?, isFirstResource: Boolean): Boolean {
+ return false
+ }
+ }
+ )
+ )
+ element.flags.setSelectable(false).persist()
+ editorModel.addElement(element)
+ image.uri to EditorState.Image(editorModel)
+ }
+
+ updateState {
+ copy(
+ selectedMedia = filterResult.filteredMedia,
+ focusedMedia = focusedMedia ?: filterResult.filteredMedia.firstOrNull(),
+ editorStateMap = editorStateMap + initializedVideoEditorStates + initializedImageEditorStates
+ )
+ }
+
+ // Update story requirements
+ updateStorySendRequirements(filterResult.filteredMedia)
+
+ // Start pre-uploads for new media
+ val newMedia = filterResult.filteredMedia.toSet().intersect(media).toList()
+ startUpload(newMedia)
+ }
+
+ if (filterResult.error != null) {
+ _mediaErrors.emit(filterResult.error)
+ }
+ }
+ }
+
+ /**
+ * Adds a single [media] item to the selection.
+ */
+ fun addMedia(media: Media) {
+ addMedia(setOf(media))
+ }
+
+ /**
+ * Removes a single [media] item from the selection.
+ */
+ fun removeMedia(media: Media) {
+ removeMedia(setOf(media))
+ }
+
+ /**
+ * Removes [media] from the selection.
+ *
+ * Cancels any pre-uploads for the removed items.
+ *
+ * @param media Media items to remove.
+ */
+ fun removeMedia(media: Set) {
+ val snapshot = state.value
+ val newSelection = snapshot.selectedMedia - media
+
+ val newFocus = when {
+ newSelection.isEmpty() -> null
+ snapshot.focusedMedia in media -> {
+ val oldFocusIndex = snapshot.selectedMedia.indexOf(snapshot.focusedMedia)
+ newSelection[oldFocusIndex.coerceIn(0, newSelection.size - 1)]
+ }
+
+ else -> snapshot.focusedMedia
+ }
+
+ val newCameraFirstCapture = if (snapshot.cameraFirstCapture in media) null else snapshot.cameraFirstCapture
+
+ updateState {
+ copy(
+ selectedMedia = newSelection,
+ focusedMedia = newFocus,
+ editorStateMap = editorStateMap - media.map { it.uri }.toSet(),
+ cameraFirstCapture = newCameraFirstCapture
+ )
+ }
+
+ if (newSelection.isEmpty() && !snapshot.suppressEmptyError) {
+ viewModelScope.launch {
+ _mediaErrors.emit(MediaFilterError.NoItems)
+ }
+ }
+
+ // Update story requirements
+ viewModelScope.launch {
+ updateStorySendRequirements(newSelection)
+ }
+
+ // Delete blobs and cancel uploads
+ viewModelScope.launch {
+ repository.deleteBlobs(media.toList())
+ }
+ preUploadManager.cancelUpload(media)
+ preUploadManager.updateDisplayOrder(newSelection)
+ }
+
+ /**
+ * Applies updates to selected media (old -> new).
+ */
+ fun applyMediaUpdates(oldToNew: Map) {
+ if (oldToNew.isEmpty()) return
+
+ val snapshot = state.value
+ val updatedSelection = snapshot.selectedMedia.map { oldToNew[it] ?: it }
+ updateState { copy(selectedMedia = updatedSelection) }
+
+ preUploadManager.applyMediaUpdates(oldToNew, snapshot.recipientId)
+ preUploadManager.updateCaptions(updatedSelection)
+ preUploadManager.updateDisplayOrder(updatedSelection)
+ }
+
+ /**
+ * Sets the current ordering of selected media.
+ */
+ fun setDisplayOrder(mediaInOrder: List) {
+ updateState { copy(selectedMedia = mediaInOrder) }
+ preUploadManager.updateDisplayOrder(mediaInOrder)
+ }
+
+ //endregion
+
+ //region Pre-Upload Management
+
+ private fun startUpload(media: List) {
+ val snapshot = state.value
+ if (!snapshot.isPreUploadEnabled) return
+
+ val filteredPreUploadMedia = if (snapshot.mode is MediaSendActivityContract.Mode.SingleRecipient) {
+ media.filter { !ContentTypeUtil.isDocumentType(it.contentType) }
+ } else {
+ media.filter { ContentTypeUtil.isStorySupportedType(it.contentType) }
+ }
+
+ preUploadManager.startUpload(filteredPreUploadMedia, snapshot.recipientId)
+ preUploadManager.updateCaptions(snapshot.selectedMedia)
+ preUploadManager.updateDisplayOrder(snapshot.selectedMedia)
+ }
+
+ //endregion
+
+ //region Quality
+
+ /**
+ * Sets the sent media quality.
+ *
+ * Cancels all pre-uploads and re-initializes video trim data.
+ */
+ fun setSentMediaQuality(sentMediaQuality: Int) {
+ val snapshot = state.value
+ if (snapshot.sentMediaQuality == sentMediaQuality) return
+
+ updateState { copy(sentMediaQuality = sentMediaQuality, isPreUploadEnabled = false) }
+ preUploadManager.cancelAllUploads()
+
+ // Re-clamp video durations based on new quality
+ val maxVideoDurationUs = getMaxVideoDurationUs()
+ snapshot.selectedMedia.forEach { mediaItem ->
+ if (isNonGifVideo(mediaItem) && repository.isVideoTranscodeAvailable()) {
+ val existingData = snapshot.editorStateMap[mediaItem.uri] as? EditorState.VideoTrim
+ if (existingData != null) {
+ onEditVideoDuration(
+ totalDurationUs = existingData.totalInputDurationUs,
+ startTimeUs = existingData.startTimeUs,
+ endTimeUs = existingData.endTimeUs,
+ touchEnabled = true,
+ uri = mediaItem.uri
+ )
+ }
+ }
+ }
+ }
+
+ //endregion
+
+ //region Video Editing
+
+ /**
+ * Notifies the view-model that a video's trim/duration has been edited.
+ */
+ override fun onVideoEdited(uri: Uri, isEdited: Boolean) {
+ if (!isEdited) return
+ if (!editedVideoUris.add(uri)) return
+
+ // Persist the updated set
+ savedStateHandle[KEY_EDITED_VIDEO_URIS] = ArrayList(editedVideoUris)
+
+ val media = state.value.selectedMedia.firstOrNull { it.uri == uri } ?: return
+ preUploadManager.cancelUpload(media)
+ }
+
+ /**
+ * Updates video trim duration.
+ */
+ fun onEditVideoDuration(
+ totalDurationUs: Long,
+ startTimeUs: Long,
+ endTimeUs: Long,
+ touchEnabled: Boolean,
+ uri: Uri? = state.value.focusedMedia?.uri
+ ) {
+ if (uri == null) return
+ if (!repository.isVideoTranscodeAvailable()) return
+
+ val snapshot = state.value
+ val existingData = snapshot.editorStateMap[uri] as? EditorState.VideoTrim
+ ?: EditorState.VideoTrim(totalInputDurationUs = totalDurationUs)
+
+ val clampedStartTime = maxOf(startTimeUs, 0)
+ val unedited = !existingData.isDurationEdited
+ val durationEdited = clampedStartTime > 0 || endTimeUs < totalDurationUs
+ val isEntireDuration = startTimeUs == 0L && endTimeUs == totalDurationUs
+ val endMoved = !isEntireDuration && existingData.endTimeUs != endTimeUs
+ val maxVideoDurationUs = getMaxVideoDurationUs()
+ val preserveStartTime = unedited || !endMoved
+
+ val newData = EditorState.VideoTrim(
+ isDurationEdited = durationEdited,
+ totalInputDurationUs = totalDurationUs,
+ startTimeUs = clampedStartTime,
+ endTimeUs = endTimeUs
+ ).clampToMaxDuration(maxVideoDurationUs, preserveStartTime)
+
+ // Cancel upload on first edit
+ if (unedited && durationEdited) {
+ val media = snapshot.selectedMedia.firstOrNull { it.uri == uri }
+ if (media != null) {
+ preUploadManager.cancelUpload(media)
+ }
+ }
+
+ if (newData != existingData) {
+ updateState {
+ copy(
+ isTouchEnabled = touchEnabled,
+ editorStateMap = editorStateMap + (uri to newData)
+ )
+ }
+ } else {
+ updateState { copy(isTouchEnabled = touchEnabled) }
+ }
+ }
+
+ private fun getMaxVideoDurationUs(): Long {
+ val snapshot = state.value
+ return repository.getMaxVideoDurationUs(
+ quality = snapshot.sentMediaQuality,
+ maxFileSizeBytes = repository.getVideoMaxSizeBytes()
+ )
+ }
+
+ //endregion
+
+ //region Page/Focus Management
+
+ override fun setFocusedMedia(media: Media) {
+ updateState { copy(focusedMedia = media) }
+ }
+
+ override fun onPageChanged(position: Int) {
+ val snapshot = state.value
+ val focused = if (position >= snapshot.selectedMedia.size) null else snapshot.selectedMedia[position]
+ updateState { copy(focusedMedia = focused) }
+ }
+
+ //endregion
+
+ //region Drag/Reordering
+
+ fun swapMedia(originalStart: Int, end: Int): Boolean {
+ var start = originalStart
+
+ if (lastMediaDrag.first == start && lastMediaDrag.second == end) {
+ return true
+ } else if (lastMediaDrag.first == start) {
+ start = lastMediaDrag.second
+ }
+
+ val snapshot = state.value
+
+ if (end >= snapshot.selectedMedia.size ||
+ end < 0 ||
+ start >= snapshot.selectedMedia.size ||
+ start < 0
+ ) {
+ return false
+ }
+
+ lastMediaDrag = Pair(originalStart, end)
+
+ val newMediaList = snapshot.selectedMedia.toMutableList()
+
+ if (start < end) {
+ for (i in start until end) {
+ Collections.swap(newMediaList, i, i + 1)
+ }
+ } else {
+ for (i in start downTo end + 1) {
+ Collections.swap(newMediaList, i, i - 1)
+ }
+ }
+
+ updateState { copy(selectedMedia = newMediaList) }
+ return true
+ }
+
+ fun isValidMediaDragPosition(position: Int): Boolean {
+ return position >= 0 && position < internalState.value.selectedMedia.size
+ }
+
+ private fun isNonGifVideo(media: Media): Boolean {
+ return ContentTypeUtil.isVideo(media.contentType) && !media.isVideoGif
+ }
+
+ fun onMediaDragFinished() {
+ lastMediaDrag = Pair(0, 0)
+ preUploadManager.updateDisplayOrder(internalState.value.selectedMedia)
+ }
+
+ //endregion
+
+ //region Editor State
+
+ fun getEditorState(uri: Uri): EditorState? {
+ return internalState.value.editorStateMap[uri]
+ }
+
+ fun setEditorState(uri: Uri, state: EditorState) {
+ updateState { copy(editorStateMap = editorStateMap + (uri to state)) }
+ }
+
+ //endregion
+
+ //region View Once
+
+ fun incrementViewOnceState() {
+ updateState { copy(viewOnceToggleState = viewOnceToggleState.next()) }
+ }
+
+ fun isViewOnceEnabled(): Boolean {
+ val snapshot = internalState.value
+ return snapshot.selectedMedia.size == 1 &&
+ snapshot.viewOnceToggleState == MediaSendState.ViewOnceToggleState.ONCE
+ }
+
+ //endregion
+
+ //region Message
+
+ fun setMessage(text: String?) {
+ updateState { copy(message = text) }
+ }
+
+ override fun onMessageChanged(text: CharSequence?) {
+ setMessage(text?.toString())
+ }
+
+ //endregion
+
+ //region Story
+
+ fun isStory(): Boolean = state.value.isStory
+
+ fun getStorySendRequirements(): StorySendRequirements = state.value.storySendRequirements
+
+ private suspend fun updateStorySendRequirements(media: List) {
+ if (!state.value.isStory) return
+ val requirements = repository.getStorySendRequirements(media)
+ updateState { copy(storySendRequirements = requirements) }
+ }
+
+ //endregion
+
+ //region Recipients
+
+ fun setAdditionalRecipients(recipientIds: List) {
+ updateState { copy(additionalRecipientIds = recipientIds) }
+ }
+
+ fun setScheduledTime(time: Long) {
+ updateState { copy(scheduledTime = time) }
+ }
+
+ //endregion
+
+ //region Camera First Capture
+
+ fun addCameraFirstCapture(media: Media) {
+ internalState.update { it.copy(cameraFirstCapture = media) }
+ addMedia(media)
+ }
+
+ fun removeCameraFirstCapture() {
+ val capture = internalState.value.cameraFirstCapture ?: return
+ setSuppressEmptyError(true)
+ removeMedia(capture)
+ }
+
+ //endregion
+
+ //region Touch & Error Suppression
+
+ fun setTouchEnabled(isEnabled: Boolean) {
+ updateState { copy(isTouchEnabled = isEnabled) }
+ }
+
+ fun setSuppressEmptyError(isSuppressed: Boolean) {
+ updateState { copy(suppressEmptyError = isSuppressed) }
+ }
+
+ fun clearMediaErrors() {
+ viewModelScope.launch {
+ _mediaErrors.resetReplayCache()
+ }
+ }
+
+ //endregion
+
+ //region Send
+
+ /**
+ * Sends the media with current state.
+ *
+ * @return Result of the send operation.
+ */
+ suspend fun send(): SendResult {
+ val snapshot = state.value
+
+ // Check for untrusted identities
+ val allRecipientIds = buildSet {
+ snapshot.recipientId?.let { add(it.id) }
+ addAll(snapshot.additionalRecipientIds.map { it.id })
+ }
+
+ if (allRecipientIds.isNotEmpty()) {
+ val untrusted = repository.checkUntrustedIdentities(allRecipientIds, identityChangesSince)
+ if (untrusted.isNotEmpty()) {
+ return SendResult.UntrustedIdentity(untrusted)
+ }
+ }
+
+ val request = SendRequest(
+ selectedMedia = snapshot.selectedMedia,
+ editorStateMap = snapshot.editorStateMap,
+ quality = snapshot.sentMediaQuality,
+ message = snapshot.message,
+ isViewOnce = isViewOnceEnabled(),
+ singleRecipientId = snapshot.recipientId,
+ recipientIds = snapshot.additionalRecipientIds,
+ scheduledTime = snapshot.scheduledTime,
+ sendType = snapshot.sendType,
+ isStory = snapshot.isStory
+ )
+
+ val result = repository.send(request)
+
+ if (result is SendResult.Success) {
+ updateState { copy(isSent = true) }
+ }
+
+ return result
+ }
+
+ //endregion
+
+ //region HUD Commands
+
+ fun sendCommand(command: HudCommand) {
+ hudCommandChannel.trySend(command)
+ }
+
+ //endregion
+
+ //region Query Methods
+
+ fun hasSelectedMedia(): Boolean = internalState.value.selectedMedia.isNotEmpty()
+
+ fun isSelectedMediaEmpty(): Boolean = internalState.value.selectedMedia.isEmpty()
+
+ fun kick() {
+ internalState.update { it }
+ }
+
+ //endregion
+
+ //region Lifecycle
+
+ override fun onCleared() {
+ preUploadManager.cancelAllUploads()
+ preUploadManager.deleteAbandonedAttachments()
+ }
+
+ private fun shouldPreUpload(metered: Boolean): Boolean = !metered
+
+ //endregion
+
+ //region Factory
+
+ class Factory(
+ private val context: Context,
+ private val args: MediaSendActivityContract.Args,
+ private val identityChangesSince: Long = System.currentTimeMillis(),
+ private val isMeteredFlow: Flow,
+ private val repository: MediaSendRepository,
+ private val preUploadCallback: PreUploadManager.Callback
+ ) : ViewModelProvider.Factory {
+
+ @Suppress("UNCHECKED_CAST")
+ override fun create(modelClass: Class, extras: CreationExtras): T {
+ val savedStateHandle = extras.createSavedStateHandle()
+ val manager = PreUploadManager(context.applicationContext, preUploadCallback)
+
+ return MediaSendViewModel(
+ args = args,
+ identityChangesSince = identityChangesSince,
+ isMeteredFlow = isMeteredFlow,
+ savedStateHandle = savedStateHandle,
+ repository = repository,
+ preUploadManager = manager
+ ) as T
+ }
+ }
+
+ //endregion
+
+ companion object {
+ private const val KEY_STATE = "media_send_vm_state"
+ private const val KEY_EDITED_VIDEO_URIS = "media_send_vm_edited_video_uris"
+ }
+}
diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MeteredConnectivity.kt b/feature/media-send/src/main/java/org/signal/mediasend/MeteredConnectivity.kt
new file mode 100644
index 0000000000..c7a1ca942d
--- /dev/null
+++ b/feature/media-send/src/main/java/org/signal/mediasend/MeteredConnectivity.kt
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.mediasend
+
+import android.Manifest
+import android.content.Context
+import android.net.ConnectivityManager
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.NetworkRequest
+import androidx.annotation.RequiresPermission
+import androidx.core.content.ContextCompat
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.distinctUntilChanged
+
+object MeteredConnectivity {
+
+ /**
+ * @return A cold [Flow] that emits `true` when the active network is metered.
+ */
+ @RequiresPermission(Manifest.permission.ACCESS_NETWORK_STATE)
+ fun isMetered(context: Context): Flow {
+ val appContext = context.applicationContext
+
+ return callbackFlow {
+ val cm: ConnectivityManager = requireNotNull(ContextCompat.getSystemService(appContext, ConnectivityManager::class.java))
+
+ fun currentMetered(): Boolean = cm.isActiveNetworkMetered
+
+ trySend(currentMetered())
+
+ val callback = object : ConnectivityManager.NetworkCallback() {
+ override fun onAvailable(network: Network) {
+ trySend(currentMetered())
+ }
+
+ override fun onLost(network: Network) {
+ trySend(currentMetered())
+ }
+
+ override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
+ val metered = !networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
+ trySend(metered)
+ }
+ }
+
+ val request = NetworkRequest.Builder()
+ .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+ .build()
+
+ cm.registerNetworkCallback(request, callback)
+
+ awaitClose {
+ cm.unregisterNetworkCallback(callback)
+ }
+ }.distinctUntilChanged()
+ }
+}
diff --git a/feature/media-send/src/main/java/org/signal/mediasend/NavBackStackExtensions.kt b/feature/media-send/src/main/java/org/signal/mediasend/NavBackStackExtensions.kt
new file mode 100644
index 0000000000..2e3520adf5
--- /dev/null
+++ b/feature/media-send/src/main/java/org/signal/mediasend/NavBackStackExtensions.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.mediasend
+
+import androidx.navigation3.runtime.NavBackStack
+import androidx.navigation3.runtime.NavKey
+
+internal fun NavBackStack.goToEdit() {
+ if (contains(MediaSendNavKey.Edit)) {
+ popTo(MediaSendNavKey.Edit)
+ } else {
+ add(MediaSendNavKey.Edit)
+ }
+}
+
+internal fun NavBackStack.pop() {
+ if (isNotEmpty()) {
+ removeAt(size - 1)
+ }
+}
+
+private fun NavBackStack.popTo(key: NavKey) {
+ while (size > 1 && get(size - 1) != key) {
+ removeAt(size - 1)
+ }
+}
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
new file mode 100644
index 0000000000..4058fe1564
--- /dev/null
+++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/AddAMessageRow.kt
@@ -0,0 +1,117 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.mediasend.edit
+
+import androidx.compose.animation.Crossfade
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import org.signal.core.ui.compose.DayNightPreviews
+import org.signal.core.ui.compose.IconButtons
+import org.signal.core.ui.compose.Previews
+import org.signal.core.ui.compose.SignalIcons
+import org.signal.core.util.isNotNullOrBlank
+
+@Composable
+internal fun AddAMessageRow(
+ message: String?,
+ callback: AddAMessageRowCallback,
+ modifier: Modifier = Modifier
+) {
+ Row(
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = modifier
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier
+ .background(color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(percent = 50))
+ .weight(1f)
+ .heightIn(min = 40.dp)
+ ) {
+ IconButtons.IconButton(
+ onClick = callback::onEmojiKeyboardClick
+ ) {
+ Icon(
+ painter = SignalIcons.Emoji.painter,
+ contentDescription = "Open emoji keyboard"
+ )
+ }
+
+ Crossfade(
+ targetState = message.isNotNullOrBlank(),
+ modifier = Modifier.weight(1f)
+ ) { isNotEmpty ->
+ if (isNotEmpty) {
+ BasicTextField(
+ value = message ?: "",
+ onValueChange = callback::onMessageChange
+ )
+ } else
+ Text(
+ text = "Message",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+
+ IconButtons.IconButton(
+ onClick = callback::onNextClick,
+ modifier = Modifier
+ .padding(start = 12.dp)
+ .background(
+ color = MaterialTheme.colorScheme.primaryContainer,
+ shape = CircleShape
+ )
+ ) {
+ Icon(
+ painter = SignalIcons.ArrowEnd.painter,
+ contentDescription = "Open emoji keyboard",
+ modifier = Modifier
+ .size(40.dp)
+ .padding(8.dp)
+ )
+ }
+ }
+}
+
+@DayNightPreviews
+@Composable
+private fun AddAMessageRowPreview() {
+ Previews.Preview {
+ AddAMessageRow(
+ message = null,
+ callback = AddAMessageRowCallback.Empty
+ )
+ }
+}
+
+internal interface AddAMessageRowCallback {
+ fun onMessageChange(message: String)
+ fun onEmojiKeyboardClick()
+ fun onNextClick()
+
+ object Empty : AddAMessageRowCallback {
+ override fun onMessageChange(message: String) = Unit
+ override fun onEmojiKeyboardClick() = Unit
+ override fun onNextClick() = Unit
+ }
+}
diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageEditor.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageEditor.kt
new file mode 100644
index 0000000000..ddacb62a6c
--- /dev/null
+++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageEditor.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.mediasend.edit
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clipToBounds
+import androidx.compose.ui.viewinterop.AndroidView
+import org.signal.imageeditor.core.ImageEditorView
+
+@Composable
+fun ImageEditor(
+ controller: ImageEditorController,
+ modifier: Modifier = Modifier
+) {
+ AndroidView(
+ factory = { context -> ImageEditorView(context) },
+ update = { view ->
+ view.model = controller.editorModel
+ view.mode = mapMode(controller.mode)
+ },
+ onReset = { },
+ modifier = modifier.clipToBounds()
+ )
+}
+
+private fun mapMode(mode: ImageEditorController.Mode): ImageEditorView.Mode {
+ return when (mode) {
+ ImageEditorController.Mode.NONE -> ImageEditorView.Mode.MoveAndResize
+ ImageEditorController.Mode.CROP -> ImageEditorView.Mode.MoveAndResize
+ ImageEditorController.Mode.TEXT -> ImageEditorView.Mode.MoveAndResize
+ ImageEditorController.Mode.DRAW -> ImageEditorView.Mode.Draw
+ ImageEditorController.Mode.HIGHLIGHT -> ImageEditorView.Mode.Draw
+ ImageEditorController.Mode.BLUR -> ImageEditorView.Mode.Blur
+ ImageEditorController.Mode.MOVE_STICKER -> ImageEditorView.Mode.MoveAndResize
+ ImageEditorController.Mode.MOVE_TEXT -> ImageEditorView.Mode.MoveAndResize
+ ImageEditorController.Mode.DELETE -> ImageEditorView.Mode.MoveAndResize
+ ImageEditorController.Mode.INSERT_STICKER -> ImageEditorView.Mode.MoveAndResize
+ }
+}
diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageEditorController.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageEditorController.kt
new file mode 100644
index 0000000000..c6952a6eb5
--- /dev/null
+++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageEditorController.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.mediasend.edit
+
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.annotation.RememberInComposition
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import org.signal.imageeditor.core.model.EditorModel
+
+@Stable
+class ImageEditorController @RememberInComposition constructor(
+ val editorModel: EditorModel
+) {
+
+ var mode: Mode by mutableStateOf(Mode.NONE)
+
+ enum class Mode {
+ NONE,
+ CROP,
+ TEXT,
+ DRAW,
+ HIGHLIGHT,
+ BLUR,
+ MOVE_STICKER,
+ MOVE_TEXT,
+ DELETE,
+ INSERT_STICKER
+ }
+}
diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageEditorToolbars.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageEditorToolbars.kt
new file mode 100644
index 0000000000..a64abbb0fa
--- /dev/null
+++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/ImageEditorToolbars.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.mediasend.edit
+
+import androidx.compose.runtime.Composable
+
+/**
+ * Allows user to perform actions while viewing an editable image.
+ */
+@Composable
+fun ImageEditorTopLevelToolbar(
+ imageEditorController: ImageEditorController
+) {
+ // Draw -- imageEditorController draw mode
+ // Crop&Rotate -- imageEditorController crop mode
+ // Quality -- callback toggle quality
+ // Save -- callback save to disk
+ // Add -- callback go to media select
+}
+
+interface ImageEditorToolbarsCallback {
+
+ object Empty : ImageEditorToolbarsCallback
+}
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
new file mode 100644
index 0000000000..83bc99bea6
--- /dev/null
+++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt
@@ -0,0 +1,154 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.mediasend.edit
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.snapping.SnapPosition
+import androidx.compose.foundation.layout.Arrangement.spacedBy
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalInspectionMode
+import androidx.compose.ui.unit.dp
+import androidx.navigation3.runtime.NavBackStack
+import androidx.navigation3.runtime.NavKey
+import androidx.navigation3.runtime.rememberNavBackStack
+import androidx.window.core.layout.WindowSizeClass
+import kotlinx.coroutines.launch
+import org.signal.core.models.media.Media
+import org.signal.core.ui.compose.AllDevicePreviews
+import org.signal.core.ui.compose.Previews
+import org.signal.core.util.ContentTypeUtil
+import org.signal.mediasend.EditorState
+import org.signal.mediasend.MediaSendNavKey
+import org.signal.mediasend.MediaSendState
+
+@Composable
+fun MediaEditScreen(
+ state: MediaSendState,
+ callback: MediaEditScreenCallback,
+ backStack: NavBackStack,
+ videoEditorSlot: @Composable () -> Unit = {}
+) {
+ val isFocusedMediaVideo = ContentTypeUtil.isVideoType(state.focusedMedia?.contentType)
+ val scope = rememberCoroutineScope()
+
+ val pagerState = rememberPagerState(
+ initialPage = state.focusedMedia?.let { state.selectedMedia.indexOf(it) } ?: 0,
+ pageCount = { state.selectedMedia.size }
+ )
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .navigationBarsPadding()
+ ) {
+ HorizontalPager(
+ state = pagerState,
+ modifier = Modifier.fillMaxSize(),
+ snapPosition = SnapPosition.Center
+ ) { index ->
+ when (val editorState = state.editorStateMap[state.selectedMedia[index].uri]) {
+ is EditorState.Image -> {
+ ImageEditor(
+ controller = remember { ImageEditorController(editorState.model) },
+ modifier = Modifier.fillMaxSize()
+ )
+ }
+
+ is EditorState.VideoTrim -> {
+ videoEditorSlot()
+ }
+
+ null -> {
+ if (!LocalInspectionMode.current) {
+ error("Invalid editor state.")
+ } else {
+ Box(modifier = Modifier.fillMaxSize().background(color = Previews.rememberRandomColor()))
+ }
+ }
+ }
+ }
+
+ Column(
+ verticalArrangement = spacedBy(20.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier.align(Alignment.BottomCenter)
+ ) {
+ if (state.selectedMedia.isNotEmpty()) {
+ ThumbnailRow(
+ selectedMedia = state.selectedMedia,
+ pagerState = pagerState,
+ onFocusedMediaChange = callback::setFocusedMedia,
+ onThumbnailClick = { index ->
+ scope.launch {
+ pagerState.animateScrollToPage(index)
+ }
+ }
+ )
+ }
+
+ if (isFocusedMediaVideo) {
+ // Video editor hud
+ } else if (!currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND)) {
+ // Image editor HU
+ }
+
+ AddAMessageRow(
+ message = state.message,
+ callback = AddAMessageRowCallback.Empty,
+ modifier = Modifier
+ .widthIn(max = 624.dp)
+ .padding(horizontal = 16.dp)
+ .padding(bottom = 16.dp)
+ )
+ }
+ }
+}
+
+@AllDevicePreviews
+@Composable
+private fun MediaEditScreenPreview() {
+ val selectedMedia = rememberPreviewMedia(10)
+
+ Previews.Preview {
+ MediaEditScreen(
+ state = MediaSendState(
+ selectedMedia = selectedMedia,
+ focusedMedia = selectedMedia.first()
+ ),
+ callback = MediaEditScreenCallback.Empty,
+ backStack = rememberNavBackStack(MediaSendNavKey.Edit),
+ videoEditorSlot = {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(color = Color.Red)
+ )
+ }
+ )
+ }
+}
+
+interface MediaEditScreenCallback {
+ fun setFocusedMedia(media: Media)
+
+ object Empty : MediaEditScreenCallback {
+ override fun setFocusedMedia(media: Media) = Unit
+ }
+}
diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/SelectedMediaRow.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/SelectedMediaRow.kt
new file mode 100644
index 0000000000..49234e3b93
--- /dev/null
+++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/SelectedMediaRow.kt
@@ -0,0 +1,232 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.mediasend.edit
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.gestures.draggable
+import androidx.compose.foundation.gestures.rememberDraggableState
+import androidx.compose.foundation.layout.Arrangement.spacedBy
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.pager.PagerState
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.platform.LocalInspectionMode
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.core.net.toUri
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.drop
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.launch
+import org.signal.core.models.media.Media
+import org.signal.core.ui.compose.DayNightPreviews
+import org.signal.core.ui.compose.Previews
+import org.signal.core.util.ContentTypeUtil
+import org.signal.glide.compose.GlideImage
+import org.signal.mediasend.MediaSendMetrics
+import kotlin.math.abs
+import kotlin.math.floor
+import kotlin.math.roundToInt
+
+private val BASE_SPACING = 4.dp
+private val MIN_PADDING = 0.dp
+private val MAX_PADDING = 8.dp
+
+/**
+ * Horizontally scrollable thumbnail strip that syncs with [pagerState].
+ * Features fish-eye padding effect where the centered item has more padding.
+ */
+@Composable
+internal fun ThumbnailRow(
+ selectedMedia: List,
+ pagerState: PagerState,
+ onFocusedMediaChange: (Media) -> Unit,
+ onThumbnailClick: (Int) -> Unit = {}
+) {
+ val density = LocalDensity.current
+ val scope = rememberCoroutineScope()
+
+ val itemWidthPx = with(density) { MediaSendMetrics.SelectedMediaPreviewSize.width.toPx() }
+ val baseSpacingPx = with(density) { BASE_SPACING.toPx() }
+ val itemStride = itemWidthPx + baseSpacingPx
+ val pagerPageSize = pagerState.layoutInfo.pageSize.takeIf { it > 0 } ?: 1
+ val listState = rememberLazyListState()
+
+ val draggableState = rememberDraggableState { delta ->
+ val scaledDelta = delta * (pagerPageSize.toFloat() / itemStride)
+ pagerState.dispatchRawDelta(-scaledDelta)
+ }
+
+ LaunchedEffect(pagerState) {
+ snapshotFlow { pagerState.isScrollInProgress }
+ .filter { !it }
+ .drop(1)
+ .collectLatest {
+ val settledPage = pagerState.currentPage
+ if (settledPage in selectedMedia.indices) {
+ onFocusedMediaChange(selectedMedia[settledPage])
+ }
+ }
+ }
+
+ LaunchedEffect(pagerState, itemStride, selectedMedia.size) {
+ if (selectedMedia.isEmpty()) return@LaunchedEffect
+
+ snapshotFlow { pagerState.currentPage + pagerState.currentPageOffsetFraction }
+ .distinctUntilChanged()
+ .collectLatest { position ->
+ val clampedPosition = position.coerceIn(0f, selectedMedia.lastIndex.toFloat())
+ val baseIndex = floor(clampedPosition.toDouble()).toInt()
+ val fraction = (clampedPosition - baseIndex).coerceIn(0f, 1f)
+ val scrollOffsetPx = (fraction * itemStride).roundToInt()
+
+ listState.scrollToItem(baseIndex, scrollOffsetPx)
+ }
+ }
+
+ BoxWithConstraints(
+ modifier = Modifier.fillMaxWidth().draggable(
+ state = draggableState,
+ orientation = Orientation.Horizontal,
+ onDragStopped = { velocity ->
+ scope.launch {
+ val currentOffset = pagerState.currentPageOffsetFraction
+ val targetPage = when {
+ velocity > 500f -> (pagerState.currentPage - 1).coerceAtLeast(0)
+ velocity < -500f -> (pagerState.currentPage + 1).coerceAtMost(selectedMedia.lastIndex)
+ currentOffset > 0.5f -> (pagerState.currentPage + 1).coerceAtMost(selectedMedia.lastIndex)
+ currentOffset < -0.5f -> (pagerState.currentPage - 1).coerceAtLeast(0)
+ else -> pagerState.currentPage
+ }
+ pagerState.animateScrollToPage(targetPage)
+ }
+ }
+ )
+ ) {
+ val itemWidth = MediaSendMetrics.SelectedMediaPreviewSize.width
+
+ val baseEdgePadding = ((maxWidth - itemWidth) / 2).coerceAtLeast(0.dp)
+ val startPadding = (baseEdgePadding - MAX_PADDING).coerceAtLeast(0.dp)
+ val endPadding = baseEdgePadding + MAX_PADDING
+
+ LazyRow(
+ horizontalArrangement = spacedBy(BASE_SPACING),
+ contentPadding = PaddingValues(start = startPadding, end = endPadding),
+ state = listState,
+ userScrollEnabled = false
+ ) {
+ itemsIndexed(selectedMedia, key = { _, media -> media.uri }) { index, media ->
+ val padding by remember(index) {
+ derivedStateOf {
+ val currentPosition = pagerState.currentPage + pagerState.currentPageOffsetFraction
+ val distanceFromCenter = abs(index - currentPosition).coerceIn(0f, 1f)
+ lerp(MAX_PADDING, MIN_PADDING, distanceFromCenter)
+ }
+ }
+
+ Thumbnail(
+ media = media,
+ modifier = Modifier
+ .padding(horizontal = padding)
+ .clickable { onThumbnailClick(index) }
+ )
+ }
+ }
+ }
+}
+
+private fun lerp(start: Dp, stop: Dp, fraction: Float): Dp {
+ return start + (stop - start) * fraction
+}
+
+@Composable
+private fun Thumbnail(media: Media, modifier: Modifier = Modifier) {
+ if (!LocalInspectionMode.current) {
+ GlideImage(
+ model = media.uri,
+ imageSize = MediaSendMetrics.SelectedMediaPreviewSize,
+ modifier = modifier
+ .size(MediaSendMetrics.SelectedMediaPreviewSize)
+ .clip(shape = RoundedCornerShape(8.dp))
+ )
+ } else {
+ Box(
+ modifier = modifier
+ .size(MediaSendMetrics.SelectedMediaPreviewSize)
+ .background(color = Color.Gray, shape = RoundedCornerShape(8.dp))
+ )
+ }
+}
+
+@DayNightPreviews
+@Composable
+private fun ThumbnailRowPreview() {
+ val media = rememberPreviewMedia(10)
+ val pagerState = rememberPagerState(pageCount = { media.size })
+
+ Previews.Preview {
+ ThumbnailRow(
+ selectedMedia = media,
+ pagerState = pagerState,
+ onFocusedMediaChange = { }
+ )
+ }
+}
+
+@DayNightPreviews
+@Composable
+private fun ThumbnailPreview() {
+ Previews.Preview {
+ Thumbnail(
+ media = rememberPreviewMedia(1).first()
+ )
+ }
+}
+
+@Composable
+internal fun rememberPreviewMedia(count: Int): List {
+ return remember(count) {
+ (0 until count).map {
+ Media(
+ uri = "https://example.com/image$it.png".toUri(),
+ contentType = ContentTypeUtil.IMAGE_PNG,
+ width = 100,
+ height = 100,
+ duration = 0,
+ date = 0,
+ size = 0,
+ isBorderless = false,
+ isVideoGif = false,
+ bucketId = null,
+ caption = null,
+ transformProperties = null,
+ fileName = null
+ )
+ }
+ }
+}
diff --git a/feature/media-send/src/main/java/org/signal/mediasend/preupload/PreUploadManager.kt b/feature/media-send/src/main/java/org/signal/mediasend/preupload/PreUploadManager.kt
new file mode 100644
index 0000000000..3ab59ed279
--- /dev/null
+++ b/feature/media-send/src/main/java/org/signal/mediasend/preupload/PreUploadManager.kt
@@ -0,0 +1,307 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.mediasend.preupload
+
+import android.content.Context
+import androidx.annotation.WorkerThread
+import org.signal.core.models.media.Media
+import org.signal.core.util.ThreadUtil
+import org.signal.core.util.concurrent.SignalExecutors
+import org.signal.core.util.logging.Log
+import org.signal.mediasend.MediaRecipientId
+import java.util.LinkedHashMap
+import java.util.concurrent.Executor
+
+/**
+ * Manages proactive upload of media during the selection process.
+ *
+ * Upload/cancel operations are serialized, because they're asynchronous operations that depend on
+ * ordered completion.
+ *
+ * For example, if we begin upload of a [Media] but then immediately cancel it (before it was fully
+ * enqueued), we need to wait until we have the job ids to cancel. This class manages everything by
+ * using a single-thread executor.
+ *
+ * This class is stateful.
+ */
+class PreUploadManager(
+ context: Context,
+ private val callback: Callback
+) {
+
+ private val context: Context = context.applicationContext
+ private val uploadResults: LinkedHashMap = LinkedHashMap()
+ private val executor: Executor =
+ SignalExecutors.newCachedSingleThreadExecutor("signal-PreUpload", ThreadUtil.PRIORITY_IMPORTANT_BACKGROUND_THREAD)
+
+ /**
+ * Starts a pre-upload for [media].
+ *
+ * @param media The media item to pre-upload.
+ * @param recipientId Optional recipient identifier. Used by the callback to apply recipient-specific behavior.
+ */
+ fun startUpload(media: Media, recipientId: MediaRecipientId?) {
+ executor.execute { uploadMediaInternal(media, recipientId) }
+ }
+
+ /**
+ * Starts (or restarts) pre-uploads for [mediaItems].
+ *
+ * This cancels any existing pre-upload for each item before starting a new one.
+ *
+ * @param mediaItems The media items to pre-upload.
+ * @param recipientId Optional recipient identifier. Used by the callback to apply recipient-specific behavior.
+ */
+ fun startUpload(mediaItems: Collection, recipientId: MediaRecipientId?) {
+ executor.execute {
+ for (media in mediaItems) {
+ Log.d(TAG, "Canceling existing preuploads.")
+ cancelUploadInternal(media)
+ Log.d(TAG, "Re-uploading media with recipient.")
+ uploadMediaInternal(media, recipientId)
+ }
+ }
+ }
+
+ /**
+ * Given a map of old->new, cancel medias that were changed and upload their replacements. Will
+ * also upload any media in the map that wasn't yet uploaded.
+ *
+ * @param oldToNew A mapping of prior media objects to their updated equivalents.
+ * @param recipientId Optional recipient identifier. Used by the callback to apply recipient-specific behavior.
+ */
+ fun applyMediaUpdates(oldToNew: Map, recipientId: MediaRecipientId?) {
+ executor.execute {
+ for ((oldMedia, newMedia) in oldToNew) {
+ val same = oldMedia == newMedia && hasSameTransformProperties(oldMedia, newMedia)
+
+ if (!same || !uploadResults.containsKey(newMedia)) {
+ Log.d(TAG, "Canceling existing preuploads.")
+ cancelUploadInternal(oldMedia)
+ Log.d(TAG, "Applying media updates.")
+ uploadMediaInternal(newMedia, recipientId)
+ }
+ }
+ }
+ }
+
+ private fun hasSameTransformProperties(oldMedia: Media, newMedia: Media): Boolean {
+ val oldProperties = oldMedia.transformProperties
+ val newProperties = newMedia.transformProperties
+
+ if (oldProperties == null || newProperties == null) {
+ return oldProperties == newProperties
+ }
+
+ // Matches legacy behavior: if the new media is "video edited", we treat it as different.
+ // Otherwise, we treat it as the same if only the sent quality matches.
+ return !newProperties.videoEdited && oldProperties.sentMediaQuality == newProperties.sentMediaQuality
+ }
+
+ /**
+ * Cancels the pre-upload (if present) for [media] and deletes any associated attachment state.
+ *
+ * @param media The media item to cancel.
+ */
+ fun cancelUpload(media: Media) {
+ Log.d(TAG, "User canceling media upload.")
+ executor.execute { cancelUploadInternal(media) }
+ }
+
+ /**
+ * Cancels pre-uploads (if present) for all [mediaItems].
+ *
+ * @param mediaItems Media items to cancel.
+ */
+ fun cancelUpload(mediaItems: Collection) {
+ Log.d(TAG, "Canceling uploads.")
+ executor.execute {
+ for (media in mediaItems) {
+ cancelUploadInternal(media)
+ }
+ }
+ }
+
+ /**
+ * Cancels all current pre-uploads and clears internal state.
+ */
+ fun cancelAllUploads() {
+ Log.d(TAG, "Canceling all uploads.")
+ executor.execute {
+ val keysSnapshot = uploadResults.keys.toList()
+ for (media in keysSnapshot) {
+ cancelUploadInternal(media)
+ }
+ }
+ }
+
+ /**
+ * Returns the current pre-upload results snapshot.
+ *
+ * @param callback Invoked with the current set of results (in display/order insertion order).
+ */
+ fun getPreUploadResults(callback: (Collection) -> Unit) {
+ executor.execute { callback(uploadResults.values) }
+ }
+
+ /**
+ * Updates captions for any pre-uploaded items in [updatedMedia].
+ *
+ * @param updatedMedia Media items containing the latest caption values.
+ */
+ fun updateCaptions(updatedMedia: List) {
+ executor.execute { updateCaptionsInternal(updatedMedia) }
+ }
+
+ /**
+ * Updates display order for pre-uploaded items, using [mediaInOrder] list order.
+ *
+ * @param mediaInOrder Media items in the desired display order.
+ */
+ fun updateDisplayOrder(mediaInOrder: List) {
+ executor.execute { updateDisplayOrderInternal(mediaInOrder) }
+ }
+
+ /**
+ * Deletes abandoned pre-upload attachments via the callback.
+ *
+ * @return Nothing. The callback controls deletion and returns a count for logging.
+ */
+ fun deleteAbandonedAttachments() {
+ executor.execute {
+ val deleted = this.callback.deleteAbandonedPreuploadedAttachments(context)
+ Log.i(TAG, "Deleted $deleted abandoned attachments.")
+ }
+ }
+
+ @WorkerThread
+ private fun uploadMediaInternal(media: Media, recipientId: MediaRecipientId?) {
+ val result = callback.preUpload(context, media, recipientId)
+
+ if (result != null) {
+ uploadResults[media] = result
+ } else {
+ Log.w(TAG, "Failed to upload media with URI: ${media.uri}")
+ }
+ }
+
+ private fun cancelUploadInternal(media: Media) {
+ val result = uploadResults[media] ?: return
+
+ Log.d(TAG, "Canceling attachment upload jobs for ${result.attachmentId}")
+ callback.cancelJobs(context, result.jobIds)
+ uploadResults.remove(media)
+ callback.deleteAttachment(context, result.attachmentId)
+ }
+
+ @WorkerThread
+ private fun updateCaptionsInternal(updatedMedia: List) {
+ for (updated in updatedMedia) {
+ val result = uploadResults[updated]
+
+ if (result != null) {
+ callback.updateAttachmentCaption(context, result.attachmentId, updated.caption)
+ } else {
+ Log.w(TAG, "When updating captions, no pre-upload result could be found for media with URI: ${updated.uri}")
+ }
+ }
+ }
+
+ @WorkerThread
+ private fun updateDisplayOrderInternal(mediaInOrder: List) {
+ val orderMap: MutableMap = LinkedHashMap()
+ val orderedUploadResults: LinkedHashMap = LinkedHashMap()
+
+ for ((index, media) in mediaInOrder.withIndex()) {
+ val result = uploadResults[media]
+
+ if (result != null) {
+ orderMap[result.attachmentId] = index
+ orderedUploadResults[media] = result
+ } else {
+ Log.w(TAG, "When updating display order, no pre-upload result could be found for media with URI: ${media.uri}")
+ }
+ }
+
+ callback.updateDisplayOrder(context, orderMap)
+
+ if (orderedUploadResults.size == uploadResults.size) {
+ uploadResults.clear()
+ uploadResults.putAll(orderedUploadResults)
+ }
+ }
+
+ /**
+ * Callbacks that perform the real side-effects (DB ops, job scheduling/cancelation, etc).
+ *
+ * This keeps `feature/media-send` free of direct dependencies on app-specific systems.
+ *
+ * Threading: all callback methods are invoked on this manager's serialized background executor
+ * thread (i.e., not the main thread).
+ */
+ interface Callback {
+ /**
+ * Performs pre-upload side-effects (e.g., create attachment state + enqueue jobs).
+ *
+ * @param context Application context.
+ * @param media The media item being pre-uploaded.
+ * @param recipientId Optional recipient identifier, if known.
+ * @return A [PreUploadResult] if enqueued, or `null` if it failed or should not pre-upload.
+ */
+ @WorkerThread
+ fun preUpload(context: Context, media: Media, recipientId: MediaRecipientId?): PreUploadResult?
+
+ /**
+ * Cancels any scheduled/running work for the provided job ids.
+ *
+ * @param context Application context.
+ * @param jobIds Job identifiers to cancel.
+ */
+ @WorkerThread
+ fun cancelJobs(context: Context, jobIds: List)
+
+ /**
+ * Deletes any persisted attachment state for [attachmentId].
+ *
+ * @param context Application context.
+ * @param attachmentId Attachment identifier to delete.
+ */
+ @WorkerThread
+ fun deleteAttachment(context: Context, attachmentId: Long)
+
+ /**
+ * Updates the caption for [attachmentId].
+ *
+ * @param context Application context.
+ * @param attachmentId Attachment identifier.
+ * @param caption New caption (or `null` to clear).
+ */
+ @WorkerThread
+ fun updateAttachmentCaption(context: Context, attachmentId: Long, caption: String?)
+
+ /**
+ * Updates display order for attachments.
+ *
+ * @param context Application context.
+ * @param orderMap Map of attachment id -> display order index.
+ */
+ @WorkerThread
+ fun updateDisplayOrder(context: Context, orderMap: Map)
+
+ /**
+ * Deletes any pre-uploaded attachments that are no longer referenced.
+ *
+ * @param context Application context.
+ * @return The number of attachments deleted.
+ */
+ @WorkerThread
+ fun deleteAbandonedPreuploadedAttachments(context: Context): Int
+ }
+
+ private companion object {
+ private val TAG = Log.tag(PreUploadManager::class.java)
+ }
+}
diff --git a/feature/media-send/src/main/java/org/signal/mediasend/preupload/PreUploadResult.kt b/feature/media-send/src/main/java/org/signal/mediasend/preupload/PreUploadResult.kt
new file mode 100644
index 0000000000..4f81013f9c
--- /dev/null
+++ b/feature/media-send/src/main/java/org/signal/mediasend/preupload/PreUploadResult.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.mediasend.preupload
+
+import android.net.Uri
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+import org.signal.core.models.media.Media
+
+/**
+ * Handle returned from a successful pre-upload.
+ *
+ * This mirrors the legacy concept of:
+ * - an attachment row identifier
+ * - the upload dependency job ids
+ * - the original media
+ */
+@Parcelize
+data class PreUploadResult(
+ val media: Media,
+ val attachmentId: Long,
+ val jobIds: List
+) : Parcelable {
+ val uri: Uri
+ get() = media.uri
+}
diff --git a/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreen.kt b/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreen.kt
new file mode 100644
index 0000000000..b235be2cb9
--- /dev/null
+++ b/feature/media-send/src/main/java/org/signal/mediasend/select/MediaSelectScreen.kt
@@ -0,0 +1,536 @@
+/*
+ * Copyright 2026 Signal Messenger, LLC
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+package org.signal.mediasend.select
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.animateDpAsState
+import androidx.compose.animation.core.animateFloatAsState
+import androidx.compose.animation.core.spring
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement.spacedBy
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.scale
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalInspectionMode
+import androidx.compose.ui.res.vectorResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+import androidx.core.net.toUri
+import androidx.navigation3.runtime.NavBackStack
+import androidx.navigation3.runtime.NavKey
+import androidx.navigation3.runtime.rememberNavBackStack
+import androidx.window.core.layout.WindowSizeClass
+import org.signal.core.models.media.Media
+import org.signal.core.models.media.MediaFolder
+import org.signal.core.ui.compose.AllDevicePreviews
+import org.signal.core.ui.compose.Buttons
+import org.signal.core.ui.compose.DayNightPreviews
+import org.signal.core.ui.compose.Previews
+import org.signal.core.ui.compose.Scaffolds
+import org.signal.core.ui.compose.ensureWidthIsAtLeastHeight
+import org.signal.glide.compose.GlideImage
+import org.signal.mediasend.MediaSendMetrics
+import org.signal.mediasend.MediaSendNavKey
+import org.signal.mediasend.MediaSendState
+import org.signal.mediasend.edit.rememberPreviewMedia
+import org.signal.mediasend.goToEdit
+import org.signal.mediasend.pop
+
+/**
+ * Allows user to select one or more pieces of content to add to the
+ * current media selection.
+ */
+@Composable
+internal fun MediaSelectScreen(
+ state: MediaSendState,
+ backStack: NavBackStack,
+ callback: MediaSelectScreenCallback
+) {
+ val gridConfiguration = rememberGridConfiguration(state.selectedMediaFolder == null)
+
+ Scaffolds.Settings(
+ title = state.selectedMediaFolder?.title ?: "Gallery",
+ navigationIcon = ImageVector.vectorResource(org.signal.core.ui.R.drawable.symbol_arrow_start_24),
+ onNavigationClick = {
+ if (state.selectedMediaFolder != null) {
+ callback.onFolderClick(null)
+ } else {
+ backStack.pop()
+ }
+ }
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier.padding(paddingValues)
+ ) {
+ LazyVerticalGrid(
+ columns = gridConfiguration.gridCells,
+ horizontalArrangement = spacedBy(gridConfiguration.horizontalSpacing),
+ verticalArrangement = spacedBy(gridConfiguration.verticalSpacing),
+ modifier = Modifier
+ .padding(horizontal = gridConfiguration.horizontalMargin)
+ .weight(1f)
+ ) {
+ if (state.selectedMediaFolder == null) {
+ items(state.mediaFolders, key = { it.bucketId }) {
+ MediaFolderTile(it, callback)
+ }
+ } else {
+ items(state.selectedMediaFolderItems, key = { it.uri }) { media ->
+ MediaTile(media = media, state.selectedMedia.indexOfFirst { it.uri == media.uri }, callback = callback)
+ }
+ }
+ }
+
+ AnimatedVisibility(
+ visible = state.selectedMedia.isNotEmpty(),
+ enter = expandVertically(
+ expandFrom = Alignment.Top,
+ animationSpec = spring()
+ ) + fadeIn(animationSpec = spring()),
+ exit = shrinkVertically(
+ shrinkTowards = Alignment.Top,
+ animationSpec = spring()
+ ) + fadeOut(animationSpec = spring()),
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(
+ modifier = Modifier
+ .background(color = MaterialTheme.colorScheme.surface)
+ .padding(vertical = gridConfiguration.bottomBarVerticalPadding, horizontal = gridConfiguration.bottomBarHorizontalPadding)
+ ) {
+ LazyRow(
+ modifier = Modifier
+ .weight(1f)
+ .padding(end = 16.dp),
+ horizontalArrangement = spacedBy(space = 12.dp, alignment = gridConfiguration.bottomBarAlignment)
+ ) {
+ items(state.selectedMedia, key = { it.uri }) { media ->
+ MediaThumbnail(media, modifier = Modifier.animateItem()) {
+ callback.setFocusedMedia(media)
+ backStack.goToEdit()
+ }
+ }
+ }
+
+ NextButton(state.selectedMedia.size) {
+ backStack.goToEdit()
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun rememberGridConfiguration(isRootGrid: Boolean): GridConfiguration {
+ val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
+
+ return remember(windowSizeClass, isRootGrid) {
+ GridConfiguration(
+ gridCells = if (isRootGrid) {
+ windowSizeClass.forWidthBreakpoint(
+ expanded = GridCells.Fixed(6),
+ medium = GridCells.Fixed(4),
+ compact = GridCells.Fixed(2)
+ )
+ } else {
+ windowSizeClass.forWidthBreakpoint(
+ expanded = GridCells.Fixed(8),
+ medium = GridCells.Fixed(6),
+ compact = GridCells.Fixed(4)
+ )
+ },
+ horizontalMargin = if (isRootGrid) {
+ windowSizeClass.forWidthBreakpoint(
+ expanded = 38.dp,
+ medium = 35.dp,
+ compact = 24.dp
+ )
+ } else {
+ 0.dp
+ },
+ horizontalSpacing = if (isRootGrid) {
+ windowSizeClass.forWidthBreakpoint(
+ expanded = 32.dp,
+ medium = 28.dp,
+ compact = 16.dp
+ )
+ } else {
+ 4.dp
+ },
+ verticalSpacing = if (isRootGrid) {
+ windowSizeClass.forWidthBreakpoint(
+ expanded = 32.dp,
+ medium = 32.dp,
+ compact = 24.dp
+ )
+ } else {
+ 4.dp
+ },
+ bottomBarVerticalPadding = windowSizeClass.forWidthBreakpoint(
+ expanded = 16.dp,
+ medium = 16.dp,
+ compact = 8.dp
+ ),
+ bottomBarHorizontalPadding = windowSizeClass.forWidthBreakpoint(
+ expanded = 24.dp,
+ medium = 24.dp,
+ compact = 16.dp
+ ),
+ bottomBarAlignment = windowSizeClass.forWidthBreakpoint(
+ expanded = Alignment.End,
+ medium = Alignment.End,
+ compact = Alignment.Start
+ )
+ )
+ }
+}
+
+private fun WindowSizeClass.forWidthBreakpoint(
+ expanded: T,
+ medium: T,
+ compact: T
+): T {
+ return when {
+ isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND) -> expanded
+ isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND) -> medium
+ else -> compact
+ }
+}
+
+@Composable
+private fun MediaFolderTile(
+ mediaFolder: MediaFolder,
+ callback: MediaSelectScreenCallback
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(
+ onClick = { callback.onFolderClick(mediaFolder) },
+ onClickLabel = mediaFolder.title,
+ role = Role.Button
+ ),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ if (LocalInspectionMode.current) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .aspectRatio(1f)
+ .background(color = Previews.rememberRandomColor(), shape = RoundedCornerShape(26.dp))
+ )
+ } else {
+ BoxWithConstraints(
+ modifier = Modifier
+ .fillMaxWidth()
+ .aspectRatio(1f)
+ .clip(RoundedCornerShape(26.dp))
+ ) {
+ val width = maxWidth
+ val height = maxHeight
+
+ GlideImage(
+ model = mediaFolder.thumbnailUri,
+ imageSize = DpSize(width, height),
+ modifier = Modifier
+ .aspectRatio(1f)
+ )
+ }
+ }
+
+ Text(text = mediaFolder.title, modifier = Modifier.padding(top = 12.dp))
+ }
+}
+
+@Composable
+private fun MediaTile(
+ media: Media,
+ selectionIndex: Int,
+ callback: MediaSelectScreenCallback
+) {
+ val scale by animateFloatAsState(
+ targetValue = if (selectionIndex >= 0) {
+ 0.8f
+ } else {
+ 1f
+ }
+ )
+
+ val cornerClip by animateDpAsState(
+ if (selectionIndex >= 0) 12.dp else 0.dp
+ )
+
+ BoxWithConstraints(
+ modifier = Modifier
+ .background(color = MaterialTheme.colorScheme.surfaceVariant)
+ .clickable(
+ onClick = { callback.onMediaClick(media) },
+ onClickLabel = media.fileName,
+ role = Role.Button
+ )
+ ) {
+ if (LocalInspectionMode.current) {
+ Box(
+ modifier = Modifier
+ .scale(scale)
+ .background(color = Previews.rememberRandomColor(), shape = RoundedCornerShape(cornerClip)).fillMaxWidth()
+ .aspectRatio(1f)
+ )
+ } else {
+ val width = maxWidth
+ val height = maxHeight
+
+ GlideImage(
+ model = media.uri,
+ imageSize = DpSize(width, height),
+ modifier = Modifier
+ .aspectRatio(1f)
+ .scale(scale)
+ .clip(RoundedCornerShape(cornerClip))
+ )
+ }
+ }
+
+ if (selectionIndex >= 0) {
+ Box(
+ modifier = Modifier.padding(3.dp)
+ ) {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = Modifier
+ .border(width = 3.dp, color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(percent = 50))
+ .padding(1.dp)
+ .background(color = MaterialTheme.colorScheme.primary, shape = RoundedCornerShape(percent = 50))
+ .ensureWidthIsAtLeastHeight()
+ .padding(horizontal = 5.5.dp, vertical = 2.dp)
+ ) {
+ Text(text = "${selectionIndex + 1}", color = MaterialTheme.colorScheme.onPrimary)
+ }
+ }
+ }
+}
+
+@Composable
+private fun NextButton(mediaSelectionCount: Int, onClick: () -> Unit) {
+ Buttons.MediumTonal(
+ onClick = onClick,
+ contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp)
+ ) {
+ Box(
+ modifier = Modifier
+ .background(color = MaterialTheme.colorScheme.primary, shape = RoundedCornerShape(percent = 50))
+ .ensureWidthIsAtLeastHeight()
+ ) {
+ Text(
+ text = "$mediaSelectionCount",
+ color = MaterialTheme.colorScheme.onPrimary,
+ modifier = Modifier
+ )
+ }
+
+ Icon(
+ imageVector = ImageVector.vectorResource(org.signal.core.ui.R.drawable.symbol_chevron_right_24),
+ contentDescription = "Next"
+ )
+ }
+}
+
+@Composable
+private fun MediaThumbnail(
+ media: Media,
+ modifier: Modifier = Modifier,
+ onClick: () -> Unit
+) {
+ if (LocalInspectionMode.current) {
+ Box(
+ modifier = modifier
+ .size(MediaSendMetrics.SelectedMediaPreviewSize)
+ .background(color = Previews.rememberRandomColor(), shape = RoundedCornerShape(8.dp))
+ )
+ } else {
+ GlideImage(
+ model = media.uri,
+ imageSize = MediaSendMetrics.SelectedMediaPreviewSize,
+ modifier = modifier
+ .size(MediaSendMetrics.SelectedMediaPreviewSize)
+ .clip(RoundedCornerShape(8.dp))
+ )
+ }
+}
+
+@AllDevicePreviews
+@Composable
+private fun MediaSelectScreenFolderPreview() {
+ Previews.Preview {
+ MediaSelectScreen(
+ state = MediaSendState(
+ mediaFolders = rememberPreviewMediaFolders(20)
+ ),
+ backStack = rememberNavBackStack(MediaSendNavKey.Edit),
+ callback = MediaSelectScreenCallback.Empty
+ )
+ }
+}
+
+@AllDevicePreviews
+@Composable
+private fun MediaSelectScreenMediaPreview() {
+ val folders = rememberPreviewMediaFolders(20)
+ val media = rememberPreviewMedia(100)
+ val selectedMedia: MutableList = remember { mutableStateListOf() }
+ val callback = remember {
+ object : MediaSelectScreenCallback by MediaSelectScreenCallback.Empty {
+ override fun onMediaClick(media: Media) {
+ if (media in selectedMedia) {
+ selectedMedia.remove(media)
+ } else {
+ selectedMedia.add(media)
+ }
+ }
+ }
+ }
+
+ Previews.Preview {
+ MediaSelectScreen(
+ state = MediaSendState(
+ mediaFolders = folders,
+ selectedMediaFolder = folders.first(),
+ selectedMediaFolderItems = media,
+ selectedMedia = selectedMedia
+ ),
+ backStack = rememberNavBackStack(MediaSendNavKey.Edit),
+ callback = callback
+ )
+ }
+}
+
+@DayNightPreviews
+@Composable
+private fun MediaFolderTilePreview() {
+ Previews.Preview {
+ Box(modifier = Modifier.width(174.dp)) {
+ MediaFolderTile(
+ mediaFolder = rememberPreviewMediaFolders(1).first(),
+ callback = MediaSelectScreenCallback.Empty
+ )
+ }
+ }
+}
+
+@DayNightPreviews
+@Composable
+private fun MediaTilePreview() {
+ Previews.Preview {
+ MediaTile(
+ media = rememberPreviewMedia(1).first(),
+ selectionIndex = -1,
+ callback = MediaSelectScreenCallback.Empty
+ )
+ }
+}
+
+@DayNightPreviews
+@Composable
+private fun MediaTileSelectedPreview() {
+ var isSelected by remember { mutableStateOf(true) }
+
+ Previews.Preview {
+ MediaTile(
+ media = rememberPreviewMedia(1).first(),
+ selectionIndex = if (isSelected) 0 else -1,
+ callback = object : MediaSelectScreenCallback by MediaSelectScreenCallback.Empty {
+ override fun onMediaClick(media: Media) {
+ isSelected = !isSelected
+ }
+ }
+ )
+ }
+}
+
+@DayNightPreviews
+@Composable
+private fun NextButtonPreview() {
+ Previews.Preview {
+ NextButton(
+ mediaSelectionCount = 3,
+ onClick = {}
+ )
+ }
+}
+
+@Composable
+private fun rememberPreviewMediaFolders(count: Int): List {
+ return remember(count) {
+ (0 until count).map { index ->
+ MediaFolder(
+ thumbnailUri = "https://example.com/folder$index.jpg".toUri(),
+ title = "Folder $index",
+ itemCount = (index + 1) * 10,
+ bucketId = "bucket_$index",
+ folderType = if (index == 0) MediaFolder.FolderType.CAMERA else MediaFolder.FolderType.NORMAL
+ )
+ }
+ }
+}
+
+private data class GridConfiguration(
+ val gridCells: GridCells,
+ val horizontalMargin: Dp,
+ val horizontalSpacing: Dp,
+ val verticalSpacing: Dp,
+ val bottomBarVerticalPadding: Dp,
+ val bottomBarHorizontalPadding: Dp,
+ val bottomBarAlignment: Alignment.Horizontal
+)
+
+interface MediaSelectScreenCallback {
+ fun onFolderClick(mediaFolder: MediaFolder?)
+ fun onMediaClick(media: Media)
+ fun setFocusedMedia(media: Media)
+
+ object Empty : MediaSelectScreenCallback {
+ override fun onFolderClick(mediaFolder: MediaFolder?) {}
+ override fun onMediaClick(media: Media) {}
+ override fun setFocusedMedia(media: Media) {}
+ }
+}
diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml
index 8831a3adf2..49a64aec58 100644
--- a/gradle/verification-metadata.xml
+++ b/gradle/verification-metadata.xml
@@ -2899,6 +2899,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -6205,6 +6221,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
+
+
+
+
+
+
+
+
@@ -10665,6 +10689,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
+
+
+
+
+
@@ -10686,6 +10715,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
+
+
+
+
+
@@ -13114,6 +13148,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
+
+
+
+
+
diff --git a/lib/glide/src/main/java/org/signal/glide/compose/GlideImage.kt b/lib/glide/src/main/java/org/signal/glide/compose/GlideImage.kt
index 8cfbe2e0a3..36a406525c 100644
--- a/lib/glide/src/main/java/org/signal/glide/compose/GlideImage.kt
+++ b/lib/glide/src/main/java/org/signal/glide/compose/GlideImage.kt
@@ -68,6 +68,9 @@ fun GlideImage(
}
.into(imageView)
},
+ onReset = {
+ Glide.with(it.context).clear(it)
+ },
modifier = modifier
)
} else {
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 80f76bb3a9..ea7c5219b7 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -91,6 +91,7 @@ include(":lib:blurhash")
// Feature modules
include(":feature:registration")
include(":feature:camera")
+include(":feature:media-send")
// Demo apps
include(":demo:paging")