Move CameraXFragment to feature:media-send.

This commit is contained in:
Alex Hart 2026-06-22 10:17:05 -03:00 committed by GitHub
parent 4e077bbb52
commit ab4a38d565
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 246 additions and 655 deletions

View File

@ -79,6 +79,7 @@ import org.thoughtcrime.securesms.providers.BlobProvider
import org.thoughtcrime.securesms.util.CommunicationActions
import java.io.ByteArrayOutputStream
import java.util.UUID
import org.signal.mediasend.R as MediaSendR
@OptIn(ExperimentalPermissionsApi::class)
class UsernameLinkSettingsFragment : ComposeFragment() {
@ -158,8 +159,8 @@ class UsernameLinkSettingsFragment : ComposeFragment() {
Permissions.with(this)
.request(Manifest.permission.CAMERA)
.ifNecessary()
.withPermanentDenialDialog(getString(R.string.CameraXFragment_signal_needs_camera_access_scan_qr_code), null, R.string.CameraXFragment_allow_access_camera, R.string.CameraXFragment_to_scan_qr_codes, parentFragmentManager)
.onAnyDenied { Toast.makeText(requireContext(), R.string.CameraXFragment_signal_needs_camera_access_scan_qr_code, Toast.LENGTH_LONG).show() }
.withPermanentDenialDialog(getString(MediaSendR.string.CameraXFragment_signal_needs_camera_access_scan_qr_code), null, MediaSendR.string.CameraXFragment_allow_access_camera, MediaSendR.string.CameraXFragment_to_scan_qr_codes, parentFragmentManager)
.onAnyDenied { Toast.makeText(requireContext(), MediaSendR.string.CameraXFragment_signal_needs_camera_access_scan_qr_code, Toast.LENGTH_LONG).show() }
.execute()
}
}

View File

