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
34 changed files with 246 additions and 655 deletions
@@ -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()
}
}
@@ -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))
}
}
}
@@ -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()
}
@@ -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)
}
@@ -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()
}
}
@@ -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()
}
@@ -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))
}
}
}
@@ -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
/**
@@ -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;
@@ -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);
}
}
@@ -1,162 +0,0 @@
package org.thoughtcrime.securesms.mediasend
import android.app.Activity
import android.content.res.Resources
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.
*/
enum class CameraDisplay(
private val aspectRatio: Float,
val roundViewFinderCorners: Boolean,
private val withTogglePositionInfo: PositionInfo,
private val withoutTogglePositionInfo: PositionInfo,
@Dimension(unit = Dimension.DP) private val toggleBottomMargin: Int
) {
DISPLAY_20_9(
aspectRatio = 9f / 20f,
roundViewFinderCorners = true,
withTogglePositionInfo = PositionInfo(
cameraCaptureMarginBottomDp = 130,
cameraViewportMarginBottomDp = 106,
cameraViewportGravity = CameraViewportGravity.BOTTOM
),
withoutTogglePositionInfo = PositionInfo(
cameraCaptureMarginBottomDp = 130,
cameraViewportGravity = CameraViewportGravity.CENTER
),
toggleBottomMargin = 52
),
DISPLAY_19_9(
aspectRatio = 9f / 19f,
roundViewFinderCorners = true,
withTogglePositionInfo = PositionInfo(
cameraCaptureMarginBottomDp = 128,
cameraViewportMarginBottomDp = 104,
cameraViewportGravity = CameraViewportGravity.BOTTOM
),
withoutTogglePositionInfo = PositionInfo(
cameraCaptureMarginBottomDp = 128,
cameraViewportGravity = CameraViewportGravity.CENTER
),
toggleBottomMargin = 52
),
DISPLAY_18_9(
aspectRatio = 9f / 18f,
roundViewFinderCorners = true,
withTogglePositionInfo = PositionInfo(
cameraCaptureMarginBottomDp = 120,
cameraViewportGravity = CameraViewportGravity.CENTER
),
withoutTogglePositionInfo = PositionInfo(
cameraCaptureMarginBottomDp = 84,
cameraViewportGravity = CameraViewportGravity.CENTER
),
toggleBottomMargin = 54
),
DISPLAY_16_9(
aspectRatio = 9f / 16f,
roundViewFinderCorners = false,
withTogglePositionInfo = PositionInfo(
cameraCaptureMarginBottomDp = 120,
cameraViewportGravity = CameraViewportGravity.BOTTOM
),
withoutTogglePositionInfo = PositionInfo(
cameraCaptureMarginBottomDp = 84,
cameraViewportGravity = CameraViewportGravity.BOTTOM
),
toggleBottomMargin = 54
),
DISPLAY_6_5(
aspectRatio = 5f / 6f,
roundViewFinderCorners = false,
withTogglePositionInfo = PositionInfo(
cameraCaptureMarginBottomDp = 120,
cameraViewportGravity = CameraViewportGravity.CENTER
),
withoutTogglePositionInfo = PositionInfo(
cameraCaptureMarginBottomDp = 84,
cameraViewportGravity = CameraViewportGravity.CENTER
),
toggleBottomMargin = 54
);
@JvmOverloads
@Px
fun getCameraCaptureMarginBottom(resources: Resources, storiesEnabled: Boolean = Stories.isFeatureEnabled()): Int {
val positionInfo = if (storiesEnabled) withTogglePositionInfo else withoutTogglePositionInfo
return positionInfo.cameraCaptureMarginBottomDp.dp - getCameraButtonSizeOffset(resources)
}
@JvmOverloads
@Px
fun getCameraViewportMarginBottom(storiesEnabled: Boolean = Stories.isFeatureEnabled()): Int {
val positionInfo = if (storiesEnabled) withTogglePositionInfo else withoutTogglePositionInfo
return positionInfo.cameraViewportMarginBottomDp.dp
}
@JvmOverloads
fun getCameraViewportGravity(storiesEnabled: Boolean = Stories.isFeatureEnabled()): CameraViewportGravity {
val positionInfo = if (storiesEnabled) withTogglePositionInfo else withoutTogglePositionInfo
return positionInfo.cameraViewportGravity
}
@Px
fun getToggleBottomMargin(): Int {
return toggleBottomMargin.dp
}
companion object {
@Px
@JvmStatic
private fun getCameraButtonSizeOffset(resources: Resources): Int {
val cameraCaptureButtonSize = resources.getDimensionPixelSize(R.dimen.camera_capture_button_size)
val cameraCaptureImageButtonSize = resources.getDimensionPixelSize(R.dimen.camera_capture_image_button_size)
return (cameraCaptureButtonSize - cameraCaptureImageButtonSize) / 2
}
/**
* Get the camera display type given the current window metrics. Note that this
* will automatically invert the aspect ratio in the case of a non-portrait orientation,
* since we fix camera to portrait.
*/
@JvmStatic
fun getDisplay(activity: Activity): CameraDisplay {
val windowMetricsCalculator = WindowMetricsCalculator.getOrCreate()
val windowMetrics = windowMetricsCalculator.computeCurrentWindowMetrics(activity)
val width = windowMetrics.bounds.width()
val height = windowMetrics.bounds.height()
val winAr = width.toFloat() / height
val aspectRatio = if (winAr > 1f) 1 / winAr else winAr
return when {
aspectRatio <= DISPLAY_20_9.aspectRatio -> DISPLAY_20_9
aspectRatio <= DISPLAY_19_9.aspectRatio -> DISPLAY_19_9
aspectRatio <= DISPLAY_18_9.aspectRatio -> DISPLAY_18_9
aspectRatio >= DISPLAY_6_5.aspectRatio -> DISPLAY_6_5
else -> DISPLAY_16_9
}
}
}
enum class CameraViewportGravity {
CENTER,
BOTTOM
}
data class PositionInfo(
@Dimension(unit = Dimension.DP) val cameraCaptureMarginBottomDp: Int,
@Dimension(unit = Dimension.DP) val cameraViewportMarginBottomDp: Int = 0,
val cameraViewportGravity: CameraViewportGravity
)
}
@@ -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
}
}
@@ -1,762 +0,0 @@
package org.thoughtcrime.securesms.mediasend
import android.Manifest
import android.content.Context
import android.content.pm.ActivityInfo
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.os.Build
import android.os.Bundle
import android.os.ParcelFileDescriptor
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
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
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.LaunchedEffect
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
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
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.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import org.signal.camera.CameraCaptureMode
import org.signal.camera.CameraScreen
import org.signal.camera.CameraScreenEvents
import org.signal.camera.CameraScreenViewModel
import org.signal.camera.CameraXUtil
import org.signal.camera.VideoCaptureResult
import org.signal.camera.VideoOutput
import org.signal.camera.hud.GalleryThumbnailButton
import org.signal.camera.hud.StandardCameraHud
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.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.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 java.io.ByteArrayOutputStream
import java.io.IOException
private val TAG = Log.tag(CameraXFragment::class.java)
/**
* Camera capture implemented using a Compose-based CameraScreen with CameraX SDK under the hood.
* This is the preferred camera implementation when supported.
*/
class CameraXFragment : ComposeFragment(), CameraFragment {
companion object {
private const val IS_VIDEO_ENABLED = "is_video_enabled"
private const val IS_QR_SCAN_ENABLED = "is_qr_scan_enabled"
private const val CONTROLS_ANIMATION_DURATION = 250L
@JvmStatic
fun newInstanceForAvatarCapture(): CameraXFragment {
return CameraXFragment().apply {
arguments = Bundle().apply {
putBoolean(IS_VIDEO_ENABLED, false)
putBoolean(IS_QR_SCAN_ENABLED, false)
}
}
}
@JvmStatic
fun newInstance(qrScanEnabled: Boolean): CameraXFragment {
return CameraXFragment().apply {
arguments = Bundle().apply {
putBoolean(IS_QR_SCAN_ENABLED, qrScanEnabled)
}
}
}
}
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)
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.")
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
captureMode = resolveCaptureMode()
Log.d(TAG, "Starting CameraX with capture mode $captureMode")
}
@Composable
override fun FragmentContent() {
CameraXScreen(
controller = controller,
isVideoEnabled = captureMode != CameraCaptureMode.ImageOnly,
isQrScanEnabled = isQrScanEnabled,
captureMode = captureMode,
controlsVisible = controlsVisible.value,
selectedMediaCount = selectedMediaCount.intValue,
onCheckPermissions = { checkPermissions(isVideoEnabled) },
hasCameraPermission = { hasCameraPermission() },
onRequestMicPermission = { requestMicPermission() },
onGalleryClicked = { controller?.onGalleryClicked() },
createVideoFileDescriptor = { createVideoFileDescriptor() },
getMaxVideoDurationInSeconds = { getMaxVideoDurationInSeconds() },
cameraDisplay = CameraDisplay.getDisplay(requireActivity())
)
}
override fun onResume() {
super.onResume()
requireActivity().requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
override fun onDestroyView() {
super.onDestroyView()
closeVideoFileDescriptor()
requireActivity().requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray
) {
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults)
}
override fun presentHud(selectedMediaCount: Int) {
this.selectedMediaCount.intValue = selectedMediaCount
}
override fun fadeOutControls(onEndAction: Runnable) {
controlsVisible.value = 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
}
private fun checkPermissions(includeAudio: Boolean) {
if (hasCameraPermission()) {
return
}
if (includeAudio) {
Permissions.with(this)
.request(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO)
.ifNecessary()
.onSomeGranted { permissions ->
// Will trigger recomposition via hasCameraPermission check
}
.onSomePermanentlyDenied { deniedPermissions ->
if (deniedPermissions.containsAll(listOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO))) {
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(
R.string.CameraXFragment_allow_access_camera,
R.string.CameraXFragment_to_capture_photos_videos,
false
).show(parentFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
.onSomeDenied { deniedPermissions ->
if (deniedPermissions.contains(Manifest.permission.CAMERA)) {
Toast.makeText(
requireContext(),
R.string.CameraXFragment_signal_needs_camera_access_capture_photos,
Toast.LENGTH_LONG
).show()
}
}
.execute()
} else {
Permissions.with(this)
.request(Manifest.permission.CAMERA)
.ifNecessary()
.onAllGranted { /* Will trigger recomposition */ }
.onAnyDenied {
Toast.makeText(
requireContext(),
R.string.CameraXFragment_signal_needs_camera_access_capture_photos,
Toast.LENGTH_LONG
).show()
}
.withPermanentDenialDialog(
getString(R.string.CameraXFragment_signal_needs_camera_access_capture_photos),
null,
R.string.CameraXFragment_allow_access_camera,
R.string.CameraXFragment_to_capture_photos,
parentFragmentManager
)
.execute()
}
}
private fun hasCameraPermission(): Boolean {
return Permissions.hasAll(requireContext(), Manifest.permission.CAMERA)
}
private fun requestMicPermission() {
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)
.onAnyDenied { Toast.makeText(requireContext(), R.string.CameraXFragment_signal_needs_microphone_access_video, Toast.LENGTH_LONG).show() }
.execute()
}
private fun createVideoFileDescriptor(): ParcelFileDescriptor? {
if (Build.VERSION.SDK_INT < 26) {
throw IllegalStateException("Video capture requires API 26 or higher")
}
return try {
closeVideoFileDescriptor()
videoFileDescriptor = CameraXUtil.createVideoFileDescriptor(requireContext())
videoFileDescriptor?.parcelFd
} catch (e: IOException) {
Log.w(TAG, "Failed to create video file descriptor", e)
null
}
}
private fun closeVideoFileDescriptor() {
videoFileDescriptor?.let {
try {
it.close()
} catch (e: IOException) {
Log.w(TAG, "Failed to close video file descriptor", e)
}
videoFileDescriptor = null
}
}
private fun getMaxVideoDurationInSeconds(): Int {
var maxDuration = VideoUtil.getMaxVideoRecordDurationInSeconds(requireContext(), 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()) &&
!RemoteConfig.cameraXMixedModelBlocklist.asListContains(Build.MODEL)
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,
onCheckPermissions: () -> Unit,
hasCameraPermission: () -> Boolean,
onRequestMicPermission: () -> Unit,
onGalleryClicked: () -> Unit,
createVideoFileDescriptor: () -> ParcelFileDescriptor?,
getMaxVideoDurationInSeconds: () -> Int,
cameraDisplay: CameraDisplay,
storiesEnabled: Boolean = Stories.isFeatureEnabled()
) {
val context = LocalContext.current
val cameraViewModel: CameraScreenViewModel = viewModel()
val cameraState by cameraViewModel.state
var hasPermission by remember { mutableStateOf(hasCameraPermission()) }
LaunchedEffect(cameraViewModel) {
val lensFacing = if (SignalStore.misc.isCameraFacingFront) {
CameraSelector.LENS_FACING_FRONT
} else {
CameraSelector.LENS_FACING_BACK
}
cameraViewModel.setLensFacing(lensFacing)
}
LaunchedEffect(cameraViewModel) {
snapshotFlow { cameraState.lensFacing }
.collect { lensFacing ->
SignalStore.misc.isCameraFacingFront = lensFacing == CameraSelector.LENS_FACING_FRONT
}
}
LaunchedEffect(Unit) {
if (!hasPermission) {
onCheckPermissions()
}
}
LaunchedEffect(cameraViewModel, isQrScanEnabled) {
if (isQrScanEnabled) {
cameraViewModel.qrCodeDetected.collect { qrCode ->
controller?.onQrCodeFound(qrCode)
}
}
}
LaunchedEffect(Unit) {
while (true) {
kotlinx.coroutines.delay(500)
val newHasPermission = hasCameraPermission()
if (newHasPermission != hasPermission) {
hasPermission = newHasPermission
}
}
}
val resources = LocalContext.current.resources
val hudBottomMargin = with(LocalDensity.current) {
cameraDisplay.getCameraCaptureMarginBottom(resources, storiesEnabled).toDp()
}
val viewportGravity = cameraDisplay.getCameraViewportGravity(storiesEnabled)
val cameraAlignment = when (viewportGravity) {
CameraDisplay.CameraViewportGravity.CENTER -> Alignment.Center
CameraDisplay.CameraViewportGravity.BOTTOM -> Alignment.BottomCenter
}
val viewportBottomMargin = if (viewportGravity == CameraDisplay.CameraViewportGravity.BOTTOM) {
with(LocalDensity.current) { cameraDisplay.getCameraViewportMarginBottom(storiesEnabled).toDp() }
} else {
0.dp
}
BoxWithConstraints(
modifier = Modifier.fillMaxSize()
) {
// We have to do a bunch of match to figure out how to place the camera buttons because
// the logic relies on positining things from the edge of the screen, which doesn't jive
// with how the composables are arranged. When this screen is re-written, we should simplify
// this whole setup. For now, I'm just doing my best to match current behavior.
val cameraAspectRatio = 9f / 16f
val availableHeight = maxHeight - viewportBottomMargin
val availableAspectRatio = maxWidth / availableHeight
val matchHeightFirst = availableAspectRatio > cameraAspectRatio
val viewportHeight = if (matchHeightFirst) {
availableHeight
} else {
maxWidth / cameraAspectRatio
}
val bottomGapFromAlignment = when (viewportGravity) {
CameraDisplay.CameraViewportGravity.CENTER -> (availableHeight - viewportHeight) / 2
CameraDisplay.CameraViewportGravity.BOTTOM -> 0.dp
}
val totalBottomOffset = viewportBottomMargin + bottomGapFromAlignment
val hudBottomPaddingInsideViewport = maxOf(0.dp, hudBottomMargin - totalBottomOffset)
if (hasPermission) {
CameraScreen(
state = cameraState,
emitter = { event -> cameraViewModel.onEvent(event) },
roundCorners = cameraDisplay.roundViewFinderCorners,
contentAlignment = cameraAlignment,
captureMode = captureMode,
enableQrScanning = isQrScanEnabled,
modifier = Modifier.padding(bottom = viewportBottomMargin)
) {
AnimatedVisibility(
visible = controlsVisible,
enter = fadeIn(animationSpec = tween(durationMillis = 150)),
exit = fadeOut(animationSpec = tween(durationMillis = 150))
) {
Box(modifier = Modifier.fillMaxSize()) {
StandardCameraHud(
state = cameraState,
modifier = Modifier.padding(bottom = hudBottomPaddingInsideViewport),
maxRecordingDurationMs = getMaxVideoDurationInSeconds() * 1000L,
mediaSelectionCount = selectedMediaCount,
hasAudioPermission = { context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED },
emitter = { event ->
handleHudEvent(
event = event,
context = context,
cameraViewModel = cameraViewModel,
controller = controller,
isVideoEnabled = isVideoEnabled,
onRequestMicPermission = onRequestMicPermission,
createVideoFileDescriptor = createVideoFileDescriptor
)
},
stringResources = StringResources(
photoCaptureFailed = R.string.CameraXFragment_photo_capture_failed,
photoProcessingFailed = R.string.CameraXFragment_photo_processing_failed
)
)
}
}
}
} else {
PermissionMissingContent(
isVideoEnabled = isVideoEnabled,
onRequestPermissions = onCheckPermissions,
onGalleryClicked = onGalleryClicked,
galleryButtonBottomPadding = hudBottomMargin + 16.dp
)
}
}
}
@Composable
private fun PermissionMissingContent(
isVideoEnabled: Boolean,
onRequestPermissions: () -> Unit,
onGalleryClicked: () -> Unit,
galleryButtonBottomPadding: Dp = 16.dp
) {
val context = LocalContext.current
val hasAudioPermission = remember { Permissions.hasAll(context, Manifest.permission.RECORD_AUDIO) }
val textResId = if (!isVideoEnabled || hasAudioPermission) {
R.string.CameraXFragment_to_capture_photos_and_video_allow_camera
} else {
R.string.CameraXFragment_to_capture_photos_and_video_allow_camera_microphone
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(16.dp)
) {
Text(
text = stringResource(textResId),
color = Color.White,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = onRequestPermissions) {
Text(text = stringResource(R.string.CameraXFragment_allow_access))
}
}
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(bottom = galleryButtonBottomPadding, end = 40.dp)
) {
GalleryThumbnailButton(onClick = onGalleryClicked)
}
}
}
private fun handleHudEvent(
event: StandardCameraHudEvents,
context: Context,
cameraViewModel: CameraScreenViewModel,
controller: CameraFragment.Controller?,
isVideoEnabled: Boolean,
onRequestMicPermission: () -> Unit,
createVideoFileDescriptor: () -> ParcelFileDescriptor?
) {
when (event) {
is StandardCameraHudEvents.PhotoCaptureTriggered -> {
cameraViewModel.capturePhoto(
context = context,
onPhotoCaptured = { bitmap ->
handlePhotoCaptured(bitmap, controller)
}
)
}
is StandardCameraHudEvents.VideoCaptureStarted -> {
if (Build.VERSION.SDK_INT >= 26 && isVideoEnabled) {
val fileDescriptor = createVideoFileDescriptor()
if (fileDescriptor != null) {
cameraViewModel.startRecording(
context = context,
output = VideoOutput.FileDescriptorOutput(fileDescriptor),
onVideoCaptured = { result ->
handleVideoCaptured(result, controller)
}
)
} else {
makeText(context, CameraFragment__video_recording_is_not_supported_on_your_device, LENGTH_SHORT)
.show()
}
} else {
makeText(context, CameraFragment__video_recording_is_not_supported_on_your_device, LENGTH_SHORT)
.show()
}
}
is StandardCameraHudEvents.VideoCaptureStopped -> {
cameraViewModel.stopRecording()
}
is StandardCameraHudEvents.GalleryClick -> {
controller?.onGalleryClicked()
}
is StandardCameraHudEvents.MediaSelectionClick -> {
controller?.onCameraCountButtonClicked()
}
is StandardCameraHudEvents.ToggleFlash -> {
cameraViewModel.onEvent(CameraScreenEvents.NextFlashMode)
}
is StandardCameraHudEvents.ClearCaptureError -> {
cameraViewModel.onEvent(CameraScreenEvents.ClearCaptureError)
}
is StandardCameraHudEvents.SwitchCamera -> {
cameraViewModel.onEvent(CameraScreenEvents.SwitchCamera(context))
}
is StandardCameraHudEvents.SetZoomLevel -> {
cameraViewModel.onEvent(CameraScreenEvents.LinearZoom(event.zoomLevel))
}
is StandardCameraHudEvents.AudioPermissionRequired -> {
onRequestMicPermission()
}
}
}
private fun handlePhotoCaptured(bitmap: Bitmap, controller: CameraFragment.Controller?) {
// 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)
}
private fun handleVideoCaptured(result: VideoCaptureResult, controller: CameraFragment.Controller?) {
when (result) {
is VideoCaptureResult.Success -> {
result.fileDescriptor?.let { parcelFd ->
try {
// Seek to beginning before reading
android.system.Os.lseek(parcelFd.fileDescriptor, 0, android.system.OsConstants.SEEK_SET)
controller?.onVideoCaptured(parcelFd.fileDescriptor)
} catch (e: Exception) {
Log.w(TAG, "Failed to seek video file descriptor", e)
controller?.onVideoCaptureError()
}
} ?: controller?.onVideoCaptureError()
}
is VideoCaptureResult.Error -> {
Log.w(TAG, "Video capture failed: ${result.message}", result.throwable)
controller?.onVideoCaptureError()
}
}
}
@androidx.compose.ui.tooling.preview.Preview(
name = "20:9 Display",
showBackground = true,
widthDp = 360,
heightDp = 800
)
@Composable
private fun CameraXScreenPreview_20_9() {
org.signal.core.ui.compose.Previews.Preview {
CameraXScreen(
controller = null,
isVideoEnabled = true,
isQrScanEnabled = false,
captureMode = CameraCaptureMode.ImageAndVideoSimultaneous,
controlsVisible = true,
selectedMediaCount = 0,
onCheckPermissions = {},
hasCameraPermission = { true },
onRequestMicPermission = { },
onGalleryClicked = { },
createVideoFileDescriptor = { null },
getMaxVideoDurationInSeconds = { 60 },
cameraDisplay = CameraDisplay.DISPLAY_20_9,
storiesEnabled = true
)
}
}
@androidx.compose.ui.tooling.preview.Preview(
name = "19:9 Display",
showBackground = true,
widthDp = 360,
heightDp = 760
)
@Composable
private fun CameraXScreenPreview_19_9() {
org.signal.core.ui.compose.Previews.Preview {
CameraXScreen(
controller = null,
isVideoEnabled = true,
isQrScanEnabled = false,
captureMode = CameraCaptureMode.ImageAndVideoSimultaneous,
controlsVisible = true,
selectedMediaCount = 0,
onCheckPermissions = {},
hasCameraPermission = { true },
onRequestMicPermission = { },
onGalleryClicked = { },
createVideoFileDescriptor = { null },
getMaxVideoDurationInSeconds = { 60 },
cameraDisplay = CameraDisplay.DISPLAY_19_9,
storiesEnabled = true
)
}
}
@androidx.compose.ui.tooling.preview.Preview(
name = "18:9 Display",
showBackground = true,
widthDp = 360,
heightDp = 720
)
@Composable
private fun CameraXScreenPreview_18_9() {
org.signal.core.ui.compose.Previews.Preview {
CameraXScreen(
controller = null,
isVideoEnabled = true,
isQrScanEnabled = false,
captureMode = CameraCaptureMode.ImageAndVideoSimultaneous,
controlsVisible = true,
selectedMediaCount = 0,
onCheckPermissions = {},
hasCameraPermission = { true },
onRequestMicPermission = { },
onGalleryClicked = { },
createVideoFileDescriptor = { null },
getMaxVideoDurationInSeconds = { 60 },
cameraDisplay = CameraDisplay.DISPLAY_18_9,
storiesEnabled = true
)
}
}
@androidx.compose.ui.tooling.preview.Preview(
name = "16:9 Display",
showBackground = true,
widthDp = 360,
heightDp = 640
)
@Composable
private fun CameraXScreenPreview_16_9() {
org.signal.core.ui.compose.Previews.Preview {
CameraXScreen(
controller = null,
isVideoEnabled = true,
isQrScanEnabled = false,
captureMode = CameraCaptureMode.ImageAndVideoSimultaneous,
controlsVisible = true,
selectedMediaCount = 0,
onCheckPermissions = {},
hasCameraPermission = { true },
onRequestMicPermission = { },
onGalleryClicked = { },
createVideoFileDescriptor = { null },
getMaxVideoDurationInSeconds = { 60 },
cameraDisplay = CameraDisplay.DISPLAY_16_9,
storiesEnabled = true
)
}
}
@androidx.compose.ui.tooling.preview.Preview(
name = "6:5 Display (Tablet)",
showBackground = true,
widthDp = 480,
heightDp = 576
)
@Composable
private fun CameraXScreenPreview_6_5() {
org.signal.core.ui.compose.Previews.Preview {
CameraXScreen(
controller = null,
isVideoEnabled = true,
isQrScanEnabled = false,
captureMode = CameraCaptureMode.ImageAndVideoSimultaneous,
controlsVisible = true,
selectedMediaCount = 0,
onCheckPermissions = {},
hasCameraPermission = { true },
onRequestMicPermission = { },
onGalleryClicked = { },
createVideoFileDescriptor = { null },
getMaxVideoDurationInSeconds = { 60 },
cameraDisplay = CameraDisplay.DISPLAY_6_5,
storiesEnabled = true
)
}
}
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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();
@@ -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()
}
@@ -1,20 +0,0 @@
package org.thoughtcrime.securesms.video;
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 {
private VideoUtil() { }
public static int getMaxVideoRecordDurationInSeconds(@NonNull Context context, @NonNull MediaConstraints mediaConstraints) {
long allowedSize = mediaConstraints.getCompressedVideoMaxSize(context);
int duration = (int) Math.floor((float) allowedSize / VideoConstants.MAX_ALLOWED_BYTES_PER_SECOND);
return Math.min(duration, VideoConstants.VIDEO_MAX_RECORD_LENGTH_S);
}
}