mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-08-05 04:45:14 +01:00
Add ability to take a photo in media-send feature module.
This commit is contained in:
@@ -1171,7 +1171,7 @@ class MainActivity :
|
||||
} else if (SignalStore.internal.useNewMediaActivity) {
|
||||
mediaSendLauncher.launch(
|
||||
MediaSendActivityContract.Args(
|
||||
isCameraFirst = false,
|
||||
isCameraFirst = true,
|
||||
isStory = destination == MainNavigationListLocation.STORIES
|
||||
)
|
||||
)
|
||||
|
||||
+7
@@ -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
|
||||
}
|
||||
|
||||
+2
-82
@@ -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
|
||||
)
|
||||
+137
-144
@@ -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
|
||||
}
|
||||
+40
@@ -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 }
|
||||
)
|
||||
}
|
||||
+11
-3
@@ -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(
|
||||
+2
-1
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user