@ -36,6 +36,7 @@ import org.signal.core.ui.compose.theme.SignalTheme
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.qr.QrCrosshair
import org.thoughtcrime.securesms.recipients.Recipient
import org.signal.mediasend.R as MediaSendR
/**
* A screen that allows you to scan a QR code to start a chat.
@ -116,7 +117,7 @@ fun UsernameQrScanScreen(
.padding(48.dp)
) {
Text(
text = stringResource(R.string.CameraXFragment_to_scan_qr_code_allow_camera),
text = stringResource(MediaSendR.string.CameraXFragment_to_scan_qr_code_allow_camera),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge,
color = Color.White
@ -125,7 +126,7 @@ fun UsernameQrScanScreen(
colors = ButtonDefaults.filledTonalButtonColors(),
onClick = onOpenCameraClicked
) {
Text(stringResource(R.string.CameraXFragment_allow_access))
Text(stringResource(MediaSendR.string.CameraXFragment_allow_access))
}
}
}

View File

@ -47,6 +47,7 @@ import org.signal.core.util.permissions.PermissionCompat
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.signal.mediasend.R as MediaSendR
/**
* Prompts the user to scan a username QR code. Uses the activity result to communicate the recipient that was found, or null if no valid usernames were scanned.
@ -132,8 +133,8 @@ class UsernameQrScannerActivity : AppCompatActivity() {
Permissions.with(this)
.request(Manifest.permission.CAMERA)
.ifNecessary()
.withPermanentDenialDialog(getString(R.string.CameraXFragment_signal_needs_camera_access_scan_qr_code), null, R.string.CameraXFragment_allow_access_camera, R.string.CameraXFragment_to_scan_qr_codes, supportFragmentManager)
.onAnyDenied { Toast.makeText(this, R.string.CameraXFragment_signal_needs_camera_access_scan_qr_code, Toast.LENGTH_LONG).show() }
.withPermanentDenialDialog(getString(MediaSendR.string.CameraXFragment_signal_needs_camera_access_scan_qr_code), null, MediaSendR.string.CameraXFragment_allow_access_camera, MediaSendR.string.CameraXFragment_to_scan_qr_codes, supportFragmentManager)
.onAnyDenied { Toast.makeText(this, MediaSendR.string.CameraXFragment_signal_needs_camera_access_scan_qr_code, Toast.LENGTH_LONG).show() }
.execute()
}

View File

@ -5,6 +5,7 @@ import android.app.Application
import androidx.media3.exoplayer.ExoPlayer
import io.reactivex.rxjava3.subjects.BehaviorSubject
import okhttp3.OkHttpClient
import org.signal.camera.CameraDependencies
import org.signal.core.ui.CoreUiDependencies
import org.signal.core.util.CoreUtilDependencies
import org.signal.core.util.billing.BillingApi
@ -118,6 +119,7 @@ object AppDependencies {
)
CoreUiDependencies.init(application, CoreUiDependenciesProvider)
SignalGlideDependencies.init(application, SignalGlideDependenciesProvider)
CameraDependencies.init(application, CameraDependenciesProvider)
MediaSendDependencies.init(application, MediaSendDependenciesProvider)
}

View File

@ -0,0 +1,15 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.dependencies
import org.signal.camera.CameraDependencies
import org.thoughtcrime.securesms.stories.Stories
object CameraDependenciesProvider : CameraDependencies.Provider {
override fun isStoriesFeatureEnabled(): Boolean {
return Stories.isFeatureEnabled()
}
}

View File

@ -38,6 +38,7 @@ import org.signal.core.ui.permissions.Permissions
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.util.VibrateUtil
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.signal.mediasend.R as MediaSendR
/**
* Fragment that allows users to scan a QR code from their camera to link a device
@ -102,8 +103,8 @@ class AddLinkDeviceFragment : ComposeFragment() {
Permissions.with(this)
.request(Manifest.permission.CAMERA)
.ifNecessary()
.withPermanentDenialDialog(getString(R.string.CameraXFragment_signal_needs_camera_access_scan_qr_code), null, R.string.CameraXFragment_allow_access_camera, R.string.CameraXFragment_to_scan_qr_codes, parentFragmentManager)
.onAnyDenied { Toast.makeText(requireContext(), R.string.CameraXFragment_signal_needs_camera_access_scan_qr_code, Toast.LENGTH_LONG).show() }
.withPermanentDenialDialog(getString(MediaSendR.string.CameraXFragment_signal_needs_camera_access_scan_qr_code), null, MediaSendR.string.CameraXFragment_allow_access_camera, MediaSendR.string.CameraXFragment_to_scan_qr_codes, parentFragmentManager)
.onAnyDenied { Toast.makeText(requireContext(), MediaSendR.string.CameraXFragment_signal_needs_camera_access_scan_qr_code, Toast.LENGTH_LONG).show() }
.execute()
}

View File

@ -36,6 +36,7 @@ import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.linkdevice.LinkDeviceRepository.LinkDeviceResult
import org.thoughtcrime.securesms.qr.QrCrosshair
import org.thoughtcrime.securesms.util.navigation.safeNavigate
import org.signal.mediasend.R as MediaSendR
/**
* A screen that allows you to scan a QR code to link a device
@ -141,7 +142,7 @@ fun LinkDeviceQrScanScreen(
.padding(48.dp)
) {
Text(
text = stringResource(R.string.CameraXFragment_to_scan_qr_code_allow_camera),
text = stringResource(MediaSendR.string.CameraXFragment_to_scan_qr_code_allow_camera),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyLarge,
color = Color.White
@ -150,7 +151,7 @@ fun LinkDeviceQrScanScreen(
colors = ButtonDefaults.filledTonalButtonColors(),
onClick = onRequestPermissions
) {
Text(stringResource(R.string.CameraXFragment_allow_access))
Text(stringResource(MediaSendR.string.CameraXFragment_allow_access))
}
}
}

View File

@ -12,7 +12,6 @@ import org.thoughtcrime.securesms.util.BucketInfo
import org.thoughtcrime.securesms.util.DeviceProperties
import org.thoughtcrime.securesms.util.WakeLockUtil
import java.util.Locale
import java.util.concurrent.TimeUnit
import kotlin.time.Duration.Companion.milliseconds
/**

View File

@ -17,7 +17,8 @@ import androidx.fragment.app.FragmentTransaction;
import org.signal.core.models.media.Media;
import org.signal.imageeditor.core.model.EditorModel;
import org.signal.mediasend.MediaConstraints;
import org.signal.mediasend.capture.CameraFragment;
import org.signal.mediasend.CameraFragment;
import org.signal.mediasend.capture.CameraXFragment;
import org.thoughtcrime.securesms.R;
import org.thoughtcrime.securesms.mediasend.v2.gallery.MediaGalleryFragment;
import org.thoughtcrime.securesms.mms.PushMediaConstraints;

View File

@ -1,246 +0,0 @@
package org.thoughtcrime.securesms.mediasend;
import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.RectF;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.animation.Animation;
import android.view.animation.AnimationUtils;
import android.view.animation.DecelerateInterpolator;
import android.view.animation.Interpolator;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.signal.core.util.DimensionUnit;
import org.thoughtcrime.securesms.R;
import org.signal.core.util.Util;
import org.thoughtcrime.securesms.util.ViewUtil;
public class CameraButtonView extends View {
private enum CameraButtonMode { IMAGE, MIXED }
private static final float CAPTURE_ARC_STROKE_WIDTH = 3.5f;
private static final int CAPTURE_FILL_PROTECTION = 10;
private static final int PROGRESS_ARC_STROKE_WIDTH = 4;
private static final int HALF_PROGRESS_ARC_STROKE_WIDTH = PROGRESS_ARC_STROKE_WIDTH / 2;
private static final float DEADZONE_REDUCTION_PERCENT = 0.35f;
private final @NonNull Paint outlinePaint = outlinePaint();
private final @NonNull Paint backgroundPaint = backgroundPaint();
private final @NonNull Paint arcPaint = arcPaint();
private final @NonNull Paint recordPaint = recordPaint();
private final @NonNull Paint progressPaint = progressPaint();
private final @NonNull Paint captureFillPaint = captureFillPaint();
private Animation growAnimation;
private Animation shrinkAnimation;
private boolean isRecordingVideo;
private float progressPercent = 0f;
private @NonNull CameraButtonMode cameraButtonMode = CameraButtonMode.IMAGE;
private final float imageCaptureSize;
private final float recordSize;
private final RectF progressRect = new RectF();
private final Rect deadzoneRect = new Rect();
public CameraButtonView(@NonNull Context context) {
this(context, null);
}
public CameraButtonView(@NonNull Context context, @Nullable AttributeSet attrs) {
this(context, attrs, 0);
}
public CameraButtonView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
TypedArray a = context.getTheme().obtainStyledAttributes(attrs, R.styleable.CameraButtonView, defStyleAttr, 0);
imageCaptureSize = a.getDimensionPixelSize(R.styleable.CameraButtonView_imageCaptureSize, -1);
recordSize = a.getDimensionPixelSize(R.styleable.CameraButtonView_recordSize, -1);
a.recycle();
initializeImageAnimations();
}
private static Paint recordPaint() {
Paint recordPaint = new Paint();
recordPaint.setColor(0xFFF44336);
recordPaint.setAntiAlias(true);
recordPaint.setStyle(Paint.Style.FILL);
return recordPaint;
}
private static Paint outlinePaint() {
Paint outlinePaint = new Paint();
outlinePaint.setColor(0x26000000);
outlinePaint.setAntiAlias(true);
outlinePaint.setStyle(Paint.Style.STROKE);
outlinePaint.setStrokeWidth(ViewUtil.dpToPx(4));
return outlinePaint;
}
private static Paint backgroundPaint() {
Paint backgroundPaint = new Paint();
backgroundPaint.setColor(0x4CFFFFFF);
backgroundPaint.setAntiAlias(true);
backgroundPaint.setStyle(Paint.Style.FILL);
return backgroundPaint;
}
private static Paint arcPaint() {
Paint arcPaint = new Paint();
arcPaint.setColor(0xFFFFFFFF);
arcPaint.setAntiAlias(true);
arcPaint.setStyle(Paint.Style.STROKE);
arcPaint.setStrokeWidth(DimensionUnit.DP.toPixels(CAPTURE_ARC_STROKE_WIDTH));
return arcPaint;
}
private static Paint captureFillPaint() {
Paint arcPaint = new Paint();
arcPaint.setColor(0xFFFFFFFF);
arcPaint.setAntiAlias(true);
arcPaint.setStyle(Paint.Style.FILL);
return arcPaint;
}
private static Paint progressPaint() {
Paint progressPaint = new Paint();
progressPaint.setColor(0xFFFFFFFF);
progressPaint.setAntiAlias(true);
progressPaint.setStyle(Paint.Style.STROKE);
progressPaint.setStrokeWidth(ViewUtil.dpToPx(PROGRESS_ARC_STROKE_WIDTH));
progressPaint.setShadowLayer(4, 0, 2, 0x40000000);
return progressPaint;
}
private void initializeImageAnimations() {
shrinkAnimation = AnimationUtils.loadAnimation(getContext(), R.anim.camera_capture_button_shrink);
growAnimation = AnimationUtils.loadAnimation(getContext(), R.anim.camera_capture_button_grow);
shrinkAnimation.setFillAfter(true);
shrinkAnimation.setFillEnabled(true);
growAnimation.setFillAfter(true);
growAnimation.setFillEnabled(true);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
if (isRecordingVideo) {
drawForVideoCapture(canvas);
} else {
drawForImageCapture(canvas);
}
}
private void drawForImageCapture(Canvas canvas) {
float centerX = getWidth() / 2f;
float centerY = getHeight() / 2f;
float radius = imageCaptureSize / 2f;
canvas.drawCircle(centerX, centerY, radius, backgroundPaint);
canvas.drawCircle(centerX, centerY, radius, arcPaint);
canvas.drawCircle(centerX, centerY, radius - DimensionUnit.DP.toPixels(CAPTURE_FILL_PROTECTION), captureFillPaint);
}
private void drawForVideoCapture(Canvas canvas) {
float centerX = getWidth() / 2f;
float centerY = getHeight() / 2f;
canvas.drawCircle(centerX, centerY, centerY, backgroundPaint);
canvas.drawCircle(centerX, centerY, centerY, outlinePaint);
canvas.drawCircle(centerX, centerY, recordSize / 2f, recordPaint);
progressRect.top = ViewUtil.dpToPx(HALF_PROGRESS_ARC_STROKE_WIDTH);
progressRect.left = ViewUtil.dpToPx(HALF_PROGRESS_ARC_STROKE_WIDTH);
progressRect.right = getWidth() - ViewUtil.dpToPx(HALF_PROGRESS_ARC_STROKE_WIDTH);
progressRect.bottom = getHeight() - ViewUtil.dpToPx(HALF_PROGRESS_ARC_STROKE_WIDTH);
canvas.drawArc(progressRect, 270f, 360f * progressPercent, false, progressPaint);
}
public void setProgress(float percentage) {
progressPercent = Util.clamp(percentage, 0f, 1f);
invalidate();
}
@Override
public boolean onTouchEvent(MotionEvent event) {
if (cameraButtonMode == CameraButtonMode.IMAGE) {
return handleImageModeTouchEvent(event);
}
boolean eventWasHandled = handleVideoModeTouchEvent(event);
int action = event.getAction();
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
isRecordingVideo = false;
}
return eventWasHandled;
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
getLocalVisibleRect(deadzoneRect);
deadzoneRect.left += (int) (getWidth() * DEADZONE_REDUCTION_PERCENT / 2f);
deadzoneRect.top += (int) (getHeight() * DEADZONE_REDUCTION_PERCENT / 2f);
deadzoneRect.right -= (int) (getWidth() * DEADZONE_REDUCTION_PERCENT / 2f);
deadzoneRect.bottom -= (int) (getHeight() * DEADZONE_REDUCTION_PERCENT / 2f);
}
private boolean handleImageModeTouchEvent(MotionEvent event) {
int action = event.getAction();
switch (action) {
case MotionEvent.ACTION_DOWN:
if (isEnabled()) {
startAnimation(shrinkAnimation);
performClick();
}
return true;
case MotionEvent.ACTION_UP:
startAnimation(growAnimation);
return true;
default:
return super.onTouchEvent(event);
}
}
private boolean handleVideoModeTouchEvent(MotionEvent event) {
int action = event.getAction();
switch (action) {
case MotionEvent.ACTION_DOWN:
if (isEnabled()) {
startAnimation(shrinkAnimation);
}
case MotionEvent.ACTION_MOVE:
break;
case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
if (!isRecordingVideo) {
startAnimation(growAnimation);
}
break;
}
return super.onTouchEvent(event);
}
}

View File

@ -1,71 +0,0 @@
package org.thoughtcrime.securesms.mediasend
import android.view.Window
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
/**
* Modifies screen brightness to increase to a max of 66% if lower than that for optimal picture
* taking conditions. This brightness is only applied when the front-facing camera is selected.
*/
class CameraScreenBrightnessController(
private val window: Window,
private val cameraStateProvider: CameraStateProvider
) : DefaultLifecycleObserver {
companion object {
private const val FRONT_CAMERA_BRIGHTNESS = 0.66f
}
private val originalBrightness: Float by lazy { window.attributes.screenBrightness }
override fun onResume(owner: LifecycleOwner) {
onCameraDirectionChanged(cameraStateProvider.isFrontFacingCameraSelected())
onCameraFlashChanged(cameraStateProvider.isFlashEnabled())
}
override fun onPause(owner: LifecycleOwner) {
disableBrightness()
}
/**
* Because setting camera direction is an asynchronous action, we cannot rely on
* the `CameraDirectionProvider` at this point.
*/
fun onCameraDirectionChanged(isFrontFacing: Boolean) {
if (isFrontFacing && cameraStateProvider.isFlashEnabled()) {
enableBrightness()
} else {
disableBrightness()
}
}
fun onCameraFlashChanged(isFlashEnabled: Boolean) {
if (isFlashEnabled && cameraStateProvider.isFrontFacingCameraSelected()) {
enableBrightness()
} else {
disableBrightness()
}
}
private fun enableBrightness() {
if (originalBrightness < FRONT_CAMERA_BRIGHTNESS) {
window.attributes = window.attributes.apply {
screenBrightness = FRONT_CAMERA_BRIGHTNESS
}
}
}
private fun disableBrightness() {
if (window.attributes.screenBrightness == FRONT_CAMERA_BRIGHTNESS) {
window.attributes = window.attributes.apply {
screenBrightness = originalBrightness
}
}
}
interface CameraStateProvider {
fun isFrontFacingCameraSelected(): Boolean
fun isFlashEnabled(): Boolean
}
}

