From 2c35a6a3fe0c2ddc3d7ee55a42835190418708e4 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Thu, 25 Jun 2026 17:32:20 -0300 Subject: [PATCH] Add ability to take a photo in media-send feature module. --- .../thoughtcrime/securesms/MainActivity.kt | 2 +- .../MediaSendDependenciesProvider.kt | 7 + .../v2/capture/MediaCaptureRepository.kt | 84 +----- .../mediasend/v3/MediaSendV3Activity.kt | 3 + .../mediasend/v3/MediaSendV3QrRepository.kt | 54 ++++ .../mediasend/v3/MediaSendV3Repository.kt | 8 + .../java/org/signal/mediasend/DialogEvent.kt | 11 + .../signal/mediasend/MediaSendDependencies.kt | 9 + .../org/signal/mediasend/MediaSendEvent.kt | 5 + .../signal/mediasend/MediaSendNavDisplay.kt | 141 +++++---- .../signal/mediasend/MediaSendQrRepository.kt | 17 ++ .../signal/mediasend/MediaSendRepository.kt | 5 + .../org/signal/mediasend/MediaSendScreen.kt | 3 +- .../signal/mediasend/MediaSendViewModel.kt | 156 +++++++++- .../mediasend/NavBackStackExtensions.kt | 30 +- .../org/signal/mediasend/SnackbarEvent.kt | 14 + .../mediasend/capture/CameraXFragment.kt | 281 +++++++++--------- .../mediasend/capture/CameraXScreenEvent.kt | 17 ++ .../capture/MediaCameraCaptureScreen.kt | 40 +++ .../{ => capture}/MediaCaptureScreen.kt | 14 +- .../{ => capture}/MediaCaptureScreenEvent.kt | 3 +- .../signal/mediasend/edit/MediaEditScreen.kt | 22 +- .../mediasend/edit/MediaEditScreenEvent.kt | 1 + .../src/main/res/values/strings.xml | 5 + 24 files changed, 629 insertions(+), 303 deletions(-) create mode 100644 app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3QrRepository.kt create mode 100644 feature/media-send/src/main/java/org/signal/mediasend/DialogEvent.kt create mode 100644 feature/media-send/src/main/java/org/signal/mediasend/MediaSendQrRepository.kt create mode 100644 feature/media-send/src/main/java/org/signal/mediasend/SnackbarEvent.kt create mode 100644 feature/media-send/src/main/java/org/signal/mediasend/capture/CameraXScreenEvent.kt create mode 100644 feature/media-send/src/main/java/org/signal/mediasend/capture/MediaCameraCaptureScreen.kt rename feature/media-send/src/main/java/org/signal/mediasend/{ => capture}/MediaCaptureScreen.kt (83%) rename feature/media-send/src/main/java/org/signal/mediasend/{ => capture}/MediaCaptureScreenEvent.kt (68%) diff --git a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt index b37764c5ad..00c610eba2 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt @@ -1171,7 +1171,7 @@ class MainActivity : } else if (SignalStore.internal.useNewMediaActivity) { mediaSendLauncher.launch( MediaSendActivityContract.Args( - isCameraFirst = false, + isCameraFirst = true, isStory = destination == MainNavigationListLocation.STORIES ) ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/dependencies/MediaSendDependenciesProvider.kt b/app/src/main/java/org/thoughtcrime/securesms/dependencies/MediaSendDependenciesProvider.kt index abdb5d62fe..37a0afe884 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/dependencies/MediaSendDependenciesProvider.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/dependencies/MediaSendDependenciesProvider.kt @@ -6,11 +6,14 @@ package org.thoughtcrime.securesms.dependencies import androidx.media3.exoplayer.ExoPlayer +import org.signal.core.util.contentproviders.BlobProvider import org.signal.mediasend.MediaSendDependencies +import org.signal.mediasend.MediaSendQrRepository import org.signal.mediasend.MediaSendRepository import org.signal.mediasend.preupload.PreUploadRepository import org.signal.video.exo.ExoPlayerPool import org.thoughtcrime.securesms.mediasend.v3.MediaSendV3PreUploadRepository +import org.thoughtcrime.securesms.mediasend.v3.MediaSendV3QrRepository import org.thoughtcrime.securesms.mediasend.v3.MediaSendV3Repository object MediaSendDependenciesProvider : MediaSendDependencies.Provider { @@ -18,5 +21,9 @@ object MediaSendDependenciesProvider : MediaSendDependencies.Provider { override fun providePreUploadRepository(): PreUploadRepository = MediaSendV3PreUploadRepository + override fun provideQrRepository(): MediaSendQrRepository = MediaSendV3QrRepository + override fun provideExoPlayerPool(): ExoPlayerPool = AppDependencies.exoPlayerPool + + override fun provideBlobs(): BlobProvider = AppDependencies.blobs } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureRepository.kt index 9b9ab7ecdd..72e0d48a0e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/capture/MediaCaptureRepository.kt @@ -1,24 +1,16 @@ package org.thoughtcrime.securesms.mediasend.v2.capture -import android.annotation.SuppressLint -import android.content.ContentUris import android.content.Context import android.net.Uri -import android.os.Build -import android.provider.MediaStore -import androidx.annotation.WorkerThread import org.signal.core.models.media.Media -import org.signal.core.util.CursorUtil +import org.signal.core.util.ContentTypeUtil import org.signal.core.util.concurrent.SignalExecutors import org.signal.core.util.contentproviders.BlobProvider import org.thoughtcrime.securesms.dependencies.AppDependencies -import org.thoughtcrime.securesms.mediasend.MediaRepository -import org.thoughtcrime.securesms.util.MediaUtil import org.thoughtcrime.securesms.video.videoconverter.utils.VideoConstants import java.io.FileDescriptor import java.io.FileInputStream import java.io.IOException -import java.util.LinkedList class MediaCaptureRepository(context: Context) { @@ -30,7 +22,7 @@ class MediaCaptureRepository(context: Context) { dataSupplier = { data }, getLength = { data.size.toLong() }, createBlobBuilder = { blobProvider, bytes, _ -> blobProvider.forData(bytes) }, - mimeType = MediaUtil.IMAGE_JPEG, + mimeType = ContentTypeUtil.IMAGE_JPEG, width = width, height = height ) @@ -96,76 +88,4 @@ class MediaCaptureRepository(context: Context) { return null } } - - companion object { - - @SuppressLint("VisibleForTests") - @WorkerThread - private fun getMediaInBucket(context: Context, bucketId: String, contentUri: Uri, isImage: Boolean): List { - val media: MutableList = LinkedList() - var selection: String? = MediaStore.Images.Media.BUCKET_ID + " = ? AND " + isNotPending() - var selectionArgs: Array? = arrayOf(bucketId) - val sortBy = MediaStore.Images.Media.DATE_MODIFIED + " DESC" - - val projection: Array = if (isImage) { - arrayOf(MediaStore.Images.Media._ID, MediaStore.Images.Media.MIME_TYPE, MediaStore.Images.Media.DATE_MODIFIED, MediaStore.Images.Media.ORIENTATION, MediaStore.Images.Media.WIDTH, MediaStore.Images.Media.HEIGHT, MediaStore.Images.Media.SIZE) - } else { - arrayOf(MediaStore.Images.Media._ID, MediaStore.Images.Media.MIME_TYPE, MediaStore.Images.Media.DATE_MODIFIED, MediaStore.Images.Media.WIDTH, MediaStore.Images.Media.HEIGHT, MediaStore.Images.Media.SIZE, MediaStore.Video.Media.DURATION) - } - - if (Media.ALL_MEDIA_BUCKET_ID == bucketId) { - selection = isNotPending() - selectionArgs = null - } - - context.contentResolver.query(contentUri, projection, selection, selectionArgs, sortBy).use { cursor -> - while (cursor != null && cursor.moveToNext()) { - val rowId = CursorUtil.requireLong(cursor, projection[0]) - val uri = ContentUris.withAppendedId(contentUri, rowId) - val mimetype = CursorUtil.requireString(cursor, MediaStore.Images.Media.MIME_TYPE) - val date = CursorUtil.requireLong(cursor, MediaStore.Images.Media.DATE_MODIFIED) - val orientation = if (isImage) CursorUtil.requireInt(cursor, MediaStore.Images.Media.ORIENTATION) else 0 - val width = CursorUtil.requireInt(cursor, getWidthColumn(orientation)) - val height = CursorUtil.requireInt(cursor, getHeightColumn(orientation)) - val size = CursorUtil.requireLong(cursor, MediaStore.Images.Media.SIZE) - val duration = if (!isImage) CursorUtil.requireInt(cursor, MediaStore.Video.Media.DURATION).toLong() else 0.toLong() - media.add( - MediaRepository.fixMimeType( - context, - Media( - uri = uri, - contentType = mimetype, - date = date, - width = width, - height = height, - size = size, - duration = duration, - isBorderless = false, - isVideoGif = false, - bucketId = bucketId, - caption = null, - transformProperties = null, - fileName = null - ) - ) - ) - } - } - - return media - } - - private fun getWidthColumn(orientation: Int): String { - return if (orientation == 0 || orientation == 180) MediaStore.Images.Media.WIDTH else MediaStore.Images.Media.HEIGHT - } - - private fun getHeightColumn(orientation: Int): String { - return if (orientation == 0 || orientation == 180) MediaStore.Images.Media.HEIGHT else MediaStore.Images.Media.WIDTH - } - - @Suppress("DEPRECATION") - private fun isNotPending(): String { - return if (Build.VERSION.SDK_INT <= 28) MediaStore.Images.Media.DATA + " NOT NULL" else MediaStore.MediaColumns.IS_PENDING + " != 1" - } - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Activity.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Activity.kt index a10a078bde..5b551f6bad 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Activity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Activity.kt @@ -63,6 +63,9 @@ class MediaSendV3Activity : PassphraseRequiredActivity() { } ) } + + // TODO - QR HudCommands (GoToConversation, GoToLinkedDevices, GoToQuickTransfer) not yet wired up. + else -> Unit } } ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3QrRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3QrRepository.kt new file mode 100644 index 0000000000..f975ed39e7 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3QrRepository.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.mediasend.v3 + +import kotlinx.coroutines.rx3.await +import org.signal.core.util.logging.Log +import org.signal.mediasend.MediaRecipientId +import org.signal.mediasend.MediaSendQrRepository +import org.thoughtcrime.securesms.profiles.manage.UsernameRepository +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.registration.data.QuickRegistrationRepository + +object MediaSendV3QrRepository : MediaSendQrRepository { + + private val TAG = Log.tag(MediaSendV3QrRepository::class) + + override suspend fun checkQrData(qrData: String): MediaSendQrRepository.QrCheckResult { + return when { + UsernameRepository.isValidLink(qrData) -> handleUsernameLink(qrData) + qrData.startsWith("sgnl://linkdevice") -> handleLinkDevice() + qrData.startsWith("sgnl://rereg") && QuickRegistrationRepository.isValidReRegistrationQr(qrData) -> handleReReg(qrData) + else -> MediaSendQrRepository.QrCheckResult.None + } + } + + private suspend fun handleUsernameLink(qrData: String): MediaSendQrRepository.QrCheckResult { + return when (val result = UsernameRepository.fetchUsernameAndAciFromLink(qrData).await()) { + is UsernameRepository.UsernameLinkConversionResult.Success -> { + val username = result.username.toString() + val recipient = Recipient.externalUsername(result.aci, result.username.toString()) + + MediaSendQrRepository.QrCheckResult.Username( + recipientId = MediaRecipientId(recipient.id.toLong()), + username = username + ) + } + else -> { + Log.w(TAG, "Failed to scan QR code") + MediaSendQrRepository.QrCheckResult.None + } + } + } + + private fun handleLinkDevice(): MediaSendQrRepository.QrCheckResult { + return MediaSendQrRepository.QrCheckResult.LinkDevice + } + + private fun handleReReg(qrData: String): MediaSendQrRepository.QrCheckResult { + return MediaSendQrRepository.QrCheckResult.ReRegistration(qrData) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Repository.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Repository.kt index 6e67d6d4c1..58a2d3b8f8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Repository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v3/MediaSendV3Repository.kt @@ -49,6 +49,8 @@ import org.thoughtcrime.securesms.util.RemoteConfig import java.io.InputStream import java.util.concurrent.TimeUnit import kotlin.coroutines.resume +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds /** @@ -195,6 +197,12 @@ object MediaSendV3Repository : MediaSendRepository { return !RemoteConfig.cameraXMixedModelBlocklist.asListContains(Build.MODEL) } + override fun getMediaConstraints(): MediaConstraints { + return PushMediaConstraints(null) + } + + override var storyMaxVideoDuration: Duration = Stories.MAX_VIDEO_DURATION_MILLIS.milliseconds + private fun resolveSendType(sendType: Int): MessageSendType { return when (sendType) { else -> MessageSendType.SignalMessageSendType diff --git a/feature/media-send/src/main/java/org/signal/mediasend/DialogEvent.kt b/feature/media-send/src/main/java/org/signal/mediasend/DialogEvent.kt new file mode 100644 index 0000000000..1327bba425 --- /dev/null +++ b/feature/media-send/src/main/java/org/signal/mediasend/DialogEvent.kt @@ -0,0 +1,11 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.mediasend + +internal sealed interface DialogEvent { + data class UsernameScanned(val qrCheckResult: MediaSendQrRepository.QrCheckResult.Username) : DialogEvent + data object LinkedDeviceScanned : DialogEvent +} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendDependencies.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendDependencies.kt index 421b90218d..afdb9efe42 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendDependencies.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendDependencies.kt @@ -7,6 +7,7 @@ package org.signal.mediasend import android.app.Application import androidx.media3.exoplayer.ExoPlayer +import org.signal.core.util.contentproviders.BlobProvider import org.signal.mediasend.preupload.PreUploadRepository import org.signal.video.exo.ExoPlayerPool @@ -39,9 +40,17 @@ object MediaSendDependencies { val exoPlayerPool: ExoPlayerPool get() = _provider.provideExoPlayerPool() + val blobs: BlobProvider + get() = _provider.provideBlobs() + + val qrRepository: MediaSendQrRepository + get() = _provider.provideQrRepository() + interface Provider { fun provideMediaSendRepository(): MediaSendRepository fun providePreUploadRepository(): PreUploadRepository + fun provideQrRepository(): MediaSendQrRepository fun provideExoPlayerPool(): ExoPlayerPool + fun provideBlobs(): BlobProvider } } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendEvent.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendEvent.kt index 50cebb9632..616442b5f1 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendEvent.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendEvent.kt @@ -5,6 +5,7 @@ package org.signal.mediasend +import org.signal.mediasend.capture.MediaCaptureScreenEvent import org.signal.mediasend.edit.MediaEditScreenEvent import org.signal.mediasend.select.MediaSelectScreenEvent @@ -33,4 +34,8 @@ sealed interface HudCommand { val startWithEmojiKeyboard: Boolean, val isViewOnceAvailable: Boolean ) : HudCommand + + data class GoToConversation(val recipientId: MediaRecipientId, val username: String) : HudCommand + data object GoToLinkedDevices : HudCommand + data class GoToQuickTransfer(val qrData: String) : HudCommand } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendNavDisplay.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendNavDisplay.kt index 1e10129091..88cf280822 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendNavDisplay.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendNavDisplay.kt @@ -1,14 +1,19 @@ package org.signal.mediasend +import android.annotation.SuppressLint import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavEntry @@ -17,10 +22,15 @@ import androidx.navigation3.runtime.rememberNavBackStack import androidx.navigation3.ui.NavDisplay import androidx.navigationevent.compose.LocalNavigationEventDispatcherOwner import androidx.navigationevent.compose.rememberNavigationEventDispatcherOwner +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.emptyFlow import org.signal.core.ui.compose.AllDevicePreviews import org.signal.core.ui.compose.Previews +import org.signal.core.ui.compose.Snackbars +import org.signal.core.ui.compose.showSnackbar +import org.signal.mediasend.capture.MediaCaptureScreen import org.signal.mediasend.edit.MediaEditScreen import org.signal.mediasend.select.MediaSelectScreen import org.signal.mediasend.select.MediaSelectScreenState @@ -32,76 +42,105 @@ import org.signal.mediasend.select.MediaSelectScreenState * Select -> Edit -> Send */ @Composable -fun MediaSendNavDisplay( +internal fun MediaSendNavDisplay( stateFlow: StateFlow, + snackbarEvents: Flow, backStack: NavBackStack, eventHandler: MediaSendEventHandler, modifier: Modifier = Modifier, - cameraSlot: @Composable () -> Unit = {}, textStoryEditorSlot: @Composable () -> Unit = {}, sendSlot: @Composable (MediaSendState) -> Unit = {} ) { - NavDisplay( - backStack = backStack, - modifier = modifier.fillMaxSize() - ) { key -> - when (key) { - is MediaSendNavKey.Capture -> NavEntry(MediaSendNavKey.Capture.Chrome) { - MediaCaptureScreen( - backStack = backStack, - onEvent = eventHandler::onMediaCaptureScreenEvent, - cameraSlot = cameraSlot, - textStoryEditorSlot = textStoryEditorSlot - ) - } + Box { + NavDisplay( + backStack = backStack, + modifier = modifier.fillMaxSize() + ) { key -> + when (key) { + is MediaSendNavKey.Capture -> NavEntry(MediaSendNavKey.Capture.Chrome) { + val state by stateFlow.collectAsStateWithLifecycle() - MediaSendNavKey.Select.Folders -> NavEntry(key) { - val state by stateFlow.collectAsStateWithLifecycle() - val screenState = remember(state.mediaFolders, state.selectedMedia) { - MediaSelectScreenState.Folders( - mediaFolders = state.mediaFolders, - selectedMedia = state.selectedMedia + MediaCaptureScreen( + backStack = backStack, + state = state, + onEvent = eventHandler::onMediaCaptureScreenEvent, + textStoryEditorSlot = textStoryEditorSlot ) } - MediaSelectScreen( - state = screenState, - onEvent = eventHandler::onMediaSelectScreenEvent - ) - } + MediaSendNavKey.Select.Folders -> NavEntry(key) { + val state by stateFlow.collectAsStateWithLifecycle() + val screenState = remember(state.mediaFolders, state.selectedMedia) { + MediaSelectScreenState.Folders( + mediaFolders = state.mediaFolders, + selectedMedia = state.selectedMedia + ) + } - is MediaSendNavKey.Select.Files -> NavEntry(key) { - val state by stateFlow.collectAsStateWithLifecycle() - val screenState = remember(state.selectedMedia, state.selectedMediaFolderItems) { - MediaSelectScreenState.Files( - selectedMediaFolder = key.folder, - selectedMediaFolderItems = state.selectedMediaFolderItems, - selectedMedia = state.selectedMedia + MediaSelectScreen( + state = screenState, + onEvent = eventHandler::onMediaSelectScreenEvent ) } - MediaSelectScreen( - state = screenState, - onEvent = eventHandler::onMediaSelectScreenEvent - ) - } + is MediaSendNavKey.Select.Files -> NavEntry(key) { + val state by stateFlow.collectAsStateWithLifecycle() + val screenState = remember(state.selectedMedia, state.selectedMediaFolderItems) { + MediaSelectScreenState.Files( + selectedMediaFolder = key.folder, + selectedMediaFolderItems = state.selectedMediaFolderItems, + selectedMedia = state.selectedMedia + ) + } - is MediaSendNavKey.Edit -> NavEntry(MediaSendNavKey.Edit) { - val state by stateFlow.collectAsStateWithLifecycle() - MediaEditScreen( - state = state, - onEvent = eventHandler::onMediaEditScreenEvent - ) - } + MediaSelectScreen( + state = screenState, + onEvent = eventHandler::onMediaSelectScreenEvent + ) + } - is MediaSendNavKey.Send -> NavEntry(key) { - val state by stateFlow.collectAsStateWithLifecycle() - sendSlot(state) - } + is MediaSendNavKey.Edit -> NavEntry(MediaSendNavKey.Edit) { + val state by stateFlow.collectAsStateWithLifecycle() + MediaEditScreen( + state = state, + onEvent = eventHandler::onMediaEditScreenEvent + ) + } - else -> error("Unknown key: $key") + is MediaSendNavKey.Send -> NavEntry(key) { + val state by stateFlow.collectAsStateWithLifecycle() + sendSlot(state) + } + + else -> error("Unknown key: $key") + } + } + + Snackbar(snackbarEvents) + } +} + +@SuppressLint("LocalContextGetResourceValueCall") +@Composable +private fun BoxScope.Snackbar( + snackbarEvents: Flow +) { + val context = LocalContext.current + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(snackbarHostState) { + snackbarEvents.collect { event -> + snackbarHostState.showSnackbar( + message = context.getString(event.message), + duration = event.duration + ) } } + + Snackbars.Host( + snackbarHostState, + modifier = Modifier.align(Alignment.BottomCenter) + ) } @AllDevicePreviews @@ -111,9 +150,9 @@ private fun MediaSendNavDisplayPreview() { CompositionLocalProvider(LocalNavigationEventDispatcherOwner provides rememberNavigationEventDispatcherOwner(parent = null)) { MediaSendNavDisplay( stateFlow = MutableStateFlow(MediaSendState(isCameraFirst = true)), + snackbarEvents = emptyFlow(), backStack = rememberNavBackStack(MediaSendNavKey.Edit), eventHandler = MediaSendEventHandler.Empty, - cameraSlot = { BoxWithText("Camera Slot") }, textStoryEditorSlot = { BoxWithText("Text Story Editor Slot") }, sendSlot = { _ -> BoxWithText("Send Slot") } ) diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendQrRepository.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendQrRepository.kt new file mode 100644 index 0000000000..4415ed502f --- /dev/null +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendQrRepository.kt @@ -0,0 +1,17 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.mediasend + +interface MediaSendQrRepository { + suspend fun checkQrData(qrData: String): QrCheckResult + + sealed interface QrCheckResult { + data object None : QrCheckResult + data class Username(val recipientId: MediaRecipientId, val username: String) : QrCheckResult + data object LinkDevice : QrCheckResult + data class ReRegistration(val qrData: String) : QrCheckResult + } +} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendRepository.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendRepository.kt index f76ff9263e..9575fab0a7 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendRepository.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendRepository.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.Flow import org.signal.core.models.media.Media import org.signal.core.models.media.MediaFolder import java.io.InputStream +import kotlin.time.Duration /** * Repository interface for media send operations that require app-layer implementation. @@ -106,6 +107,10 @@ interface MediaSendRepository { fun isMixedModeAvailable(): Boolean var isCameraFacingFront: Boolean + + fun getMediaConstraints(): MediaConstraints + + var storyMaxVideoDuration: Duration } /** diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendScreen.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendScreen.kt index c3400292ec..bacea93442 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendScreen.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendScreen.kt @@ -20,7 +20,6 @@ import org.signal.core.ui.compose.theme.SignalTheme fun MediaSendScreen( contractArgs: MediaSendActivityContract.Args, modifier: Modifier = Modifier, - cameraSlot: @Composable () -> Unit = {}, textStoryEditorSlot: @Composable () -> Unit = {}, sendSlot: @Composable (MediaSendState) -> Unit = {}, onExternalHudCommand: (HudCommand) -> Unit = {} @@ -38,10 +37,10 @@ fun MediaSendScreen( Surface { MediaSendNavDisplay( stateFlow = viewModel.state, + snackbarEvents = viewModel.snackbarEvents, backStack = viewModel.backStack, eventHandler = viewModel, modifier = modifier, - cameraSlot = cameraSlot, textStoryEditorSlot = textStoryEditorSlot, sendSlot = sendSlot ) diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt index c89449e873..badbe3dc07 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/MediaSendViewModel.kt @@ -22,6 +22,7 @@ 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.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -31,23 +32,32 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext 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.core.util.throttleLatest 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.capture.CameraXScreenEvent +import org.signal.mediasend.capture.MediaCaptureScreenEvent import org.signal.mediasend.edit.MediaEditScreenEvent import org.signal.mediasend.edit.video.VideoTrimData import org.signal.mediasend.preupload.PreUploadController import org.signal.mediasend.select.MediaSelectScreenEvent +import org.thoughtcrime.securesms.video.videoconverter.utils.VideoConstants +import java.io.FileInputStream +import java.io.IOException import java.util.Collections import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds /** * Configuration-survivable state manager for the media send flow. @@ -88,6 +98,14 @@ class MediaSendViewModel( NavBackStack(if (args.isCameraFirst) MediaSendNavKey.Capture.Camera else MediaSendNavKey.Select.Folders) } + private val internalSnackbarEvents: Channel = Channel(Channel.BUFFERED) + internal val snackbarEvents: Flow = internalSnackbarEvents.receiveAsFlow() + + private val internalDialogEvents: Channel = Channel(Channel.BUFFERED) + internal val dialogEvents: Flow = internalDialogEvents.receiveAsFlow() + + private val qrCheckRequest: MutableStateFlow = MutableStateFlow("") + /** * Main UI state. Backed by [SavedStateHandle] for automatic process death survival. * Writes to this flow are automatically persisted. @@ -124,6 +142,17 @@ class MediaSendViewModel( } } + viewModelScope.launch(Dispatchers.IO) { + qrCheckRequest.throttleLatest(5.seconds).filter { it.isNotEmpty() }.collect { + when (MediaSendDependencies.qrRepository.checkQrData(it)) { + MediaSendQrRepository.QrCheckResult.LinkDevice -> Unit // TODO() // dialog -> linked devices activity -> finish + MediaSendQrRepository.QrCheckResult.None -> Unit + is MediaSendQrRepository.QrCheckResult.ReRegistration -> Unit // TODO() // quick transfer activity -> finish + is MediaSendQrRepository.QrCheckResult.Username -> Unit // TODO() // dialog -> start conversation -> finish + } + } + } + // Observe recipient validity for pre-upload eligibility args.recipientId?.let { recipientId -> viewModelScope.launch { @@ -176,6 +205,20 @@ class MediaSendViewModel( when (mediaCaptureScreenEvent) { MediaCaptureScreenEvent.ShowCamera -> backStack.goToCamera() MediaCaptureScreenEvent.ShowTextStory -> backStack.goToTextStory() + is MediaCaptureScreenEvent.Camera -> onCameraXScreenEvent(mediaCaptureScreenEvent.event) + } + } + + private fun onCameraXScreenEvent(event: CameraXScreenEvent) { + when (event) { + CameraXScreenEvent.CameraCountButtonClicked -> backStack.goToEdit() + CameraXScreenEvent.GalleryClicked -> backStack.goToFolders() + is CameraXScreenEvent.ImageCaptured -> handleImageCaptured(event) + is CameraXScreenEvent.VideoCaptured -> handleVideoCaptured(event) + is CameraXScreenEvent.QrCodeFound -> qrCheckRequest.update { event.data } + CameraXScreenEvent.VideoCaptureError -> { + internalSnackbarEvents.trySend(SnackbarEvent(message = R.string.MediaSendViewModel__error_recording_video)) + } } } @@ -183,12 +226,14 @@ class MediaSendViewModel( when (mediaEditScreenEvent) { is MediaEditScreenEvent.FocusedMediaChanged -> setFocusedMedia(mediaEditScreenEvent.media) MediaEditScreenEvent.NavigateToSend -> backStack.goToSend() + MediaEditScreenEvent.NavigateBack -> onPopFromEdit() is MediaEditScreenEvent.VideoTrimChanged -> onEditVideoDuration( totalDurationUs = mediaEditScreenEvent.videoTrimData.totalInputDurationUs, startTimeUs = mediaEditScreenEvent.videoTrimData.startTimeUs, endTimeUs = mediaEditScreenEvent.videoTrimData.endTimeUs, touchEnabled = mediaEditScreenEvent.editingComplete ) + is MediaEditScreenEvent.VideoSeek -> error("VideoSeek is routed to the video player bus by MediaEditScreen and must not reach the view-model.") is MediaEditScreenEvent.AddMessageClick -> { val snapshot: MediaSendState = state.value @@ -204,6 +249,84 @@ class MediaSendViewModel( } } + private fun handleImageCaptured(imageCaptured: CameraXScreenEvent.ImageCaptured) { + viewModelScope.launch { + val media: Media? = withContext(Dispatchers.IO) { + try { + val length = imageCaptured.data.size.toLong() + val uri = MediaSendDependencies.blobs + .forData(imageCaptured.data) + .withMimeType(ContentTypeUtil.IMAGE_JPEG) + .createForSingleSessionOnDisk(MediaSendDependencies.application) + + buildCapturedMedia(uri, ContentTypeUtil.IMAGE_JPEG, imageCaptured.width, imageCaptured.height, length) + } catch (e: IOException) { + null + } + } + + if (media != null) { + onMediaRendered(media) + } else { + internalSnackbarEvents.trySend(SnackbarEvent(message = R.string.MediaSendViewModel__error_taking_photo)) + } + } + } + + private fun handleVideoCaptured(videoCaptured: CameraXScreenEvent.VideoCaptured) { + viewModelScope.launch { + val media: Media? = withContext(Dispatchers.IO) { + try { + FileInputStream(videoCaptured.fd).use { stream -> + val length = stream.channel.size() + val uri = MediaSendDependencies.blobs + .forData(stream, length) + .withMimeType(VideoConstants.RECORDED_VIDEO_CONTENT_TYPE) + .createForSingleSessionOnDisk(MediaSendDependencies.application) + + buildCapturedMedia(uri, VideoConstants.RECORDED_VIDEO_CONTENT_TYPE, 0, 0, length) + } + } catch (e: IOException) { + null + } + } + + if (media != null) { + onMediaRendered(media) + } else { + internalSnackbarEvents.trySend(SnackbarEvent(message = R.string.MediaSendViewModel__error_recording_video)) + } + } + } + + private fun buildCapturedMedia(uri: Uri, mimeType: String, width: Int, height: Int, size: Long): Media { + return Media( + uri = uri, + contentType = mimeType, + date = System.currentTimeMillis(), + width = width, + height = height, + size = size, + duration = 0, + isBorderless = false, + isVideoGif = false, + bucketId = Media.ALL_MEDIA_BUCKET_ID, + caption = null, + transformProperties = null, + fileName = null + ) + } + + private fun onMediaRendered(media: Media) { + if (args.isCameraFirst && internalState.value.cameraFirstCapture == null) { + addCameraFirstCapture(media) + } else { + addMedia(setOf(media), focusNewlyAdded = true) + } + + backStack.goToEdit() + } + private fun onFolderClick(mediaFolder: MediaFolder?) { if (mediaFolder != null) { backStack.goToFiles(mediaFolder) @@ -241,6 +364,16 @@ class MediaSendViewModel( * @param media Media items to add. */ fun addMedia(media: Set) { + addMedia(media, focusNewlyAdded = false) + } + + /** + * Adds [media] to the selection, optionally moving focus to the newly added item. + * + * Focus is updated within the same atomic state write that adds the media, so [MediaSendState.focusedMedia] + * is never left pointing at an item that is not yet present in [MediaSendState.selectedMedia]. + */ + private fun addMedia(media: Set, focusNewlyAdded: Boolean) { viewModelScope.launch { val snapshot = state.value val newSelectionList: List = linkedSetOf().apply { @@ -296,9 +429,15 @@ class MediaSendViewModel( } updateState { + val newFocus = if (focusNewlyAdded) { + filterResult.filteredMedia.lastOrNull { it in media } ?: focusedMedia ?: filterResult.filteredMedia.firstOrNull() + } else { + focusedMedia ?: filterResult.filteredMedia.firstOrNull() + } + copy( selectedMedia = filterResult.filteredMedia, - focusedMedia = focusedMedia ?: filterResult.filteredMedia.firstOrNull(), + focusedMedia = newFocus, editorStateMap = editorStateMap + initializedVideoEditorStates + initializedImageEditorStates ) } @@ -672,17 +811,26 @@ class MediaSendViewModel( //region Camera First Capture - fun addCameraFirstCapture(media: Media) { + private fun addCameraFirstCapture(media: Media) { internalState.update { it.copy(cameraFirstCapture = media) } - addMedia(media) + addMedia(setOf(media), focusNewlyAdded = true) } - fun removeCameraFirstCapture() { + private fun removeCameraFirstCapture() { val capture = internalState.value.cameraFirstCapture ?: return setSuppressEmptyError(true) removeMedia(capture) } + /** + * Handles a back press out of the edit screen during a camera-first flow where the only selected media is the + * camera-first capture. Discards that capture and returns to the camera, matching the legacy review behavior. + */ + private fun onPopFromEdit() { + removeCameraFirstCapture() + backStack.goToCamera() + } + //endregion //region Touch & Error Suppression diff --git a/feature/media-send/src/main/java/org/signal/mediasend/NavBackStackExtensions.kt b/feature/media-send/src/main/java/org/signal/mediasend/NavBackStackExtensions.kt index 9e18859ae6..c0048628d9 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/NavBackStackExtensions.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/NavBackStackExtensions.kt @@ -10,19 +10,15 @@ import androidx.navigation3.runtime.NavKey import org.signal.core.models.media.MediaFolder internal fun NavBackStack.goToEdit() { - if (contains(MediaSendNavKey.Edit)) { - popTo(MediaSendNavKey.Edit) - } else { - add(MediaSendNavKey.Edit) - } + goToSingle(MediaSendNavKey.Edit) } internal fun NavBackStack.goToSend() { - if (contains(MediaSendNavKey.Send)) { - popTo(MediaSendNavKey.Send) - } else { - add(MediaSendNavKey.Send) - } + goToSingle(MediaSendNavKey.Send) +} + +internal fun NavBackStack.goToFolders() { + goToSingle(MediaSendNavKey.Select.Folders) } internal fun NavBackStack.goToFiles(mediaFolder: MediaFolder) { @@ -30,13 +26,11 @@ internal fun NavBackStack.goToFiles(mediaFolder: MediaFolder) { } internal fun NavBackStack.goToTextStory() { - if (!contains(MediaSendNavKey.Capture.TextStory)) { - add(MediaSendNavKey.Capture.TextStory) - } + goToSingle(MediaSendNavKey.Capture.TextStory) } internal fun NavBackStack.goToCamera() { - remove(MediaSendNavKey.Capture.TextStory) + goToSingle(MediaSendNavKey.Capture.Camera) } internal fun NavBackStack.pop() { @@ -45,6 +39,14 @@ internal fun NavBackStack.pop() { } } +private fun NavBackStack.goToSingle(key: NavKey) { + if (contains(key)) { + popTo(key) + } else { + add(key) + } +} + private fun NavBackStack.popTo(key: NavKey) { while (size > 1 && get(size - 1) != key) { removeAt(size - 1) diff --git a/feature/media-send/src/main/java/org/signal/mediasend/SnackbarEvent.kt b/feature/media-send/src/main/java/org/signal/mediasend/SnackbarEvent.kt new file mode 100644 index 0000000000..b9610689a4 --- /dev/null +++ b/feature/media-send/src/main/java/org/signal/mediasend/SnackbarEvent.kt @@ -0,0 +1,14 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.mediasend + +import androidx.annotation.StringRes +import org.signal.core.ui.compose.Snackbars + +internal data class SnackbarEvent( + @get:StringRes val message: Int, + val duration: Snackbars.Duration = Snackbars.Duration.SHORT +) diff --git a/feature/media-send/src/main/java/org/signal/mediasend/capture/CameraXFragment.kt b/feature/media-send/src/main/java/org/signal/mediasend/capture/CameraXFragment.kt index b7be151608..d8017b7e06 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/capture/CameraXFragment.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/capture/CameraXFragment.kt @@ -16,6 +16,7 @@ import android.os.ParcelFileDescriptor import android.system.Os import android.system.OsConstants import android.widget.Toast +import androidx.activity.compose.LocalActivity import androidx.camera.core.CameraSelector import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween @@ -32,9 +33,10 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -49,8 +51,11 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import org.signal.camera.CameraCaptureMode import org.signal.camera.CameraDependencies import org.signal.camera.CameraDisplay @@ -109,52 +114,46 @@ class CameraXFragment : ComposeFragment(), CameraFragment { } } } + + private fun readStateFromArgs(args: Bundle): CameraXScreenState { + return CameraXScreenState( + isVideoEnabled = args.getBoolean(IS_VIDEO_ENABLED, true), + isQrScanEnabled = args.getBoolean(IS_QR_SCAN_ENABLED, false) + ) + } } private var controller: CameraFragment.Controller? = null - private var videoFileDescriptor: MemoryFileDescriptor? = null - private var captureMode: CameraCaptureMode = CameraCaptureMode.ImageOnly - private val isVideoEnabled: Boolean - get() = requireArguments().getBoolean(IS_VIDEO_ENABLED, true) - - private val isQrScanEnabled: Boolean - get() = requireArguments().getBoolean(IS_QR_SCAN_ENABLED, false) - - private var controlsVisible = mutableStateOf(true) - private var selectedMediaCount = mutableIntStateOf(0) + private val state by lazy { + MutableStateFlow(readStateFromArgs(requireArguments())) + } override fun onAttach(context: Context) { super.onAttach(context) controller = when { activity is CameraFragment.Controller -> activity as CameraFragment.Controller parentFragment is CameraFragment.Controller -> parentFragment as CameraFragment.Controller - else -> throw IllegalStateException("Parent must implement Controller interface.") + else -> controller ?: throw IllegalStateException("Parent must implement Controller interface.") } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - captureMode = resolveCaptureMode() - Log.d(TAG, "Starting CameraX with capture mode $captureMode") + Log.d(TAG, "Starting CameraX") } @Composable override fun FragmentContent() { + val state by state.collectAsStateWithLifecycle() + val controller = controller CameraXScreen( - controller = controller, - isVideoEnabled = captureMode != CameraCaptureMode.ImageOnly, - isQrScanEnabled = isQrScanEnabled, - captureMode = captureMode, - controlsVisible = controlsVisible.value, - selectedMediaCount = selectedMediaCount.intValue, - onCheckPermissions = { checkPermissions(isVideoEnabled) }, + state = state, + onEvent = { event -> controller?.onCameraXScreenEvent(event) }, + maxVideoDurationSeconds = controller?.let { getMaxVideoDurationInSeconds(it.mediaConstraints, it.maxVideoDuration) } ?: 0, + onCheckPermissions = { checkPermissions(state.isVideoEnabled) }, hasCameraPermission = { hasCameraPermission() }, - onRequestMicPermission = { requestMicPermission() }, - onGalleryClicked = { controller?.onGalleryClicked() }, - createVideoFileDescriptor = { createVideoFileDescriptor() }, - getMaxVideoDurationInSeconds = { getMaxVideoDurationInSeconds() }, - cameraDisplay = CameraDisplay.getDisplay(requireActivity()) + onRequestMicPermission = { requestMicPermission() } ) } @@ -165,7 +164,6 @@ class CameraXFragment : ComposeFragment(), CameraFragment { override fun onDestroyView() { super.onDestroyView() - closeVideoFileDescriptor() requireActivity().requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED } @@ -178,17 +176,17 @@ class CameraXFragment : ComposeFragment(), CameraFragment { } override fun presentHud(selectedMediaCount: Int) { - this.selectedMediaCount.intValue = selectedMediaCount + state.update { it.copy(selectedMediaCount = selectedMediaCount) } } override fun fadeOutControls(onEndAction: Runnable) { - controlsVisible.value = false + state.update { it.copy(controlsVisible = false) } // Post the end action after a short delay to allow animation to complete view?.postDelayed({ onEndAction.run() }, CONTROLS_ANIMATION_DURATION) } override fun fadeInControls() { - controlsVisible.value = true + state.update { it.copy(controlsVisible = true) } } private fun checkPermissions(includeAudio: Boolean) { @@ -270,15 +268,67 @@ class CameraXFragment : ComposeFragment(), CameraFragment { .onAnyDenied { Toast.makeText(requireContext(), R.string.CameraXFragment_signal_needs_microphone_access_video, Toast.LENGTH_LONG).show() } .execute() } +} - private fun createVideoFileDescriptor(): ParcelFileDescriptor? { +internal fun getMaxVideoDurationInSeconds(mediaConstraints: MediaConstraints, maxVideoDuration: Int): Int { + var maxDuration = VideoUtil.getMaxVideoRecordDurationInSeconds(mediaConstraints) + if (maxVideoDuration > 0) { + maxDuration = maxVideoDuration + } + return maxDuration +} + +/** + * Bridges [CameraXScreenEvent]s emitted by [CameraXScreen] back onto the legacy [CameraFragment.Controller] callbacks + * for Fragment-based consumers. + */ +private fun CameraFragment.Controller.onCameraXScreenEvent(event: CameraXScreenEvent) { + when (event) { + is CameraXScreenEvent.ImageCaptured -> onImageCaptured(event.data, event.width, event.height) + is CameraXScreenEvent.VideoCaptured -> onVideoCaptured(event.fd) + is CameraXScreenEvent.QrCodeFound -> onQrCodeFound(event.data) + CameraXScreenEvent.VideoCaptureError -> onVideoCaptureError() + CameraXScreenEvent.GalleryClicked -> onGalleryClicked() + CameraXScreenEvent.CameraCountButtonClicked -> onCameraCountButtonClicked() + } +} + +private fun resolveCaptureMode(context: Context, isVideoEnabled: Boolean): CameraCaptureMode { + val isVideoSupported = Build.VERSION.SDK_INT >= 26 && + isVideoEnabled && + MediaConstraints.isVideoTranscodeAvailable() + + val isMixedModeSupported = isVideoSupported && + CameraXUtil.isMixedModeSupported(context) && + MediaSendDependencies.mediaSendRepository.isMixedModeAvailable() + + return when { + isMixedModeSupported -> CameraCaptureMode.ImageAndVideoSimultaneous + isVideoSupported -> CameraCaptureMode.ImageAndVideoExclusive + else -> CameraCaptureMode.ImageOnly + } +} + +data class CameraXScreenState( + val isVideoEnabled: Boolean = true, + val isQrScanEnabled: Boolean = false, + val controlsVisible: Boolean = true, + val selectedMediaCount: Int = 0 +) + +@Stable +class VideoFileDescriptor(val context: Context) { + + private var videoFileDescriptor: MemoryFileDescriptor? = null + + fun create(): ParcelFileDescriptor? { if (Build.VERSION.SDK_INT < 26) { throw IllegalStateException("Video capture requires API 26 or higher") } return try { - closeVideoFileDescriptor() - videoFileDescriptor = CameraXUtil.createVideoFileDescriptor(requireContext()) + destroy() + videoFileDescriptor = CameraXUtil.createVideoFileDescriptor(context) videoFileDescriptor?.parcelFd } catch (e: IOException) { Log.w(TAG, "Failed to create video file descriptor", e) @@ -286,7 +336,7 @@ class CameraXFragment : ComposeFragment(), CameraFragment { } } - private fun closeVideoFileDescriptor() { + fun destroy() { videoFileDescriptor?.let { try { it.close() @@ -296,55 +346,33 @@ class CameraXFragment : ComposeFragment(), CameraFragment { videoFileDescriptor = null } } - - private fun getMaxVideoDurationInSeconds(): Int { - var maxDuration = VideoUtil.getMaxVideoRecordDurationInSeconds(controller!!.mediaConstraints) - val controllerMaxDuration = controller?.maxVideoDuration ?: 0 - if (controllerMaxDuration > 0) { - maxDuration = controllerMaxDuration - } - return maxDuration - } - - private fun resolveCaptureMode(): CameraCaptureMode { - val isVideoSupported = Build.VERSION.SDK_INT >= 26 && - isVideoEnabled && - MediaConstraints.isVideoTranscodeAvailable() - - val isMixedModeSupported = isVideoSupported && - CameraXUtil.isMixedModeSupported(requireContext()) && - MediaSendDependencies.mediaSendRepository.isMixedModeAvailable() - - return when { - isMixedModeSupported -> CameraCaptureMode.ImageAndVideoSimultaneous - isVideoSupported -> CameraCaptureMode.ImageAndVideoExclusive - else -> CameraCaptureMode.ImageOnly - } - } } @Composable -private fun CameraXScreen( - controller: CameraFragment.Controller?, - isVideoEnabled: Boolean, - isQrScanEnabled: Boolean, - captureMode: CameraCaptureMode, - controlsVisible: Boolean, - selectedMediaCount: Int, +fun CameraXScreen( + state: CameraXScreenState, + onEvent: (CameraXScreenEvent) -> Unit, + maxVideoDurationSeconds: Int, onCheckPermissions: () -> Unit, hasCameraPermission: () -> Boolean, onRequestMicPermission: () -> Unit, - onGalleryClicked: () -> Unit, - createVideoFileDescriptor: () -> ParcelFileDescriptor?, - getMaxVideoDurationInSeconds: () -> Int, - cameraDisplay: CameraDisplay, storiesEnabled: Boolean = CameraDependencies.isStoriesFeatureEnabled() ) { val context = LocalContext.current + val activity = LocalActivity.current + + val captureMode = remember { resolveCaptureMode(context, state.isVideoEnabled) } + val cameraDisplay = remember { CameraDisplay.getDisplay(activity!!) } + val videoFileDescriptor = remember { VideoFileDescriptor(context) } + val cameraViewModel: CameraScreenViewModel = viewModel() val cameraState by cameraViewModel.state var hasPermission by remember { mutableStateOf(hasCameraPermission()) } + DisposableEffect(Unit) { + onDispose { videoFileDescriptor.destroy() } + } + LaunchedEffect(cameraViewModel) { val lensFacing = if (MediaSendDependencies.mediaSendRepository.isCameraFacingFront) { CameraSelector.LENS_FACING_FRONT @@ -367,10 +395,10 @@ private fun CameraXScreen( } } - LaunchedEffect(cameraViewModel, isQrScanEnabled) { - if (isQrScanEnabled) { + LaunchedEffect(cameraViewModel, state.isQrScanEnabled) { + if (state.isQrScanEnabled) { cameraViewModel.qrCodeDetected.collect { qrCode -> - controller?.onQrCodeFound(qrCode) + onEvent(CameraXScreenEvent.QrCodeFound(qrCode)) } } } @@ -436,11 +464,11 @@ private fun CameraXScreen( roundCorners = cameraDisplay.roundViewFinderCorners, contentAlignment = cameraAlignment, captureMode = captureMode, - enableQrScanning = isQrScanEnabled, + enableQrScanning = state.isQrScanEnabled, modifier = Modifier.padding(bottom = viewportBottomMargin) ) { AnimatedVisibility( - visible = controlsVisible, + visible = state.controlsVisible, enter = fadeIn(animationSpec = tween(durationMillis = 150)), exit = fadeOut(animationSpec = tween(durationMillis = 150)) ) { @@ -448,18 +476,18 @@ private fun CameraXScreen( StandardCameraHud( state = cameraState, modifier = Modifier.padding(bottom = hudBottomPaddingInsideViewport), - maxRecordingDurationMs = getMaxVideoDurationInSeconds() * 1000L, - mediaSelectionCount = selectedMediaCount, + maxRecordingDurationMs = maxVideoDurationSeconds * 1000L, + mediaSelectionCount = state.selectedMediaCount, hasAudioPermission = { context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED }, emitter = { event -> handleHudEvent( event = event, context = context, cameraViewModel = cameraViewModel, - controller = controller, - isVideoEnabled = isVideoEnabled, + onEvent = onEvent, + isVideoEnabled = captureMode != CameraCaptureMode.ImageOnly, onRequestMicPermission = onRequestMicPermission, - createVideoFileDescriptor = createVideoFileDescriptor + createVideoFileDescriptor = { videoFileDescriptor.create() } ) }, stringResources = StringResources( @@ -472,9 +500,9 @@ private fun CameraXScreen( } } else { PermissionMissingContent( - isVideoEnabled = isVideoEnabled, + isVideoEnabled = captureMode != CameraCaptureMode.ImageOnly, onRequestPermissions = onCheckPermissions, - onGalleryClicked = onGalleryClicked, + onGalleryClicked = { onEvent(CameraXScreenEvent.GalleryClicked) }, galleryButtonBottomPadding = hudBottomMargin + 16.dp ) } @@ -532,7 +560,7 @@ private fun handleHudEvent( event: StandardCameraHudEvents, context: Context, cameraViewModel: CameraScreenViewModel, - controller: CameraFragment.Controller?, + onEvent: (CameraXScreenEvent) -> Unit, isVideoEnabled: Boolean, onRequestMicPermission: () -> Unit, createVideoFileDescriptor: () -> ParcelFileDescriptor? @@ -542,7 +570,7 @@ private fun handleHudEvent( cameraViewModel.capturePhoto( context = context, onPhotoCaptured = { bitmap -> - handlePhotoCaptured(bitmap, controller) + handlePhotoCaptured(bitmap, onEvent) } ) } @@ -555,7 +583,7 @@ private fun handleHudEvent( context = context, output = VideoOutput.FileDescriptorOutput(fileDescriptor), onVideoCaptured = { result -> - handleVideoCaptured(result, controller) + handleVideoCaptured(result, onEvent) } ) } else { @@ -573,11 +601,11 @@ private fun handleHudEvent( } is StandardCameraHudEvents.GalleryClick -> { - controller?.onGalleryClicked() + onEvent(CameraXScreenEvent.GalleryClicked) } is StandardCameraHudEvents.MediaSelectionClick -> { - controller?.onCameraCountButtonClicked() + onEvent(CameraXScreenEvent.CameraCountButtonClicked) } is StandardCameraHudEvents.ToggleFlash -> { @@ -602,33 +630,33 @@ private fun handleHudEvent( } } -private fun handlePhotoCaptured(bitmap: Bitmap, controller: CameraFragment.Controller?) { +private fun handlePhotoCaptured(bitmap: Bitmap, onEvent: (CameraXScreenEvent) -> Unit) { // Convert bitmap to JPEG byte array val outputStream = ByteArrayOutputStream() bitmap.compress(Bitmap.CompressFormat.JPEG, 90, outputStream) val data = outputStream.toByteArray() - controller?.onImageCaptured(data, bitmap.width, bitmap.height) + onEvent(CameraXScreenEvent.ImageCaptured(data, bitmap.width, bitmap.height)) } -private fun handleVideoCaptured(result: VideoCaptureResult, controller: CameraFragment.Controller?) { +private fun handleVideoCaptured(result: VideoCaptureResult, onEvent: (CameraXScreenEvent) -> Unit) { when (result) { is VideoCaptureResult.Success -> { result.fileDescriptor?.let { parcelFd -> try { // Seek to beginning before reading Os.lseek(parcelFd.fileDescriptor, 0, OsConstants.SEEK_SET) - controller?.onVideoCaptured(parcelFd.fileDescriptor) + onEvent(CameraXScreenEvent.VideoCaptured(parcelFd.fileDescriptor)) } catch (e: Exception) { Log.w(TAG, "Failed to seek video file descriptor", e) - controller?.onVideoCaptureError() + onEvent(CameraXScreenEvent.VideoCaptureError) } - } ?: controller?.onVideoCaptureError() + } ?: onEvent(CameraXScreenEvent.VideoCaptureError) } is VideoCaptureResult.Error -> { Log.w(TAG, "Video capture failed: ${result.message}", result.throwable) - controller?.onVideoCaptureError() + onEvent(CameraXScreenEvent.VideoCaptureError) } } } @@ -643,19 +671,12 @@ private fun handleVideoCaptured(result: VideoCaptureResult, controller: CameraFr private fun CameraXScreenPreview_20_9() { Previews.Preview { CameraXScreen( - controller = null, - isVideoEnabled = true, - isQrScanEnabled = false, - captureMode = CameraCaptureMode.ImageAndVideoSimultaneous, - controlsVisible = true, - selectedMediaCount = 0, + state = CameraXScreenState(), + onEvent = {}, + maxVideoDurationSeconds = 0, onCheckPermissions = {}, hasCameraPermission = { true }, onRequestMicPermission = { }, - onGalleryClicked = { }, - createVideoFileDescriptor = { null }, - getMaxVideoDurationInSeconds = { 60 }, - cameraDisplay = CameraDisplay.DISPLAY_20_9, storiesEnabled = true ) } @@ -671,19 +692,12 @@ private fun CameraXScreenPreview_20_9() { private fun CameraXScreenPreview_19_9() { Previews.Preview { CameraXScreen( - controller = null, - isVideoEnabled = true, - isQrScanEnabled = false, - captureMode = CameraCaptureMode.ImageAndVideoSimultaneous, - controlsVisible = true, - selectedMediaCount = 0, + state = CameraXScreenState(), + onEvent = {}, + maxVideoDurationSeconds = 0, onCheckPermissions = {}, hasCameraPermission = { true }, onRequestMicPermission = { }, - onGalleryClicked = { }, - createVideoFileDescriptor = { null }, - getMaxVideoDurationInSeconds = { 60 }, - cameraDisplay = CameraDisplay.DISPLAY_19_9, storiesEnabled = true ) } @@ -699,19 +713,12 @@ private fun CameraXScreenPreview_19_9() { private fun CameraXScreenPreview_18_9() { Previews.Preview { CameraXScreen( - controller = null, - isVideoEnabled = true, - isQrScanEnabled = false, - captureMode = CameraCaptureMode.ImageAndVideoSimultaneous, - controlsVisible = true, - selectedMediaCount = 0, + state = CameraXScreenState(), + onEvent = {}, + maxVideoDurationSeconds = 0, onCheckPermissions = {}, hasCameraPermission = { true }, onRequestMicPermission = { }, - onGalleryClicked = { }, - createVideoFileDescriptor = { null }, - getMaxVideoDurationInSeconds = { 60 }, - cameraDisplay = CameraDisplay.DISPLAY_18_9, storiesEnabled = true ) } @@ -727,19 +734,12 @@ private fun CameraXScreenPreview_18_9() { private fun CameraXScreenPreview_16_9() { Previews.Preview { CameraXScreen( - controller = null, - isVideoEnabled = true, - isQrScanEnabled = false, - captureMode = CameraCaptureMode.ImageAndVideoSimultaneous, - controlsVisible = true, - selectedMediaCount = 0, + state = CameraXScreenState(), + onEvent = {}, + maxVideoDurationSeconds = 0, onCheckPermissions = {}, hasCameraPermission = { true }, onRequestMicPermission = { }, - onGalleryClicked = { }, - createVideoFileDescriptor = { null }, - getMaxVideoDurationInSeconds = { 60 }, - cameraDisplay = CameraDisplay.DISPLAY_16_9, storiesEnabled = true ) } @@ -755,19 +755,12 @@ private fun CameraXScreenPreview_16_9() { private fun CameraXScreenPreview_6_5() { Previews.Preview { CameraXScreen( - controller = null, - isVideoEnabled = true, - isQrScanEnabled = false, - captureMode = CameraCaptureMode.ImageAndVideoSimultaneous, - controlsVisible = true, - selectedMediaCount = 0, + state = CameraXScreenState(), + onEvent = {}, + maxVideoDurationSeconds = 0, onCheckPermissions = {}, hasCameraPermission = { true }, onRequestMicPermission = { }, - onGalleryClicked = { }, - createVideoFileDescriptor = { null }, - getMaxVideoDurationInSeconds = { 60 }, - cameraDisplay = CameraDisplay.DISPLAY_6_5, storiesEnabled = true ) } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/capture/CameraXScreenEvent.kt b/feature/media-send/src/main/java/org/signal/mediasend/capture/CameraXScreenEvent.kt new file mode 100644 index 0000000000..4a95a227e5 --- /dev/null +++ b/feature/media-send/src/main/java/org/signal/mediasend/capture/CameraXScreenEvent.kt @@ -0,0 +1,17 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.mediasend.capture + +import java.io.FileDescriptor + +sealed interface CameraXScreenEvent { + class ImageCaptured(val data: ByteArray, val width: Int, val height: Int) : CameraXScreenEvent + class VideoCaptured(val fd: FileDescriptor) : CameraXScreenEvent + class QrCodeFound(val data: String) : CameraXScreenEvent + data object VideoCaptureError : CameraXScreenEvent + data object GalleryClicked : CameraXScreenEvent + data object CameraCountButtonClicked : CameraXScreenEvent +} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/capture/MediaCameraCaptureScreen.kt b/feature/media-send/src/main/java/org/signal/mediasend/capture/MediaCameraCaptureScreen.kt new file mode 100644 index 0000000000..33a0bc1f96 --- /dev/null +++ b/feature/media-send/src/main/java/org/signal/mediasend/capture/MediaCameraCaptureScreen.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.mediasend.capture + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import org.signal.mediasend.MediaSendDependencies +import org.signal.mediasend.MediaSendState + +/** + * Allows the user to capture images and video from the hardware camera to utilize in the media send flow. + */ +@Composable +fun MediaCameraCaptureScreen( + state: MediaSendState, + onEvent: (MediaCaptureScreenEvent) -> Unit +) { + CameraXScreen( + state = remember(state.selectedMedia) { + CameraXScreenState( + isVideoEnabled = true, + isQrScanEnabled = true, + selectedMediaCount = state.selectedMedia.size + ) + }, + onEvent = { event -> onEvent(MediaCaptureScreenEvent.Camera(event)) }, + maxVideoDurationSeconds = remember(state.isStory) { + getMaxVideoDurationInSeconds( + mediaConstraints = MediaSendDependencies.mediaSendRepository.getMediaConstraints(), + maxVideoDuration = if (state.isStory) MediaSendDependencies.mediaSendRepository.storyMaxVideoDuration.inWholeSeconds.toInt() else -1 + ) + }, + onCheckPermissions = {}, + onRequestMicPermission = {}, + hasCameraPermission = { true } + ) +} diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaCaptureScreen.kt b/feature/media-send/src/main/java/org/signal/mediasend/capture/MediaCaptureScreen.kt similarity index 83% rename from feature/media-send/src/main/java/org/signal/mediasend/MediaCaptureScreen.kt rename to feature/media-send/src/main/java/org/signal/mediasend/capture/MediaCaptureScreen.kt index 486ae5be58..869921903d 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaCaptureScreen.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/capture/MediaCaptureScreen.kt @@ -3,7 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend +package org.signal.mediasend.capture import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row @@ -17,6 +17,9 @@ import androidx.compose.ui.res.stringResource import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey import org.signal.core.ui.compose.Buttons +import org.signal.mediasend.MediaSendNavKey +import org.signal.mediasend.MediaSendState +import org.signal.mediasend.R /** * Screen that allows user to capture the media they will send using a camera or text story @@ -24,14 +27,19 @@ import org.signal.core.ui.compose.Buttons @Composable fun MediaCaptureScreen( backStack: NavBackStack, + state: MediaSendState, onEvent: (MediaCaptureScreenEvent) -> Unit, - cameraSlot: @Composable () -> Unit, textStoryEditorSlot: @Composable () -> Unit ) { Box(modifier = Modifier.fillMaxSize()) { when (backStack.last()) { is MediaSendNavKey.Capture.TextStory -> textStoryEditorSlot() - else -> cameraSlot() + else -> { + MediaCameraCaptureScreen( + state = state, + onEvent = onEvent + ) + } } Row( diff --git a/feature/media-send/src/main/java/org/signal/mediasend/MediaCaptureScreenEvent.kt b/feature/media-send/src/main/java/org/signal/mediasend/capture/MediaCaptureScreenEvent.kt similarity index 68% rename from feature/media-send/src/main/java/org/signal/mediasend/MediaCaptureScreenEvent.kt rename to feature/media-send/src/main/java/org/signal/mediasend/capture/MediaCaptureScreenEvent.kt index 1dad345219..67d089087f 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/MediaCaptureScreenEvent.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/capture/MediaCaptureScreenEvent.kt @@ -3,9 +3,10 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -package org.signal.mediasend +package org.signal.mediasend.capture sealed interface MediaCaptureScreenEvent { data object ShowCamera : MediaCaptureScreenEvent data object ShowTextStory : MediaCaptureScreenEvent + class Camera(val event: CameraXScreenEvent) : MediaCaptureScreenEvent } diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt index c38c6b37a0..383f0bf764 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreen.kt @@ -5,6 +5,7 @@ package org.signal.mediasend.edit +import androidx.activity.compose.BackHandler import androidx.activity.compose.LocalActivity import androidx.compose.foundation.background import androidx.compose.foundation.gestures.snapping.SnapPosition @@ -19,6 +20,7 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState @@ -55,10 +57,28 @@ fun MediaEditScreen( val scope = rememberCoroutineScope() val pagerState = rememberPagerState( - initialPage = state.focusedMedia?.let { state.selectedMedia.indexOf(it) } ?: 0, + initialPage = state.focusedMedia?.let { state.selectedMedia.indexOf(it).coerceAtLeast(0) } ?: 0, pageCount = { state.selectedMedia.size } ) + // Media captured from the camera is added to the selection asynchronously, so the Edit screen can compose before the + // new item lands in selectedMedia. Keep the pager aligned with focusedMedia once it does. + LaunchedEffect(state.focusedMedia, state.selectedMedia) { + val targetPage = state.focusedMedia?.let { state.selectedMedia.indexOf(it) } ?: -1 + if (targetPage >= 0 && targetPage != pagerState.currentPage) { + pagerState.scrollToPage(targetPage) + } + } + + // During a camera-first flow, backing out of edit when the only selection is the capture itself should discard the + // capture and return to the camera rather than leaving the empty editor on the back stack. + val isOnlyCameraFirstCapture = state.cameraFirstCapture != null && + state.selectedMedia.size == 1 && + state.selectedMedia.firstOrNull() == state.cameraFirstCapture + BackHandler(enabled = isOnlyCameraFirstCapture) { + onEvent(MediaEditScreenEvent.NavigateBack) + } + Box( modifier = Modifier .fillMaxSize() diff --git a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreenEvent.kt b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreenEvent.kt index a9d2ba4662..284b89834d 100644 --- a/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreenEvent.kt +++ b/feature/media-send/src/main/java/org/signal/mediasend/edit/MediaEditScreenEvent.kt @@ -12,6 +12,7 @@ sealed interface MediaEditScreenEvent { data class FocusedMediaChanged(val media: Media) : MediaEditScreenEvent data class AddMessageClick(val startWithEmojiKeyboard: Boolean = false) : MediaEditScreenEvent data object NavigateToSend : MediaEditScreenEvent + data object NavigateBack : MediaEditScreenEvent data class VideoTrimChanged(val videoTrimData: VideoTrimData, val editingComplete: Boolean) : MediaEditScreenEvent data class VideoSeek(val positionUs: Long, val editingComplete: Boolean) : MediaEditScreenEvent } diff --git a/feature/media-send/src/main/res/values/strings.xml b/feature/media-send/src/main/res/values/strings.xml index db4566bdb2..a9c287396f 100644 --- a/feature/media-send/src/main/res/values/strings.xml +++ b/feature/media-send/src/main/res/values/strings.xml @@ -85,4 +85,9 @@ Send Signal needs microphone permissions to record videos, but they have been denied. Please continue to app settings, select \"Permissions\", and enable \"Microphone\" and \"Camera\". + + + Error recording video + + Error taking photo