Add ability to take a photo in media-send feature module.

This commit is contained in:
Alex Hart
2026-06-25 17:32:20 -03:00
committed by Michelle Tang
parent 248a787d74
commit 2c35a6a3fe
24 changed files with 629 additions and 303 deletions
@@ -1171,7 +1171,7 @@ class MainActivity :
} else if (SignalStore.internal.useNewMediaActivity) {
mediaSendLauncher.launch(
MediaSendActivityContract.Args(
isCameraFirst = false,
isCameraFirst = true,
isStory = destination == MainNavigationListLocation.STORIES
)
)
@@ -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<ExoPlayer> = AppDependencies.exoPlayerPool
override fun provideBlobs(): BlobProvider = AppDependencies.blobs
}
@@ -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<Media> {
val media: MutableList<Media> = LinkedList()
var selection: String? = MediaStore.Images.Media.BUCKET_ID + " = ? AND " + isNotPending()
var selectionArgs: Array<String>? = arrayOf(bucketId)
val sortBy = MediaStore.Images.Media.DATE_MODIFIED + " DESC"
val projection: Array<String> = 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"
}
}
}
@@ -63,6 +63,9 @@ class MediaSendV3Activity : PassphraseRequiredActivity() {
}
)
}
// TODO - QR HudCommands (GoToConversation, GoToLinkedDevices, GoToQuickTransfer) not yet wired up.
else -> Unit
}
}
)
@@ -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)
}
}
@@ -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
@@ -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
}
@@ -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<ExoPlayer>
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<ExoPlayer>
fun provideBlobs(): BlobProvider
}
}
@@ -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
}
@@ -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<MediaSendState>,
snackbarEvents: Flow<SnackbarEvent>,
backStack: NavBackStack<NavKey>,
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<SnackbarEvent>
) {
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") }
)
@@ -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
}
}
@@ -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
}
/**
@@ -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
)
@@ -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<SnackbarEvent> = Channel(Channel.BUFFERED)
internal val snackbarEvents: Flow<SnackbarEvent> = internalSnackbarEvents.receiveAsFlow()
private val internalDialogEvents: Channel<DialogEvent> = Channel(Channel.BUFFERED)
internal val dialogEvents: Flow<DialogEvent> = internalDialogEvents.receiveAsFlow()
private val qrCheckRequest: MutableStateFlow<String> = 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<Media>) {
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<Media>, focusNewlyAdded: Boolean) {
viewModelScope.launch {
val snapshot = state.value
val newSelectionList: List<Media> = linkedSetOf<Media>().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
@@ -10,19 +10,15 @@ import androidx.navigation3.runtime.NavKey
import org.signal.core.models.media.MediaFolder
internal fun NavBackStack<NavKey>.goToEdit() {
if (contains(MediaSendNavKey.Edit)) {
popTo(MediaSendNavKey.Edit)
} else {
add(MediaSendNavKey.Edit)
}
goToSingle(MediaSendNavKey.Edit)
}
internal fun NavBackStack<NavKey>.goToSend() {
if (contains(MediaSendNavKey.Send)) {
popTo(MediaSendNavKey.Send)
} else {
add(MediaSendNavKey.Send)
}
goToSingle(MediaSendNavKey.Send)
}
internal fun NavBackStack<NavKey>.goToFolders() {
goToSingle(MediaSendNavKey.Select.Folders)
}
internal fun NavBackStack<NavKey>.goToFiles(mediaFolder: MediaFolder) {
@@ -30,13 +26,11 @@ internal fun NavBackStack<NavKey>.goToFiles(mediaFolder: MediaFolder) {
}
internal fun NavBackStack<NavKey>.goToTextStory() {
if (!contains(MediaSendNavKey.Capture.TextStory)) {
add(MediaSendNavKey.Capture.TextStory)
}
goToSingle(MediaSendNavKey.Capture.TextStory)
}
internal fun NavBackStack<NavKey>.goToCamera() {
remove(MediaSendNavKey.Capture.TextStory)
goToSingle(MediaSendNavKey.Capture.Camera)
}
internal fun NavBackStack<NavKey>.pop() {
@@ -45,6 +39,14 @@ internal fun NavBackStack<NavKey>.pop() {
}
}
private fun NavBackStack<NavKey>.goToSingle(key: NavKey) {
if (contains(key)) {
popTo(key)
} else {
add(key)
}
}
private fun NavBackStack<NavKey>.popTo(key: NavKey) {
while (size > 1 && get(size - 1) != key) {
removeAt(size - 1)
@@ -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
)
@@ -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
)
}
@@ -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
}
@@ -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 }
)
}
@@ -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<NavKey>,
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(
@@ -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
}
@@ -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()
@@ -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
}
@@ -85,4 +85,9 @@
<string name="CameraXFragment_send">Send</string>
<!-- Displayed in a permissions dialog when the user has denied access to hardware for image and video capture. -->
<string name="CameraXFragment_signal_needs_the_recording_permissions_to_capture_video">Signal needs microphone permissions to record videos, but they have been denied. Please continue to app settings, select \"Permissions\", and enable \"Microphone\" and \"Camera\".</string>
<!-- Displayed when there is an error capturing video -->
<string name="MediaSendViewModel__error_recording_video">Error recording video</string>
<!-- Displayed when there is an error capturing image -->
<string name="MediaSendViewModel__error_taking_photo">Error taking photo</string>
</resources>