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