Add media send feature module.

This commit is contained in:
Alex Hart
2026-02-03 11:25:57 -04:00
committed by GitHub
parent bc7ba5f2c6
commit 0cd93986bd
58 changed files with 4364 additions and 68 deletions

View File

@@ -639,6 +639,7 @@ dependencies {
implementation(libs.kotlinx.serialization.json)
implementation(project(":lib:billing"))
implementation(project(":feature:media-send"))
"spinnerImplementation"(project(":lib:spinner"))

View File

@@ -474,6 +474,15 @@
android:theme="@style/TextSecure.DarkNoActionBar"
android:windowSoftInputMode="stateAlwaysHidden|adjustNothing" />
<activity
android:name=".mediasend.v3.MediaSendV3Activity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
android:exported="false"
android:launchMode="singleTop"
android:screenOrientation="portrait"
android:theme="@style/Signal.DayNight.NoActionBar"
android:windowSoftInputMode="stateAlwaysHidden|adjustNothing" />
<activity
android:name=".conversation.mutiselect.forward.MultiselectForwardActivity"
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"

View File

@@ -88,6 +88,7 @@ import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.getSerializableCompat
import org.signal.core.util.logging.Log
import org.signal.donations.StripeApi
import org.signal.mediasend.MediaSendActivityContract
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress
import org.thoughtcrime.securesms.backup.v2.ui.verify.VerifyBackupKeyActivity
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar.show
@@ -155,6 +156,7 @@ import org.thoughtcrime.securesms.main.rememberMainNavigationDetailLocation
import org.thoughtcrime.securesms.main.storiesNavGraphBuilder
import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
import org.thoughtcrime.securesms.mediasend.v3.MediaSendV3ActivityContract
import org.thoughtcrime.securesms.megaphone.Megaphone
import org.thoughtcrime.securesms.megaphone.MegaphoneActionController
import org.thoughtcrime.securesms.megaphone.Megaphones
@@ -251,6 +253,8 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
override val googlePayRepository: GooglePayRepository by lazy { GooglePayRepository(this) }
override val googlePayResultPublisher: Subject<GooglePayComponent.GooglePayResult> = PublishSubject.create()
private lateinit var mediaActivityLauncher: ActivityResultLauncher<MediaSendActivityContract.Args>
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()) {

View File

@@ -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)
}
)
}
}

View File

@@ -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
)

View File

@@ -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() {

View File

@@ -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)
}
/**

View File

@@ -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.
*/

View File

@@ -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()) {

View File

@@ -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;

View File

@@ -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<Optional<Media?>> {
error("Not yet implemented")
}
override fun getMediaConstraints(): MediaConstraints {
return MediaConstraints.getPushMediaConstraints()
}
override fun getMaxVideoDuration(): Int {
error("Not yet implemented")
}
// endregion
}

View File

@@ -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)

View File

@@ -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()
)
}

View File

@@ -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<String>) {
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<Long, Int>) {
val mapped = orderMap.mapKeys { AttachmentId(it.key) }
SignalDatabase.attachments.updateDisplayOrder(mapped)
}
@WorkerThread
override fun deleteAbandonedPreuploadedAttachments(context: Context): Int {
return SignalDatabase.attachments.deleteAbandonedPreuploadedAttachments()
}
}

View File

@@ -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<MediaFolder> = suspendCancellableCoroutine { continuation ->
mediaRepository.getFolders(appContext) { folders ->
continuation.resume(folders)
}
}
override suspend fun getMedia(bucketId: String): List<Media> = suspendCancellableCoroutine { continuation ->
mediaRepository.getMediaInBucket(appContext, bucketId) { media ->
continuation.resume(media)
}
}
override suspend fun validateAndFilterMedia(
media: List<Media>,
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>) {
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<Media>): 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<Long>, since: Long): List<Long> = withContext(Dispatchers.IO) {
if (contactIds.isEmpty()) return@withContext emptyList<Long>()
val recipients: List<Recipient> = 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<Boolean> {
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<ContactSearchKey.RecipientSearchKey> {
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<Uri, EditorState>): Map<Uri, Any> {
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<Media>,
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>): 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<Media>,
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
}
}
}

View File

