Add media send feature module.

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

View File

@@ -88,6 +88,7 @@ import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.getSerializableCompat
import org.signal.core.util.logging.Log
import org.signal.donations.StripeApi
import org.signal.mediasend.MediaSendActivityContract
import org.thoughtcrime.securesms.backup.v2.ArchiveRestoreProgress
import org.thoughtcrime.securesms.backup.v2.ui.verify.VerifyBackupKeyActivity
import org.thoughtcrime.securesms.calls.YouAreAlreadyInACallSnackbar.show
@@ -155,6 +156,7 @@ import org.thoughtcrime.securesms.main.rememberMainNavigationDetailLocation
import org.thoughtcrime.securesms.main.storiesNavGraphBuilder
import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity
import org.thoughtcrime.securesms.mediasend.v3.MediaSendV3ActivityContract
import org.thoughtcrime.securesms.megaphone.Megaphone
import org.thoughtcrime.securesms.megaphone.MegaphoneActionController
import org.thoughtcrime.securesms.megaphone.Megaphones
@@ -251,6 +253,8 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
override val googlePayRepository: GooglePayRepository by lazy { GooglePayRepository(this) }
override val googlePayResultPublisher: Subject<GooglePayComponent.GooglePayResult> = PublishSubject.create()
private lateinit var mediaActivityLauncher: ActivityResultLauncher<MediaSendActivityContract.Args>
override fun dispatchTouchEvent(ev: MotionEvent?): Boolean {
return motionEventRelay.offer(ev) || super.dispatchTouchEvent(ev)
}
@@ -276,6 +280,8 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
super.onCreate(savedInstanceState, ready)
navigator = MainNavigator(this, mainNavigationViewModel)
mediaActivityLauncher = registerForActivityResult(MediaSendV3ActivityContract()) { }
AppForegroundObserver.addListener(object : AppForegroundObserver.Listener {
override fun onForeground() {
mainNavigationViewModel.getNextMegaphone()
@@ -1088,15 +1094,23 @@ class MainActivity : PassphraseRequiredActivity(), VoiceNoteMediaControllerOwner
private fun onCameraClick(destination: MainNavigationListLocation, isForQuickRestore: Boolean) {
val onGranted = {
val intent = if (isForQuickRestore) {
MediaSelectionActivity.cameraForQuickRestore(context = this@MainActivity)
if (isForQuickRestore) {
startActivity(MediaSelectionActivity.cameraForQuickRestore(context = this@MainActivity))
} else if (SignalStore.internal.useNewMediaActivity) {
mediaActivityLauncher.launch(
MediaSendActivityContract.Args(
isCameraFirst = false,
isStory = destination == MainNavigationListLocation.STORIES
)
)
} else {
MediaSelectionActivity.camera(
context = this@MainActivity,
isStory = destination == MainNavigationListLocation.STORIES
startActivity(
MediaSelectionActivity.camera(
context = this@MainActivity,
isStory = destination == MainNavigationListLocation.STORIES
)
)
}
startActivity(intent)
}
if (CameraXUtil.isSupported()) {

View File

@@ -913,6 +913,14 @@ class InternalSettingsFragment : DSLSettingsFragment(R.string.preferences__inter
viewModel.setUseConversationItemV2Media(!state.useConversationItemV2ForMedia)
}
)
switchPref(
title = DSLSettingsText.from("Use new media activity"),
isChecked = state.useNewMediaActivity,
onClick = {
viewModel.setUseNewMediaActivity(!state.useNewMediaActivity)
}
)
}
}

View File

@@ -30,5 +30,6 @@ data class InternalSettingsState(
val useConversationItemV2ForMedia: Boolean,
val hasPendingOneTimeDonation: Boolean,
val hevcEncoding: Boolean,
val forceSplitPane: Boolean
val forceSplitPane: Boolean,
val useNewMediaActivity: Boolean
)

View File

@@ -144,6 +144,11 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
refresh()
}
fun setUseNewMediaActivity(enabled: Boolean) {
SignalStore.internal.useNewMediaActivity = enabled
refresh()
}
fun setHevcEncoding(enabled: Boolean) {
SignalStore.internal.hevcEncoding = enabled
refresh()
@@ -196,7 +201,8 @@ class InternalSettingsViewModel(private val repository: InternalSettingsReposito
useConversationItemV2ForMedia = SignalStore.internal.useConversationItemV2Media,
hasPendingOneTimeDonation = SignalStore.inAppPayments.getPendingOneTimeDonation() != null,
hevcEncoding = SignalStore.internal.hevcEncoding,
forceSplitPane = SignalStore.internal.forceSplitPane
forceSplitPane = SignalStore.internal.forceSplitPane,
useNewMediaActivity = SignalStore.internal.useNewMediaActivity
)
fun onClearOnboardingState() {

View File

@@ -5,6 +5,7 @@
package org.thoughtcrime.securesms.database
import kotlinx.serialization.json.Json
import org.signal.core.models.media.TransformProperties
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.mms.SentMediaQuality
@@ -18,7 +19,7 @@ private val TAG = Log.tag(TransformProperties::class.java)
* Serializes the TransformProperties to a JSON string using Jackson.
*/
fun TransformProperties.serialize(): String {
return JsonUtil.toJson(this)
return Json.encodeToString(this)
}
/**

View File

@@ -35,6 +35,7 @@ class InternalValues internal constructor(store: KeyValueStore) : SignalStoreVal
const val SHOW_ARCHIVE_STATE_HINT: String = "internal.show_archive_state_hint"
const val INCLUDE_DEBUGLOG_IN_BACKUP: String = "internal.include_debuglog_in_backup"
const val IMPORTED_BACKUP_DEBUG_INFO: String = "internal.imported_backup_debug_info"
const val USE_NEW_MEDIA_ACTIVITY: String = "internal.use_new_media_activity"
}
public override fun onFirstEverAppLaunch() = Unit
@@ -46,6 +47,8 @@ class InternalValues internal constructor(store: KeyValueStore) : SignalStoreVal
*/
var forceSplitPane by booleanValue(FORCE_SPLIT_PANE_ON_COMPACT_LANDSCAPE, false).falseForExternalUsers()
var useNewMediaActivity by booleanValue(USE_NEW_MEDIA_ACTIVITY, false).falseForExternalUsers()
/**
* Members will not be added directly to a GV2 even if they could be.
*/

View File

@@ -31,6 +31,14 @@ public interface CameraFragment {
}
}
static Class<? extends Fragment> getFragmentClass() {
if (CameraXUtil.isSupported()) {
return CameraXFragment.class;
} else {
return Camera1Fragment.class;
}
}
@SuppressLint({ "RestrictedApi", "UnsafeOptInUsageError" })
static Fragment newInstanceForAvatarCapture() {
if (CameraXUtil.isSupported()) {

View File

@@ -24,7 +24,6 @@ import org.thoughtcrime.securesms.R;
import org.signal.core.models.media.Media;
import org.signal.core.models.media.MediaFolder;
import org.signal.core.models.media.TransformProperties;
import org.thoughtcrime.securesms.database.AttachmentTable;
import org.thoughtcrime.securesms.dependencies.AppDependencies;
import org.thoughtcrime.securesms.keyvalue.SignalStore;
import org.thoughtcrime.securesms.mms.PartAuthority;

View File

@@ -0,0 +1,87 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.mediasend.v3
import androidx.compose.runtime.Composable
import io.reactivex.rxjava3.core.Flowable
import org.signal.core.models.media.Media
import org.signal.mediasend.MediaSendActivity
import org.thoughtcrime.securesms.mediasend.CameraFragment
import org.thoughtcrime.securesms.mms.MediaConstraints
import java.io.FileDescriptor
import java.util.Optional
/**
* App-layer implementation of the feature module media send activity.
*/
class MediaSendV3Activity : MediaSendActivity(), CameraFragment.Controller {
override val preUploadCallback = MediaSendV3PreUploadCallback()
override val repository by lazy { MediaSendV3Repository(applicationContext) }
@Composable
override fun CameraSlot() {
MediaSendV3CameraSlot()
}
@Composable
override fun TextStoryEditorSlot() {
MediaSendV3PlaceholderScreen(text = "Text Story Editor")
}
@Composable
override fun VideoEditorSlot() {
MediaSendV3PlaceholderScreen(text = "Video Editor")
}
@Composable
override fun SendSlot() {
MediaSendV3PlaceholderScreen(text = "Send Review")
}
// region Camera Callbacks
override fun onCameraError() {
error("Not yet implemented")
}
override fun onImageCaptured(data: ByteArray, width: Int, height: Int) {
error("Not yet implemented")
}
override fun onVideoCaptured(fd: FileDescriptor) {
error("Not yet implemented")
}
override fun onVideoCaptureError() {
error("Not yet implemented")
}
override fun onGalleryClicked() {
error("Not yet implemented")
}
override fun onCameraCountButtonClicked() {
error("Not yet implemented")
}
override fun onQrCodeFound(data: String) {
error("Not yet implemented")
}
override fun getMostRecentMediaItem(): Flowable<Optional<Media?>> {
error("Not yet implemented")
}
override fun getMediaConstraints(): MediaConstraints {
return MediaConstraints.getPushMediaConstraints()
}
override fun getMaxVideoDuration(): Int {
error("Not yet implemented")
}
// endregion
}

View File

@@ -0,0 +1,13 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.mediasend.v3
import org.signal.mediasend.MediaSendActivityContract
/**
* Activity result contract bound to [MediaSendV3Activity].
*/
class MediaSendV3ActivityContract : MediaSendActivityContract(MediaSendV3Activity::class.java)

View File

@@ -0,0 +1,28 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.mediasend.v3
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.fragment.compose.AndroidFragment
import org.thoughtcrime.securesms.mediasend.CameraFragment
/**
* Displays the proper camera capture ui
*/
@Composable
fun MediaSendV3CameraSlot() {
val fragmentClass = remember {
CameraFragment.getFragmentClass()
}
AndroidFragment(
clazz = fragmentClass,
modifier = Modifier.fillMaxSize()
)
}

View File

@@ -0,0 +1,62 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.mediasend.v3
import android.content.Context
import androidx.annotation.WorkerThread
import org.signal.core.models.media.Media
import org.signal.mediasend.MediaRecipientId
import org.signal.mediasend.preupload.PreUploadManager
import org.signal.mediasend.preupload.PreUploadResult
import org.thoughtcrime.securesms.attachments.AttachmentId
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.mediasend.MediaUploadRepository
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.sms.MessageSender
class MediaSendV3PreUploadCallback : PreUploadManager.Callback {
@WorkerThread
override fun preUpload(context: Context, media: Media, recipientId: MediaRecipientId?): PreUploadResult? {
val attachment = MediaUploadRepository.asAttachment(context, media)
val recipient = recipientId?.let { Recipient.resolved(RecipientId.from(it.id)) }
val legacyResult = MessageSender.preUploadPushAttachment(context, attachment, recipient, media) ?: return null
return PreUploadResult(
legacyResult.media,
legacyResult.attachmentId.id,
legacyResult.jobIds.toMutableList()
)
}
@WorkerThread
override fun cancelJobs(context: Context, jobIds: List<String>) {
val jobManager = AppDependencies.jobManager
jobIds.forEach(jobManager::cancel)
}
@WorkerThread
override fun deleteAttachment(context: Context, attachmentId: Long) {
SignalDatabase.attachments.deleteAttachment(AttachmentId(attachmentId))
}
@WorkerThread
override fun updateAttachmentCaption(context: Context, attachmentId: Long, caption: String?) {
SignalDatabase.attachments.updateAttachmentCaption(AttachmentId(attachmentId), caption)
}
@WorkerThread
override fun updateDisplayOrder(context: Context, orderMap: Map<Long, Int>) {
val mapped = orderMap.mapKeys { AttachmentId(it.key) }
SignalDatabase.attachments.updateDisplayOrder(mapped)
}
@WorkerThread
override fun deleteAbandonedPreuploadedAttachments(context: Context): Int {
return SignalDatabase.attachments.deleteAbandonedPreuploadedAttachments()
}
}

View File

@@ -0,0 +1,267 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.mediasend.v3
import android.content.Context
import android.net.Uri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.rx3.asFlow
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import org.signal.core.models.media.Media
import org.signal.core.models.media.MediaFolder
import org.signal.mediasend.EditorState
import org.signal.mediasend.MediaFilterError
import org.signal.mediasend.MediaFilterResult
import org.signal.mediasend.MediaRecipientId
import org.signal.mediasend.MediaSendRepository
import org.signal.mediasend.SendRequest
import org.signal.mediasend.SendResult
import org.signal.mediasend.StorySendRequirements
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
import org.thoughtcrime.securesms.conversation.MessageSendType
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.IdentityRecord
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.mediasend.MediaRepository
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionRepository
import org.thoughtcrime.securesms.mediasend.v2.MediaValidator
import org.thoughtcrime.securesms.mediasend.v2.videos.VideoTrimData
import org.thoughtcrime.securesms.mms.MediaConstraints
import org.thoughtcrime.securesms.mms.SentMediaQuality
import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.scribbles.ImageEditorFragment
import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.util.MediaUtil
import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume
import kotlin.time.Duration.Companion.seconds
/**
* App-layer implementation of [MediaSendRepository] that bridges to legacy v2 infrastructure.
*/
class MediaSendV3Repository(
context: Context
) : MediaSendRepository {
private val appContext = context.applicationContext
private val legacyRepository = MediaSelectionRepository(appContext)
private val mediaRepository = MediaRepository()
override suspend fun getFolders(): List<MediaFolder> = suspendCancellableCoroutine { continuation ->
mediaRepository.getFolders(appContext) { folders ->
continuation.resume(folders)
}
}
override suspend fun getMedia(bucketId: String): List<Media> = suspendCancellableCoroutine { continuation ->
mediaRepository.getMediaInBucket(appContext, bucketId) { media ->
continuation.resume(media)
}
}
override suspend fun validateAndFilterMedia(
media: List<Media>,
maxSelection: Int,
isStory: Boolean
): MediaFilterResult = withContext(Dispatchers.IO) {
val populated = MediaRepository().getPopulatedMedia(appContext, media)
val constraints = MediaConstraints.getPushMediaConstraints()
val result = MediaValidator.filterMedia(appContext, populated, constraints, maxSelection, isStory)
val error = mapFilterError(result.filterError, populated, constraints, maxSelection, isStory)
MediaFilterResult(result.filteredMedia, error)
}
override suspend fun deleteBlobs(media: List<Media>) {
media
.map(Media::uri)
.filter(BlobProvider::isAuthority)
.forEach { BlobProvider.getInstance().delete(appContext, it) }
}
override suspend fun send(request: SendRequest): SendResult = withContext(Dispatchers.IO) {
val recipients = buildRecipients(request)
if (recipients.isEmpty()) {
return@withContext SendResult.Error("No recipients provided.")
}
val singleContact = if (recipients.size == 1) recipients.first() else null
val contacts = if (recipients.size > 1) recipients else emptyList()
val legacyEditorStateMap = mapLegacyEditorState(request.editorStateMap)
val quality = SentMediaQuality.fromCode(request.quality)
return@withContext try {
legacyRepository.send(
selectedMedia = request.selectedMedia,
stateMap = legacyEditorStateMap,
quality = quality,
message = request.message,
isViewOnce = request.isViewOnce,
singleContact = singleContact,
contacts = contacts,
mentions = emptyList(),
bodyRanges = null,
sendType = resolveSendType(request.sendType),
scheduledTime = request.scheduledTime
).blockingGet()
SendResult.Success
} catch (exception: Exception) {
SendResult.Error(exception.message ?: "Failed to send media.")
}
}
override fun getMaxVideoDurationUs(quality: Int, maxFileSizeBytes: Long): Long {
val preset = MediaConstraints.getPushMediaConstraints(SentMediaQuality.fromCode(quality)).videoTranscodingSettings
return preset.calculateMaxVideoUploadDurationInSeconds(maxFileSizeBytes).seconds.inWholeMicroseconds
}
override fun getVideoMaxSizeBytes(): Long {
return MediaConstraints.getPushMediaConstraints().videoMaxSize
}
override fun isVideoTranscodeAvailable(): Boolean {
return MediaConstraints.isVideoTranscodeAvailable()
}
override suspend fun getStorySendRequirements(media: List<Media>): StorySendRequirements = withContext(Dispatchers.IO) {
when (Stories.MediaTransform.getSendRequirements(media)) {
Stories.MediaTransform.SendRequirements.VALID_DURATION -> StorySendRequirements.CAN_SEND
Stories.MediaTransform.SendRequirements.REQUIRES_CLIP -> StorySendRequirements.REQUIRES_CROP
Stories.MediaTransform.SendRequirements.CAN_NOT_SEND -> StorySendRequirements.CAN_NOT_SEND
}
}
override suspend fun checkUntrustedIdentities(contactIds: Set<Long>, since: Long): List<Long> = withContext(Dispatchers.IO) {
if (contactIds.isEmpty()) return@withContext emptyList<Long>()
val recipients: List<Recipient> = contactIds
.map { Recipient.resolved(RecipientId.from(it)) }
.map { recipient ->
when {
recipient.isGroup -> Recipient.resolvedList(recipient.participantIds)
recipient.isDistributionList -> Recipient.resolvedList(SignalDatabase.distributionLists.getMembers(recipient.distributionListId.get()))
else -> listOf(recipient)
}
}
.flatten()
val calculatedWindow = System.currentTimeMillis() - since
val identityRecords = AppDependencies
.protocolStore
.aci()
.identities()
.getIdentityRecords(recipients)
val untrusted = identityRecords.getUntrustedRecords(
calculatedWindow.coerceIn(TimeUnit.SECONDS.toMillis(5)..TimeUnit.HOURS.toMillis(1))
)
(untrusted + identityRecords.unverifiedRecords)
.distinctBy(IdentityRecord::recipientId)
.map { it.recipientId.toLong() }
}
override fun observeRecipientValid(recipientId: MediaRecipientId): Flow<Boolean> {
return Recipient.observable(RecipientId.from(recipientId.id))
.asFlow()
.map { recipient ->
recipient.isGroup || recipient.isDistributionList || recipient.isRegistered
}
.distinctUntilChanged()
}
private fun resolveSendType(sendType: Int): MessageSendType {
return when (sendType) {
else -> MessageSendType.SignalMessageSendType
}
}
private fun buildRecipients(request: SendRequest): List<ContactSearchKey.RecipientSearchKey> {
return buildList {
request.singleRecipientId?.let { add(it) }
addAll(request.recipientIds)
}.distinctBy(MediaRecipientId::id).map {
ContactSearchKey.RecipientSearchKey(RecipientId.from(it.id), request.isStory)
}
}
private fun mapLegacyEditorState(editorStateMap: Map<Uri, EditorState>): Map<Uri, Any> {
return editorStateMap.mapNotNull { (uri, state) ->
val legacyState: Any = when (state) {
is EditorState.Image -> ImageEditorFragment.Data().apply { writeModel(state.model) }
is EditorState.VideoTrim -> VideoTrimData(
isDurationEdited = state.isDurationEdited,
totalInputDurationUs = state.totalInputDurationUs,
startTimeUs = state.startTimeUs,
endTimeUs = state.endTimeUs
)
}
uri to legacyState
}.toMap()
}
private fun mapFilterError(
error: MediaValidator.FilterError?,
media: List<Media>,
constraints: MediaConstraints,
maxSelection: Int,
isStory: Boolean
): MediaFilterError? {
return when (error) {
is MediaValidator.FilterError.NoItems -> MediaFilterError.NoItems
is MediaValidator.FilterError.TooManyItems -> MediaFilterError.TooManyItems(maxSelection)
is MediaValidator.FilterError.ItemInvalidType -> {
findFirstInvalidType(media)?.let { MediaFilterError.ItemInvalidType(it) }
?: MediaFilterError.Other("One or more items have an invalid type.")
}
is MediaValidator.FilterError.ItemTooLarge -> {
findFirstTooLarge(media, constraints, isStory)?.let { MediaFilterError.ItemTooLarge(it) }
?: MediaFilterError.Other("One or more items are too large.")
}
MediaValidator.FilterError.None, null -> null
}
}
private fun findFirstInvalidType(media: List<Media>): Media? {
return media.firstOrNull { item ->
val contentType = item.contentType ?: return@firstOrNull true
!MediaUtil.isGif(contentType) &&
!MediaUtil.isImageType(contentType) &&
!MediaUtil.isVideoType(contentType) &&
!MediaUtil.isDocumentType(contentType)
}
}
private fun findFirstTooLarge(
media: List<Media>,
constraints: MediaConstraints,
isStory: Boolean
): Media? {
return media.firstOrNull { item ->
val contentType = item.contentType ?: return@firstOrNull true
val size = item.size
val isTooLarge = when {
MediaUtil.isGif(contentType) -> size > constraints.getGifMaxSize(appContext)
MediaUtil.isVideoType(contentType) -> size > constraints.getUncompressedVideoMaxSize(appContext)
MediaUtil.isImageType(contentType) -> size > constraints.getImageMaxSize(appContext)
MediaUtil.isDocumentType(contentType) -> size > constraints.getDocumentMaxSize(appContext)
else -> true
}
val isStoryInvalid = isStory && Stories.MediaTransform.getSendRequirements(item) == Stories.MediaTransform.SendRequirements.CAN_NOT_SEND
isTooLarge || isStoryInvalid
}
}
}

View File

@@ -0,0 +1,20 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.mediasend.v3
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@Composable
internal fun MediaSendV3PlaceholderScreen(text: String, modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(text = text)
}
}

View File

@@ -17,13 +17,11 @@ import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.attachments.AttachmentId;
import org.thoughtcrime.securesms.avatar.AvatarPickerStorage;
import org.signal.core.models.media.TransformProperties;
import org.thoughtcrime.securesms.database.AttachmentTable;
import org.thoughtcrime.securesms.database.SignalDatabase;
import org.thoughtcrime.securesms.emoji.EmojiFiles;
import org.thoughtcrime.securesms.providers.BlobProvider;
import org.thoughtcrime.securesms.providers.DeprecatedPersistentBlobProvider;
import org.thoughtcrime.securesms.providers.PartProvider;
import org.thoughtcrime.securesms.wallpaper.WallpaperStorage;
import java.io.FileNotFoundException;
import java.io.IOException;

View File

@@ -129,7 +129,7 @@ public final class ImageEditorFragment extends Fragment implements ImageEditorHu
this(new Bundle());
}
void writeModel(@NonNull EditorModel model) {
public void writeModel(@NonNull EditorModel model) {
byte[] bytes = ParcelUtil.serialize(model);
bundle.putByteArray("MODEL", bytes);
}

View File

@@ -26,6 +26,7 @@ import com.bumptech.glide.Glide;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.bumptech.glide.load.resource.gif.GifDrawable;
import org.signal.core.util.ContentTypeUtil;
import org.signal.core.util.logging.Log;
import org.thoughtcrime.securesms.attachments.Attachment;
import org.thoughtcrime.securesms.attachments.AttachmentId;
@@ -56,23 +57,23 @@ public class MediaUtil {
private static final String TAG = Log.tag(MediaUtil.class);
public static final String IMAGE_PNG = "image/png";
public static final String IMAGE_JPEG = "image/jpeg";
public static final String IMAGE_HEIC = "image/heic";
public static final String IMAGE_HEIF = "image/heif";
public static final String IMAGE_AVIF = "image/avif";
public static final String IMAGE_WEBP = "image/webp";
public static final String IMAGE_GIF = "image/gif";
public static final String AUDIO_AAC = "audio/aac";
public static final String AUDIO_MP4 = "audio/mp4";
public static final String AUDIO_UNSPECIFIED = "audio/*";
public static final String VIDEO_MP4 = "video/mp4";
public static final String VIDEO_UNSPECIFIED = "video/*";
public static final String VCARD = "text/x-vcard";
public static final String LONG_TEXT = "text/x-signal-plain";
public static final String VIEW_ONCE = "application/x-signal-view-once";
public static final String UNKNOWN = "*/*";
public static final String OCTET = "application/octet-stream";
public static final String IMAGE_PNG = ContentTypeUtil.IMAGE_PNG;
public static final String IMAGE_JPEG = ContentTypeUtil.IMAGE_JPEG;
public static final String IMAGE_HEIC = ContentTypeUtil.IMAGE_HEIC;
public static final String IMAGE_HEIF = ContentTypeUtil.IMAGE_HEIF;
public static final String IMAGE_AVIF = ContentTypeUtil.IMAGE_AVIF;
public static final String IMAGE_WEBP = ContentTypeUtil.IMAGE_WEBP;
public static final String IMAGE_GIF = ContentTypeUtil.IMAGE_GIF;
public static final String AUDIO_AAC = ContentTypeUtil.AUDIO_AAC;
public static final String AUDIO_MP4 = ContentTypeUtil.AUDIO_MP4;
public static final String AUDIO_UNSPECIFIED = ContentTypeUtil.AUDIO_UNSPECIFIED;
public static final String VIDEO_MP4 = ContentTypeUtil.VIDEO_MP4;
public static final String VIDEO_UNSPECIFIED = ContentTypeUtil.VIDEO_UNSPECIFIED;
public static final String VCARD = ContentTypeUtil.VCARD;
public static final String LONG_TEXT = ContentTypeUtil.LONG_TEXT;
public static final String VIEW_ONCE = ContentTypeUtil.VIEW_ONCE;
public static final String UNKNOWN = ContentTypeUtil.UNKNOWN;
public static final String OCTET = ContentTypeUtil.OCTET;
public static @NonNull SlideType getSlideTypeFromContentType(@Nullable String contentType) {
if (isGif(contentType)) {
@@ -286,7 +287,7 @@ public class MediaUtil {
}
public static boolean isMms(String contentType) {
return !TextUtils.isEmpty(contentType) && contentType.trim().equals("application/mms");
return ContentTypeUtil.isMms(contentType);
}
public static boolean isGif(Attachment attachment) {
@@ -318,39 +319,39 @@ public class MediaUtil {
}
public static boolean isVideo(String contentType) {
return !TextUtils.isEmpty(contentType) && contentType.trim().startsWith("video/");
return ContentTypeUtil.isVideo(contentType);
}
public static boolean isVcard(String contentType) {
return !TextUtils.isEmpty(contentType) && contentType.trim().equals(VCARD);
return ContentTypeUtil.isVcard(contentType);
}
public static boolean isGif(String contentType) {
return !TextUtils.isEmpty(contentType) && contentType.trim().equals("image/gif");
return ContentTypeUtil.isGif(contentType);
}
public static boolean isJpegType(String contentType) {
return !TextUtils.isEmpty(contentType) && contentType.trim().equals(IMAGE_JPEG);
return ContentTypeUtil.isJpegType(contentType);
}
public static boolean isHeicType(String contentType) {
return !TextUtils.isEmpty(contentType) && contentType.trim().equals(IMAGE_HEIC);
return ContentTypeUtil.isHeicType(contentType);
}
public static boolean isHeifType(String contentType) {
return !TextUtils.isEmpty(contentType) && contentType.trim().equals(IMAGE_HEIF);
return ContentTypeUtil.isHeifType(contentType);
}
public static boolean isAvifType(String contentType) {
return !TextUtils.isEmpty(contentType) && contentType.trim().equals(IMAGE_AVIF);
return ContentTypeUtil.isAvifType(contentType);
}
public static boolean isWebpType(String contentType) {
return !TextUtils.isEmpty(contentType) && contentType.trim().equals(IMAGE_WEBP);
return ContentTypeUtil.isWebpType(contentType);
}
public static boolean isPngType(String contentType) {
return !TextUtils.isEmpty(contentType) && contentType.trim().equals(IMAGE_PNG);
return ContentTypeUtil.isPngType(contentType);
}
public static boolean isFile(Attachment attachment) {
@@ -358,7 +359,7 @@ public class MediaUtil {
}
public static boolean isTextType(String contentType) {
return (null != contentType) && contentType.startsWith("text/");
return ContentTypeUtil.isTextType(contentType);
}
public static boolean isNonGifVideo(Media media) {
@@ -366,62 +367,47 @@ public class MediaUtil {
}
public static boolean isImageType(String contentType) {
if (contentType == null) {
return false;
}
return (contentType.startsWith("image/") && !contentType.equals("image/svg+xml")) ||
contentType.equals(MediaStore.Images.Media.CONTENT_TYPE);
return ContentTypeUtil.isImageType(contentType);
}
public static boolean isAudioType(String contentType) {
if (contentType == null) {
return false;
}
return contentType.startsWith("audio/") ||
contentType.equals(MediaStore.Audio.Media.CONTENT_TYPE);
return ContentTypeUtil.isAudioType(contentType);
}
public static boolean isVideoType(String contentType) {
if (contentType == null) {
return false;
}
return contentType.startsWith("video/") ||
contentType.equals(MediaStore.Video.Media.CONTENT_TYPE);
return ContentTypeUtil.isVideoType(contentType);
}
public static boolean isImageOrVideoType(String contentType) {
return isImageType(contentType) || isVideoType(contentType);
return ContentTypeUtil.isImageOrVideoType(contentType);
}
public static boolean isStorySupportedType(String contentType) {
return isImageOrVideoType(contentType) && !isGif(contentType);
return ContentTypeUtil.isStorySupportedType(contentType);
}
public static boolean isImageVideoOrAudioType(String contentType) {
return isImageOrVideoType(contentType) || isAudioType(contentType);
return ContentTypeUtil.isImageVideoOrAudioType(contentType);
}
public static boolean isImageAndNotGif(@NonNull String contentType) {
return isImageType(contentType) && !isGif(contentType);
return ContentTypeUtil.isImageAndNotGif(contentType);
}
public static boolean isLongTextType(String contentType) {
return (null != contentType) && contentType.equals(LONG_TEXT);
return ContentTypeUtil.isLongTextType(contentType);
}
public static boolean isViewOnceType(String contentType) {
return (null != contentType) && contentType.equals(VIEW_ONCE);
return ContentTypeUtil.isViewOnceType(contentType);
}
public static boolean isOctetStream(@Nullable String contentType) {
return OCTET.equals(contentType);
return ContentTypeUtil.isOctetStream(contentType);
}
public static boolean isDocumentType(String contentType) {
return !isImageOrVideoType(contentType) && !isGif(contentType) && !isLongTextType(contentType) && !isViewOnceType(contentType);
return ContentTypeUtil.isDocumentType(contentType);
}
public static boolean hasVideoThumbnail(@NonNull Context context, @Nullable Uri uri) {