View File

@ -23,6 +23,7 @@ import androidx.transition.AutoTransition
import androidx.transition.TransitionManager
import com.google.android.material.animation.ArgbEvaluatorCompat
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import org.signal.camera.CameraDisplay
import org.signal.core.models.media.Media
import org.signal.core.util.BreakIteratorCompat
import org.signal.core.util.Debouncer
@ -42,7 +43,6 @@ import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardEventViewModel
import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardPageFragment
import org.thoughtcrime.securesms.keyboard.emoji.search.EmojiSearchFragment
import org.thoughtcrime.securesms.linkpreview.LinkPreviewUtil
import org.thoughtcrime.securesms.mediasend.CameraDisplay
import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult
import org.thoughtcrime.securesms.mediasend.v2.review.MediaReviewFragment
import org.thoughtcrime.securesms.mediasend.v2.text.TextStoryPostCreationViewModel

View File

@ -11,11 +11,11 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.signal.core.ui.permissions.Permissions
import org.signal.core.util.concurrent.LifecycleDisposable
import org.signal.core.util.logging.Log
import org.signal.mediasend.CameraFragment
import org.signal.mediasend.MediaConstraints
import org.signal.mediasend.capture.CameraFragment
import org.signal.mediasend.capture.CameraXFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity
import org.thoughtcrime.securesms.mediasend.CameraXFragment
import org.thoughtcrime.securesms.mediasend.v2.HudCommand
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionNavigator
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel

View File

@ -17,6 +17,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.kotlin.subscribeBy
import org.signal.camera.CameraDisplay
import org.signal.core.util.concurrent.LifecycleDisposable
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.contacts.paged.ContactSearchKey
@ -25,7 +26,6 @@ import org.thoughtcrime.securesms.databinding.StoriesTextPostCreationFragmentBin
import org.thoughtcrime.securesms.linkpreview.LinkPreview
import org.thoughtcrime.securesms.linkpreview.LinkPreviewState
import org.thoughtcrime.securesms.linkpreview.LinkPreviewViewModelV2
import org.thoughtcrime.securesms.mediasend.CameraDisplay
import org.thoughtcrime.securesms.mediasend.v2.HudCommand
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionViewModel
import org.thoughtcrime.securesms.mediasend.v2.stories.StoriesMultiselectForwardActivity

View File