@@ -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)
}
}

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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) {

View File

@@ -16,4 +16,5 @@ android {
dependencies {
implementation(libs.kotlinx.serialization.json)
implementation(libs.jackson.core)
implementation(libs.jackson.module.kotlin)
}

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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<TransformProperties>(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)
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)),

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M20.13 12c0 0.23-0.1 0.45-0.26 0.62l-6.5 6.5c-0.34 0.34-0.9 0.34-1.24 0-0.34-0.34-0.34-0.9 0-1.24l5.13-5.13-1.76 0.13H4.25c-0.48 0-0.88-0.4-0.88-0.88s0.4-0.88 0.88-0.88H15.5l1.76 0.13-5.13-5.13c-0.34-0.34-0.34-0.9 0-1.24 0.34-0.34 0.9-0.34 1.24 0l6.5 6.5c0.16 0.17 0.25 0.39 0.25 0.62Z" />
</vector>

View File

@@ -0,0 +1,18 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M8.43 14.94c-0.3-0.37-0.86-0.42-1.23-0.11-0.37 0.3-0.43 0.85-0.12 1.23 1.17 1.41 2.94 2.32 4.92 2.32s3.75-0.9 4.92-2.32c0.3-0.38 0.25-0.93-0.12-1.23-0.37-0.31-0.92-0.26-1.23 0.11-0.85 1.03-2.13 1.69-3.57 1.69s-2.72-0.66-3.57-1.69Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M15.25 7.75c-0.5 0-0.88 0.3-1.12 0.66-0.25 0.37-0.38 0.84-0.38 1.34 0 0.5 0.13 0.97 0.38 1.34 0.24 0.36 0.62 0.66 1.12 0.66 0.5 0 0.88-0.3 1.12-0.66 0.25-0.37 0.38-0.84 0.38-1.34 0-0.5-0.13-0.97-0.38-1.34-0.24-0.36-0.62-0.66-1.12-0.66Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M7.63 8.41c0.24-0.36 0.62-0.66 1.12-0.66 0.5 0 0.88 0.3 1.12 0.66 0.25 0.37 0.38 0.84 0.38 1.34 0 0.5-0.13 0.97-0.38 1.34-0.24 0.36-0.62 0.66-1.12 0.66-0.5 0-0.88-0.3-1.12-0.66-0.25-0.37-0.38-0.84-0.38-1.34 0-0.5 0.13-0.97 0.38-1.34Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M12 1.13C6 1.13 1.12 5.99 1.12 12 1.13 18 6 22.88 12 22.88c6 0 10.88-4.87 10.88-10.88C22.88 6 18 1.12 12 1.12ZM2.87 12c0-5.04 4.09-9.13 9.13-9.13 5.04 0 9.13 4.09 9.13 9.13 0 5.04-4.09 9.13-9.13 9.13-5.04 0-9.13-4.09-9.13-9.13Z"/>
</vector>

View File

@@ -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);
}
}

View File

@@ -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)
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!--
MediaSendActivity is abstract. Concrete implementations should be
declared in the app module's manifest.
-->
</manifest>

View File

@@ -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)
}
}

View File

@@ -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<NavKey>,
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")
}
}
}
}

View File

@@ -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)"
}

View File

@@ -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<MediaSendViewModel>(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 <T : MediaSendActivity> createIntent(
context: Context,
activityClass: Class<T>,
args: MediaSendActivityContract.Args = MediaSendActivityContract.Args()
): Intent {
return Intent(context, activityClass).apply {
putExtra(MediaSendActivityContract.EXTRA_ARGS, args)
}
}
}
}

View File

@@ -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<out MediaSendActivity>? = null
) : ActivityResultContract<MediaSendActivityContract.Args, MediaSendActivityContract.Result?>() {
/**
* 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<Media> = 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<PreUploadHandle>
) : Payload
/**
* Local media that the caller should upload/send as part of the normal pipeline.
*/
@Parcelize
data class LocalMedia(
val items: List<SelectedMedia>
) : 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<String>,
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)
}
}
}
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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<MediaFolder>
/**
* Retrieves media for a given bucketId (folder)
*/
suspend fun getMedia(bucketId: String): List<Media>
/**
* 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<Media>,
maxSelection: Int,
isStory: Boolean
): MediaFilterResult
/**
* Deletes temporary blob files for the given media.
*/
suspend fun deleteBlobs(media: List<Media>)
/**
* 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<Media>): 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<Long>,
since: Long
): List<Long>
/**
* 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<Boolean>
}
/**
* Result of media validation/filtering.
*/
data class MediaFilterResult(
val filteredMedia: List<Media>,
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<Media>,
val editorStateMap: Map<Uri, EditorState>,
val quality: Int,
val message: String?,
val isViewOnce: Boolean,
val singleRecipientId: MediaRecipientId?,
val recipientIds: List<MediaRecipientId>,
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<Long>) : 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
}

