mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-05-04 07:25:25 +01:00
Add media send feature module.
This commit is contained in:
@@ -31,6 +31,14 @@ public interface CameraFragment {
|
||||
}
|
||||
}
|
||||
|
||||
static Class<? extends Fragment> getFragmentClass() {
|
||||
if (CameraXUtil.isSupported()) {
|
||||
return CameraXFragment.class;
|
||||
} else {
|
||||
return Camera1Fragment.class;
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint({ "RestrictedApi", "UnsafeOptInUsageError" })
|
||||
static Fragment newInstanceForAvatarCapture() {
|
||||
if (CameraXUtil.isSupported()) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.mediasend.v3
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import org.signal.core.models.media.Media
|
||||
import org.signal.mediasend.MediaSendActivity
|
||||
import org.thoughtcrime.securesms.mediasend.CameraFragment
|
||||
import org.thoughtcrime.securesms.mms.MediaConstraints
|
||||
import java.io.FileDescriptor
|
||||
import java.util.Optional
|
||||
|
||||
/**
|
||||
* App-layer implementation of the feature module media send activity.
|
||||
*/
|
||||
class MediaSendV3Activity : MediaSendActivity(), CameraFragment.Controller {
|
||||
|
||||
override val preUploadCallback = MediaSendV3PreUploadCallback()
|
||||
override val repository by lazy { MediaSendV3Repository(applicationContext) }
|
||||
|
||||
@Composable
|
||||
override fun CameraSlot() {
|
||||
MediaSendV3CameraSlot()
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun TextStoryEditorSlot() {
|
||||
MediaSendV3PlaceholderScreen(text = "Text Story Editor")
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun VideoEditorSlot() {
|
||||
MediaSendV3PlaceholderScreen(text = "Video Editor")
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun SendSlot() {
|
||||
MediaSendV3PlaceholderScreen(text = "Send Review")
|
||||
}
|
||||
|
||||
// region Camera Callbacks
|
||||
|
||||
override fun onCameraError() {
|
||||
error("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun onImageCaptured(data: ByteArray, width: Int, height: Int) {
|
||||
error("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun onVideoCaptured(fd: FileDescriptor) {
|
||||
error("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun onVideoCaptureError() {
|
||||
error("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun onGalleryClicked() {
|
||||
error("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun onCameraCountButtonClicked() {
|
||||
error("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun onQrCodeFound(data: String) {
|
||||
error("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun getMostRecentMediaItem(): Flowable<Optional<Media?>> {
|
||||
error("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun getMediaConstraints(): MediaConstraints {
|
||||
return MediaConstraints.getPushMediaConstraints()
|
||||
}
|
||||
|
||||
override fun getMaxVideoDuration(): Int {
|
||||
error("Not yet implemented")
|
||||
}
|
||||
// endregion
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.mediasend.v3
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.WorkerThread
|
||||
import org.signal.core.models.media.Media
|
||||
import org.signal.mediasend.MediaRecipientId
|
||||
import org.signal.mediasend.preupload.PreUploadManager
|
||||
import org.signal.mediasend.preupload.PreUploadResult
|
||||
import org.thoughtcrime.securesms.attachments.AttachmentId
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.mediasend.MediaUploadRepository
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.sms.MessageSender
|
||||
|
||||
class MediaSendV3PreUploadCallback : PreUploadManager.Callback {
|
||||
|
||||
@WorkerThread
|
||||
override fun preUpload(context: Context, media: Media, recipientId: MediaRecipientId?): PreUploadResult? {
|
||||
val attachment = MediaUploadRepository.asAttachment(context, media)
|
||||
val recipient = recipientId?.let { Recipient.resolved(RecipientId.from(it.id)) }
|
||||
val legacyResult = MessageSender.preUploadPushAttachment(context, attachment, recipient, media) ?: return null
|
||||
return PreUploadResult(
|
||||
legacyResult.media,
|
||||
legacyResult.attachmentId.id,
|
||||
legacyResult.jobIds.toMutableList()
|
||||
)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun cancelJobs(context: Context, jobIds: List<String>) {
|
||||
val jobManager = AppDependencies.jobManager
|
||||
jobIds.forEach(jobManager::cancel)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun deleteAttachment(context: Context, attachmentId: Long) {
|
||||
SignalDatabase.attachments.deleteAttachment(AttachmentId(attachmentId))
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun updateAttachmentCaption(context: Context, attachmentId: Long, caption: String?) {
|
||||
SignalDatabase.attachments.updateAttachmentCaption(AttachmentId(attachmentId), caption)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun updateDisplayOrder(context: Context, orderMap: Map<Long, Int>) {
|
||||
val mapped = orderMap.mapKeys { AttachmentId(it.key) }
|
||||
SignalDatabase.attachments.updateDisplayOrder(mapped)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun deleteAbandonedPreuploadedAttachments(context: Context): Int {
|
||||
return SignalDatabase.attachments.deleteAbandonedPreuploadedAttachments()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.mediasend.v3
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.rx3.asFlow
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.signal.core.models.media.Media
|
||||
import org.signal.core.models.media.MediaFolder
|
||||
import org.signal.mediasend.EditorState
|
||||
import org.signal.mediasend.MediaFilterError
|
||||
import org.signal.mediasend.MediaFilterResult
|
||||
import org.signal.mediasend.MediaRecipientId
|
||||
import org.signal.mediasend.MediaSendRepository
|
||||
import org.signal.mediasend.SendRequest
|
||||
import org.signal.mediasend.SendResult
|
||||
import org.signal.mediasend.StorySendRequirements
|
||||
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
|
||||
import org.thoughtcrime.securesms.conversation.MessageSendType
|
||||
import org.thoughtcrime.securesms.database.SignalDatabase
|
||||
import org.thoughtcrime.securesms.database.model.IdentityRecord
|
||||
import org.thoughtcrime.securesms.dependencies.AppDependencies
|
||||
import org.thoughtcrime.securesms.mediasend.MediaRepository
|
||||
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionRepository
|
||||
import org.thoughtcrime.securesms.mediasend.v2.MediaValidator
|
||||
import org.thoughtcrime.securesms.mediasend.v2.videos.VideoTrimData
|
||||
import org.thoughtcrime.securesms.mms.MediaConstraints
|
||||
import org.thoughtcrime.securesms.mms.SentMediaQuality
|
||||
import org.thoughtcrime.securesms.providers.BlobProvider
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.scribbles.ImageEditorFragment
|
||||
import org.thoughtcrime.securesms.stories.Stories
|
||||
import org.thoughtcrime.securesms.util.MediaUtil
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* App-layer implementation of [MediaSendRepository] that bridges to legacy v2 infrastructure.
|
||||
*/
|
||||
class MediaSendV3Repository(
|
||||
context: Context
|
||||
) : MediaSendRepository {
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
private val legacyRepository = MediaSelectionRepository(appContext)
|
||||
private val mediaRepository = MediaRepository()
|
||||
|
||||
override suspend fun getFolders(): List<MediaFolder> = suspendCancellableCoroutine { continuation ->
|
||||
mediaRepository.getFolders(appContext) { folders ->
|
||||
continuation.resume(folders)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getMedia(bucketId: String): List<Media> = suspendCancellableCoroutine { continuation ->
|
||||
mediaRepository.getMediaInBucket(appContext, bucketId) { media ->
|
||||
continuation.resume(media)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun validateAndFilterMedia(
|
||||
media: List<Media>,
|
||||
maxSelection: Int,
|
||||
isStory: Boolean
|
||||
): MediaFilterResult = withContext(Dispatchers.IO) {
|
||||
val populated = MediaRepository().getPopulatedMedia(appContext, media)
|
||||
val constraints = MediaConstraints.getPushMediaConstraints()
|
||||
val result = MediaValidator.filterMedia(appContext, populated, constraints, maxSelection, isStory)
|
||||
|
||||
val error = mapFilterError(result.filterError, populated, constraints, maxSelection, isStory)
|
||||
MediaFilterResult(result.filteredMedia, error)
|
||||
}
|
||||
|
||||
override suspend fun deleteBlobs(media: List<Media>) {
|
||||
media
|
||||
.map(Media::uri)
|
||||
.filter(BlobProvider::isAuthority)
|
||||
.forEach { BlobProvider.getInstance().delete(appContext, it) }
|
||||
}
|
||||
|
||||
override suspend fun send(request: SendRequest): SendResult = withContext(Dispatchers.IO) {
|
||||
val recipients = buildRecipients(request)
|
||||
if (recipients.isEmpty()) {
|
||||
return@withContext SendResult.Error("No recipients provided.")
|
||||
}
|
||||
|
||||
val singleContact = if (recipients.size == 1) recipients.first() else null
|
||||
val contacts = if (recipients.size > 1) recipients else emptyList()
|
||||
|
||||
val legacyEditorStateMap = mapLegacyEditorState(request.editorStateMap)
|
||||
val quality = SentMediaQuality.fromCode(request.quality)
|
||||
|
||||
return@withContext try {
|
||||
legacyRepository.send(
|
||||
selectedMedia = request.selectedMedia,
|
||||
stateMap = legacyEditorStateMap,
|
||||
quality = quality,
|
||||
message = request.message,
|
||||
isViewOnce = request.isViewOnce,
|
||||
singleContact = singleContact,
|
||||
contacts = contacts,
|
||||
mentions = emptyList(),
|
||||
bodyRanges = null,
|
||||
sendType = resolveSendType(request.sendType),
|
||||
scheduledTime = request.scheduledTime
|
||||
).blockingGet()
|
||||
SendResult.Success
|
||||
} catch (exception: Exception) {
|
||||
SendResult.Error(exception.message ?: "Failed to send media.")
|
||||
}
|
||||
}
|
||||
|
||||
override fun getMaxVideoDurationUs(quality: Int, maxFileSizeBytes: Long): Long {
|
||||
val preset = MediaConstraints.getPushMediaConstraints(SentMediaQuality.fromCode(quality)).videoTranscodingSettings
|
||||
return preset.calculateMaxVideoUploadDurationInSeconds(maxFileSizeBytes).seconds.inWholeMicroseconds
|
||||
}
|
||||
|
||||
override fun getVideoMaxSizeBytes(): Long {
|
||||
return MediaConstraints.getPushMediaConstraints().videoMaxSize
|
||||
}
|
||||
|
||||
override fun isVideoTranscodeAvailable(): Boolean {
|
||||
return MediaConstraints.isVideoTranscodeAvailable()
|
||||
}
|
||||
|
||||
override suspend fun getStorySendRequirements(media: List<Media>): StorySendRequirements = withContext(Dispatchers.IO) {
|
||||
when (Stories.MediaTransform.getSendRequirements(media)) {
|
||||
Stories.MediaTransform.SendRequirements.VALID_DURATION -> StorySendRequirements.CAN_SEND
|
||||
Stories.MediaTransform.SendRequirements.REQUIRES_CLIP -> StorySendRequirements.REQUIRES_CROP
|
||||
Stories.MediaTransform.SendRequirements.CAN_NOT_SEND -> StorySendRequirements.CAN_NOT_SEND
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun checkUntrustedIdentities(contactIds: Set<Long>, since: Long): List<Long> = withContext(Dispatchers.IO) {
|
||||
if (contactIds.isEmpty()) return@withContext emptyList<Long>()
|
||||
|
||||
val recipients: List<Recipient> = contactIds
|
||||
.map { Recipient.resolved(RecipientId.from(it)) }
|
||||
.map { recipient ->
|
||||
when {
|
||||
recipient.isGroup -> Recipient.resolvedList(recipient.participantIds)
|
||||
recipient.isDistributionList -> Recipient.resolvedList(SignalDatabase.distributionLists.getMembers(recipient.distributionListId.get()))
|
||||
else -> listOf(recipient)
|
||||
}
|
||||
}
|
||||
.flatten()
|
||||
|
||||
val calculatedWindow = System.currentTimeMillis() - since
|
||||
val identityRecords = AppDependencies
|
||||
.protocolStore
|
||||
.aci()
|
||||
.identities()
|
||||
.getIdentityRecords(recipients)
|
||||
|
||||
val untrusted = identityRecords.getUntrustedRecords(
|
||||
calculatedWindow.coerceIn(TimeUnit.SECONDS.toMillis(5)..TimeUnit.HOURS.toMillis(1))
|
||||
)
|
||||
|
||||
(untrusted + identityRecords.unverifiedRecords)
|
||||
.distinctBy(IdentityRecord::recipientId)
|
||||
.map { it.recipientId.toLong() }
|
||||
}
|
||||
|
||||
override fun observeRecipientValid(recipientId: MediaRecipientId): Flow<Boolean> {
|
||||
return Recipient.observable(RecipientId.from(recipientId.id))
|
||||
.asFlow()
|
||||
.map { recipient ->
|
||||
recipient.isGroup || recipient.isDistributionList || recipient.isRegistered
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
|
||||
private fun resolveSendType(sendType: Int): MessageSendType {
|
||||
return when (sendType) {
|
||||
else -> MessageSendType.SignalMessageSendType
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildRecipients(request: SendRequest): List<ContactSearchKey.RecipientSearchKey> {
|
||||
return buildList {
|
||||
request.singleRecipientId?.let { add(it) }
|
||||
addAll(request.recipientIds)
|
||||
}.distinctBy(MediaRecipientId::id).map {
|
||||
ContactSearchKey.RecipientSearchKey(RecipientId.from(it.id), request.isStory)
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapLegacyEditorState(editorStateMap: Map<Uri, EditorState>): Map<Uri, Any> {
|
||||
return editorStateMap.mapNotNull { (uri, state) ->
|
||||
val legacyState: Any = when (state) {
|
||||
is EditorState.Image -> ImageEditorFragment.Data().apply { writeModel(state.model) }
|
||||
is EditorState.VideoTrim -> VideoTrimData(
|
||||
isDurationEdited = state.isDurationEdited,
|
||||
totalInputDurationUs = state.totalInputDurationUs,
|
||||
startTimeUs = state.startTimeUs,
|
||||
endTimeUs = state.endTimeUs
|
||||
)
|
||||
}
|
||||
uri to legacyState
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
private fun mapFilterError(
|
||||
error: MediaValidator.FilterError?,
|
||||
media: List<Media>,
|
||||
constraints: MediaConstraints,
|
||||
maxSelection: Int,
|
||||
isStory: Boolean
|
||||
): MediaFilterError? {
|
||||
return when (error) {
|
||||
is MediaValidator.FilterError.NoItems -> MediaFilterError.NoItems
|
||||
is MediaValidator.FilterError.TooManyItems -> MediaFilterError.TooManyItems(maxSelection)
|
||||
is MediaValidator.FilterError.ItemInvalidType -> {
|
||||
findFirstInvalidType(media)?.let { MediaFilterError.ItemInvalidType(it) }
|
||||
?: MediaFilterError.Other("One or more items have an invalid type.")
|
||||
}
|
||||
is MediaValidator.FilterError.ItemTooLarge -> {
|
||||
findFirstTooLarge(media, constraints, isStory)?.let { MediaFilterError.ItemTooLarge(it) }
|
||||
?: MediaFilterError.Other("One or more items are too large.")
|
||||
}
|
||||
MediaValidator.FilterError.None, null -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun findFirstInvalidType(media: List<Media>): Media? {
|
||||
return media.firstOrNull { item ->
|
||||
val contentType = item.contentType ?: return@firstOrNull true
|
||||
!MediaUtil.isGif(contentType) &&
|
||||
!MediaUtil.isImageType(contentType) &&
|
||||
!MediaUtil.isVideoType(contentType) &&
|
||||
!MediaUtil.isDocumentType(contentType)
|
||||
}
|
||||
}
|
||||
|
||||
private fun findFirstTooLarge(
|
||||
media: List<Media>,
|
||||
constraints: MediaConstraints,
|
||||
isStory: Boolean
|
||||
): Media? {
|
||||
return media.firstOrNull { item ->
|
||||
val contentType = item.contentType ?: return@firstOrNull true
|
||||
val size = item.size
|
||||
|
||||
val isTooLarge = when {
|
||||
MediaUtil.isGif(contentType) -> size > constraints.getGifMaxSize(appContext)
|
||||
MediaUtil.isVideoType(contentType) -> size > constraints.getUncompressedVideoMaxSize(appContext)
|
||||
MediaUtil.isImageType(contentType) -> size > constraints.getImageMaxSize(appContext)
|
||||
MediaUtil.isDocumentType(contentType) -> size > constraints.getDocumentMaxSize(appContext)
|
||||
else -> true
|
||||
}
|
||||
|
||||
val isStoryInvalid = isStory && Stories.MediaTransform.getSendRequirements(item) == Stories.MediaTransform.SendRequirements.CAN_NOT_SEND
|
||||
|
||||
isTooLarge || isStoryInvalid
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user