@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.mediasend.v3
import android.content.Context
import android.net.Uri
import android.os.Build
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
@ -16,6 +17,7 @@ import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import org.signal.core.models.media.Media
import org.signal.core.models.media.MediaFolder
import org.signal.core.util.asListContains
import org.signal.core.util.logging.Log
import org.signal.mediasend.EditorState
import org.signal.mediasend.MediaConstraints
@ -32,6 +34,7 @@ import org.thoughtcrime.securesms.conversation.MessageSendType
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.database.model.IdentityRecord
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.mediasend.MediaRepository
import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionRepository
import org.thoughtcrime.securesms.mediasend.v2.MediaValidator
@ -43,6 +46,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId
import org.thoughtcrime.securesms.scribbles.ImageEditorFragment
import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.util.MediaUtil
import org.thoughtcrime.securesms.util.RemoteConfig
import java.io.InputStream
import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume
@ -58,6 +62,12 @@ object MediaSendV3Repository : MediaSendRepository {
private val legacyRepository = MediaSelectionRepository(appContext)
private val mediaRepository = MediaRepository()
override var isCameraFacingFront: Boolean
get() = SignalStore.misc.isCameraFacingFront
set(value) {
SignalStore.misc.isCameraFacingFront = value
}
override suspend fun getFolders(): List<MediaFolder> = suspendCancellableCoroutine { continuation ->
mediaRepository.getFolders(appContext) { folders ->
continuation.resume(folders)
@ -182,6 +192,10 @@ object MediaSendV3Repository : MediaSendRepository {
return PartAuthority.getAttachmentStream(context, uri)
}
override fun isMixedModeAvailable(): Boolean {
return !RemoteConfig.cameraXMixedModelBlocklist.asListContains(Build.MODEL)
}
private fun resolveSendType(sendType: Int): MessageSendType {
return when (sendType) {
else -> MessageSendType.SignalMessageSendType

View File

@ -96,8 +96,8 @@ public final class PaymentsTransferFragment extends LoggingFragment {
Permissions.with(this)
.request(Manifest.permission.CAMERA)
.ifNecessary()
.withRationaleDialog(getString(R.string.CameraXFragment_allow_access_camera), getString(R.string.PaymentsTransferFragment__to_scan_a_qr_code_signal_needs), R.drawable.ic_camera_24)
.withPermanentDenialDialog(getString(R.string.PaymentsTransferFragment__to_scan_a_qr_code_signal_needs_access_to_the_camera), null, R.string.CameraXFragment_allow_access_camera, R.string.CameraXFragment_to_scan_qr_codes, getParentFragmentManager())
.withRationaleDialog(getString(org.signal.mediasend.R.string.CameraXFragment_allow_access_camera), getString(R.string.PaymentsTransferFragment__to_scan_a_qr_code_signal_needs), R.drawable.ic_camera_24)
.withPermanentDenialDialog(getString(R.string.PaymentsTransferFragment__to_scan_a_qr_code_signal_needs_access_to_the_camera), null, org.signal.mediasend.R.string.CameraXFragment_allow_access_camera, org.signal.mediasend.R.string.CameraXFragment_to_scan_qr_codes, getParentFragmentManager())
.onAllGranted(() -> SafeNavigation.safeNavigate(Navigation.findNavController(requireView()), R.id.action_paymentsTransfer_to_paymentsScanQr))
.onAnyDenied(() -> Toast.makeText(requireContext(), R.string.PaymentsTransferFragment__to_scan_a_qr_code_signal_needs_access_to_the_camera, Toast.LENGTH_LONG).show())
.execute();

View File

@ -18,6 +18,7 @@ import org.thoughtcrime.securesms.crypto.IdentityKeyParcelable
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.recipients.Recipient
import org.thoughtcrime.securesms.recipients.RecipientId
import org.signal.mediasend.R as MediaSendR
/**
* Fragment to assist user in verifying recipient identity utilizing keys.
@ -106,8 +107,8 @@ class VerifyIdentityFragment : Fragment(R.layout.fragment_container), ScanListen
Permissions.with(this)
.request(Manifest.permission.CAMERA)
.ifNecessary()
.withRationaleDialog(getString(R.string.CameraXFragment_allow_access_camera), getString(R.string.CameraXFragment_to_scan_qr_code_allow_camera), R.drawable.ic_camera_24)
.withPermanentDenialDialog(getString(R.string.VerifyIdentityActivity_signal_needs_the_camera_permission_in_order_to_scan_a_qr_code_but_it_has_been_permanently_denied), null, R.string.CameraXFragment_allow_access_camera, R.string.CameraXFragment_to_scan_qr_codes, getParentFragmentManager())
.withRationaleDialog(getString(MediaSendR.string.CameraXFragment_allow_access_camera), getString(MediaSendR.string.CameraXFragment_to_scan_qr_code_allow_camera), R.drawable.ic_camera_24)
.withPermanentDenialDialog(getString(R.string.VerifyIdentityActivity_signal_needs_the_camera_permission_in_order_to_scan_a_qr_code_but_it_has_been_permanently_denied), null, MediaSendR.string.CameraXFragment_allow_access_camera, MediaSendR.string.CameraXFragment_to_scan_qr_codes, getParentFragmentManager())
.onAllGranted {
childFragmentManager.beginTransaction()
.setCustomAnimations(R.anim.slide_from_top, R.anim.slide_to_bottom, R.anim.slide_from_bottom, R.anim.slide_to_top)
@ -115,7 +116,7 @@ class VerifyIdentityFragment : Fragment(R.layout.fragment_container), ScanListen
.addToBackStack(null)
.commitAllowingStateLoss()
}
.onAnyDenied { Toast.makeText(requireContext(), R.string.CameraXFragment_signal_needs_camera_access_scan_qr_code, Toast.LENGTH_LONG).show() }
.onAnyDenied { Toast.makeText(requireContext(), MediaSendR.string.CameraXFragment_signal_needs_camera_access_scan_qr_code, Toast.LENGTH_LONG).show() }
.execute()
}

View File

@ -1,100 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
tools:viewBindingIgnore="true"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<org.thoughtcrime.securesms.mediasend.CameraButtonView
android:id="@+id/camera_capture_button"
android:layout_width="@dimen/camera_capture_button_size"
android:layout_height="@dimen/camera_capture_button_size"
android:contentDescription="@string/CameraXFragment_capture_description"
app:imageCaptureSize="@dimen/camera_capture_image_button_size"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:recordSize="54dp" />
<org.thoughtcrime.securesms.mediasend.camerax.CameraXFlashToggleView
android:id="@+id/camera_flash_button"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_marginTop="14dp"
android:layout_marginEnd="16dp"
android:background="@drawable/circle_transparent_black_40"
android:contentDescription="@string/CameraControls_toggle_flash_mode_accessibility_label"
android:padding="6dp"
android:scaleType="fitCenter"
android:src="@drawable/camerax_flash_toggle"
android:tint="@color/core_white"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.appcompat.widget.AppCompatImageButton
android:id="@+id/camera_flip_button"
android:layout_width="52dp"
android:layout_height="52dp"
android:layout_marginStart="40dp"
android:layout_marginBottom="36dp"
android:background="@drawable/media_selection_camera_switch_background"
android:contentDescription="@string/CameraXFragment_change_camera_description"
android:scaleType="centerInside"
android:visibility="gone"
android:tint="@color/core_white"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:srcCompat="@drawable/symbol_switch_24"
tools:visibility="visible" />
<FrameLayout
android:id="@+id/camera_gallery_button_background"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="40dp"
android:layout_marginBottom="36dp"
android:background="@drawable/circle_tintable"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/camera_gallery_button"
android:layout_width="52dp"
android:layout_height="52dp"
android:contentDescription="@string/CameraXFragment_open_gallery_description"
android:padding="2dp"
android:scaleType="centerCrop"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Signal.Circle"
tools:src="@color/black" />
</FrameLayout>
<org.thoughtcrime.securesms.mediasend.v2.MediaCountIndicatorButton
android:id="@+id/camera_review_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="40dp"
android:background="@drawable/v2_media_count_indicator_background"
android:minHeight="44dp"
android:visibility="gone"
android:contentDescription="@string/CameraControls_continue_button_accessibility_label"
app:layout_constraintBottom_toBottomOf="@id/camera_capture_button"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/camera_capture_button"
tools:visibility="visible" />
<View
android:id="@+id/camera_selfie_flash"
android:layout_width="0dp"
android:layout_height="0dp"
android:alpha="0"
android:background="@color/white"
android:contentDescription="@string/CameraControls_capture_button_accessibility_label"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,31 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:viewBindingIgnore="true"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.card.MaterialCardView
android:id="@+id/camera_preview_parent"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:cardCornerRadius="18dp"
app:cardElevation="0dp"
app:layout_constraintBottom_toBottomOf="parent">
<TextureView
android:id="@+id/camera_preview"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</com.google.android.material.card.MaterialCardView>
<FrameLayout
android:id="@+id/camera_controls_container"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toTopOf="@id/camera_preview_parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,81 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:viewBindingIgnore="true">
<com.google.android.material.card.MaterialCardView
android:id="@+id/camerax_camera_parent"
android:layout_width="0dp"
android:layout_height="0dp"
app:cardCornerRadius="18dp"
app:cardElevation="0dp"
app:layout_constraintDimensionRatio="9:16"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent">
<androidx.camera.view.PreviewView
android:id="@+id/camerax_camera"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="top"
app:implementationMode="compatible" />
<ImageView
android:id="@+id/camerax_focus_indicator"
android:layout_width="60dp"
android:layout_height="60dp"
android:src="@drawable/focus_indicator"
android:scaleType="fitCenter"
android:visibility="gone"/>
</com.google.android.material.card.MaterialCardView>
<FrameLayout
android:id="@+id/camerax_controls_container"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toTopOf="@id/camerax_camera_parent" />
<LinearLayout
android:id="@+id/missing_permissions_container"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_gravity="center"
android:layout_marginTop="-50dp"
android:layout_marginHorizontal="20dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toTopOf="parent"
android:gravity="center"
android:visibility="gone"
android:orientation="vertical">
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/permission_camera" />
<TextView
android:id="@+id/missing_permissions_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:layout_marginTop="20dp"
style="@style/Signal.Text.Body"
android:text="@string/CameraXFragment_to_capture_photos_and_video_allow_camera" />
<com.google.android.material.button.MaterialButton
android:id="@+id/allow_access_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
style="@style/Signal.Widget.Button.Large.Tonal"
android:text="@string/CameraXFragment_allow_access" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -30,9 +30,6 @@
<dimen name="album_5_cell_size_big">104dp</dimen>
<dimen name="album_5_cell_size_small">69dp</dimen>
<dimen name="camera_capture_button_size">124dp</dimen>
<dimen name="camera_capture_image_button_size">76dp</dimen>
<dimen name="message_corner_radius">18dp</dimen>
<dimen name="message_corner_collapse_radius">4dp</dimen>
<dimen name="message_bubble_corner_radius">2dp</dimen>

View File

@ -275,60 +275,6 @@
<!-- Unknown people row info dialog action to close the dialog -->
<string name="CallInfoView__got_it">Got it</string>
<!-- CameraFragment -->
<!-- Toasted when user device does not support video recording -->
<string name="CameraFragment__video_recording_is_not_supported_on_your_device">Video recording is not supported on your device</string>
<!-- CameraXFragment -->
<string name="CameraXFragment_tap_for_photo_hold_for_video">Tap for photo, hold for video</string>
<!-- Accessibility content description to describe the capture button when taking an image/video -->
<string name="CameraXFragment_capture_description">Capture</string>
<string name="CameraXFragment_change_camera_description">Change camera</string>
<string name="CameraXFragment_open_gallery_description">Open gallery</string>
<!-- Button text asking for access to camera permissions -->
<string name="CameraXFragment_allow_access">Allow access</string>
<!-- Dialog title asking users for camera and microphone permission -->
<string name="CameraXFragment_allow_access_camera_microphone">Allow access to your camera and microphone</string>
<!-- Dialog title asking users for camera permission -->
<string name="CameraXFragment_allow_access_camera">Allow access to your camera</string>
<!-- Dialog title asking users for microphone permission -->
<string name="CameraXFragment_allow_access_microphone">Allow access to your microphone</string>
<!-- Text explaining why Signal needs camera access in order to take photos and videos -->
<string name="CameraXFragment_to_capture_photos_and_video_allow_camera">To capture photos and video, allow Signal access to the camera.</string>
<!-- Text explaining why Signal needs camera and microphone access in order to take photos and videos -->
<string name="CameraXFragment_to_capture_photos_and_video_allow_camera_microphone">To capture photos and video, allow Signal access to the camera and microphone.</string>
<!-- Text explaining why Signal needs microphone access to take videos -->
<string name="CameraXFragment_to_capture_videos_with_sound">To capture videos with sound, allow Signal access to your microphone.</string>
<!-- Text explaining why Signal needs camera access to scan QR codes -->
<string name="CameraXFragment_to_scan_qr_code_allow_camera">To scan a QR code, allow Signal access to the camera.</string>
<!-- Toast dialog explaining why Signal needs camera permissions when capturing photos -->
<string name="CameraXFragment_signal_needs_camera_access_capture_photos">Signal needs camera access to capture photos</string>
<!-- Toast dialog explaining why Signal needs camera permissions when scanning QR codes -->
<string name="CameraXFragment_signal_needs_camera_access_scan_qr_code">Signal needs camera access to scan QR codes</string>
<!-- Toast dialog explaining why Signal needs microphone permissions -->
<string name="CameraXFragment_signal_needs_microphone_access_video">Signal needs microphone access to capture video</string>
<!-- Dialog description that explains the steps needed to give camera permission -->
<string name="CameraXFragment_to_capture_photos">To capture photos in Signal:</string>
<!-- Dialog description that explains the steps needed to give camera and microphone permission -->
<string name="CameraXFragment_to_capture_photos_videos">To capture photos and videos in Signal:</string>
<!-- Dialog description that explains the steps needed to give microphone permission -->
<string name="CameraXFragment_to_capture_videos">To capture videos with sound:</string>
<!-- Dialog description that explains the steps needed to give Signal camera permissions -->
<string name="CameraXFragment_to_scan_qr_codes">To scan QR codes:</string>
<!-- Error message shown when we try to take a photo, but fail -->
<string name="CameraXFragment_photo_capture_failed">Failed to capture photo. Please try again.</string>
<!-- Error message shown when we try to take a photo, but fail when trying to process it (convert it into something the user can see). -->
<string name="CameraXFragment_photo_processing_failed">Failed to process photo. Please try again.</string>
<!-- Accessibility label for the switch camera button -->
<string name="CameraXFragment_switch_camera">Switch camera</string>
<!-- Accessibility label for flash button when flash is off -->
<string name="CameraXFragment_flash_off">Flash off</string>
<!-- Accessibility label for flash button when flash is on -->
<string name="CameraXFragment_flash_on">Flash on</string>
<!-- Accessibility label for flash button when flash is set to auto -->
<string name="CameraXFragment_flash_auto">Flash auto</string>
<!-- Accessibility label for the send button in media selection -->
<string name="CameraXFragment_send">Send</string>
<string name="CameraContacts__menu_search">Search</string>
@ -530,7 +476,6 @@
<string name="ConversationActivity_signal_needs_the_camera_permission_to_take_photos_or_video">Signal needs the Camera permission to take photos or video, but it has been permanently denied. Please continue to app settings, select \"Permissions\", and enable \"Camera\".</string>
<string name="ConversationActivity_signal_needs_camera_permissions_to_take_photos_or_video">Signal needs Camera permissions to take photos or video</string>
<string name="ConversationActivity_enable_the_microphone_permission_to_capture_videos_with_sound">Enable the microphone permission to capture videos with sound.</string>
<string name="ConversationActivity_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>
<string name="ConversationActivity_signal_needs_recording_permissions_to_capture_video">Signal needs microphone permissions to record videos.</string>
<string name="ConversationActivity_quoted_contact_message">%1$s %2$s</string>

View File

@ -64,6 +64,7 @@ enum class SignalIcons(private val icon: SignalIcon) : SignalIcon by icon {
Link(icon(R.drawable.symbol_link_24)),
Lock(icon(R.drawable.symbol_lock_24)),
Maximize(icon(R.drawable.symbol_maximize_24)),
Mic(icon(R.drawable.symbol_mic_24)),
MoreVertical(icon(R.drawable.symbol_more_vertical_24)),
PersonCircle(icon(R.drawable.symbol_person_circle_24)),
Phone(icon(R.drawable.symbol_phone_24)),

View File

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M12 1.13C9.3 1.13 7.12 3.3 7.12 6v5.5c0 2.7 2.19 4.88 4.88 4.88 2.7 0 4.88-2.19 4.88-4.88V6c0-2.7-2.19-4.88-4.88-4.88ZM8.87 6c0-1.73 1.4-3.13 3.13-3.13s3.13 1.4 3.13 3.13v5.5c0 1.73-1.4 3.13-3.13 3.13s-3.13-1.4-3.13-3.13V6Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M7.75 21.13c-0.48 0-0.88 0.39-0.88 0.87s0.4 0.88 0.88 0.88h8.5c0.48 0 0.88-0.4 0.88-0.88s-0.4-0.88-0.88-0.88h-3.38v-1.29c4.22-0.44 7.5-4 7.5-8.33v-1c0-0.48-0.39-0.88-0.87-0.88s-0.88 0.4-0.88 0.88v1c0 3.66-2.96 6.63-6.62 6.63s-6.63-2.97-6.63-6.63v-1c0-0.48-0.39-0.88-0.87-0.88s-0.88 0.4-0.88 0.88v1c0 4.33 3.3 7.9 7.5 8.33v1.3H7.75Z"/>
</vector>

View File

@ -16,8 +16,8 @@ import org.junit.Assume
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.signal.core.util.video.TranscodingPreset
import org.thoughtcrime.securesms.video.StreamingTranscoder
import org.thoughtcrime.securesms.video.TranscodingPreset
import org.thoughtcrime.securesms.video.exceptions.VideoSourceException
import org.thoughtcrime.securesms.video.videoconverter.exceptions.CodecUnavailableException
import org.thoughtcrime.securesms.video.videoconverter.exceptions.EncodingException

View File

@ -0,0 +1,37 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.camera
import android.app.Application
/**
* Camera Feature Module dependencies
*/
object CameraDependencies {
private lateinit var _application: Application
private lateinit var _provider: Provider
@Synchronized
fun init(application: Application, provider: Provider) {
if (this::_application.isInitialized || this::_provider.isInitialized) {
return
}
_application = application
_provider = provider
}
val application
get() = _application
fun isStoriesFeatureEnabled(): Boolean {
return _provider.isStoriesFeatureEnabled()
}
interface Provider {
fun isStoriesFeatureEnabled(): Boolean
}
}

View File

@ -1,4 +1,9 @@
package org.thoughtcrime.securesms.mediasend
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.camera
import android.app.Activity
import android.content.res.Resources
@ -6,8 +11,6 @@ import androidx.annotation.Dimension
import androidx.annotation.Px
import androidx.window.layout.WindowMetricsCalculator
import org.signal.core.util.dp
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.stories.Stories
/**
* Description of the Camera Viewport, Controls, and Toggle position information.
@ -17,7 +20,7 @@ enum class CameraDisplay(
val roundViewFinderCorners: Boolean,
private val withTogglePositionInfo: PositionInfo,
private val withoutTogglePositionInfo: PositionInfo,
@Dimension(unit = Dimension.DP) private val toggleBottomMargin: Int
@get:Dimension(unit = Dimension.DP) private val toggleBottomMargin: Int
) {
DISPLAY_20_9(
aspectRatio = 9f / 20f,
@ -89,7 +92,7 @@ enum class CameraDisplay(
@JvmOverloads
@Px
fun getCameraCaptureMarginBottom(resources: Resources, storiesEnabled: Boolean = Stories.isFeatureEnabled()): Int {
fun getCameraCaptureMarginBottom(resources: Resources, storiesEnabled: Boolean = CameraDependencies.isStoriesFeatureEnabled()): Int {
val positionInfo = if (storiesEnabled) withTogglePositionInfo else withoutTogglePositionInfo
return positionInfo.cameraCaptureMarginBottomDp.dp - getCameraButtonSizeOffset(resources)
@ -97,14 +100,14 @@ enum class CameraDisplay(
@JvmOverloads
@Px
fun getCameraViewportMarginBottom(storiesEnabled: Boolean = Stories.isFeatureEnabled()): Int {
fun getCameraViewportMarginBottom(storiesEnabled: Boolean = CameraDependencies.isStoriesFeatureEnabled()): Int {
val positionInfo = if (storiesEnabled) withTogglePositionInfo else withoutTogglePositionInfo
return positionInfo.cameraViewportMarginBottomDp.dp
}
@JvmOverloads
fun getCameraViewportGravity(storiesEnabled: Boolean = Stories.isFeatureEnabled()): CameraViewportGravity {
fun getCameraViewportGravity(storiesEnabled: Boolean = CameraDependencies.isStoriesFeatureEnabled()): CameraViewportGravity {
val positionInfo = if (storiesEnabled) withTogglePositionInfo else withoutTogglePositionInfo
return positionInfo.cameraViewportGravity

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright 2026 Signal Messenger, LLC
~ SPDX-License-Identifier: AGPL-3.0-only
-->
<resources>
<dimen name="camera_capture_button_size">124dp</dimen>
<dimen name="camera_capture_image_button_size">76dp</dimen>
</resources>

View File

@ -35,6 +35,7 @@ dependencies {
implementation(project(":lib:image-editor"))
implementation(project(":lib:glide"))
implementation(project(":lib:video"))
implementation(project(":feature:camera"))
// Compose BOM
platform(libs.androidx.compose.bom).let { composeBom ->
@ -67,6 +68,9 @@ dependencies {
// Media
implementation(libs.androidx.media3.exoplayer)
// CameraX
implementation(libs.androidx.camera.core)
// Testing
testImplementation(testLibs.junit.junit)
testImplementation(testLibs.mockk)

View File

@ -3,12 +3,10 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediasend.capture;
package org.signal.mediasend;
import androidx.annotation.NonNull;
import org.signal.mediasend.MediaConstraints;
import java.io.FileDescriptor;
public interface CameraFragment {

View File

@ -102,6 +102,10 @@ interface MediaSendRepository {
fun observeRecipientValid(recipientId: MediaRecipientId): Flow<Boolean>
fun getAttachmentStream(context: Context, uri: Uri): InputStream
fun isMixedModeAvailable(): Boolean
var isCameraFacingFront: Boolean
}
/**

View File

@ -1,10 +1,14 @@
package org.thoughtcrime.securesms.video;
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediasend;
import android.content.Context;
import androidx.annotation.NonNull;
import org.signal.mediasend.MediaConstraints;
import org.thoughtcrime.securesms.video.videoconverter.utils.VideoConstants;
public final class VideoUtil {

View File

@ -1,4 +1,9 @@
package org.thoughtcrime.securesms.mediasend
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.mediasend.capture
import android.Manifest
import android.content.Context
@ -8,9 +13,9 @@ import android.graphics.Bitmap
import android.os.Build
import android.os.Bundle
import android.os.ParcelFileDescriptor
import android.system.Os
import android.system.OsConstants
import android.widget.Toast
import android.widget.Toast.LENGTH_SHORT
import android.widget.Toast.makeText
import androidx.camera.core.CameraSelector
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
@ -41,10 +46,14 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
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.viewmodel.compose.viewModel
import kotlinx.coroutines.delay
import org.signal.camera.CameraCaptureMode
import org.signal.camera.CameraDependencies
import org.signal.camera.CameraDisplay
import org.signal.camera.CameraScreen
import org.signal.camera.CameraScreenEvents
import org.signal.camera.CameraScreenViewModel
@ -57,19 +66,16 @@ import org.signal.camera.hud.StandardCameraHudEvents
import org.signal.camera.hud.StringResources
import org.signal.core.ui.BottomSheetUtil
import org.signal.core.ui.compose.ComposeFragment
import org.signal.core.ui.permissions.PermissionDeniedBottomSheet.Companion.showPermissionFragment
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.permissions.PermissionDeniedBottomSheet
import org.signal.core.ui.permissions.Permissions
import org.signal.core.util.MemoryFileDescriptor
import org.signal.core.util.asListContains
import org.signal.core.util.logging.Log
import org.signal.mediasend.CameraFragment
import org.signal.mediasend.MediaConstraints
import org.signal.mediasend.capture.CameraFragment
import org.thoughtcrime.securesms.R
import org.thoughtcrime.securesms.R.string.CameraFragment__video_recording_is_not_supported_on_your_device
import org.thoughtcrime.securesms.keyvalue.SignalStore
import org.thoughtcrime.securesms.stories.Stories
import org.thoughtcrime.securesms.util.RemoteConfig
import org.thoughtcrime.securesms.video.VideoUtil
import org.signal.mediasend.MediaSendDependencies
import org.signal.mediasend.R
import org.signal.mediasend.VideoUtil
import java.io.ByteArrayOutputStream
import java.io.IOException
@ -199,13 +205,13 @@ class CameraXFragment : ComposeFragment(), CameraFragment {
}
.onSomePermanentlyDenied { deniedPermissions ->
if (deniedPermissions.containsAll(listOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO))) {
showPermissionFragment(
PermissionDeniedBottomSheet.showPermissionFragment(
R.string.CameraXFragment_allow_access_camera_microphone,
R.string.CameraXFragment_to_capture_photos_videos,
false
).show(parentFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
} else if (deniedPermissions.contains(Manifest.permission.CAMERA)) {
showPermissionFragment(
PermissionDeniedBottomSheet.showPermissionFragment(
R.string.CameraXFragment_allow_access_camera,
R.string.CameraXFragment_to_capture_photos_videos,
false
@ -253,8 +259,14 @@ class CameraXFragment : ComposeFragment(), CameraFragment {
Permissions.with(this)
.request(Manifest.permission.RECORD_AUDIO)
.ifNecessary()
.withRationaleDialog(getString(R.string.CameraXFragment_allow_access_microphone), getString(R.string.CameraXFragment_to_capture_videos_with_sound), R.drawable.ic_mic_24)
.withPermanentDenialDialog(getString(R.string.ConversationActivity_signal_needs_the_recording_permissions_to_capture_video), null, R.string.CameraXFragment_allow_access_microphone, R.string.CameraXFragment_to_capture_videos, parentFragmentManager)
.withRationaleDialog(getString(R.string.CameraXFragment_allow_access_microphone), getString(R.string.CameraXFragment_to_capture_videos_with_sound), org.signal.core.ui.R.drawable.symbol_mic_24)
.withPermanentDenialDialog(
getString(R.string.CameraXFragment_signal_needs_the_recording_permissions_to_capture_video),
null,
R.string.CameraXFragment_allow_access_microphone,
R.string.CameraXFragment_to_capture_videos,
parentFragmentManager
)
.onAnyDenied { Toast.makeText(requireContext(), R.string.CameraXFragment_signal_needs_microphone_access_video, Toast.LENGTH_LONG).show() }
.execute()
}
@ -301,7 +313,7 @@ class CameraXFragment : ComposeFragment(), CameraFragment {
val isMixedModeSupported = isVideoSupported &&
CameraXUtil.isMixedModeSupported(requireContext()) &&
!RemoteConfig.cameraXMixedModelBlocklist.asListContains(Build.MODEL)
MediaSendDependencies.mediaSendRepository.isMixedModeAvailable()
return when {
isMixedModeSupported -> CameraCaptureMode.ImageAndVideoSimultaneous
@ -326,7 +338,7 @@ private fun CameraXScreen(
createVideoFileDescriptor: () -> ParcelFileDescriptor?,
getMaxVideoDurationInSeconds: () -> Int,
cameraDisplay: CameraDisplay,
storiesEnabled: Boolean = Stories.isFeatureEnabled()
storiesEnabled: Boolean = CameraDependencies.isStoriesFeatureEnabled()
) {
val context = LocalContext.current
val cameraViewModel: CameraScreenViewModel = viewModel()
@ -334,7 +346,7 @@ private fun CameraXScreen(
var hasPermission by remember { mutableStateOf(hasCameraPermission()) }
LaunchedEffect(cameraViewModel) {
val lensFacing = if (SignalStore.misc.isCameraFacingFront) {
val lensFacing = if (MediaSendDependencies.mediaSendRepository.isCameraFacingFront) {
CameraSelector.LENS_FACING_FRONT
} else {
CameraSelector.LENS_FACING_BACK
@ -345,7 +357,7 @@ private fun CameraXScreen(
LaunchedEffect(cameraViewModel) {
snapshotFlow { cameraState.lensFacing }
.collect { lensFacing ->
SignalStore.misc.isCameraFacingFront = lensFacing == CameraSelector.LENS_FACING_FRONT
MediaSendDependencies.mediaSendRepository.isCameraFacingFront = lensFacing == CameraSelector.LENS_FACING_FRONT
}
}
@ -365,7 +377,7 @@ private fun CameraXScreen(
LaunchedEffect(Unit) {
while (true) {
kotlinx.coroutines.delay(500)
delay(500)
val newHasPermission = hasCameraPermission()
if (newHasPermission != hasPermission) {
hasPermission = newHasPermission
@ -547,11 +559,11 @@ private fun handleHudEvent(
}
)
} else {
makeText(context, CameraFragment__video_recording_is_not_supported_on_your_device, LENGTH_SHORT)
Toast.makeText(context, R.string.CameraFragment__video_recording_is_not_supported_on_your_device, Toast.LENGTH_SHORT)
.show()
}
} else {
makeText(context, CameraFragment__video_recording_is_not_supported_on_your_device, LENGTH_SHORT)
Toast.makeText(context, R.string.CameraFragment__video_recording_is_not_supported_on_your_device, Toast.LENGTH_SHORT)
.show()
}
}
@ -605,7 +617,7 @@ private fun handleVideoCaptured(result: VideoCaptureResult, controller: CameraFr
result.fileDescriptor?.let { parcelFd ->
try {
// Seek to beginning before reading
android.system.Os.lseek(parcelFd.fileDescriptor, 0, android.system.OsConstants.SEEK_SET)
Os.lseek(parcelFd.fileDescriptor, 0, OsConstants.SEEK_SET)
controller?.onVideoCaptured(parcelFd.fileDescriptor)
} catch (e: Exception) {
Log.w(TAG, "Failed to seek video file descriptor", e)
@ -621,7 +633,7 @@ private fun handleVideoCaptured(result: VideoCaptureResult, controller: CameraFr
}
}
@androidx.compose.ui.tooling.preview.Preview(
@Preview(
name = "20:9 Display",
showBackground = true,
widthDp = 360,
@ -629,7 +641,7 @@ private fun handleVideoCaptured(result: VideoCaptureResult, controller: CameraFr
)
@Composable
private fun CameraXScreenPreview_20_9() {
org.signal.core.ui.compose.Previews.Preview {
Previews.Preview {
CameraXScreen(
controller = null,
isVideoEnabled = true,
@ -649,7 +661,7 @@ private fun CameraXScreenPreview_20_9() {
}
}
@androidx.compose.ui.tooling.preview.Preview(
@Preview(
name = "19:9 Display",
showBackground = true,
widthDp = 360,
@ -657,7 +669,7 @@ private fun CameraXScreenPreview_20_9() {
)
@Composable
private fun CameraXScreenPreview_19_9() {
org.signal.core.ui.compose.Previews.Preview {
Previews.Preview {
CameraXScreen(
controller = null,
isVideoEnabled = true,
@ -677,7 +689,7 @@ private fun CameraXScreenPreview_19_9() {
}
}
@androidx.compose.ui.tooling.preview.Preview(
@Preview(
name = "18:9 Display",
showBackground = true,
widthDp = 360,
@ -685,7 +697,7 @@ private fun CameraXScreenPreview_19_9() {
)
@Composable
private fun CameraXScreenPreview_18_9() {
org.signal.core.ui.compose.Previews.Preview {
Previews.Preview {
CameraXScreen(
controller = null,
isVideoEnabled = true,
@ -705,7 +717,7 @@ private fun CameraXScreenPreview_18_9() {
}
}
@androidx.compose.ui.tooling.preview.Preview(
@Preview(
name = "16:9 Display",
showBackground = true,
widthDp = 360,
@ -713,7 +725,7 @@ private fun CameraXScreenPreview_18_9() {
)
@Composable
private fun CameraXScreenPreview_16_9() {
org.signal.core.ui.compose.Previews.Preview {
Previews.Preview {
CameraXScreen(
controller = null,
isVideoEnabled = true,
@ -733,7 +745,7 @@ private fun CameraXScreenPreview_16_9() {
}
}
@androidx.compose.ui.tooling.preview.Preview(
@Preview(
name = "6:5 Display (Tablet)",
showBackground = true,
widthDp = 480,
@ -741,7 +753,7 @@ private fun CameraXScreenPreview_16_9() {
)
@Composable
private fun CameraXScreenPreview_6_5() {
org.signal.core.ui.compose.Previews.Preview {
Previews.Preview {
CameraXScreen(
controller = null,
isVideoEnabled = true,

View File

@ -28,4 +28,61 @@
<string name="MediaCaptureScreen__text_story">Text Story</string>
<!-- Video editor play button content description -->
<string name="VideoEditorHud_play_video_description">Play video</string>
<!-- CameraFragment -->
<!-- Toasted when user device does not support video recording -->
<string name="CameraFragment__video_recording_is_not_supported_on_your_device">Video recording is not supported on your device</string>
<!-- CameraXFragment -->
<string name="CameraXFragment_tap_for_photo_hold_for_video">Tap for photo, hold for video</string>
<!-- Accessibility content description to describe the capture button when taking an image/video -->
<string name="CameraXFragment_capture_description">Capture</string>
<string name="CameraXFragment_change_camera_description">Change camera</string>
<string name="CameraXFragment_open_gallery_description">Open gallery</string>
<!-- Button text asking for access to camera permissions -->
<string name="CameraXFragment_allow_access">Allow access</string>
<!-- Dialog title asking users for camera and microphone permission -->
<string name="CameraXFragment_allow_access_camera_microphone">Allow access to your camera and microphone</string>
<!-- Dialog title asking users for camera permission -->
<string name="CameraXFragment_allow_access_camera">Allow access to your camera</string>
<!-- Dialog title asking users for microphone permission -->
<string name="CameraXFragment_allow_access_microphone">Allow access to your microphone</string>
<!-- Text explaining why Signal needs camera access in order to take photos and videos -->
<string name="CameraXFragment_to_capture_photos_and_video_allow_camera">To capture photos and video, allow Signal access to the camera.</string>
<!-- Text explaining why Signal needs camera and microphone access in order to take photos and videos -->
<string name="CameraXFragment_to_capture_photos_and_video_allow_camera_microphone">To capture photos and video, allow Signal access to the camera and microphone.</string>
<!-- Text explaining why Signal needs microphone access to take videos -->
<string name="CameraXFragment_to_capture_videos_with_sound">To capture videos with sound, allow Signal access to your microphone.</string>
<!-- Text explaining why Signal needs camera access to scan QR codes -->
<string name="CameraXFragment_to_scan_qr_code_allow_camera">To scan a QR code, allow Signal access to the camera.</string>
<!-- Toast dialog explaining why Signal needs camera permissions when capturing photos -->
<string name="CameraXFragment_signal_needs_camera_access_capture_photos">Signal needs camera access to capture photos</string>
<!-- Toast dialog explaining why Signal needs camera permissions when scanning QR codes -->
<string name="CameraXFragment_signal_needs_camera_access_scan_qr_code">Signal needs camera access to scan QR codes</string>
<!-- Toast dialog explaining why Signal needs microphone permissions -->
<string name="CameraXFragment_signal_needs_microphone_access_video">Signal needs microphone access to capture video</string>
<!-- Dialog description that explains the steps needed to give camera permission -->
<string name="CameraXFragment_to_capture_photos">To capture photos in Signal:</string>
<!-- Dialog description that explains the steps needed to give camera and microphone permission -->
<string name="CameraXFragment_to_capture_photos_videos">To capture photos and videos in Signal:</string>
<!-- Dialog description that explains the steps needed to give microphone permission -->
<string name="CameraXFragment_to_capture_videos">To capture videos with sound:</string>
<!-- Dialog description that explains the steps needed to give Signal camera permissions -->
<string name="CameraXFragment_to_scan_qr_codes">To scan QR codes:</string>
<!-- Error message shown when we try to take a photo, but fail -->
<string name="CameraXFragment_photo_capture_failed">Failed to capture photo. Please try again.</string>
<!-- Error message shown when we try to take a photo, but fail when trying to process it (convert it into something the user can see). -->
<string name="CameraXFragment_photo_processing_failed">Failed to process photo. Please try again.</string>
<!-- Accessibility label for the switch camera button -->
<string name="CameraXFragment_switch_camera">Switch camera</string>
<!-- Accessibility label for flash button when flash is off -->
<string name="CameraXFragment_flash_off">Flash off</string>
<!-- Accessibility label for flash button when flash is on -->
<string name="CameraXFragment_flash_on">Flash on</string>
<!-- Accessibility label for flash button when flash is set to auto -->
<string name="CameraXFragment_flash_auto">Flash auto</string>
<!-- Accessibility label for the send button in media selection -->
<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>
</resources>