View File

@@ -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<NavKey>,
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)
}
}

View File

@@ -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<Media> = 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<Uri, EditorState> = 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<MediaRecipientId> = emptyList(),
/**
* Scheduled send time (-1 for immediate).
*/
val scheduledTime: Long = -1,
/**
* The [MediaFolder] list available on the system
*/
val mediaFolders: List<MediaFolder> = 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<Media> = 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
}
}
}

View File

@@ -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() {
}

View File

@@ -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<Boolean>,
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<MediaSendState> = savedStateHandle.getMutableStateFlow(KEY_STATE, defaultState)
val state: StateFlow<MediaSendState> = internalState.asStateFlow()
private val editedVideoUris: MutableSet<Uri> = mutableSetOf<Uri>().apply {
addAll(savedStateHandle[KEY_EDITED_VIDEO_URIS] ?: emptyList())
}
/** One-shot HUD commands exposed as a Flow. */
private val hudCommandChannel = Channel<HudCommand>(Channel.BUFFERED)
val hudCommands: Flow<HudCommand> = hudCommandChannel.receiveAsFlow()
/** Media filter errors. */
private val _mediaErrors = MutableSharedFlow<MediaFilterError>(replay = 1)
val mediaErrors: SharedFlow<MediaFilterError> = _mediaErrors.asSharedFlow()
/** Character count for the message field. */
val messageCharacterCount: Flow<Int> = state
.map { it.message?.let { msg -> StringUtil.getGraphemeCount(msg) } ?: 0 }
.distinctUntilChanged()
/** Tracks drag state for media reordering. */
private var lastMediaDrag: Pair<Int, Int> = 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<Media>) {
viewModelScope.launch {
val snapshot = state.value
val newSelectionList: List<Media> = linkedSetOf<Media>().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<Bitmap> {
override fun onResourceReady(resource: Bitmap?, model: Any?, target: Target<Bitmap?>?, dataSource: DataSource?, isFirstResource: Boolean): Boolean {
return false
}
override fun onLoadFailed(e: GlideException?, model: Any?, target: Target<Bitmap?>?, 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<Media>) {
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<Media, Media>) {
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<Media>) {
updateState { copy(selectedMedia = mediaInOrder) }
preUploadManager.updateDisplayOrder(mediaInOrder)
}
//endregion
//region Pre-Upload Management
private fun startUpload(media: List<Media>) {
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<Media>) {
if (!state.value.isStory) return
val requirements = repository.getStorySendRequirements(media)
updateState { copy(storySendRequirements = requirements) }
}
//endregion
//region Recipients
fun setAdditionalRecipients(recipientIds: List<MediaRecipientId>) {
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<Boolean>,
private val repository: MediaSendRepository,
private val preUploadCallback: PreUploadManager.Callback
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>, 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"
}
}

View File

@@ -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<Boolean> {
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()
}
}

View File

@@ -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<NavKey>.goToEdit() {
if (contains(MediaSendNavKey.Edit)) {
popTo(MediaSendNavKey.Edit)
} else {
add(MediaSendNavKey.Edit)
}
}
internal fun NavBackStack<NavKey>.pop() {
if (isNotEmpty()) {
removeAt(size - 1)
}
}
private fun NavBackStack<NavKey>.popTo(key: NavKey) {
while (size > 1 && get(size - 1) != key) {
removeAt(size - 1)
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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<NavKey>,
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
}
}

View File

@@ -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<Media>,
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<Media> {
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
)
}
}
}

View File

