mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-14 23:18:43 +00:00
Add media send feature module.
This commit is contained in:
@@ -639,6 +639,7 @@ dependencies {
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
implementation(project(":lib:billing"))
|
||||
implementation(project(":feature:media-send"))
|
||||
|
||||
"spinnerImplementation"(project(":lib:spinner"))
|
||||
|
||||
|
||||
@@ -474,6 +474,15 @@
|
||||
android:theme="@style/TextSecure.DarkNoActionBar"
|
||||
android:windowSoftInputMode="stateAlwaysHidden|adjustNothing" />
|
||||
|
||||
<activity
|
||||
android:name=".mediasend.v3.MediaSendV3Activity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize|uiMode"
|
||||
android:exported="false"
|
||||
android:launchMode="singleTop"
|
||||
android:screenOrientation="portrait"
|
||||
android:theme="@style/Signal.DayNight.NoActionBar"
|
||||
android:windowSoftInputMode="stateAlwaysHidden|adjustNothing" />
|
||||
|
||||
<activity
|
||||
android:name=".conversation.mutiselect.forward.MultiselectForwardActivity"
|
||||
android:configChanges="touchscreen|keyboard|keyboardHidden|orientation|screenLayout|screenSize"
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -16,4 +16,5 @@ android {
|
||||
dependencies {
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.jackson.core)
|
||||
implementation(libs.jackson.module.kotlin)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.core.models.media
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.registerKotlinModule
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert
|
||||
import org.junit.Test
|
||||
|
||||
class TransformPropertiesTest {
|
||||
|
||||
@Test
|
||||
fun `kotlinx serialize matches legacy json keys`() {
|
||||
val properties = TransformProperties.empty()
|
||||
|
||||
Assert.assertEquals(
|
||||
"{\"skipTransform\":false,\"videoTrim\":false,\"videoTrimStartTimeUs\":0,\"videoTrimEndTimeUs\":0,\"sentMediaQuality\":0,\"mp4Faststart\":false,\"videoEdited\":false}",
|
||||
Json.encodeToString(properties)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `kotlinx parse tolerates legacy mp4Faststart key`() {
|
||||
val json = "{\"skipTransform\":false,\"videoTrim\":false,\"videoTrimStartTimeUs\":0,\"videoTrimEndTimeUs\":0,\"videoEdited\":false,\"mp4Faststart\":true}"
|
||||
|
||||
val parsed = Json.decodeFromString<TransformProperties>(json)
|
||||
|
||||
Assert.assertEquals(true, parsed.mp4FastStart)
|
||||
Assert.assertEquals(false, parsed.videoTrim)
|
||||
Assert.assertEquals(false, parsed.videoEdited)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `jackson serialize matches legacy json keys`() {
|
||||
val properties = TransformProperties.empty()
|
||||
|
||||
val objectMapper = ObjectMapper().registerKotlinModule()
|
||||
val encoded = objectMapper.writeValueAsString(properties)
|
||||
|
||||
Assert.assertEquals(
|
||||
"{\"skipTransform\":false,\"videoTrim\":false,\"videoTrimStartTimeUs\":0,\"videoTrimEndTimeUs\":0,\"sentMediaQuality\":0,\"mp4Faststart\":false,\"videoEdited\":false}",
|
||||
encoded
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `jackson parse tolerates legacy mp4Faststart key`() {
|
||||
val json = "{\"skipTransform\":false,\"videoTrim\":false,\"videoTrimStartTimeUs\":0,\"videoTrimEndTimeUs\":0,\"videoEdited\":false,\"mp4Faststart\":true}"
|
||||
|
||||
val objectMapper = ObjectMapper().registerKotlinModule()
|
||||
val parsed = objectMapper.readValue(json, TransformProperties::class.java)
|
||||
|
||||
Assert.assertEquals(true, parsed.mp4FastStart)
|
||||
Assert.assertEquals(false, parsed.videoTrim)
|
||||
Assert.assertEquals(false, parsed.videoEdited)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
|
||||
10
core/ui/src/main/res/drawable/symbol_arrow_end_24.xml
Normal file
10
core/ui/src/main/res/drawable/symbol_arrow_end_24.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:autoMirrored="true"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M20.13 12c0 0.23-0.1 0.45-0.26 0.62l-6.5 6.5c-0.34 0.34-0.9 0.34-1.24 0-0.34-0.34-0.34-0.9 0-1.24l5.13-5.13-1.76 0.13H4.25c-0.48 0-0.88-0.4-0.88-0.88s0.4-0.88 0.88-0.88H15.5l1.76 0.13-5.13-5.13c-0.34-0.34-0.34-0.9 0-1.24 0.34-0.34 0.9-0.34 1.24 0l6.5 6.5c0.16 0.17 0.25 0.39 0.25 0.62Z" />
|
||||
</vector>
|
||||
18
core/ui/src/main/res/drawable/symbol_emoji_24.xml
Normal file
18
core/ui/src/main/res/drawable/symbol_emoji_24.xml
Normal file
@@ -0,0 +1,18 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M8.43 14.94c-0.3-0.37-0.86-0.42-1.23-0.11-0.37 0.3-0.43 0.85-0.12 1.23 1.17 1.41 2.94 2.32 4.92 2.32s3.75-0.9 4.92-2.32c0.3-0.38 0.25-0.93-0.12-1.23-0.37-0.31-0.92-0.26-1.23 0.11-0.85 1.03-2.13 1.69-3.57 1.69s-2.72-0.66-3.57-1.69Z"/>
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M15.25 7.75c-0.5 0-0.88 0.3-1.12 0.66-0.25 0.37-0.38 0.84-0.38 1.34 0 0.5 0.13 0.97 0.38 1.34 0.24 0.36 0.62 0.66 1.12 0.66 0.5 0 0.88-0.3 1.12-0.66 0.25-0.37 0.38-0.84 0.38-1.34 0-0.5-0.13-0.97-0.38-1.34-0.24-0.36-0.62-0.66-1.12-0.66Z"/>
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M7.63 8.41c0.24-0.36 0.62-0.66 1.12-0.66 0.5 0 0.88 0.3 1.12 0.66 0.25 0.37 0.38 0.84 0.38 1.34 0 0.5-0.13 0.97-0.38 1.34-0.24 0.36-0.62 0.66-1.12 0.66-0.5 0-0.88-0.3-1.12-0.66-0.25-0.37-0.38-0.84-0.38-1.34 0-0.5 0.13-0.97 0.38-1.34Z"/>
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M12 1.13C6 1.13 1.12 5.99 1.12 12 1.13 18 6 22.88 12 22.88c6 0 10.88-4.87 10.88-10.88C22.88 6 18 1.12 12 1.12ZM2.87 12c0-5.04 4.09-9.13 9.13-9.13 5.04 0 9.13 4.09 9.13 9.13 0 5.04-4.09 9.13-9.13 9.13-5.04 0-9.13-4.09-9.13-9.13Z"/>
|
||||
</vector>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
77
feature/media-send/build.gradle.kts
Normal file
77
feature/media-send/build.gradle.kts
Normal file
@@ -0,0 +1,77 @@
|
||||
plugins {
|
||||
id("signal-library")
|
||||
id("kotlin-parcelize")
|
||||
alias(libs.plugins.ktlint)
|
||||
alias(libs.plugins.compose.compiler)
|
||||
alias(libs.plugins.kotlinx.serialization)
|
||||
}
|
||||
|
||||
ktlint {
|
||||
version.set("1.5.0")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "org.signal.mediasend"
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
|
||||
testOptions {
|
||||
unitTests {
|
||||
isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
lintChecks(project(":lintchecks"))
|
||||
ktlintRuleset(libs.ktlint.twitter.compose)
|
||||
|
||||
// Project dependencies
|
||||
implementation(libs.androidx.ui.test.junit4)
|
||||
implementation(project(":core:ui"))
|
||||
implementation(project(":core:util"))
|
||||
implementation(project(":core:models"))
|
||||
implementation(project(":lib:image-editor"))
|
||||
implementation(project(":lib:glide"))
|
||||
|
||||
// Compose BOM
|
||||
platform(libs.androidx.compose.bom).let { composeBom ->
|
||||
implementation(composeBom)
|
||||
androidTestImplementation(composeBom)
|
||||
}
|
||||
|
||||
// Compose dependencies
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling.core)
|
||||
|
||||
// Navigation 3
|
||||
implementation(libs.androidx.navigation3.runtime)
|
||||
implementation(libs.androidx.navigation3.ui)
|
||||
|
||||
// Kotlinx Serialization
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
// Lifecycle
|
||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
||||
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
|
||||
|
||||
// Permissions
|
||||
implementation(libs.accompanist.permissions)
|
||||
|
||||
// Testing
|
||||
testImplementation(testLibs.junit.junit)
|
||||
testImplementation(testLibs.mockk)
|
||||
testImplementation(testLibs.assertk)
|
||||
testImplementation(testLibs.kotlinx.coroutines.test)
|
||||
testImplementation(testLibs.robolectric.robolectric)
|
||||
testImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
androidTestImplementation(testLibs.androidx.test.ext.junit)
|
||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||
implementation(libs.androidx.compose.ui.test.manifest)
|
||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
}
|
||||
10
feature/media-send/src/main/AndroidManifest.xml
Normal file
10
feature/media-send/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<!--
|
||||
MediaSendActivity is abstract. Concrete implementations should be
|
||||
declared in the app module's manifest.
|
||||
-->
|
||||
</manifest>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
|
||||
/**
|
||||
* Screen that allows user to capture the media they will send using a camera or text story
|
||||
*/
|
||||
@Composable
|
||||
fun MediaCaptureScreen(
|
||||
backStack: NavBackStack<NavKey>,
|
||||
cameraSlot: @Composable () -> Unit,
|
||||
textStoryEditorSlot: @Composable () -> Unit
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
val top = backStack.last()
|
||||
|
||||
when (top) {
|
||||
is MediaSendNavKey.Capture.Camera -> cameraSlot()
|
||||
is MediaSendNavKey.Capture.TextStory -> textStoryEditorSlot()
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.BottomCenter)
|
||||
) {
|
||||
Buttons.Small(onClick = {
|
||||
if (top == MediaSendNavKey.Capture.TextStory) {
|
||||
backStack.remove(top)
|
||||
}
|
||||
}) {
|
||||
Text(text = "Camera")
|
||||
}
|
||||
|
||||
Buttons.Small(onClick = {
|
||||
if (top == MediaSendNavKey.Capture.Camera) {
|
||||
backStack.add(MediaSendNavKey.Capture.TextStory)
|
||||
}
|
||||
}) {
|
||||
Text(text = "Text Story")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package org.signal.mediasend
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation3.runtime.rememberNavBackStack
|
||||
import org.signal.core.ui.compose.theme.SignalTheme
|
||||
import org.signal.mediasend.preupload.PreUploadManager
|
||||
import org.signal.mediasend.select.MediaSelectScreen
|
||||
|
||||
/**
|
||||
* Abstract base activity for the media sending flow.
|
||||
*
|
||||
* App-layer implementations must extend this class and provide:
|
||||
* - [preUploadCallback] — For pre-upload job management
|
||||
* - [repository] — For media validation, sending, and other app-layer operations
|
||||
* - UI slots: [CameraSlot], [TextStoryEditorSlot], [MediaSelectSlot], [ImageEditorSlot], [VideoEditorSlot], [SendSlot]
|
||||
*
|
||||
* The concrete implementation should be registered in the app's manifest.
|
||||
*/
|
||||
abstract class MediaSendActivity : FragmentActivity() {
|
||||
|
||||
/** Pre-upload callback implementation for job management. */
|
||||
protected abstract val preUploadCallback: PreUploadManager.Callback
|
||||
|
||||
/** Repository implementation for app-layer operations. */
|
||||
protected abstract val repository: MediaSendRepository
|
||||
|
||||
/** Contract args extracted from intent. Available after super.onCreate(). */
|
||||
protected lateinit var contractArgs: MediaSendActivityContract.Args
|
||||
private set
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
contractArgs = MediaSendActivityContract.Args.fromIntent(intent)
|
||||
|
||||
setContent {
|
||||
val viewModel by viewModels<MediaSendViewModel>(factoryProducer = {
|
||||
MediaSendViewModel.Factory(
|
||||
context = applicationContext,
|
||||
args = contractArgs,
|
||||
isMeteredFlow = MeteredConnectivity.isMetered(applicationContext),
|
||||
repository = repository,
|
||||
preUploadCallback = preUploadCallback
|
||||
)
|
||||
})
|
||||
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val backStack = rememberNavBackStack(
|
||||
if (state.isCameraFirst) MediaSendNavKey.Capture.Camera else MediaSendNavKey.Select
|
||||
)
|
||||
|
||||
Theme {
|
||||
Surface {
|
||||
MediaSendNavDisplay(
|
||||
state = state,
|
||||
backStack = backStack,
|
||||
callback = viewModel,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
cameraSlot = { CameraSlot() },
|
||||
textStoryEditorSlot = { TextStoryEditorSlot() },
|
||||
mediaSelectSlot = {
|
||||
MediaSelectScreen(
|
||||
state = state,
|
||||
backStack = backStack,
|
||||
callback = viewModel
|
||||
)
|
||||
},
|
||||
videoEditorSlot = { VideoEditorSlot() },
|
||||
sendSlot = { SendSlot() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Theme wrapper */
|
||||
@Composable
|
||||
protected open fun Theme(content: @Composable () -> Unit) {
|
||||
SignalTheme(incognitoKeyboardEnabled = false) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
/** Camera capture UI slot. */
|
||||
@Composable
|
||||
protected abstract fun CameraSlot()
|
||||
|
||||
/** Text story editor UI slot. */
|
||||
@Composable
|
||||
protected abstract fun TextStoryEditorSlot()
|
||||
|
||||
/** Video editor UI slot. */
|
||||
@Composable
|
||||
protected abstract fun VideoEditorSlot()
|
||||
|
||||
/** Send/review UI slot. */
|
||||
@Composable
|
||||
protected abstract fun SendSlot()
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Creates an intent for a concrete [MediaSendActivity] subclass.
|
||||
*
|
||||
* @param context The context.
|
||||
* @param activityClass The concrete activity class to launch.
|
||||
* @param args The activity arguments.
|
||||
*/
|
||||
fun <T : MediaSendActivity> createIntent(
|
||||
context: Context,
|
||||
activityClass: Class<T>,
|
||||
args: MediaSendActivityContract.Args = MediaSendActivityContract.Args()
|
||||
): Intent {
|
||||
return Intent(context, activityClass).apply {
|
||||
putExtra(MediaSendActivityContract.EXTRA_ARGS, args)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Parcelable
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.signal.core.models.media.Media
|
||||
|
||||
/**
|
||||
* Well-defined entry/exit contract for the media sending flow.
|
||||
*
|
||||
* This intentionally supports a "multi-tier" outcome:
|
||||
* - Return a payload to be sent by the caller (single-recipient / conversation-owned send pipeline).
|
||||
* - Or send immediately in the flow and return an acknowledgement (multi-recipient / broadcast paths).
|
||||
*
|
||||
* Since [MediaSendActivity] is abstract, app-layer implementations should either:
|
||||
* 1. Extend this contract and override [createIntent] to use their concrete activity class
|
||||
* 2. Use the constructor that takes an activity class parameter
|
||||
*
|
||||
* Example:
|
||||
* ```kotlin
|
||||
* class MyMediaSendContract : MediaSendActivityContract(MyMediaSendActivity::class.java)
|
||||
* ```
|
||||
*/
|
||||
open class MediaSendActivityContract(
|
||||
private val activityClass: Class<out MediaSendActivity>? = null
|
||||
) : ActivityResultContract<MediaSendActivityContract.Args, MediaSendActivityContract.Result?>() {
|
||||
|
||||
/**
|
||||
* Creates the intent to launch the media send activity.
|
||||
*
|
||||
* Subclasses should override this if not using the constructor parameter.
|
||||
*/
|
||||
override fun createIntent(context: Context, input: Args): Intent {
|
||||
val clazz = activityClass
|
||||
?: throw IllegalStateException(
|
||||
"MediaSendActivityContract requires either a concrete activity class in the constructor " +
|
||||
"or an overridden createIntent() method. MediaSendActivity is abstract and cannot be launched directly."
|
||||
)
|
||||
return MediaSendActivity.createIntent(context, clazz, input)
|
||||
}
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): Result? {
|
||||
if (resultCode != Activity.RESULT_OK || intent == null) return null
|
||||
return intent.getParcelableExtra(EXTRA_RESULT)
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class Args(
|
||||
val isCameraFirst: Boolean = false,
|
||||
/**
|
||||
* Optional recipient identifier for single-recipient flows.
|
||||
*/
|
||||
val recipientId: MediaRecipientId? = null,
|
||||
val mode: Mode = Mode.SingleRecipient,
|
||||
/**
|
||||
* Initial media to populate the selection.
|
||||
* For gallery/editor flows, this is the pre-selected media.
|
||||
* For camera-first flows, this is typically empty.
|
||||
*/
|
||||
val initialMedia: List<Media> = emptyList(),
|
||||
/**
|
||||
* Initial message/caption text.
|
||||
*/
|
||||
val initialMessage: String? = null,
|
||||
/**
|
||||
* Whether this is a reply flow (affects UI/constraints).
|
||||
*/
|
||||
val isReply: Boolean = false,
|
||||
/**
|
||||
* Whether this is a story send flow.
|
||||
*/
|
||||
val isStory: Boolean = false,
|
||||
/**
|
||||
* Whether this is specifically the "add to group story" flow.
|
||||
*/
|
||||
val isAddToGroupStoryFlow: Boolean = false,
|
||||
/**
|
||||
* Maximum number of media items that can be selected.
|
||||
*/
|
||||
val maxSelection: Int = 32,
|
||||
/**
|
||||
* Send type identifier (app-layer enum ordinal).
|
||||
*/
|
||||
val sendType: Int = 0
|
||||
) : Parcelable {
|
||||
companion object {
|
||||
fun fromIntent(intent: Intent): Args {
|
||||
return intent.getParcelableExtra(EXTRA_ARGS) ?: Args()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* High-level mode of operation for the flow.
|
||||
*
|
||||
* Note: We keep this free of app/database types. Callers can wrap their own identifiers as needed.
|
||||
*/
|
||||
sealed interface Mode : Parcelable {
|
||||
/** Single known recipient — returns result for caller to send. */
|
||||
@Parcelize
|
||||
data object SingleRecipient : Mode
|
||||
|
||||
/** Multiple known recipients — sends immediately, returns [Result.Sent]. */
|
||||
@Parcelize
|
||||
data object MultiRecipient : Mode
|
||||
|
||||
/** User will select contacts during the flow — sends immediately, returns [Result.Sent]. */
|
||||
@Parcelize
|
||||
data object ChooseAfterMediaSelection : Mode
|
||||
}
|
||||
|
||||
/**
|
||||
* Result returned when the flow completes successfully.
|
||||
*
|
||||
* - [ReadyToSend] mirrors the historical "return a result to Conversation to send" pattern.
|
||||
* - [Sent] mirrors the historical "broadcast paths send immediately" behavior.
|
||||
*/
|
||||
sealed interface Result : Parcelable {
|
||||
|
||||
/**
|
||||
* Caller should send via its canonical pipeline (e.g., conversation-owned send path).
|
||||
*/
|
||||
@Parcelize
|
||||
data class ReadyToSend(
|
||||
val recipientId: MediaRecipientId,
|
||||
val body: String,
|
||||
val isViewOnce: Boolean,
|
||||
val scheduledTime: Long = -1,
|
||||
val payload: Payload
|
||||
) : Result
|
||||
|
||||
/**
|
||||
* The flow already performed the send (e.g., multi-recipient broadcast), so there is no payload.
|
||||
*/
|
||||
@Parcelize
|
||||
data object Sent : Result
|
||||
}
|
||||
|
||||
sealed interface Payload : Parcelable {
|
||||
/**
|
||||
* Pre-upload handles + dependencies. This is intentionally "handle-only" so the feature module
|
||||
* does not need to understand DB details; the app layer can interpret these primitives.
|
||||
*/
|
||||
@Parcelize
|
||||
data class PreUploaded(
|
||||
val items: List<PreUploadHandle>
|
||||
) : Payload
|
||||
|
||||
/**
|
||||
* Local media that the caller should upload/send as part of the normal pipeline.
|
||||
*/
|
||||
@Parcelize
|
||||
data class LocalMedia(
|
||||
val items: List<SelectedMedia>
|
||||
) : Payload
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the minimum data the legacy send pipeline needs for pre-upload sends:
|
||||
* - an attachment row identifier
|
||||
* - job dependency ids that the send jobs should wait on
|
||||
*
|
||||
* The feature module does not create or interpret these; it only transports them.
|
||||
*/
|
||||
@Parcelize
|
||||
data class PreUploadHandle(
|
||||
val attachmentId: Long,
|
||||
val jobIds: List<String>,
|
||||
val mediaUri: Uri
|
||||
) : Parcelable
|
||||
|
||||
/**
|
||||
* Feature-level representation of selected media when the caller is responsible for upload/send.
|
||||
* This intentionally avoids coupling to app-layer `Media` / `TransformProperties`.
|
||||
*/
|
||||
@Parcelize
|
||||
data class SelectedMedia(
|
||||
val uri: Uri,
|
||||
val contentType: String?,
|
||||
val width: Int = 0,
|
||||
val height: Int = 0,
|
||||
val size: Long = 0,
|
||||
val duration: Long = 0,
|
||||
val isBorderless: Boolean = false,
|
||||
val isVideoGif: Boolean = false,
|
||||
val caption: String? = null,
|
||||
val fileName: String? = null,
|
||||
val transform: Transform? = null
|
||||
) : Parcelable
|
||||
|
||||
/**
|
||||
* Feature-level transform model for media edits/quality.
|
||||
* Kept Parcelable for the Activity result boundary; a pure JVM equivalent can live in `core-models`.
|
||||
*/
|
||||
@Parcelize
|
||||
data class Transform(
|
||||
val skipTransform: Boolean = false,
|
||||
val videoTrim: Boolean = false,
|
||||
val videoTrimStartTimeUs: Long = 0,
|
||||
val videoTrimEndTimeUs: Long = 0,
|
||||
val sentMediaQuality: Int = 0,
|
||||
val mp4FastStart: Boolean = false
|
||||
) : Parcelable
|
||||
|
||||
companion object {
|
||||
const val EXTRA_ARGS = "org.signal.mediasend.args"
|
||||
const val EXTRA_RESULT = "result"
|
||||
|
||||
fun toResultIntent(result: Result): Intent {
|
||||
return Intent().apply {
|
||||
putExtra(EXTRA_RESULT, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend
|
||||
|
||||
import android.net.Uri
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.signal.core.models.media.Media
|
||||
import org.signal.core.models.media.MediaFolder
|
||||
|
||||
/**
|
||||
* Repository interface for media send operations that require app-layer implementation.
|
||||
*
|
||||
* This allows the feature module to remain decoupled from database, recipient,
|
||||
* and constraint implementations while still supporting full functionality.
|
||||
*/
|
||||
interface MediaSendRepository {
|
||||
|
||||
/**
|
||||
* Retrieves the top-level folders which contain media
|
||||
*/
|
||||
suspend fun getFolders(): List<MediaFolder>
|
||||
|
||||
/**
|
||||
* Retrieves media for a given bucketId (folder)
|
||||
*/
|
||||
suspend fun getMedia(bucketId: String): List<Media>
|
||||
|
||||
/**
|
||||
* Validates and filters media against constraints.
|
||||
*
|
||||
* @param media The media items to validate.
|
||||
* @param maxSelection Maximum number of items allowed.
|
||||
* @param isStory Whether this is for a story (may have different constraints).
|
||||
* @return Result containing filtered media and any validation errors.
|
||||
*/
|
||||
suspend fun validateAndFilterMedia(
|
||||
media: List<Media>,
|
||||
maxSelection: Int,
|
||||
isStory: Boolean
|
||||
): MediaFilterResult
|
||||
|
||||
/**
|
||||
* Deletes temporary blob files for the given media.
|
||||
*/
|
||||
suspend fun deleteBlobs(media: List<Media>)
|
||||
|
||||
/**
|
||||
* Sends the media with the given parameters.
|
||||
*
|
||||
* @return Result indicating success or containing a send result.
|
||||
*/
|
||||
suspend fun send(request: SendRequest): SendResult
|
||||
|
||||
/**
|
||||
* Gets the maximum video duration in microseconds based on quality and file size limits.
|
||||
*
|
||||
* @param quality The sent media quality code.
|
||||
* @param maxFileSizeBytes Maximum file size in bytes.
|
||||
* @return Maximum duration in microseconds.
|
||||
*/
|
||||
fun getMaxVideoDurationUs(quality: Int, maxFileSizeBytes: Long): Long
|
||||
|
||||
/**
|
||||
* Gets the maximum video file size in bytes.
|
||||
*/
|
||||
fun getVideoMaxSizeBytes(): Long
|
||||
|
||||
/**
|
||||
* Checks if video transcoding is available on this device.
|
||||
*/
|
||||
fun isVideoTranscodeAvailable(): Boolean
|
||||
|
||||
/**
|
||||
* Gets story send requirements for the given media.
|
||||
*/
|
||||
suspend fun getStorySendRequirements(media: List<Media>): StorySendRequirements
|
||||
|
||||
/**
|
||||
* Checks for untrusted identity records among the given contacts.
|
||||
*
|
||||
* @param contactIds Contact identifiers to check.
|
||||
* @param since Timestamp to check identity changes since.
|
||||
* @return List of contacts with bad identity records, empty if all trusted.
|
||||
*/
|
||||
suspend fun checkUntrustedIdentities(
|
||||
contactIds: Set<Long>,
|
||||
since: Long
|
||||
): List<Long>
|
||||
|
||||
/**
|
||||
* Provides a flow of recipient "exists" state for determining pre-upload eligibility.
|
||||
* Emits true if the recipient is valid and can receive pre-uploads.
|
||||
*
|
||||
* @param recipientId The recipient to observe.
|
||||
* @return Flow that emits whenever recipient validity changes.
|
||||
*/
|
||||
fun observeRecipientValid(recipientId: MediaRecipientId): Flow<Boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of media validation/filtering.
|
||||
*/
|
||||
data class MediaFilterResult(
|
||||
val filteredMedia: List<Media>,
|
||||
val error: MediaFilterError?
|
||||
)
|
||||
|
||||
/**
|
||||
* Errors that can occur during media filtering.
|
||||
*/
|
||||
sealed interface MediaFilterError {
|
||||
data object NoItems : MediaFilterError
|
||||
data class ItemTooLarge(val media: Media) : MediaFilterError
|
||||
data class ItemInvalidType(val media: Media) : MediaFilterError
|
||||
data class TooManyItems(val max: Int) : MediaFilterError
|
||||
data class CannotMixMediaTypes(val message: String) : MediaFilterError
|
||||
data class Other(val message: String) : MediaFilterError
|
||||
}
|
||||
|
||||
/**
|
||||
* Request parameters for sending media.
|
||||
*/
|
||||
data class SendRequest(
|
||||
val selectedMedia: List<Media>,
|
||||
val editorStateMap: Map<Uri, EditorState>,
|
||||
val quality: Int,
|
||||
val message: String?,
|
||||
val isViewOnce: Boolean,
|
||||
val singleRecipientId: MediaRecipientId?,
|
||||
val recipientIds: List<MediaRecipientId>,
|
||||
val scheduledTime: Long,
|
||||
val sendType: Int,
|
||||
val isStory: Boolean
|
||||
)
|
||||
|
||||
/**
|
||||
* Result of a send operation.
|
||||
*/
|
||||
sealed interface SendResult {
|
||||
data object Success : SendResult
|
||||
data class Error(val message: String) : SendResult
|
||||
data class UntrustedIdentity(val recipientIds: List<Long>) : SendResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Story send requirements based on media content.
|
||||
*/
|
||||
enum class StorySendRequirements {
|
||||
/** Can send to stories. */
|
||||
CAN_SEND,
|
||||
|
||||
/** Cannot send to stories. */
|
||||
CAN_NOT_SEND,
|
||||
|
||||
/** Requires cropping before sending to stories. */
|
||||
REQUIRES_CROP
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package org.signal.mediasend
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavEntry
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import androidx.navigation3.runtime.rememberNavBackStack
|
||||
import androidx.navigation3.ui.NavDisplay
|
||||
import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner
|
||||
import androidx.navigationevent.compose.rememberNavigationEventDispatcherOwner
|
||||
import org.signal.core.ui.compose.AllDevicePreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.mediasend.edit.MediaEditScreen
|
||||
|
||||
/**
|
||||
* Enforces the following flow of:
|
||||
*
|
||||
* Capture -> Edit -> Send
|
||||
* Select -> Edit -> Send
|
||||
*/
|
||||
@Composable
|
||||
fun MediaSendNavDisplay(
|
||||
state: MediaSendState,
|
||||
backStack: NavBackStack<NavKey>,
|
||||
callback: MediaSendCallback,
|
||||
modifier: Modifier = Modifier,
|
||||
cameraSlot: @Composable () -> Unit = {},
|
||||
textStoryEditorSlot: @Composable () -> Unit = {},
|
||||
mediaSelectSlot: @Composable () -> Unit = {},
|
||||
videoEditorSlot: @Composable () -> Unit = {},
|
||||
sendSlot: @Composable () -> Unit = {}
|
||||
) {
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
modifier = modifier.fillMaxSize()
|
||||
) { key ->
|
||||
when (key) {
|
||||
is MediaSendNavKey.Capture -> NavEntry(MediaSendNavKey.Capture.Chrome) {
|
||||
MediaCaptureScreen(
|
||||
backStack = backStack,
|
||||
cameraSlot = cameraSlot,
|
||||
textStoryEditorSlot = textStoryEditorSlot
|
||||
)
|
||||
}
|
||||
|
||||
MediaSendNavKey.Select -> NavEntry(key) {
|
||||
mediaSelectSlot()
|
||||
}
|
||||
|
||||
is MediaSendNavKey.Edit -> NavEntry(MediaSendNavKey.Edit) {
|
||||
MediaEditScreen(
|
||||
state = state,
|
||||
backStack = backStack,
|
||||
videoEditorSlot = videoEditorSlot,
|
||||
callback = callback
|
||||
)
|
||||
}
|
||||
|
||||
is MediaSendNavKey.Send -> NavEntry(key) {
|
||||
sendSlot()
|
||||
}
|
||||
|
||||
else -> error("Unknown key: $key")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@AllDevicePreviews
|
||||
@Composable
|
||||
private fun MediaSendNavDisplayPreview() {
|
||||
Previews.Preview {
|
||||
CompositionLocalProvider(LocalNavigationEventDispatcherOwner provides rememberNavigationEventDispatcherOwner(parent = null)) {
|
||||
MediaSendNavDisplay(
|
||||
state = MediaSendState(isCameraFirst = true),
|
||||
backStack = rememberNavBackStack(MediaSendNavKey.Edit),
|
||||
callback = MediaSendCallback.Empty,
|
||||
cameraSlot = { BoxWithText("Camera Slot") },
|
||||
textStoryEditorSlot = { BoxWithText("Text Story Editor Slot") },
|
||||
mediaSelectSlot = { BoxWithText("Media Select Slot") },
|
||||
videoEditorSlot = { BoxWithText("Video Editor Slot") },
|
||||
sendSlot = { BoxWithText("Send Slot") }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoxWithText(text: String, modifier: Modifier = Modifier) {
|
||||
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text(text = text)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.signal.core.models.media.Media
|
||||
import org.signal.core.models.media.MediaFolder
|
||||
|
||||
/**
|
||||
* The collective state of the media send flow.
|
||||
*
|
||||
* Fully [Parcelable] for [SavedStateHandle] persistence — no separate serialization needed.
|
||||
*/
|
||||
@Parcelize
|
||||
data class MediaSendState(
|
||||
val isCameraFirst: Boolean = false,
|
||||
/**
|
||||
* Optional recipient identifier for single-recipient flows.
|
||||
*/
|
||||
val recipientId: MediaRecipientId? = null,
|
||||
/**
|
||||
* Mode of operation — determines whether we return a result or send immediately.
|
||||
*/
|
||||
val mode: MediaSendActivityContract.Mode = MediaSendActivityContract.Mode.SingleRecipient,
|
||||
val selectedMedia: List<Media> = emptyList(),
|
||||
/**
|
||||
* The currently focused/visible media item in the pager.
|
||||
*/
|
||||
val focusedMedia: Media? = null,
|
||||
val isMeteredConnection: Boolean = false,
|
||||
val isPreUploadEnabled: Boolean = false,
|
||||
/**
|
||||
* Int code to avoid depending on app-layer enums. Conventionally 0 == STANDARD.
|
||||
*/
|
||||
val sentMediaQuality: Int = 0,
|
||||
/**
|
||||
* Per-media editor state keyed by URI (video trim data, image editor data, etc.).
|
||||
*/
|
||||
val editorStateMap: Map<Uri, EditorState> = emptyMap(),
|
||||
/**
|
||||
* View-once toggle state. Cycles: OFF -> ONCE -> OFF.
|
||||
*/
|
||||
val viewOnceToggleState: ViewOnceToggleState = ViewOnceToggleState.OFF,
|
||||
/**
|
||||
* Optional message/caption text to accompany the media.
|
||||
*/
|
||||
val message: String? = null,
|
||||
/**
|
||||
* If non-null, this media was the first capture from the camera and may be
|
||||
* removed if the user backs out of camera-first flow.
|
||||
*/
|
||||
val cameraFirstCapture: Media? = null,
|
||||
/**
|
||||
* Whether touch interactions are enabled (disabled during animations/transitions).
|
||||
*/
|
||||
val isTouchEnabled: Boolean = true,
|
||||
/**
|
||||
* When true, suppresses the "no items" error when selection becomes empty.
|
||||
* Used during camera-first flow exit.
|
||||
*/
|
||||
val suppressEmptyError: Boolean = false,
|
||||
/**
|
||||
* Whether the media has been sent (prevents duplicate sends).
|
||||
*/
|
||||
val isSent: Boolean = false,
|
||||
/**
|
||||
* Whether this is a story send flow.
|
||||
*/
|
||||
val isStory: Boolean = false,
|
||||
/**
|
||||
* Send type code (SMS vs Signal). Conventionally 0 == SignalMessageSendType.
|
||||
*/
|
||||
val sendType: Int = 0,
|
||||
/**
|
||||
* Story send requirements based on current media selection.
|
||||
*/
|
||||
val storySendRequirements: StorySendRequirements = StorySendRequirements.CAN_NOT_SEND,
|
||||
/**
|
||||
* Maximum number of media items that can be selected.
|
||||
*/
|
||||
val maxSelection: Int = 32,
|
||||
/**
|
||||
* Whether contact selection is required (for choose-after-media flows).
|
||||
*/
|
||||
val isContactSelectionRequired: Boolean = false,
|
||||
/**
|
||||
* Whether this is a reply to an existing message.
|
||||
*/
|
||||
val isReply: Boolean = false,
|
||||
/**
|
||||
* Whether this is the "add to group story" flow.
|
||||
*/
|
||||
val isAddToGroupStoryFlow: Boolean = false,
|
||||
/**
|
||||
* Additional recipient IDs for multi-recipient sends.
|
||||
*/
|
||||
val additionalRecipientIds: List<MediaRecipientId> = emptyList(),
|
||||
/**
|
||||
* Scheduled send time (-1 for immediate).
|
||||
*/
|
||||
val scheduledTime: Long = -1,
|
||||
|
||||
/**
|
||||
* The [MediaFolder] list available on the system
|
||||
*/
|
||||
val mediaFolders: List<MediaFolder> = emptyList(),
|
||||
|
||||
/**
|
||||
* The selected [MediaFolder] for which to display content in the Select screen
|
||||
*/
|
||||
val selectedMediaFolder: MediaFolder? = null,
|
||||
|
||||
/**
|
||||
* The media content for a given selected [MediaFolder]
|
||||
*/
|
||||
val selectedMediaFolderItems: List<Media> = emptyList()
|
||||
) : Parcelable {
|
||||
|
||||
/**
|
||||
* View-once toggle state.
|
||||
*/
|
||||
enum class ViewOnceToggleState(val code: Int) {
|
||||
OFF(0),
|
||||
ONCE(1);
|
||||
|
||||
fun next(): ViewOnceToggleState = when (this) {
|
||||
OFF -> ONCE
|
||||
ONCE -> OFF
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromCode(code: Int): ViewOnceToggleState = entries.firstOrNull { it.code == code } ?: OFF
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
}
|
||||
@@ -0,0 +1,748 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.createSavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.CreationExtras
|
||||
import com.bumptech.glide.load.DataSource
|
||||
import com.bumptech.glide.load.engine.GlideException
|
||||
import com.bumptech.glide.request.RequestListener
|
||||
import com.bumptech.glide.request.target.Target
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.models.media.Media
|
||||
import org.signal.core.models.media.MediaFolder
|
||||
import org.signal.core.util.ContentTypeUtil
|
||||
import org.signal.core.util.StringUtil
|
||||
import org.signal.imageeditor.core.model.EditorElement
|
||||
import org.signal.imageeditor.core.model.EditorModel
|
||||
import org.signal.imageeditor.core.renderers.UriGlideRenderer
|
||||
import org.signal.mediasend.preupload.PreUploadManager
|
||||
import java.util.Collections
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* Configuration-survivable state manager for the media send flow.
|
||||
*
|
||||
* Uses [SavedStateHandle] for automatic state persistence across process death.
|
||||
* [MediaSendState] is fully [Parcelable] and saved directly as a single key.
|
||||
*/
|
||||
class MediaSendViewModel(
|
||||
private val args: MediaSendActivityContract.Args,
|
||||
private val identityChangesSince: Long,
|
||||
isMeteredFlow: Flow<Boolean>,
|
||||
private val savedStateHandle: SavedStateHandle,
|
||||
private val repository: MediaSendRepository,
|
||||
private val preUploadManager: PreUploadManager
|
||||
) : ViewModel(), MediaSendCallback {
|
||||
|
||||
private val defaultState = MediaSendState(
|
||||
isCameraFirst = args.isCameraFirst,
|
||||
recipientId = args.recipientId,
|
||||
mode = args.mode,
|
||||
isStory = args.isStory,
|
||||
isReply = args.isReply,
|
||||
isAddToGroupStoryFlow = args.isAddToGroupStoryFlow,
|
||||
maxSelection = args.maxSelection,
|
||||
message = args.initialMessage,
|
||||
isContactSelectionRequired = args.mode == MediaSendActivityContract.Mode.ChooseAfterMediaSelection,
|
||||
sendType = args.sendType
|
||||
)
|
||||
|
||||
/**
|
||||
* Main UI state. Backed by [SavedStateHandle] for automatic process death survival.
|
||||
* Writes to this flow are automatically persisted.
|
||||
*/
|
||||
private val internalState: MutableStateFlow<MediaSendState> = savedStateHandle.getMutableStateFlow(KEY_STATE, defaultState)
|
||||
val state: StateFlow<MediaSendState> = internalState.asStateFlow()
|
||||
|
||||
private val editedVideoUris: MutableSet<Uri> = mutableSetOf<Uri>().apply {
|
||||
addAll(savedStateHandle[KEY_EDITED_VIDEO_URIS] ?: emptyList())
|
||||
}
|
||||
|
||||
/** One-shot HUD commands exposed as a Flow. */
|
||||
private val hudCommandChannel = Channel<HudCommand>(Channel.BUFFERED)
|
||||
val hudCommands: Flow<HudCommand> = hudCommandChannel.receiveAsFlow()
|
||||
|
||||
/** Media filter errors. */
|
||||
private val _mediaErrors = MutableSharedFlow<MediaFilterError>(replay = 1)
|
||||
val mediaErrors: SharedFlow<MediaFilterError> = _mediaErrors.asSharedFlow()
|
||||
|
||||
/** Character count for the message field. */
|
||||
val messageCharacterCount: Flow<Int> = state
|
||||
.map { it.message?.let { msg -> StringUtil.getGraphemeCount(msg) } ?: 0 }
|
||||
.distinctUntilChanged()
|
||||
|
||||
/** Tracks drag state for media reordering. */
|
||||
private var lastMediaDrag: Pair<Int, Int> = Pair(0, 0)
|
||||
|
||||
init {
|
||||
// Matches legacy behavior: VM subscribes to connectivity updates and derives
|
||||
// isPreUploadEnabled from metered state.
|
||||
viewModelScope.launch {
|
||||
isMeteredFlow.collect { metered ->
|
||||
updateState { copy(isMeteredConnection = metered, isPreUploadEnabled = shouldPreUpload(metered)) }
|
||||
}
|
||||
}
|
||||
|
||||
// Observe recipient validity for pre-upload eligibility
|
||||
args.recipientId?.let { recipientId ->
|
||||
viewModelScope.launch {
|
||||
repository.observeRecipientValid(recipientId).collect { isValid ->
|
||||
if (isValid) {
|
||||
updateState { copy(isPreUploadEnabled = shouldPreUpload(isMeteredConnection)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add initial media if provided
|
||||
if (args.initialMedia.isNotEmpty()) {
|
||||
addMedia(args.initialMedia.toSet())
|
||||
}
|
||||
|
||||
refreshMediaFolders()
|
||||
}
|
||||
|
||||
/** Updates state atomically — automatically persisted via SavedStateHandle-backed MutableStateFlow. */
|
||||
private inline fun updateState(crossinline transform: MediaSendState.() -> MediaSendState) {
|
||||
internalState.update { it.transform() }
|
||||
}
|
||||
|
||||
//region Media Selection
|
||||
|
||||
fun refreshMediaFolders() {
|
||||
viewModelScope.launch {
|
||||
val folders = repository.getFolders()
|
||||
internalState.update {
|
||||
it.copy(
|
||||
mediaFolders = folders,
|
||||
selectedMediaFolder = if (it.selectedMediaFolder in folders) it.selectedMediaFolder else null,
|
||||
selectedMedia = if (it.selectedMediaFolder in folders) it.selectedMediaFolderItems else emptyList()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFolderClick(mediaFolder: MediaFolder?) {
|
||||
viewModelScope.launch {
|
||||
if (mediaFolder != null) {
|
||||
val media = repository.getMedia(mediaFolder.bucketId)
|
||||
internalState.update { it.copy(selectedMediaFolder = mediaFolder, selectedMediaFolderItems = media) }
|
||||
} else {
|
||||
internalState.update { it.copy(selectedMediaFolder = null, selectedMediaFolderItems = emptyList()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMediaClick(media: Media) {
|
||||
if (media.uri in internalState.value.selectedMedia.map { it.uri }) {
|
||||
removeMedia(media)
|
||||
} else {
|
||||
addMedia(media)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds [media] to the selection, preserving insertion order and uniqueness by equality.
|
||||
*
|
||||
* Validates against constraints and starts pre-uploads for newly added items.
|
||||
*
|
||||
* @param media Media items to add.
|
||||
*/
|
||||
fun addMedia(media: Set<Media>) {
|
||||
viewModelScope.launch {
|
||||
val snapshot = state.value
|
||||
val newSelectionList: List<Media> = linkedSetOf<Media>().apply {
|
||||
addAll(snapshot.selectedMedia)
|
||||
addAll(media)
|
||||
}.toList()
|
||||
|
||||
// Validate and filter through repository
|
||||
val filterResult = repository.validateAndFilterMedia(
|
||||
media = newSelectionList,
|
||||
maxSelection = snapshot.maxSelection,
|
||||
isStory = snapshot.isStory
|
||||
)
|
||||
|
||||
if (filterResult.filteredMedia.isNotEmpty()) {
|
||||
// Initialize video trim states for new videos
|
||||
val maxVideoDurationUs = getMaxVideoDurationUs()
|
||||
val initializedVideoEditorStates = filterResult.filteredMedia
|
||||
.filterNot { snapshot.editorStateMap.containsKey(it.uri) }
|
||||
.filter { isNonGifVideo(it) }
|
||||
.associate { video ->
|
||||
val durationUs = video.duration.milliseconds.inWholeMicroseconds
|
||||
video.uri to EditorState.VideoTrim.forVideo(durationUs, maxVideoDurationUs)
|
||||
}
|
||||
|
||||
val initializedImageEditorStates = filterResult.filteredMedia
|
||||
.filterNot { snapshot.editorStateMap.containsKey(it.uri) }
|
||||
.filter { ContentTypeUtil.isImageType(it.contentType) }
|
||||
.associate { image ->
|
||||
// TODO - this should likely be in a repository?
|
||||
val editorModel = EditorModel.create(0x0)
|
||||
val element = EditorElement(
|
||||
UriGlideRenderer(
|
||||
image.uri,
|
||||
true,
|
||||
0,
|
||||
0,
|
||||
UriGlideRenderer.STRONG_BLUR,
|
||||
object : RequestListener<Bitmap> {
|
||||
override fun onResourceReady(resource: Bitmap?, model: Any?, target: Target<Bitmap?>?, dataSource: DataSource?, isFirstResource: Boolean): Boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onLoadFailed(e: GlideException?, model: Any?, target: Target<Bitmap?>?, isFirstResource: Boolean): Boolean {
|
||||
return false
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
element.flags.setSelectable(false).persist()
|
||||
editorModel.addElement(element)
|
||||
image.uri to EditorState.Image(editorModel)
|
||||
}
|
||||
|
||||
updateState {
|
||||
copy(
|
||||
selectedMedia = filterResult.filteredMedia,
|
||||
focusedMedia = focusedMedia ?: filterResult.filteredMedia.firstOrNull(),
|
||||
editorStateMap = editorStateMap + initializedVideoEditorStates + initializedImageEditorStates
|
||||
)
|
||||
}
|
||||
|
||||
// Update story requirements
|
||||
updateStorySendRequirements(filterResult.filteredMedia)
|
||||
|
||||
// Start pre-uploads for new media
|
||||
val newMedia = filterResult.filteredMedia.toSet().intersect(media).toList()
|
||||
startUpload(newMedia)
|
||||
}
|
||||
|
||||
if (filterResult.error != null) {
|
||||
_mediaErrors.emit(filterResult.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a single [media] item to the selection.
|
||||
*/
|
||||
fun addMedia(media: Media) {
|
||||
addMedia(setOf(media))
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a single [media] item from the selection.
|
||||
*/
|
||||
fun removeMedia(media: Media) {
|
||||
removeMedia(setOf(media))
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes [media] from the selection.
|
||||
*
|
||||
* Cancels any pre-uploads for the removed items.
|
||||
*
|
||||
* @param media Media items to remove.
|
||||
*/
|
||||
fun removeMedia(media: Set<Media>) {
|
||||
val snapshot = state.value
|
||||
val newSelection = snapshot.selectedMedia - media
|
||||
|
||||
val newFocus = when {
|
||||
newSelection.isEmpty() -> null
|
||||
snapshot.focusedMedia in media -> {
|
||||
val oldFocusIndex = snapshot.selectedMedia.indexOf(snapshot.focusedMedia)
|
||||
newSelection[oldFocusIndex.coerceIn(0, newSelection.size - 1)]
|
||||
}
|
||||
|
||||
else -> snapshot.focusedMedia
|
||||
}
|
||||
|
||||
val newCameraFirstCapture = if (snapshot.cameraFirstCapture in media) null else snapshot.cameraFirstCapture
|
||||
|
||||
updateState {
|
||||
copy(
|
||||
selectedMedia = newSelection,
|
||||
focusedMedia = newFocus,
|
||||
editorStateMap = editorStateMap - media.map { it.uri }.toSet(),
|
||||
cameraFirstCapture = newCameraFirstCapture
|
||||
)
|
||||
}
|
||||
|
||||
if (newSelection.isEmpty() && !snapshot.suppressEmptyError) {
|
||||
viewModelScope.launch {
|
||||
_mediaErrors.emit(MediaFilterError.NoItems)
|
||||
}
|
||||
}
|
||||
|
||||
// Update story requirements
|
||||
viewModelScope.launch {
|
||||
updateStorySendRequirements(newSelection)
|
||||
}
|
||||
|
||||
// Delete blobs and cancel uploads
|
||||
viewModelScope.launch {
|
||||
repository.deleteBlobs(media.toList())
|
||||
}
|
||||
preUploadManager.cancelUpload(media)
|
||||
preUploadManager.updateDisplayOrder(newSelection)
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies updates to selected media (old -> new).
|
||||
*/
|
||||
fun applyMediaUpdates(oldToNew: Map<Media, Media>) {
|
||||
if (oldToNew.isEmpty()) return
|
||||
|
||||
val snapshot = state.value
|
||||
val updatedSelection = snapshot.selectedMedia.map { oldToNew[it] ?: it }
|
||||
updateState { copy(selectedMedia = updatedSelection) }
|
||||
|
||||
preUploadManager.applyMediaUpdates(oldToNew, snapshot.recipientId)
|
||||
preUploadManager.updateCaptions(updatedSelection)
|
||||
preUploadManager.updateDisplayOrder(updatedSelection)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the current ordering of selected media.
|
||||
*/
|
||||
fun setDisplayOrder(mediaInOrder: List<Media>) {
|
||||
updateState { copy(selectedMedia = mediaInOrder) }
|
||||
preUploadManager.updateDisplayOrder(mediaInOrder)
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region Pre-Upload Management
|
||||
|
||||
private fun startUpload(media: List<Media>) {
|
||||
val snapshot = state.value
|
||||
if (!snapshot.isPreUploadEnabled) return
|
||||
|
||||
val filteredPreUploadMedia = if (snapshot.mode is MediaSendActivityContract.Mode.SingleRecipient) {
|
||||
media.filter { !ContentTypeUtil.isDocumentType(it.contentType) }
|
||||
} else {
|
||||
media.filter { ContentTypeUtil.isStorySupportedType(it.contentType) }
|
||||
}
|
||||
|
||||
preUploadManager.startUpload(filteredPreUploadMedia, snapshot.recipientId)
|
||||
preUploadManager.updateCaptions(snapshot.selectedMedia)
|
||||
preUploadManager.updateDisplayOrder(snapshot.selectedMedia)
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region Quality
|
||||
|
||||
/**
|
||||
* Sets the sent media quality.
|
||||
*
|
||||
* Cancels all pre-uploads and re-initializes video trim data.
|
||||
*/
|
||||
fun setSentMediaQuality(sentMediaQuality: Int) {
|
||||
val snapshot = state.value
|
||||
if (snapshot.sentMediaQuality == sentMediaQuality) return
|
||||
|
||||
updateState { copy(sentMediaQuality = sentMediaQuality, isPreUploadEnabled = false) }
|
||||
preUploadManager.cancelAllUploads()
|
||||
|
||||
// Re-clamp video durations based on new quality
|
||||
val maxVideoDurationUs = getMaxVideoDurationUs()
|
||||
snapshot.selectedMedia.forEach { mediaItem ->
|
||||
if (isNonGifVideo(mediaItem) && repository.isVideoTranscodeAvailable()) {
|
||||
val existingData = snapshot.editorStateMap[mediaItem.uri] as? EditorState.VideoTrim
|
||||
if (existingData != null) {
|
||||
onEditVideoDuration(
|
||||
totalDurationUs = existingData.totalInputDurationUs,
|
||||
startTimeUs = existingData.startTimeUs,
|
||||
endTimeUs = existingData.endTimeUs,
|
||||
touchEnabled = true,
|
||||
uri = mediaItem.uri
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region Video Editing
|
||||
|
||||
/**
|
||||
* Notifies the view-model that a video's trim/duration has been edited.
|
||||
*/
|
||||
override fun onVideoEdited(uri: Uri, isEdited: Boolean) {
|
||||
if (!isEdited) return
|
||||
if (!editedVideoUris.add(uri)) return
|
||||
|
||||
// Persist the updated set
|
||||
savedStateHandle[KEY_EDITED_VIDEO_URIS] = ArrayList(editedVideoUris)
|
||||
|
||||
val media = state.value.selectedMedia.firstOrNull { it.uri == uri } ?: return
|
||||
preUploadManager.cancelUpload(media)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates video trim duration.
|
||||
*/
|
||||
fun onEditVideoDuration(
|
||||
totalDurationUs: Long,
|
||||
startTimeUs: Long,
|
||||
endTimeUs: Long,
|
||||
touchEnabled: Boolean,
|
||||
uri: Uri? = state.value.focusedMedia?.uri
|
||||
) {
|
||||
if (uri == null) return
|
||||
if (!repository.isVideoTranscodeAvailable()) return
|
||||
|
||||
val snapshot = state.value
|
||||
val existingData = snapshot.editorStateMap[uri] as? EditorState.VideoTrim
|
||||
?: EditorState.VideoTrim(totalInputDurationUs = totalDurationUs)
|
||||
|
||||
val clampedStartTime = maxOf(startTimeUs, 0)
|
||||
val unedited = !existingData.isDurationEdited
|
||||
val durationEdited = clampedStartTime > 0 || endTimeUs < totalDurationUs
|
||||
val isEntireDuration = startTimeUs == 0L && endTimeUs == totalDurationUs
|
||||
val endMoved = !isEntireDuration && existingData.endTimeUs != endTimeUs
|
||||
val maxVideoDurationUs = getMaxVideoDurationUs()
|
||||
val preserveStartTime = unedited || !endMoved
|
||||
|
||||
val newData = EditorState.VideoTrim(
|
||||
isDurationEdited = durationEdited,
|
||||
totalInputDurationUs = totalDurationUs,
|
||||
startTimeUs = clampedStartTime,
|
||||
endTimeUs = endTimeUs
|
||||
).clampToMaxDuration(maxVideoDurationUs, preserveStartTime)
|
||||
|
||||
// Cancel upload on first edit
|
||||
if (unedited && durationEdited) {
|
||||
val media = snapshot.selectedMedia.firstOrNull { it.uri == uri }
|
||||
if (media != null) {
|
||||
preUploadManager.cancelUpload(media)
|
||||
}
|
||||
}
|
||||
|
||||
if (newData != existingData) {
|
||||
updateState {
|
||||
copy(
|
||||
isTouchEnabled = touchEnabled,
|
||||
editorStateMap = editorStateMap + (uri to newData)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
updateState { copy(isTouchEnabled = touchEnabled) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun getMaxVideoDurationUs(): Long {
|
||||
val snapshot = state.value
|
||||
return repository.getMaxVideoDurationUs(
|
||||
quality = snapshot.sentMediaQuality,
|
||||
maxFileSizeBytes = repository.getVideoMaxSizeBytes()
|
||||
)
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region Page/Focus Management
|
||||
|
||||
override fun setFocusedMedia(media: Media) {
|
||||
updateState { copy(focusedMedia = media) }
|
||||
}
|
||||
|
||||
override fun onPageChanged(position: Int) {
|
||||
val snapshot = state.value
|
||||
val focused = if (position >= snapshot.selectedMedia.size) null else snapshot.selectedMedia[position]
|
||||
updateState { copy(focusedMedia = focused) }
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region Drag/Reordering
|
||||
|
||||
fun swapMedia(originalStart: Int, end: Int): Boolean {
|
||||
var start = originalStart
|
||||
|
||||
if (lastMediaDrag.first == start && lastMediaDrag.second == end) {
|
||||
return true
|
||||
} else if (lastMediaDrag.first == start) {
|
||||
start = lastMediaDrag.second
|
||||
}
|
||||
|
||||
val snapshot = state.value
|
||||
|
||||
if (end >= snapshot.selectedMedia.size ||
|
||||
end < 0 ||
|
||||
start >= snapshot.selectedMedia.size ||
|
||||
start < 0
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
lastMediaDrag = Pair(originalStart, end)
|
||||
|
||||
val newMediaList = snapshot.selectedMedia.toMutableList()
|
||||
|
||||
if (start < end) {
|
||||
for (i in start until end) {
|
||||
Collections.swap(newMediaList, i, i + 1)
|
||||
}
|
||||
} else {
|
||||
for (i in start downTo end + 1) {
|
||||
Collections.swap(newMediaList, i, i - 1)
|
||||
}
|
||||
}
|
||||
|
||||
updateState { copy(selectedMedia = newMediaList) }
|
||||
return true
|
||||
}
|
||||
|
||||
fun isValidMediaDragPosition(position: Int): Boolean {
|
||||
return position >= 0 && position < internalState.value.selectedMedia.size
|
||||
}
|
||||
|
||||
private fun isNonGifVideo(media: Media): Boolean {
|
||||
return ContentTypeUtil.isVideo(media.contentType) && !media.isVideoGif
|
||||
}
|
||||
|
||||
fun onMediaDragFinished() {
|
||||
lastMediaDrag = Pair(0, 0)
|
||||
preUploadManager.updateDisplayOrder(internalState.value.selectedMedia)
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region Editor State
|
||||
|
||||
fun getEditorState(uri: Uri): EditorState? {
|
||||
return internalState.value.editorStateMap[uri]
|
||||
}
|
||||
|
||||
fun setEditorState(uri: Uri, state: EditorState) {
|
||||
updateState { copy(editorStateMap = editorStateMap + (uri to state)) }
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region View Once
|
||||
|
||||
fun incrementViewOnceState() {
|
||||
updateState { copy(viewOnceToggleState = viewOnceToggleState.next()) }
|
||||
}
|
||||
|
||||
fun isViewOnceEnabled(): Boolean {
|
||||
val snapshot = internalState.value
|
||||
return snapshot.selectedMedia.size == 1 &&
|
||||
snapshot.viewOnceToggleState == MediaSendState.ViewOnceToggleState.ONCE
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region Message
|
||||
|
||||
fun setMessage(text: String?) {
|
||||
updateState { copy(message = text) }
|
||||
}
|
||||
|
||||
override fun onMessageChanged(text: CharSequence?) {
|
||||
setMessage(text?.toString())
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region Story
|
||||
|
||||
fun isStory(): Boolean = state.value.isStory
|
||||
|
||||
fun getStorySendRequirements(): StorySendRequirements = state.value.storySendRequirements
|
||||
|
||||
private suspend fun updateStorySendRequirements(media: List<Media>) {
|
||||
if (!state.value.isStory) return
|
||||
val requirements = repository.getStorySendRequirements(media)
|
||||
updateState { copy(storySendRequirements = requirements) }
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region Recipients
|
||||
|
||||
fun setAdditionalRecipients(recipientIds: List<MediaRecipientId>) {
|
||||
updateState { copy(additionalRecipientIds = recipientIds) }
|
||||
}
|
||||
|
||||
fun setScheduledTime(time: Long) {
|
||||
updateState { copy(scheduledTime = time) }
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region Camera First Capture
|
||||
|
||||
fun addCameraFirstCapture(media: Media) {
|
||||
internalState.update { it.copy(cameraFirstCapture = media) }
|
||||
addMedia(media)
|
||||
}
|
||||
|
||||
fun removeCameraFirstCapture() {
|
||||
val capture = internalState.value.cameraFirstCapture ?: return
|
||||
setSuppressEmptyError(true)
|
||||
removeMedia(capture)
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region Touch & Error Suppression
|
||||
|
||||
fun setTouchEnabled(isEnabled: Boolean) {
|
||||
updateState { copy(isTouchEnabled = isEnabled) }
|
||||
}
|
||||
|
||||
fun setSuppressEmptyError(isSuppressed: Boolean) {
|
||||
updateState { copy(suppressEmptyError = isSuppressed) }
|
||||
}
|
||||
|
||||
fun clearMediaErrors() {
|
||||
viewModelScope.launch {
|
||||
_mediaErrors.resetReplayCache()
|
||||
}
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region Send
|
||||
|
||||
/**
|
||||
* Sends the media with current state.
|
||||
*
|
||||
* @return Result of the send operation.
|
||||
*/
|
||||
suspend fun send(): SendResult {
|
||||
val snapshot = state.value
|
||||
|
||||
// Check for untrusted identities
|
||||
val allRecipientIds = buildSet {
|
||||
snapshot.recipientId?.let { add(it.id) }
|
||||
addAll(snapshot.additionalRecipientIds.map { it.id })
|
||||
}
|
||||
|
||||
if (allRecipientIds.isNotEmpty()) {
|
||||
val untrusted = repository.checkUntrustedIdentities(allRecipientIds, identityChangesSince)
|
||||
if (untrusted.isNotEmpty()) {
|
||||
return SendResult.UntrustedIdentity(untrusted)
|
||||
}
|
||||
}
|
||||
|
||||
val request = SendRequest(
|
||||
selectedMedia = snapshot.selectedMedia,
|
||||
editorStateMap = snapshot.editorStateMap,
|
||||
quality = snapshot.sentMediaQuality,
|
||||
message = snapshot.message,
|
||||
isViewOnce = isViewOnceEnabled(),
|
||||
singleRecipientId = snapshot.recipientId,
|
||||
recipientIds = snapshot.additionalRecipientIds,
|
||||
scheduledTime = snapshot.scheduledTime,
|
||||
sendType = snapshot.sendType,
|
||||
isStory = snapshot.isStory
|
||||
)
|
||||
|
||||
val result = repository.send(request)
|
||||
|
||||
if (result is SendResult.Success) {
|
||||
updateState { copy(isSent = true) }
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region HUD Commands
|
||||
|
||||
fun sendCommand(command: HudCommand) {
|
||||
hudCommandChannel.trySend(command)
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region Query Methods
|
||||
|
||||
fun hasSelectedMedia(): Boolean = internalState.value.selectedMedia.isNotEmpty()
|
||||
|
||||
fun isSelectedMediaEmpty(): Boolean = internalState.value.selectedMedia.isEmpty()
|
||||
|
||||
fun kick() {
|
||||
internalState.update { it }
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region Lifecycle
|
||||
|
||||
override fun onCleared() {
|
||||
preUploadManager.cancelAllUploads()
|
||||
preUploadManager.deleteAbandonedAttachments()
|
||||
}
|
||||
|
||||
private fun shouldPreUpload(metered: Boolean): Boolean = !metered
|
||||
|
||||
//endregion
|
||||
|
||||
//region Factory
|
||||
|
||||
class Factory(
|
||||
private val context: Context,
|
||||
private val args: MediaSendActivityContract.Args,
|
||||
private val identityChangesSince: Long = System.currentTimeMillis(),
|
||||
private val isMeteredFlow: Flow<Boolean>,
|
||||
private val repository: MediaSendRepository,
|
||||
private val preUploadCallback: PreUploadManager.Callback
|
||||
) : ViewModelProvider.Factory {
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>, extras: CreationExtras): T {
|
||||
val savedStateHandle = extras.createSavedStateHandle()
|
||||
val manager = PreUploadManager(context.applicationContext, preUploadCallback)
|
||||
|
||||
return MediaSendViewModel(
|
||||
args = args,
|
||||
identityChangesSince = identityChangesSince,
|
||||
isMeteredFlow = isMeteredFlow,
|
||||
savedStateHandle = savedStateHandle,
|
||||
repository = repository,
|
||||
preUploadManager = manager
|
||||
) as T
|
||||
}
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
companion object {
|
||||
private const val KEY_STATE = "media_send_vm_state"
|
||||
private const val KEY_EDITED_VIDEO_URIS = "media_send_vm_edited_video_uris"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import androidx.annotation.RequiresPermission
|
||||
import androidx.core.content.ContextCompat
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
||||
object MeteredConnectivity {
|
||||
|
||||
/**
|
||||
* @return A cold [Flow] that emits `true` when the active network is metered.
|
||||
*/
|
||||
@RequiresPermission(Manifest.permission.ACCESS_NETWORK_STATE)
|
||||
fun isMetered(context: Context): Flow<Boolean> {
|
||||
val appContext = context.applicationContext
|
||||
|
||||
return callbackFlow {
|
||||
val cm: ConnectivityManager = requireNotNull(ContextCompat.getSystemService(appContext, ConnectivityManager::class.java))
|
||||
|
||||
fun currentMetered(): Boolean = cm.isActiveNetworkMetered
|
||||
|
||||
trySend(currentMetered())
|
||||
|
||||
val callback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
trySend(currentMetered())
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
trySend(currentMetered())
|
||||
}
|
||||
|
||||
override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
|
||||
val metered = !networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
|
||||
trySend(metered)
|
||||
}
|
||||
}
|
||||
|
||||
val request = NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.build()
|
||||
|
||||
cm.registerNetworkCallback(request, callback)
|
||||
|
||||
awaitClose {
|
||||
cm.unregisterNetworkCallback(callback)
|
||||
}
|
||||
}.distinctUntilChanged()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend
|
||||
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
|
||||
internal fun NavBackStack<NavKey>.goToEdit() {
|
||||
if (contains(MediaSendNavKey.Edit)) {
|
||||
popTo(MediaSendNavKey.Edit)
|
||||
} else {
|
||||
add(MediaSendNavKey.Edit)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun NavBackStack<NavKey>.pop() {
|
||||
if (isNotEmpty()) {
|
||||
removeAt(size - 1)
|
||||
}
|
||||
}
|
||||
|
||||
private fun NavBackStack<NavKey>.popTo(key: NavKey) {
|
||||
while (size > 1 && get(size - 1) != key) {
|
||||
removeAt(size - 1)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend.edit
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.snapping.SnapPosition
|
||||
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import androidx.navigation3.runtime.rememberNavBackStack
|
||||
import androidx.window.core.layout.WindowSizeClass
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.models.media.Media
|
||||
import org.signal.core.ui.compose.AllDevicePreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.util.ContentTypeUtil
|
||||
import org.signal.mediasend.EditorState
|
||||
import org.signal.mediasend.MediaSendNavKey
|
||||
import org.signal.mediasend.MediaSendState
|
||||
|
||||
@Composable
|
||||
fun MediaEditScreen(
|
||||
state: MediaSendState,
|
||||
callback: MediaEditScreenCallback,
|
||||
backStack: NavBackStack<NavKey>,
|
||||
videoEditorSlot: @Composable () -> Unit = {}
|
||||
) {
|
||||
val isFocusedMediaVideo = ContentTypeUtil.isVideoType(state.focusedMedia?.contentType)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val pagerState = rememberPagerState(
|
||||
initialPage = state.focusedMedia?.let { state.selectedMedia.indexOf(it) } ?: 0,
|
||||
pageCount = { state.selectedMedia.size }
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.navigationBarsPadding()
|
||||
) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
snapPosition = SnapPosition.Center
|
||||
) { index ->
|
||||
when (val editorState = state.editorStateMap[state.selectedMedia[index].uri]) {
|
||||
is EditorState.Image -> {
|
||||
ImageEditor(
|
||||
controller = remember { ImageEditorController(editorState.model) },
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
|
||||
is EditorState.VideoTrim -> {
|
||||
videoEditorSlot()
|
||||
}
|
||||
|
||||
null -> {
|
||||
if (!LocalInspectionMode.current) {
|
||||
error("Invalid editor state.")
|
||||
} else {
|
||||
Box(modifier = Modifier.fillMaxSize().background(color = Previews.rememberRandomColor()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
verticalArrangement = spacedBy(20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.align(Alignment.BottomCenter)
|
||||
) {
|
||||
if (state.selectedMedia.isNotEmpty()) {
|
||||
ThumbnailRow(
|
||||
selectedMedia = state.selectedMedia,
|
||||
pagerState = pagerState,
|
||||
onFocusedMediaChange = callback::setFocusedMedia,
|
||||
onThumbnailClick = { index ->
|
||||
scope.launch {
|
||||
pagerState.animateScrollToPage(index)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (isFocusedMediaVideo) {
|
||||
// Video editor hud
|
||||
} else if (!currentWindowAdaptiveInfo().windowSizeClass.isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND)) {
|
||||
// Image editor HU
|
||||
}
|
||||
|
||||
AddAMessageRow(
|
||||
message = state.message,
|
||||
callback = AddAMessageRowCallback.Empty,
|
||||
modifier = Modifier
|
||||
.widthIn(max = 624.dp)
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@AllDevicePreviews
|
||||
@Composable
|
||||
private fun MediaEditScreenPreview() {
|
||||
val selectedMedia = rememberPreviewMedia(10)
|
||||
|
||||
Previews.Preview {
|
||||
MediaEditScreen(
|
||||
state = MediaSendState(
|
||||
selectedMedia = selectedMedia,
|
||||
focusedMedia = selectedMedia.first()
|
||||
),
|
||||
callback = MediaEditScreenCallback.Empty,
|
||||
backStack = rememberNavBackStack(MediaSendNavKey.Edit),
|
||||
videoEditorSlot = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(color = Color.Red)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
interface MediaEditScreenCallback {
|
||||
fun setFocusedMedia(media: Media)
|
||||
|
||||
object Empty : MediaEditScreenCallback {
|
||||
override fun setFocusedMedia(media: Media) = Unit
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend.edit
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.gestures.draggable
|
||||
import androidx.compose.foundation.gestures.rememberDraggableState
|
||||
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.models.media.Media
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.util.ContentTypeUtil
|
||||
import org.signal.glide.compose.GlideImage
|
||||
import org.signal.mediasend.MediaSendMetrics
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
private val BASE_SPACING = 4.dp
|
||||
private val MIN_PADDING = 0.dp
|
||||
private val MAX_PADDING = 8.dp
|
||||
|
||||
/**
|
||||
* Horizontally scrollable thumbnail strip that syncs with [pagerState].
|
||||
* Features fish-eye padding effect where the centered item has more padding.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ThumbnailRow(
|
||||
selectedMedia: List<Media>,
|
||||
pagerState: PagerState,
|
||||
onFocusedMediaChange: (Media) -> Unit,
|
||||
onThumbnailClick: (Int) -> Unit = {}
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val itemWidthPx = with(density) { MediaSendMetrics.SelectedMediaPreviewSize.width.toPx() }
|
||||
val baseSpacingPx = with(density) { BASE_SPACING.toPx() }
|
||||
val itemStride = itemWidthPx + baseSpacingPx
|
||||
val pagerPageSize = pagerState.layoutInfo.pageSize.takeIf { it > 0 } ?: 1
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
val draggableState = rememberDraggableState { delta ->
|
||||
val scaledDelta = delta * (pagerPageSize.toFloat() / itemStride)
|
||||
pagerState.dispatchRawDelta(-scaledDelta)
|
||||
}
|
||||
|
||||
LaunchedEffect(pagerState) {
|
||||
snapshotFlow { pagerState.isScrollInProgress }
|
||||
.filter { !it }
|
||||
.drop(1)
|
||||
.collectLatest {
|
||||
val settledPage = pagerState.currentPage
|
||||
if (settledPage in selectedMedia.indices) {
|
||||
onFocusedMediaChange(selectedMedia[settledPage])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(pagerState, itemStride, selectedMedia.size) {
|
||||
if (selectedMedia.isEmpty()) return@LaunchedEffect
|
||||
|
||||
snapshotFlow { pagerState.currentPage + pagerState.currentPageOffsetFraction }
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { position ->
|
||||
val clampedPosition = position.coerceIn(0f, selectedMedia.lastIndex.toFloat())
|
||||
val baseIndex = floor(clampedPosition.toDouble()).toInt()
|
||||
val fraction = (clampedPosition - baseIndex).coerceIn(0f, 1f)
|
||||
val scrollOffsetPx = (fraction * itemStride).roundToInt()
|
||||
|
||||
listState.scrollToItem(baseIndex, scrollOffsetPx)
|
||||
}
|
||||
}
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier.fillMaxWidth().draggable(
|
||||
state = draggableState,
|
||||
orientation = Orientation.Horizontal,
|
||||
onDragStopped = { velocity ->
|
||||
scope.launch {
|
||||
val currentOffset = pagerState.currentPageOffsetFraction
|
||||
val targetPage = when {
|
||||
velocity > 500f -> (pagerState.currentPage - 1).coerceAtLeast(0)
|
||||
velocity < -500f -> (pagerState.currentPage + 1).coerceAtMost(selectedMedia.lastIndex)
|
||||
currentOffset > 0.5f -> (pagerState.currentPage + 1).coerceAtMost(selectedMedia.lastIndex)
|
||||
currentOffset < -0.5f -> (pagerState.currentPage - 1).coerceAtLeast(0)
|
||||
else -> pagerState.currentPage
|
||||
}
|
||||
pagerState.animateScrollToPage(targetPage)
|
||||
}
|
||||
}
|
||||
)
|
||||
) {
|
||||
val itemWidth = MediaSendMetrics.SelectedMediaPreviewSize.width
|
||||
|
||||
val baseEdgePadding = ((maxWidth - itemWidth) / 2).coerceAtLeast(0.dp)
|
||||
val startPadding = (baseEdgePadding - MAX_PADDING).coerceAtLeast(0.dp)
|
||||
val endPadding = baseEdgePadding + MAX_PADDING
|
||||
|
||||
LazyRow(
|
||||
horizontalArrangement = spacedBy(BASE_SPACING),
|
||||
contentPadding = PaddingValues(start = startPadding, end = endPadding),
|
||||
state = listState,
|
||||
userScrollEnabled = false
|
||||
) {
|
||||
itemsIndexed(selectedMedia, key = { _, media -> media.uri }) { index, media ->
|
||||
val padding by remember(index) {
|
||||
derivedStateOf {
|
||||
val currentPosition = pagerState.currentPage + pagerState.currentPageOffsetFraction
|
||||
val distanceFromCenter = abs(index - currentPosition).coerceIn(0f, 1f)
|
||||
lerp(MAX_PADDING, MIN_PADDING, distanceFromCenter)
|
||||
}
|
||||
}
|
||||
|
||||
Thumbnail(
|
||||
media = media,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = padding)
|
||||
.clickable { onThumbnailClick(index) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun lerp(start: Dp, stop: Dp, fraction: Float): Dp {
|
||||
return start + (stop - start) * fraction
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Thumbnail(media: Media, modifier: Modifier = Modifier) {
|
||||
if (!LocalInspectionMode.current) {
|
||||
GlideImage(
|
||||
model = media.uri,
|
||||
imageSize = MediaSendMetrics.SelectedMediaPreviewSize,
|
||||
modifier = modifier
|
||||
.size(MediaSendMetrics.SelectedMediaPreviewSize)
|
||||
.clip(shape = RoundedCornerShape(8.dp))
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(MediaSendMetrics.SelectedMediaPreviewSize)
|
||||
.background(color = Color.Gray, shape = RoundedCornerShape(8.dp))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun ThumbnailRowPreview() {
|
||||
val media = rememberPreviewMedia(10)
|
||||
val pagerState = rememberPagerState(pageCount = { media.size })
|
||||
|
||||
Previews.Preview {
|
||||
ThumbnailRow(
|
||||
selectedMedia = media,
|
||||
pagerState = pagerState,
|
||||
onFocusedMediaChange = { }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun ThumbnailPreview() {
|
||||
Previews.Preview {
|
||||
Thumbnail(
|
||||
media = rememberPreviewMedia(1).first()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun rememberPreviewMedia(count: Int): List<Media> {
|
||||
return remember(count) {
|
||||
(0 until count).map {
|
||||
Media(
|
||||
uri = "https://example.com/image$it.png".toUri(),
|
||||
contentType = ContentTypeUtil.IMAGE_PNG,
|
||||
width = 100,
|
||||
height = 100,
|
||||
duration = 0,
|
||||
date = 0,
|
||||
size = 0,
|
||||
isBorderless = false,
|
||||
isVideoGif = false,
|
||||
bucketId = null,
|
||||
caption = null,
|
||||
transformProperties = null,
|
||||
fileName = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend.preupload
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.WorkerThread
|
||||
import org.signal.core.models.media.Media
|
||||
import org.signal.core.util.ThreadUtil
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.signal.mediasend.MediaRecipientId
|
||||
import java.util.LinkedHashMap
|
||||
import java.util.concurrent.Executor
|
||||
|
||||
/**
|
||||
* Manages proactive upload of media during the selection process.
|
||||
*
|
||||
* Upload/cancel operations are serialized, because they're asynchronous operations that depend on
|
||||
* ordered completion.
|
||||
*
|
||||
* For example, if we begin upload of a [Media] but then immediately cancel it (before it was fully
|
||||
* enqueued), we need to wait until we have the job ids to cancel. This class manages everything by
|
||||
* using a single-thread executor.
|
||||
*
|
||||
* This class is stateful.
|
||||
*/
|
||||
class PreUploadManager(
|
||||
context: Context,
|
||||
private val callback: Callback
|
||||
) {
|
||||
|
||||
private val context: Context = context.applicationContext
|
||||
private val uploadResults: LinkedHashMap<Media, PreUploadResult> = LinkedHashMap()
|
||||
private val executor: Executor =
|
||||
SignalExecutors.newCachedSingleThreadExecutor("signal-PreUpload", ThreadUtil.PRIORITY_IMPORTANT_BACKGROUND_THREAD)
|
||||
|
||||
/**
|
||||
* Starts a pre-upload for [media].
|
||||
*
|
||||
* @param media The media item to pre-upload.
|
||||
* @param recipientId Optional recipient identifier. Used by the callback to apply recipient-specific behavior.
|
||||
*/
|
||||
fun startUpload(media: Media, recipientId: MediaRecipientId?) {
|
||||
executor.execute { uploadMediaInternal(media, recipientId) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts (or restarts) pre-uploads for [mediaItems].
|
||||
*
|
||||
* This cancels any existing pre-upload for each item before starting a new one.
|
||||
*
|
||||
* @param mediaItems The media items to pre-upload.
|
||||
* @param recipientId Optional recipient identifier. Used by the callback to apply recipient-specific behavior.
|
||||
*/
|
||||
fun startUpload(mediaItems: Collection<Media>, recipientId: MediaRecipientId?) {
|
||||
executor.execute {
|
||||
for (media in mediaItems) {
|
||||
Log.d(TAG, "Canceling existing preuploads.")
|
||||
cancelUploadInternal(media)
|
||||
Log.d(TAG, "Re-uploading media with recipient.")
|
||||
uploadMediaInternal(media, recipientId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a map of old->new, cancel medias that were changed and upload their replacements. Will
|
||||
* also upload any media in the map that wasn't yet uploaded.
|
||||
*
|
||||
* @param oldToNew A mapping of prior media objects to their updated equivalents.
|
||||
* @param recipientId Optional recipient identifier. Used by the callback to apply recipient-specific behavior.
|
||||
*/
|
||||
fun applyMediaUpdates(oldToNew: Map<Media, Media>, recipientId: MediaRecipientId?) {
|
||||
executor.execute {
|
||||
for ((oldMedia, newMedia) in oldToNew) {
|
||||
val same = oldMedia == newMedia && hasSameTransformProperties(oldMedia, newMedia)
|
||||
|
||||
if (!same || !uploadResults.containsKey(newMedia)) {
|
||||
Log.d(TAG, "Canceling existing preuploads.")
|
||||
cancelUploadInternal(oldMedia)
|
||||
Log.d(TAG, "Applying media updates.")
|
||||
uploadMediaInternal(newMedia, recipientId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasSameTransformProperties(oldMedia: Media, newMedia: Media): Boolean {
|
||||
val oldProperties = oldMedia.transformProperties
|
||||
val newProperties = newMedia.transformProperties
|
||||
|
||||
if (oldProperties == null || newProperties == null) {
|
||||
return oldProperties == newProperties
|
||||
}
|
||||
|
||||
// Matches legacy behavior: if the new media is "video edited", we treat it as different.
|
||||
// Otherwise, we treat it as the same if only the sent quality matches.
|
||||
return !newProperties.videoEdited && oldProperties.sentMediaQuality == newProperties.sentMediaQuality
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels the pre-upload (if present) for [media] and deletes any associated attachment state.
|
||||
*
|
||||
* @param media The media item to cancel.
|
||||
*/
|
||||
fun cancelUpload(media: Media) {
|
||||
Log.d(TAG, "User canceling media upload.")
|
||||
executor.execute { cancelUploadInternal(media) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels pre-uploads (if present) for all [mediaItems].
|
||||
*
|
||||
* @param mediaItems Media items to cancel.
|
||||
*/
|
||||
fun cancelUpload(mediaItems: Collection<Media>) {
|
||||
Log.d(TAG, "Canceling uploads.")
|
||||
executor.execute {
|
||||
for (media in mediaItems) {
|
||||
cancelUploadInternal(media)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels all current pre-uploads and clears internal state.
|
||||
*/
|
||||
fun cancelAllUploads() {
|
||||
Log.d(TAG, "Canceling all uploads.")
|
||||
executor.execute {
|
||||
val keysSnapshot = uploadResults.keys.toList()
|
||||
for (media in keysSnapshot) {
|
||||
cancelUploadInternal(media)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current pre-upload results snapshot.
|
||||
*
|
||||
* @param callback Invoked with the current set of results (in display/order insertion order).
|
||||
*/
|
||||
fun getPreUploadResults(callback: (Collection<PreUploadResult>) -> Unit) {
|
||||
executor.execute { callback(uploadResults.values) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates captions for any pre-uploaded items in [updatedMedia].
|
||||
*
|
||||
* @param updatedMedia Media items containing the latest caption values.
|
||||
*/
|
||||
fun updateCaptions(updatedMedia: List<Media>) {
|
||||
executor.execute { updateCaptionsInternal(updatedMedia) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates display order for pre-uploaded items, using [mediaInOrder] list order.
|
||||
*
|
||||
* @param mediaInOrder Media items in the desired display order.
|
||||
*/
|
||||
fun updateDisplayOrder(mediaInOrder: List<Media>) {
|
||||
executor.execute { updateDisplayOrderInternal(mediaInOrder) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes abandoned pre-upload attachments via the callback.
|
||||
*
|
||||
* @return Nothing. The callback controls deletion and returns a count for logging.
|
||||
*/
|
||||
fun deleteAbandonedAttachments() {
|
||||
executor.execute {
|
||||
val deleted = this.callback.deleteAbandonedPreuploadedAttachments(context)
|
||||
Log.i(TAG, "Deleted $deleted abandoned attachments.")
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun uploadMediaInternal(media: Media, recipientId: MediaRecipientId?) {
|
||||
val result = callback.preUpload(context, media, recipientId)
|
||||
|
||||
if (result != null) {
|
||||
uploadResults[media] = result
|
||||
} else {
|
||||
Log.w(TAG, "Failed to upload media with URI: ${media.uri}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelUploadInternal(media: Media) {
|
||||
val result = uploadResults[media] ?: return
|
||||
|
||||
Log.d(TAG, "Canceling attachment upload jobs for ${result.attachmentId}")
|
||||
callback.cancelJobs(context, result.jobIds)
|
||||
uploadResults.remove(media)
|
||||
callback.deleteAttachment(context, result.attachmentId)
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun updateCaptionsInternal(updatedMedia: List<Media>) {
|
||||
for (updated in updatedMedia) {
|
||||
val result = uploadResults[updated]
|
||||
|
||||
if (result != null) {
|
||||
callback.updateAttachmentCaption(context, result.attachmentId, updated.caption)
|
||||
} else {
|
||||
Log.w(TAG, "When updating captions, no pre-upload result could be found for media with URI: ${updated.uri}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun updateDisplayOrderInternal(mediaInOrder: List<Media>) {
|
||||
val orderMap: MutableMap<Long, Int> = LinkedHashMap()
|
||||
val orderedUploadResults: LinkedHashMap<Media, PreUploadResult> = LinkedHashMap()
|
||||
|
||||
for ((index, media) in mediaInOrder.withIndex()) {
|
||||
val result = uploadResults[media]
|
||||
|
||||
if (result != null) {
|
||||
orderMap[result.attachmentId] = index
|
||||
orderedUploadResults[media] = result
|
||||
} else {
|
||||
Log.w(TAG, "When updating display order, no pre-upload result could be found for media with URI: ${media.uri}")
|
||||
}
|
||||
}
|
||||
|
||||
callback.updateDisplayOrder(context, orderMap)
|
||||
|
||||
if (orderedUploadResults.size == uploadResults.size) {
|
||||
uploadResults.clear()
|
||||
uploadResults.putAll(orderedUploadResults)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Callbacks that perform the real side-effects (DB ops, job scheduling/cancelation, etc).
|
||||
*
|
||||
* This keeps `feature/media-send` free of direct dependencies on app-specific systems.
|
||||
*
|
||||
* Threading: all callback methods are invoked on this manager's serialized background executor
|
||||
* thread (i.e., not the main thread).
|
||||
*/
|
||||
interface Callback {
|
||||
/**
|
||||
* Performs pre-upload side-effects (e.g., create attachment state + enqueue jobs).
|
||||
*
|
||||
* @param context Application context.
|
||||
* @param media The media item being pre-uploaded.
|
||||
* @param recipientId Optional recipient identifier, if known.
|
||||
* @return A [PreUploadResult] if enqueued, or `null` if it failed or should not pre-upload.
|
||||
*/
|
||||
@WorkerThread
|
||||
fun preUpload(context: Context, media: Media, recipientId: MediaRecipientId?): PreUploadResult?
|
||||
|
||||
/**
|
||||
* Cancels any scheduled/running work for the provided job ids.
|
||||
*
|
||||
* @param context Application context.
|
||||
* @param jobIds Job identifiers to cancel.
|
||||
*/
|
||||
@WorkerThread
|
||||
fun cancelJobs(context: Context, jobIds: List<String>)
|
||||
|
||||
/**
|
||||
* Deletes any persisted attachment state for [attachmentId].
|
||||
*
|
||||
* @param context Application context.
|
||||
* @param attachmentId Attachment identifier to delete.
|
||||
*/
|
||||
@WorkerThread
|
||||
fun deleteAttachment(context: Context, attachmentId: Long)
|
||||
|
||||
/**
|
||||
* Updates the caption for [attachmentId].
|
||||
*
|
||||
* @param context Application context.
|
||||
* @param attachmentId Attachment identifier.
|
||||
* @param caption New caption (or `null` to clear).
|
||||
*/
|
||||
@WorkerThread
|
||||
fun updateAttachmentCaption(context: Context, attachmentId: Long, caption: String?)
|
||||
|
||||
/**
|
||||
* Updates display order for attachments.
|
||||
*
|
||||
* @param context Application context.
|
||||
* @param orderMap Map of attachment id -> display order index.
|
||||
*/
|
||||
@WorkerThread
|
||||
fun updateDisplayOrder(context: Context, orderMap: Map<Long, Int>)
|
||||
|
||||
/**
|
||||
* Deletes any pre-uploaded attachments that are no longer referenced.
|
||||
*
|
||||
* @param context Application context.
|
||||
* @return The number of attachments deleted.
|
||||
*/
|
||||
@WorkerThread
|
||||
fun deleteAbandonedPreuploadedAttachments(context: Context): Int
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private val TAG = Log.tag(PreUploadManager::class.java)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend.preupload
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.signal.core.models.media.Media
|
||||
|
||||
/**
|
||||
* Handle returned from a successful pre-upload.
|
||||
*
|
||||
* This mirrors the legacy concept of:
|
||||
* - an attachment row identifier
|
||||
* - the upload dependency job ids
|
||||
* - the original media
|
||||
*/
|
||||
@Parcelize
|
||||
data class PreUploadResult(
|
||||
val media: Media,
|
||||
val attachmentId: Long,
|
||||
val jobIds: List<String>
|
||||
) : Parcelable {
|
||||
val uri: Uri
|
||||
get() = media.uri
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
/*
|
||||
* Copyright 2026 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.signal.mediasend.select
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.DpSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import androidx.navigation3.runtime.NavBackStack
|
||||
import androidx.navigation3.runtime.NavKey
|
||||
import androidx.navigation3.runtime.rememberNavBackStack
|
||||
import androidx.window.core.layout.WindowSizeClass
|
||||
import org.signal.core.models.media.Media
|
||||
import org.signal.core.models.media.MediaFolder
|
||||
import org.signal.core.ui.compose.AllDevicePreviews
|
||||
import org.signal.core.ui.compose.Buttons
|
||||
import org.signal.core.ui.compose.DayNightPreviews
|
||||
import org.signal.core.ui.compose.Previews
|
||||
import org.signal.core.ui.compose.Scaffolds
|
||||
import org.signal.core.ui.compose.ensureWidthIsAtLeastHeight
|
||||
import org.signal.glide.compose.GlideImage
|
||||
import org.signal.mediasend.MediaSendMetrics
|
||||
import org.signal.mediasend.MediaSendNavKey
|
||||
import org.signal.mediasend.MediaSendState
|
||||
import org.signal.mediasend.edit.rememberPreviewMedia
|
||||
import org.signal.mediasend.goToEdit
|
||||
import org.signal.mediasend.pop
|
||||
|
||||
/**
|
||||
* Allows user to select one or more pieces of content to add to the
|
||||
* current media selection.
|
||||
*/
|
||||
@Composable
|
||||
internal fun MediaSelectScreen(
|
||||
state: MediaSendState,
|
||||
backStack: NavBackStack<NavKey>,
|
||||
callback: MediaSelectScreenCallback
|
||||
) {
|
||||
val gridConfiguration = rememberGridConfiguration(state.selectedMediaFolder == null)
|
||||
|
||||
Scaffolds.Settings(
|
||||
title = state.selectedMediaFolder?.title ?: "Gallery",
|
||||
navigationIcon = ImageVector.vectorResource(org.signal.core.ui.R.drawable.symbol_arrow_start_24),
|
||||
onNavigationClick = {
|
||||
if (state.selectedMediaFolder != null) {
|
||||
callback.onFolderClick(null)
|
||||
} else {
|
||||
backStack.pop()
|
||||
}
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier.padding(paddingValues)
|
||||
) {
|
||||
LazyVerticalGrid(
|
||||
columns = gridConfiguration.gridCells,
|
||||
horizontalArrangement = spacedBy(gridConfiguration.horizontalSpacing),
|
||||
verticalArrangement = spacedBy(gridConfiguration.verticalSpacing),
|
||||
modifier = Modifier
|
||||
.padding(horizontal = gridConfiguration.horizontalMargin)
|
||||
.weight(1f)
|
||||
) {
|
||||
if (state.selectedMediaFolder == null) {
|
||||
items(state.mediaFolders, key = { it.bucketId }) {
|
||||
MediaFolderTile(it, callback)
|
||||
}
|
||||
} else {
|
||||
items(state.selectedMediaFolderItems, key = { it.uri }) { media ->
|
||||
MediaTile(media = media, state.selectedMedia.indexOfFirst { it.uri == media.uri }, callback = callback)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = state.selectedMedia.isNotEmpty(),
|
||||
enter = expandVertically(
|
||||
expandFrom = Alignment.Top,
|
||||
animationSpec = spring()
|
||||
) + fadeIn(animationSpec = spring()),
|
||||
exit = shrinkVertically(
|
||||
shrinkTowards = Alignment.Top,
|
||||
animationSpec = spring()
|
||||
) + fadeOut(animationSpec = spring()),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.background(color = MaterialTheme.colorScheme.surface)
|
||||
.padding(vertical = gridConfiguration.bottomBarVerticalPadding, horizontal = gridConfiguration.bottomBarHorizontalPadding)
|
||||
) {
|
||||
LazyRow(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 16.dp),
|
||||
horizontalArrangement = spacedBy(space = 12.dp, alignment = gridConfiguration.bottomBarAlignment)
|
||||
) {
|
||||
items(state.selectedMedia, key = { it.uri }) { media ->
|
||||
MediaThumbnail(media, modifier = Modifier.animateItem()) {
|
||||
callback.setFocusedMedia(media)
|
||||
backStack.goToEdit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NextButton(state.selectedMedia.size) {
|
||||
backStack.goToEdit()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberGridConfiguration(isRootGrid: Boolean): GridConfiguration {
|
||||
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
|
||||
|
||||
return remember(windowSizeClass, isRootGrid) {
|
||||
GridConfiguration(
|
||||
gridCells = if (isRootGrid) {
|
||||
windowSizeClass.forWidthBreakpoint(
|
||||
expanded = GridCells.Fixed(6),
|
||||
medium = GridCells.Fixed(4),
|
||||
compact = GridCells.Fixed(2)
|
||||
)
|
||||
} else {
|
||||
windowSizeClass.forWidthBreakpoint(
|
||||
expanded = GridCells.Fixed(8),
|
||||
medium = GridCells.Fixed(6),
|
||||
compact = GridCells.Fixed(4)
|
||||
)
|
||||
},
|
||||
horizontalMargin = if (isRootGrid) {
|
||||
windowSizeClass.forWidthBreakpoint(
|
||||
expanded = 38.dp,
|
||||
medium = 35.dp,
|
||||
compact = 24.dp
|
||||
)
|
||||
} else {
|
||||
0.dp
|
||||
},
|
||||
horizontalSpacing = if (isRootGrid) {
|
||||
windowSizeClass.forWidthBreakpoint(
|
||||
expanded = 32.dp,
|
||||
medium = 28.dp,
|
||||
compact = 16.dp
|
||||
)
|
||||
} else {
|
||||
4.dp
|
||||
},
|
||||
verticalSpacing = if (isRootGrid) {
|
||||
windowSizeClass.forWidthBreakpoint(
|
||||
expanded = 32.dp,
|
||||
medium = 32.dp,
|
||||
compact = 24.dp
|
||||
)
|
||||
} else {
|
||||
4.dp
|
||||
},
|
||||
bottomBarVerticalPadding = windowSizeClass.forWidthBreakpoint(
|
||||
expanded = 16.dp,
|
||||
medium = 16.dp,
|
||||
compact = 8.dp
|
||||
),
|
||||
bottomBarHorizontalPadding = windowSizeClass.forWidthBreakpoint(
|
||||
expanded = 24.dp,
|
||||
medium = 24.dp,
|
||||
compact = 16.dp
|
||||
),
|
||||
bottomBarAlignment = windowSizeClass.forWidthBreakpoint(
|
||||
expanded = Alignment.End,
|
||||
medium = Alignment.End,
|
||||
compact = Alignment.Start
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> WindowSizeClass.forWidthBreakpoint(
|
||||
expanded: T,
|
||||
medium: T,
|
||||
compact: T
|
||||
): T {
|
||||
return when {
|
||||
isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_EXPANDED_LOWER_BOUND) -> expanded
|
||||
isWidthAtLeastBreakpoint(WindowSizeClass.WIDTH_DP_MEDIUM_LOWER_BOUND) -> medium
|
||||
else -> compact
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MediaFolderTile(
|
||||
mediaFolder: MediaFolder,
|
||||
callback: MediaSelectScreenCallback
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(
|
||||
onClick = { callback.onFolderClick(mediaFolder) },
|
||||
onClickLabel = mediaFolder.title,
|
||||
role = Role.Button
|
||||
),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
if (LocalInspectionMode.current) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.background(color = Previews.rememberRandomColor(), shape = RoundedCornerShape(26.dp))
|
||||
)
|
||||
} else {
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.clip(RoundedCornerShape(26.dp))
|
||||
) {
|
||||
val width = maxWidth
|
||||
val height = maxHeight
|
||||
|
||||
GlideImage(
|
||||
model = mediaFolder.thumbnailUri,
|
||||
imageSize = DpSize(width, height),
|
||||
modifier = Modifier
|
||||
.aspectRatio(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text(text = mediaFolder.title, modifier = Modifier.padding(top = 12.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MediaTile(
|
||||
media: Media,
|
||||
selectionIndex: Int,
|
||||
callback: MediaSelectScreenCallback
|
||||
) {
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (selectionIndex >= 0) {
|
||||
0.8f
|
||||
} else {
|
||||
1f
|
||||
}
|
||||
)
|
||||
|
||||
val cornerClip by animateDpAsState(
|
||||
if (selectionIndex >= 0) 12.dp else 0.dp
|
||||
)
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.background(color = MaterialTheme.colorScheme.surfaceVariant)
|
||||
.clickable(
|
||||
onClick = { callback.onMediaClick(media) },
|
||||
onClickLabel = media.fileName,
|
||||
role = Role.Button
|
||||
)
|
||||
) {
|
||||
if (LocalInspectionMode.current) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.scale(scale)
|
||||
.background(color = Previews.rememberRandomColor(), shape = RoundedCornerShape(cornerClip)).fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
)
|
||||
} else {
|
||||
val width = maxWidth
|
||||
val height = maxHeight
|
||||
|
||||
GlideImage(
|
||||
model = media.uri,
|
||||
imageSize = DpSize(width, height),
|
||||
modifier = Modifier
|
||||
.aspectRatio(1f)
|
||||
.scale(scale)
|
||||
.clip(RoundedCornerShape(cornerClip))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (selectionIndex >= 0) {
|
||||
Box(
|
||||
modifier = Modifier.padding(3.dp)
|
||||
) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier
|
||||
.border(width = 3.dp, color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(percent = 50))
|
||||
.padding(1.dp)
|
||||
.background(color = MaterialTheme.colorScheme.primary, shape = RoundedCornerShape(percent = 50))
|
||||
.ensureWidthIsAtLeastHeight()
|
||||
.padding(horizontal = 5.5.dp, vertical = 2.dp)
|
||||
) {
|
||||
Text(text = "${selectionIndex + 1}", color = MaterialTheme.colorScheme.onPrimary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NextButton(mediaSelectionCount: Int, onClick: () -> Unit) {
|
||||
Buttons.MediumTonal(
|
||||
onClick = onClick,
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(color = MaterialTheme.colorScheme.primary, shape = RoundedCornerShape(percent = 50))
|
||||
.ensureWidthIsAtLeastHeight()
|
||||
) {
|
||||
Text(
|
||||
text = "$mediaSelectionCount",
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier
|
||||
)
|
||||
}
|
||||
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(org.signal.core.ui.R.drawable.symbol_chevron_right_24),
|
||||
contentDescription = "Next"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MediaThumbnail(
|
||||
media: Media,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
if (LocalInspectionMode.current) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(MediaSendMetrics.SelectedMediaPreviewSize)
|
||||
.background(color = Previews.rememberRandomColor(), shape = RoundedCornerShape(8.dp))
|
||||
)
|
||||
} else {
|
||||
GlideImage(
|
||||
model = media.uri,
|
||||
imageSize = MediaSendMetrics.SelectedMediaPreviewSize,
|
||||
modifier = modifier
|
||||
.size(MediaSendMetrics.SelectedMediaPreviewSize)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@AllDevicePreviews
|
||||
@Composable
|
||||
private fun MediaSelectScreenFolderPreview() {
|
||||
Previews.Preview {
|
||||
MediaSelectScreen(
|
||||
state = MediaSendState(
|
||||
mediaFolders = rememberPreviewMediaFolders(20)
|
||||
),
|
||||
backStack = rememberNavBackStack(MediaSendNavKey.Edit),
|
||||
callback = MediaSelectScreenCallback.Empty
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@AllDevicePreviews
|
||||
@Composable
|
||||
private fun MediaSelectScreenMediaPreview() {
|
||||
val folders = rememberPreviewMediaFolders(20)
|
||||
val media = rememberPreviewMedia(100)
|
||||
val selectedMedia: MutableList<Media> = remember { mutableStateListOf() }
|
||||
val callback = remember {
|
||||
object : MediaSelectScreenCallback by MediaSelectScreenCallback.Empty {
|
||||
override fun onMediaClick(media: Media) {
|
||||
if (media in selectedMedia) {
|
||||
selectedMedia.remove(media)
|
||||
} else {
|
||||
selectedMedia.add(media)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Previews.Preview {
|
||||
MediaSelectScreen(
|
||||
state = MediaSendState(
|
||||
mediaFolders = folders,
|
||||
selectedMediaFolder = folders.first(),
|
||||
selectedMediaFolderItems = media,
|
||||
selectedMedia = selectedMedia
|
||||
),
|
||||
backStack = rememberNavBackStack(MediaSendNavKey.Edit),
|
||||
callback = callback
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun MediaFolderTilePreview() {
|
||||
Previews.Preview {
|
||||
Box(modifier = Modifier.width(174.dp)) {
|
||||
MediaFolderTile(
|
||||
mediaFolder = rememberPreviewMediaFolders(1).first(),
|
||||
callback = MediaSelectScreenCallback.Empty
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun MediaTilePreview() {
|
||||
Previews.Preview {
|
||||
MediaTile(
|
||||
media = rememberPreviewMedia(1).first(),
|
||||
selectionIndex = -1,
|
||||
callback = MediaSelectScreenCallback.Empty
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun MediaTileSelectedPreview() {
|
||||
var isSelected by remember { mutableStateOf(true) }
|
||||
|
||||
Previews.Preview {
|
||||
MediaTile(
|
||||
media = rememberPreviewMedia(1).first(),
|
||||
selectionIndex = if (isSelected) 0 else -1,
|
||||
callback = object : MediaSelectScreenCallback by MediaSelectScreenCallback.Empty {
|
||||
override fun onMediaClick(media: Media) {
|
||||
isSelected = !isSelected
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
private fun NextButtonPreview() {
|
||||
Previews.Preview {
|
||||
NextButton(
|
||||
mediaSelectionCount = 3,
|
||||
onClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun rememberPreviewMediaFolders(count: Int): List<MediaFolder> {
|
||||
return remember(count) {
|
||||
(0 until count).map { index ->
|
||||
MediaFolder(
|
||||
thumbnailUri = "https://example.com/folder$index.jpg".toUri(),
|
||||
title = "Folder $index",
|
||||
itemCount = (index + 1) * 10,
|
||||
bucketId = "bucket_$index",
|
||||
folderType = if (index == 0) MediaFolder.FolderType.CAMERA else MediaFolder.FolderType.NORMAL
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class GridConfiguration(
|
||||
val gridCells: GridCells,
|
||||
val horizontalMargin: Dp,
|
||||
val horizontalSpacing: Dp,
|
||||
val verticalSpacing: Dp,
|
||||
val bottomBarVerticalPadding: Dp,
|
||||
val bottomBarHorizontalPadding: Dp,
|
||||
val bottomBarAlignment: Alignment.Horizontal
|
||||
)
|
||||
|
||||
interface MediaSelectScreenCallback {
|
||||
fun onFolderClick(mediaFolder: MediaFolder?)
|
||||
fun onMediaClick(media: Media)
|
||||
fun setFocusedMedia(media: Media)
|
||||
|
||||
object Empty : MediaSelectScreenCallback {
|
||||
override fun onFolderClick(mediaFolder: MediaFolder?) {}
|
||||
override fun onMediaClick(media: Media) {}
|
||||
override fun setFocusedMedia(media: Media) {}
|
||||
}
|
||||
}
|
||||
@@ -2899,6 +2899,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
|
||||
<sha256 value="bd98cf873283bb3e1bda363b69406ffe963d7034ba2d248775cb3ea553f76871" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.compose.ui" name="ui-test-junit4-jvmstubs" version="1.9.5">
|
||||
<artifact name="ui-test-junit4-jvmstubs-1.9.5.jar">
|
||||
<sha256 value="09826ed0804f4df455776c97948a049218fb4831d5497911e8ea7ead217fd6f5" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
<artifact name="ui-test-junit4-jvmstubs-1.9.5.module">
|
||||
<sha256 value="2713f4daca855eb02c0e2d63741d92d4bbda369ce589a00c08cda186dfcf3b1b" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.compose.ui" name="ui-test-jvmstubs" version="1.9.5">
|
||||
<artifact name="ui-test-jvmstubs-1.9.5.jar">
|
||||
<sha256 value="d0134362ed92417037dac95a7b3dfafb9fb7dc65e456161c95b9890a4a2fefe3" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
<artifact name="ui-test-jvmstubs-1.9.5.module">
|
||||
<sha256 value="348628f9529a85ad237e645039a49437cc812d922c93b7c21030614fe6ac4e4c" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.compose.ui" name="ui-test-manifest" version="1.9.0">
|
||||
<artifact name="ui-test-manifest-1.9.0.aar">
|
||||
<sha256 value="3faf6ca99e9541fb65e858da316ac7cfb7798c49ce674db80417f00a1b29cd69" origin="Generated by Gradle"/>
|
||||
@@ -6205,6 +6221,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
|
||||
<sha256 value="a4e0e66288a40659d927938c605fee060acdbad610b041fbf8984866318cb5ed" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.lifecycle" name="lifecycle-viewmodel-navigation3-jvmstubs" version="2.10.0">
|
||||
<artifact name="lifecycle-viewmodel-navigation3-jvmstubs-2.10.0.jar">
|
||||
<sha256 value="f7bdef6f50d15d2c9556c4868f455917a1fe158f980ace764da46b11be7f8240" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
<artifact name="lifecycle-viewmodel-navigation3-jvmstubs-2.10.0.module">
|
||||
<sha256 value="e7fd4cfedf378a222e2a828f300f5935cf0b9962c964e7c889c78400bfb30a8a" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="androidx.lifecycle" name="lifecycle-viewmodel-savedstate" version="2.10.0">
|
||||
<artifact name="lifecycle-viewmodel-savedstate-2.10.0.module">
|
||||
<md5 value="10e7d7afd3a3ef6bb775f0e19f98787a" origin="Generated by Gradle"/>
|
||||
@@ -10665,6 +10689,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
|
||||
<sha256 value="e5a7c649c54c412049908247ca5e25fe6921d746849c6017a84dc6044237a4b4" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="com.google.truth" name="truth" version="1.0.1">
|
||||
<artifact name="truth-1.0.1.jar">
|
||||
<sha256 value="1ccf4334e7a94cf00a20a619b5462b53acf3274e00b70498bf5b28a3bc1be9b1" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="com.google.zxing" name="android-integration" version="3.3.0">
|
||||
<artifact name="android-integration-3.3.0.jar">
|
||||
<md5 value="3607c0180c63926919a2caa1575fc919" origin="Generated by Gradle"/>
|
||||
@@ -10686,6 +10715,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
|
||||
<sha256 value="7e24ad50b222d2f70ac91bdccfa3c0f6200b078d797cb784837f75e77bb4210f" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="com.googlecode.java-diff-utils" name="diffutils" version="1.3.0">
|
||||
<artifact name="diffutils-1.3.0.jar">
|
||||
<sha256 value="61ba4dc49adca95243beaa0569adc2a23aedb5292ae78aa01186fa782ebdc5c2" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="com.googlecode.juniversalchardet" name="juniversalchardet" version="1.0.3">
|
||||
<artifact name="juniversalchardet-1.0.3.jar">
|
||||
<md5 value="d9ea0a9a275336c175b343f2e4cd8f27" origin="Generated by Gradle"/>
|
||||
@@ -13114,6 +13148,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
|
||||
<sha256 value="22eca687f7955411f456af33e6ea8e68fc73cd80cb8b32aa5f7a8b1827d7c678" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.checkerframework" name="checker-compat-qual" version="2.5.5">
|
||||
<artifact name="checker-compat-qual-2.5.5.jar">
|
||||
<sha256 value="11d134b245e9cacc474514d2d66b5b8618f8039a1465cdc55bbc0b34e0008b7a" origin="Generated by Gradle"/>
|
||||
</artifact>
|
||||
</component>
|
||||
<component group="org.checkerframework" name="checker-qual" version="3.12.0">
|
||||
<artifact name="checker-qual-3.12.0.jar">
|
||||
<md5 value="ab1ae0e2f2f63601597a5a96fca8a54f" origin="Generated by Gradle"/>
|
||||
|
||||
@@ -68,6 +68,9 @@ fun <T> GlideImage(
|
||||
}
|
||||
.into(imageView)
|
||||
},
|
||||
onReset = {
|
||||
Glide.with(it.context).clear(it)
|
||||
},
|
||||
modifier = modifier
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -91,6 +91,7 @@ include(":lib:blurhash")
|
||||
// Feature modules
|
||||
include(":feature:registration")
|
||||
include(":feature:camera")
|
||||
include(":feature:media-send")
|
||||
|
||||
// Demo apps
|
||||
include(":demo:paging")
|
||||
|
||||
Reference in New Issue
Block a user