@@ -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<Media, PreUploadResult> = 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<Media>, 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<Media, Media>, 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<Media>) {
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<PreUploadResult>) -> 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<Media>) {
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<Media>) {
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<Media>) {
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<Media>) {
val orderMap: MutableMap<Long, Int> = LinkedHashMap()
val orderedUploadResults: LinkedHashMap<Media, PreUploadResult> = 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<String>)
/**
* 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<Long, Int>)
/**
* 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)
}
}

View File

@@ -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<String>
) : Parcelable {
val uri: Uri
get() = media.uri
}

View File

@@ -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<NavKey>,
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 <T> 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<Media> = 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<MediaFolder> {
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) {}
}
}

View File

@@ -2899,6 +2899,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="bd98cf873283bb3e1bda363b69406ffe963d7034ba2d248775cb3ea553f76871" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.ui" name="ui-test-junit4-jvmstubs" version="1.9.5">
<artifact name="ui-test-junit4-jvmstubs-1.9.5.jar">
<sha256 value="09826ed0804f4df455776c97948a049218fb4831d5497911e8ea7ead217fd6f5" origin="Generated by Gradle"/>
</artifact>
<artifact name="ui-test-junit4-jvmstubs-1.9.5.module">
<sha256 value="2713f4daca855eb02c0e2d63741d92d4bbda369ce589a00c08cda186dfcf3b1b" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.ui" name="ui-test-jvmstubs" version="1.9.5">
<artifact name="ui-test-jvmstubs-1.9.5.jar">
<sha256 value="d0134362ed92417037dac95a7b3dfafb9fb7dc65e456161c95b9890a4a2fefe3" origin="Generated by Gradle"/>
</artifact>
<artifact name="ui-test-jvmstubs-1.9.5.module">
<sha256 value="348628f9529a85ad237e645039a49437cc812d922c93b7c21030614fe6ac4e4c" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.ui" name="ui-test-manifest" version="1.9.0">
<artifact name="ui-test-manifest-1.9.0.aar">
<sha256 value="3faf6ca99e9541fb65e858da316ac7cfb7798c49ce674db80417f00a1b29cd69" origin="Generated by Gradle"/>
@@ -6205,6 +6221,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="a4e0e66288a40659d927938c605fee060acdbad610b041fbf8984866318cb5ed" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.lifecycle" name="lifecycle-viewmodel-navigation3-jvmstubs" version="2.10.0">
<artifact name="lifecycle-viewmodel-navigation3-jvmstubs-2.10.0.jar">
<sha256 value="f7bdef6f50d15d2c9556c4868f455917a1fe158f980ace764da46b11be7f8240" origin="Generated by Gradle"/>
</artifact>
<artifact name="lifecycle-viewmodel-navigation3-jvmstubs-2.10.0.module">
<sha256 value="e7fd4cfedf378a222e2a828f300f5935cf0b9962c964e7c889c78400bfb30a8a" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.lifecycle" name="lifecycle-viewmodel-savedstate" version="2.10.0">
<artifact name="lifecycle-viewmodel-savedstate-2.10.0.module">
<md5 value="10e7d7afd3a3ef6bb775f0e19f98787a" origin="Generated by Gradle"/>
@@ -10665,6 +10689,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="e5a7c649c54c412049908247ca5e25fe6921d746849c6017a84dc6044237a4b4" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.truth" name="truth" version="1.0.1">
<artifact name="truth-1.0.1.jar">
<sha256 value="1ccf4334e7a94cf00a20a619b5462b53acf3274e00b70498bf5b28a3bc1be9b1" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.google.zxing" name="android-integration" version="3.3.0">
<artifact name="android-integration-3.3.0.jar">
<md5 value="3607c0180c63926919a2caa1575fc919" origin="Generated by Gradle"/>
@@ -10686,6 +10715,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="7e24ad50b222d2f70ac91bdccfa3c0f6200b078d797cb784837f75e77bb4210f" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.googlecode.java-diff-utils" name="diffutils" version="1.3.0">
<artifact name="diffutils-1.3.0.jar">
<sha256 value="61ba4dc49adca95243beaa0569adc2a23aedb5292ae78aa01186fa782ebdc5c2" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.googlecode.juniversalchardet" name="juniversalchardet" version="1.0.3">
<artifact name="juniversalchardet-1.0.3.jar">
<md5 value="d9ea0a9a275336c175b343f2e4cd8f27" origin="Generated by Gradle"/>
@@ -13114,6 +13148,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="22eca687f7955411f456af33e6ea8e68fc73cd80cb8b32aa5f7a8b1827d7c678" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.checkerframework" name="checker-compat-qual" version="2.5.5">
<artifact name="checker-compat-qual-2.5.5.jar">
<sha256 value="11d134b245e9cacc474514d2d66b5b8618f8039a1465cdc55bbc0b34e0008b7a" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.checkerframework" name="checker-qual" version="3.12.0">
<artifact name="checker-qual-3.12.0.jar">
<md5 value="ab1ae0e2f2f63601597a5a96fca8a54f" origin="Generated by Gradle"/>

View File

@@ -68,6 +68,9 @@ fun <T> GlideImage(
}
.into(imageView)
},
onReset = {
Glide.with(it.context).clear(it)
},
modifier = modifier
)
} else {

View File

@@ -91,6 +91,7 @@ include(":lib:blurhash")
// Feature modules
include(":feature:registration")
include(":feature:camera")
include(":feature:media-send")
// Demo apps
include(":demo:paging")