From 36f7c60a9937aac5899211bbeccfde2107fb2bd7 Mon Sep 17 00:00:00 2001 From: Greyson Parrelli Date: Tue, 31 Mar 2026 11:17:33 -0400 Subject: [PATCH] Improve camera mixed mode handling and clean up dead code. --- .../thoughtcrime/securesms/MainActivity.kt | 4 +- .../avatar/picker/AvatarPickerFragment.kt | 4 +- .../main/UsernameQrScanScreen.kt | 4 +- .../ConversationSettingsFragment.kt | 4 +- .../v2/ConversationActivityResultContracts.kt | 4 +- .../jobs/AttachmentCompressionJob.java | 2 +- .../linkdevice/LinkDeviceQrScanScreen.kt | 4 +- .../securesms/mediasend/Camera1Fragment.java | 4 +- .../securesms/mediasend/CameraButtonView.java | 73 ----- .../securesms/mediasend/CameraFragment.java | 8 +- .../securesms/mediasend/CameraXFragment.kt | 59 ++-- .../mediasend/CameraXSelfieFlashHelper.java | 76 ----- .../mediasend/CameraXVideoCaptureHelper.java | 285 ------------------ .../mediasend/camerax/CameraXModePolicy.kt | 98 ------ ...delBlocklist.kt => CameraXRemoteConfig.kt} | 7 +- .../mediasend/camerax/CameraXUtil.java | 262 ---------------- .../mediasend/v2/MediaSelectionNavigator.kt | 4 +- .../v2/gallery/MediaGalleryFragment.kt | 4 +- .../securesms/mms/MediaConstraints.java | 2 +- .../PaymentsTransferQrScanFragment.java | 4 +- .../securesms/util/MemoryFileDescriptor.java | 196 ------------ .../securesms/util/RemoteConfig.kt | 2 +- .../securesms/verify/VerifyScanFragment.kt | 4 +- .../securesms/video/VideoUtil.java | 15 - .../signal/core/util/MemoryFileDescriptor.kt | 162 ++++++++++ .../securesms/util/FileUtils.java | 2 +- .../main/jniLibs/arm64-v8a/libnative-utils.so | Bin .../jniLibs/armeabi-v7a/libnative-utils.so | Bin .../src/main/jniLibs/x86/libnative-utils.so | Bin .../main/jniLibs/x86_64/libnative-utils.so | Bin feature/camera/build.gradle.kts | 2 +- .../org/signal/camera/CameraCaptureMode.kt | 12 + .../java/org/signal/camera/CameraScreen.kt | 4 +- .../org/signal/camera/CameraScreenEvents.kt | 2 +- .../signal/camera/CameraScreenViewModel.kt | 25 +- .../java/org/signal/camera/CameraXUtil.kt | 113 +++++++ .../camera/CameraScreenViewModelTest.kt | 26 +- 37 files changed, 396 insertions(+), 1081 deletions(-) delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXSelfieFlashHelper.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXVideoCaptureHelper.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXModePolicy.kt rename app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/{CameraXModelBlocklist.kt => CameraXRemoteConfig.kt} (80%) delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXUtil.java delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/util/MemoryFileDescriptor.java create mode 100644 core/util/src/main/java/org/signal/core/util/MemoryFileDescriptor.kt rename {app => core/util}/src/main/java/org/thoughtcrime/securesms/util/FileUtils.java (95%) rename {app => core/util}/src/main/jniLibs/arm64-v8a/libnative-utils.so (100%) rename {app => core/util}/src/main/jniLibs/armeabi-v7a/libnative-utils.so (100%) rename {app => core/util}/src/main/jniLibs/x86/libnative-utils.so (100%) rename {app => core/util}/src/main/jniLibs/x86_64/libnative-utils.so (100%) create mode 100644 feature/camera/src/main/java/org/signal/camera/CameraCaptureMode.kt create mode 100644 feature/camera/src/main/java/org/signal/camera/CameraXUtil.kt diff --git a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt index 298ae0942a..7389256aa5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/MainActivity.kt @@ -159,7 +159,7 @@ import org.thoughtcrime.securesms.main.navigateToDetailLocation import org.thoughtcrime.securesms.main.rememberDetailNavHostController import org.thoughtcrime.securesms.main.rememberFocusRequester import org.thoughtcrime.securesms.main.storiesNavGraphBuilder -import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil +import org.thoughtcrime.securesms.mediasend.camerax.CameraXRemoteConfig import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity import org.thoughtcrime.securesms.megaphone.Megaphone import org.thoughtcrime.securesms.megaphone.MegaphoneActionController @@ -1125,7 +1125,7 @@ class MainActivity : } } - if (CameraXUtil.isSupported()) { + if (CameraXRemoteConfig.isSupported()) { onGranted() } else { Permissions.with(this@MainActivity) diff --git a/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerFragment.kt index 1a4ffb244b..f501569829 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/avatar/picker/AvatarPickerFragment.kt @@ -30,7 +30,7 @@ import org.thoughtcrime.securesms.avatar.vector.VectorAvatarCreationFragment import org.thoughtcrime.securesms.components.ButtonStripItemView import org.thoughtcrime.securesms.components.recyclerview.GridDividerDecoration import org.thoughtcrime.securesms.mediasend.AvatarSelectionActivity -import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil +import org.thoughtcrime.securesms.mediasend.camerax.CameraXRemoteConfig import org.thoughtcrime.securesms.util.ViewUtil import org.thoughtcrime.securesms.util.adapter.mapping.MappingAdapter import org.thoughtcrime.securesms.util.navigation.safeNavigate @@ -223,7 +223,7 @@ class AvatarPickerFragment : Fragment(R.layout.avatar_picker_fragment) { @Suppress("DEPRECATION") private fun openCameraCapture() { - if (CameraXUtil.isSupported()) { + if (CameraXRemoteConfig.isSupported()) { val intent = AvatarSelectionActivity.getIntentForCameraCapture(requireContext()) startActivityForResult(intent, REQUEST_CODE_SELECT_IMAGE) } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameQrScanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameQrScanScreen.kt index 263f9f9c30..51d193398a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameQrScanScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/usernamelinks/main/UsernameQrScanScreen.kt @@ -26,7 +26,7 @@ import org.signal.core.ui.compose.Dialogs import org.signal.core.ui.compose.theme.SignalTheme import org.signal.qr.QrScannerView import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.mediasend.camerax.CameraXModelBlocklist +import org.thoughtcrime.securesms.mediasend.camerax.CameraXRemoteConfig import org.thoughtcrime.securesms.qr.QrScanScreens import org.thoughtcrime.securesms.recipients.Recipient import java.util.concurrent.TimeUnit @@ -98,7 +98,7 @@ fun UsernameQrScanScreen( view }, update = { view -> - view.start(lifecycleOwner = lifecycleOwner, forceLegacy = CameraXModelBlocklist.isBlocklisted()) + view.start(lifecycleOwner = lifecycleOwner, forceLegacy = CameraXRemoteConfig.isBlocklisted()) }, hasPermission = hasCameraPermission, onRequestPermissions = onOpenCameraClicked, diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt index 89cee12b76..736478041a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/conversation/ConversationSettingsFragment.kt @@ -96,7 +96,7 @@ import org.thoughtcrime.securesms.main.MainNavigationDetailLocation import org.thoughtcrime.securesms.main.MainNavigationRouter import org.thoughtcrime.securesms.mediaoverview.MediaOverviewActivity import org.thoughtcrime.securesms.mediapreview.MediaIntentFactory -import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil +import org.thoughtcrime.securesms.mediasend.camerax.CameraXRemoteConfig import org.thoughtcrime.securesms.messagerequests.MessageRequestRepository import org.thoughtcrime.securesms.nicknames.NicknameActivity import org.thoughtcrime.securesms.profiles.edit.CreateProfileActivity @@ -486,7 +486,7 @@ class ConversationSettingsFragment : .setMessage(R.string.ConversationSettingsFragment__only_admins_of_this_group_can_add_to_its_story) .setPositiveButton(android.R.string.ok) { d, _ -> d.dismiss() } .show() - } else if (CameraXUtil.isSupported()) { + } else if (CameraXRemoteConfig.isSupported()) { addToGroupStoryDelegate.addToStory(state.recipient.id) } else { Permissions.with(this@ConversationSettingsFragment) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityResultContracts.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityResultContracts.kt index c87cc3c2d9..fda768417e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityResultContracts.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityResultContracts.kt @@ -29,7 +29,7 @@ import org.thoughtcrime.securesms.conversation.v2.ConversationActivityResultCont import org.thoughtcrime.securesms.giph.ui.GiphyActivity import org.thoughtcrime.securesms.maps.PlacePickerActivity import org.thoughtcrime.securesms.mediasend.MediaSendActivityResult -import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil +import org.thoughtcrime.securesms.mediasend.camerax.CameraXRemoteConfig import org.thoughtcrime.securesms.mediasend.v2.MediaSelectionActivity import org.thoughtcrime.securesms.recipients.RecipientId import org.signal.core.ui.R as CoreUiR @@ -76,7 +76,7 @@ class ConversationActivityResultContracts(private val fragment: Fragment, privat } fun launchCamera(recipientId: RecipientId, isReply: Boolean) { - if (CameraXUtil.isSupported()) { + if (CameraXRemoteConfig.isSupported()) { cameraLauncher.launch(MediaSelectionInput(emptyList(), recipientId, null, isReply)) fragment.requireActivity().overridePendingTransition(R.anim.camera_slide_from_bottom, R.anim.stationary) } else { diff --git a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCompressionJob.java b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCompressionJob.java index 89b6306834..ad0ddbb91d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCompressionJob.java +++ b/app/src/main/java/org/thoughtcrime/securesms/jobs/AttachmentCompressionJob.java @@ -37,7 +37,7 @@ import org.thoughtcrime.securesms.transport.UndeliverableMessageException; import org.thoughtcrime.securesms.util.BitmapDecodingException; import org.thoughtcrime.securesms.util.ImageCompressionUtil; import org.thoughtcrime.securesms.util.MediaUtil; -import org.thoughtcrime.securesms.util.MemoryFileDescriptor.MemoryFileException; +import org.signal.core.util.MemoryFileDescriptor.MemoryFileException; import org.thoughtcrime.securesms.util.RemoteConfig; import org.thoughtcrime.securesms.video.StreamingTranscoder; import org.thoughtcrime.securesms.video.TranscoderOptions; diff --git a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceQrScanScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceQrScanScreen.kt index 42e4f75ab1..255c865a96 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceQrScanScreen.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/linkdevice/LinkDeviceQrScanScreen.kt @@ -17,7 +17,7 @@ import org.signal.core.ui.compose.Dialogs import org.signal.qr.QrScannerView import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.linkdevice.LinkDeviceRepository.LinkDeviceResult -import org.thoughtcrime.securesms.mediasend.camerax.CameraXModelBlocklist +import org.thoughtcrime.securesms.mediasend.camerax.CameraXRemoteConfig import org.thoughtcrime.securesms.qr.QrScanScreens import org.thoughtcrime.securesms.util.navigation.safeNavigate import java.util.concurrent.TimeUnit @@ -104,7 +104,7 @@ fun LinkDeviceQrScanScreen( view }, update = { view: QrScannerView -> - view.start(lifecycleOwner = lifecycleOwner, forceLegacy = CameraXModelBlocklist.isBlocklisted()) + view.start(lifecycleOwner = lifecycleOwner, forceLegacy = CameraXRemoteConfig.isBlocklisted()) if (showFrontCamera != null) { view.toggleCamera() } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/Camera1Fragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/Camera1Fragment.java index cfc45bf0e1..2a61672e9e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/Camera1Fragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/Camera1Fragment.java @@ -46,7 +46,7 @@ import org.signal.core.models.media.Media; import org.signal.core.ui.logging.LoggingFragment; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.animation.AnimationCompleteListener; -import org.thoughtcrime.securesms.mediasend.camerax.CameraXModelBlocklist; +import org.thoughtcrime.securesms.mediasend.camerax.CameraXRemoteConfig; import org.thoughtcrime.securesms.mediasend.v2.MediaAnimations; import org.thoughtcrime.securesms.mediasend.v2.MediaCountIndicatorButton; import org.signal.glide.decryptableuri.DecryptableUri; @@ -60,7 +60,7 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.disposables.Disposable; /** - * Camera capture implemented with the legacy camera API's. Should only be used if a device is on the {@link CameraXModelBlocklist}. + * Camera capture implemented with the legacy camera API's. Should only be used if a device is on the {@link CameraXRemoteConfig}. */ public class Camera1Fragment extends LoggingFragment implements CameraFragment, TextureView.SurfaceTextureListener, diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraButtonView.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraButtonView.java index 2cce344c7d..3777d3bcb1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraButtonView.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraButtonView.java @@ -32,8 +32,6 @@ public class CameraButtonView extends View { 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 static final int DRAG_DISTANCE_MULTIPLIER = 3; - private static final Interpolator ZOOM_INTERPOLATOR = new DecelerateInterpolator(); private final @NonNull Paint outlinePaint = outlinePaint(); private final @NonNull Paint backgroundPaint = backgroundPaint(); @@ -49,21 +47,12 @@ public class CameraButtonView extends View { private float progressPercent = 0f; private @NonNull CameraButtonMode cameraButtonMode = CameraButtonMode.IMAGE; - private @Nullable VideoCaptureListener videoCaptureListener; private final float imageCaptureSize; private final float recordSize; private final RectF progressRect = new RectF(); private final Rect deadzoneRect = new Rect(); - private final @NonNull OnLongClickListener internalLongClickListener = v -> { - notifyVideoCaptureStarted(); - shrinkAnimation.cancel(); - setScaleX(1f); - setScaleY(1f); - isRecordingVideo = true; - return true; - }; public CameraButtonView(@NonNull Context context) { this(context, null); @@ -186,20 +175,6 @@ public class CameraButtonView extends View { canvas.drawArc(progressRect, 270f, 360f * progressPercent, false, progressPaint); } - public void setVideoCaptureListener(@Nullable VideoCaptureListener videoCaptureListener) { - if (isRecordingVideo) throw new IllegalStateException("Cannot set video capture listener while recording"); - - if (videoCaptureListener != null) { - this.cameraButtonMode = CameraButtonMode.MIXED; - this.videoCaptureListener = videoCaptureListener; - super.setOnLongClickListener(internalLongClickListener); - } else { - this.cameraButtonMode = CameraButtonMode.IMAGE; - this.videoCaptureListener = null; - super.setOnLongClickListener(null); - } - } - public void setProgress(float percentage) { progressPercent = Util.clamp(percentage, 0f, 1f); invalidate(); @@ -257,63 +232,15 @@ public class CameraButtonView extends View { startAnimation(shrinkAnimation); } case MotionEvent.ACTION_MOVE: - if (isRecordingVideo) { - float maxRange = getHeight() * DRAG_DISTANCE_MULTIPLIER; - - if (eventIsAboveDeadzone(event)) { - float deltaY = Math.abs(event.getY() - deadzoneRect.top); - float increment = Math.min(1f, deltaY / maxRange); - notifyZoomPercent(ZOOM_INTERPOLATOR.getInterpolation(increment)); - invalidate(); - } else if (eventIsBelowDeadzone(event)) { - float deltaY = Math.abs(event.getY() - deadzoneRect.bottom); - float increment = Math.min(1f, deltaY / maxRange); - notifyZoomPercent(-ZOOM_INTERPOLATOR.getInterpolation(increment)); - invalidate(); - } - } break; case MotionEvent.ACTION_CANCEL: case MotionEvent.ACTION_UP: if (!isRecordingVideo) { startAnimation(growAnimation); } - notifyVideoCaptureEnded(); break; } return super.onTouchEvent(event); } - - private boolean eventIsAboveDeadzone(MotionEvent event) { - return Math.round(event.getY()) < deadzoneRect.top; - } - - private boolean eventIsBelowDeadzone(MotionEvent event) { - return Math.round(event.getY()) > deadzoneRect.bottom; - } - - private void notifyVideoCaptureStarted() { - if (!isRecordingVideo && videoCaptureListener != null) { - videoCaptureListener.onVideoCaptureStarted(); - } - } - - private void notifyVideoCaptureEnded() { - if (isRecordingVideo && videoCaptureListener != null) { - videoCaptureListener.onVideoCaptureComplete(); - } - } - - private void notifyZoomPercent(float percent) { - if (isRecordingVideo && videoCaptureListener != null) { - videoCaptureListener.onZoomIncremented(percent); - } - } - - interface VideoCaptureListener { - void onVideoCaptureStarted(); - void onVideoCaptureComplete(); - void onZoomIncremented(float percent); - } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraFragment.java index 8119c641db..1a2c78cf19 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraFragment.java @@ -10,7 +10,7 @@ import androidx.fragment.app.Fragment; import org.signal.core.models.media.Media; import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil; +import org.thoughtcrime.securesms.mediasend.camerax.CameraXRemoteConfig; import org.thoughtcrime.securesms.mms.MediaConstraints; import java.io.FileDescriptor; @@ -24,7 +24,7 @@ public interface CameraFragment { @SuppressLint({ "RestrictedApi", "UnsafeOptInUsageError" }) static Fragment newInstance(boolean qrScanEnabled) { - if (CameraXUtil.isSupported()) { + if (CameraXRemoteConfig.isSupported()) { return CameraXFragment.newInstance(qrScanEnabled); } else { return Camera1Fragment.newInstance(); @@ -32,7 +32,7 @@ public interface CameraFragment { } static Class getFragmentClass() { - if (CameraXUtil.isSupported()) { + if (CameraXRemoteConfig.isSupported()) { return CameraXFragment.class; } else { return Camera1Fragment.class; @@ -41,7 +41,7 @@ public interface CameraFragment { @SuppressLint({ "RestrictedApi", "UnsafeOptInUsageError" }) static Fragment newInstanceForAvatarCapture() { - if (CameraXUtil.isSupported()) { + if (CameraXRemoteConfig.isSupported()) { return CameraXFragment.newInstanceForAvatarCapture(); } else { return Camera1Fragment.newInstance(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.kt index 69f1d0959c..17eae16372 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.kt @@ -42,9 +42,11 @@ 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 @@ -55,12 +57,14 @@ 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.thoughtcrime.securesms.R import org.thoughtcrime.securesms.keyvalue.SignalStore -import org.thoughtcrime.securesms.mediasend.camerax.CameraXModePolicy +import org.thoughtcrime.securesms.mms.MediaConstraints import org.thoughtcrime.securesms.stories.Stories -import org.thoughtcrime.securesms.util.MemoryFileDescriptor +import org.thoughtcrime.securesms.util.RemoteConfig import org.thoughtcrime.securesms.video.VideoUtil import java.io.ByteArrayOutputStream import java.io.IOException @@ -99,7 +103,7 @@ class CameraXFragment : ComposeFragment(), CameraFragment { private var controller: CameraFragment.Controller? = null private var videoFileDescriptor: MemoryFileDescriptor? = null - private var cameraXModePolicy: CameraXModePolicy? = null + private var captureMode: CameraCaptureMode = CameraCaptureMode.ImageOnly private val isVideoEnabled: Boolean get() = requireArguments().getBoolean(IS_VIDEO_ENABLED, true) @@ -121,24 +125,17 @@ class CameraXFragment : ComposeFragment(), CameraFragment { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - - cameraXModePolicy = CameraXModePolicy.acquire( - requireContext(), - controller!!.mediaConstraints, - isVideoEnabled, - isQrScanEnabled - ) - - Log.d(TAG, "Starting CameraX with mode policy ${cameraXModePolicy?.javaClass?.simpleName}") + captureMode = resolveCaptureMode() + Log.d(TAG, "Starting CameraX with capture mode $captureMode") } @Composable override fun FragmentContent() { CameraXScreen( controller = controller, - isVideoEnabled = isVideoEnabled && Build.VERSION.SDK_INT >= 26, + isVideoEnabled = captureMode != CameraCaptureMode.ImageOnly, isQrScanEnabled = isQrScanEnabled, - isVideoCaptureBindingEnabled = cameraXModePolicy is CameraXModePolicy.Mixed, + captureMode = captureMode, controlsVisible = controlsVisible.value, selectedMediaCount = selectedMediaCount.intValue, onCheckPermissions = { checkPermissions(isVideoEnabled) }, @@ -265,8 +262,8 @@ class CameraXFragment : ComposeFragment(), CameraFragment { return try { closeVideoFileDescriptor() - videoFileDescriptor = CameraXVideoCaptureHelper.createFileDescriptor(requireContext()) - videoFileDescriptor?.parcelFileDescriptor + videoFileDescriptor = CameraXUtil.createVideoFileDescriptor(requireContext()) + videoFileDescriptor?.parcelFd } catch (e: IOException) { Log.w(TAG, "Failed to create video file descriptor", e) null @@ -292,6 +289,22 @@ class CameraXFragment : ComposeFragment(), CameraFragment { } 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 @@ -299,7 +312,7 @@ private fun CameraXScreen( controller: CameraFragment.Controller?, isVideoEnabled: Boolean, isQrScanEnabled: Boolean, - isVideoCaptureBindingEnabled: Boolean, + captureMode: CameraCaptureMode, controlsVisible: Boolean, selectedMediaCount: Int, onCheckPermissions: () -> Unit, @@ -406,7 +419,7 @@ private fun CameraXScreen( emitter = { event -> cameraViewModel.onEvent(event) }, roundCorners = cameraDisplay.roundViewFinderCorners, contentAlignment = cameraAlignment, - enableVideoCapture = isVideoCaptureBindingEnabled, + captureMode = captureMode, enableQrScanning = isQrScanEnabled, modifier = Modifier.padding(bottom = viewportBottomMargin) ) { @@ -615,7 +628,7 @@ private fun CameraXScreenPreview_20_9() { controller = null, isVideoEnabled = true, isQrScanEnabled = false, - isVideoCaptureBindingEnabled = true, + captureMode = CameraCaptureMode.ImageAndVideoSimultaneous, controlsVisible = true, selectedMediaCount = 0, onCheckPermissions = {}, @@ -643,7 +656,7 @@ private fun CameraXScreenPreview_19_9() { controller = null, isVideoEnabled = true, isQrScanEnabled = false, - isVideoCaptureBindingEnabled = true, + captureMode = CameraCaptureMode.ImageAndVideoSimultaneous, controlsVisible = true, selectedMediaCount = 0, onCheckPermissions = {}, @@ -671,7 +684,7 @@ private fun CameraXScreenPreview_18_9() { controller = null, isVideoEnabled = true, isQrScanEnabled = false, - isVideoCaptureBindingEnabled = true, + captureMode = CameraCaptureMode.ImageAndVideoSimultaneous, controlsVisible = true, selectedMediaCount = 0, onCheckPermissions = {}, @@ -699,7 +712,7 @@ private fun CameraXScreenPreview_16_9() { controller = null, isVideoEnabled = true, isQrScanEnabled = false, - isVideoCaptureBindingEnabled = true, + captureMode = CameraCaptureMode.ImageAndVideoSimultaneous, controlsVisible = true, selectedMediaCount = 0, onCheckPermissions = {}, @@ -727,7 +740,7 @@ private fun CameraXScreenPreview_6_5() { controller = null, isVideoEnabled = true, isQrScanEnabled = false, - isVideoCaptureBindingEnabled = true, + captureMode = CameraCaptureMode.ImageAndVideoSimultaneous, controlsVisible = true, selectedMediaCount = 0, onCheckPermissions = {}, diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXSelfieFlashHelper.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXSelfieFlashHelper.java deleted file mode 100644 index 2b96ff99e0..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXSelfieFlashHelper.java +++ /dev/null @@ -1,76 +0,0 @@ -package org.thoughtcrime.securesms.mediasend; - -import android.view.View; -import android.view.Window; -import android.view.WindowManager; - -import androidx.annotation.NonNull; -import androidx.camera.core.CameraSelector; -import androidx.camera.core.ImageCapture; -import androidx.camera.view.CameraController; - -final class CameraXSelfieFlashHelper { - - private static final float MAX_SCREEN_BRIGHTNESS = 1f; - private static final float MAX_SELFIE_FLASH_ALPHA = 0.9f; - - private final Window window; - private final CameraController camera; - private final View selfieFlash; - - private float brightnessBeforeFlash; - private boolean inFlash; - private int flashMode = -1; - - CameraXSelfieFlashHelper(@NonNull Window window, - @NonNull CameraController camera, - @NonNull View selfieFlash) - { - this.window = window; - this.camera = camera; - this.selfieFlash = selfieFlash; - } - - void onWillTakePicture() { - if (!inFlash && shouldUseViewBasedFlash()) { - flashMode = camera.getImageCaptureFlashMode(); - camera.setImageCaptureFlashMode(ImageCapture.FLASH_MODE_OFF); - } - } - - void startFlash() { - if (inFlash || !shouldUseViewBasedFlash()) return; - inFlash = true; - - WindowManager.LayoutParams params = window.getAttributes(); - - brightnessBeforeFlash = params.screenBrightness; - params.screenBrightness = MAX_SCREEN_BRIGHTNESS; - window.setAttributes(params); - - selfieFlash.setAlpha(MAX_SELFIE_FLASH_ALPHA); - } - - void endFlash() { - if (!inFlash) return; - - WindowManager.LayoutParams params = window.getAttributes(); - - params.screenBrightness = brightnessBeforeFlash; - window.setAttributes(params); - - camera.setImageCaptureFlashMode(flashMode); - flashMode = -1; - - selfieFlash.setAlpha(MAX_SELFIE_FLASH_ALPHA); - - inFlash = false; - } - - private boolean shouldUseViewBasedFlash() { - CameraSelector cameraSelector = camera.getCameraSelector(); - - return (camera.getImageCaptureFlashMode() == ImageCapture.FLASH_MODE_ON || flashMode == ImageCapture.FLASH_MODE_ON) && - cameraSelector == CameraSelector.DEFAULT_FRONT_CAMERA; - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXVideoCaptureHelper.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXVideoCaptureHelper.java deleted file mode 100644 index c2ee8d2263..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXVideoCaptureHelper.java +++ /dev/null @@ -1,285 +0,0 @@ -package org.thoughtcrime.securesms.mediasend; - -import android.Manifest; -import android.animation.ValueAnimator; -import android.annotation.SuppressLint; -import android.content.Context; -import android.util.DisplayMetrics; -import android.util.Size; -import android.view.ViewGroup; -import android.view.animation.LinearInterpolator; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.RequiresApi; -import androidx.camera.core.ImageCapture; -import androidx.camera.core.ZoomState; -import androidx.camera.video.FileDescriptorOutputOptions; -import androidx.camera.video.Recording; -import androidx.camera.video.VideoRecordEvent; -import androidx.camera.view.CameraController; -import androidx.camera.view.PreviewView; -import androidx.camera.view.video.AudioConfig; -import androidx.core.content.ContextCompat; -import androidx.core.util.Consumer; -import androidx.fragment.app.Fragment; - -import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.mediasend.camerax.CameraXModePolicy; -import org.signal.core.ui.permissions.Permissions; -import org.thoughtcrime.securesms.util.ContextUtil; -import org.thoughtcrime.securesms.util.Debouncer; -import org.thoughtcrime.securesms.util.RemoteConfig; -import org.thoughtcrime.securesms.util.MemoryFileDescriptor; -import org.thoughtcrime.securesms.video.VideoUtil; - -import java.io.FileDescriptor; -import java.io.IOException; -import java.util.Objects; -import java.util.concurrent.TimeUnit; - -@RequiresApi(26) -class CameraXVideoCaptureHelper implements CameraButtonView.VideoCaptureListener { - - private static final String TAG = Log.tag(CameraXVideoCaptureHelper.class); - private static final String VIDEO_DEBUG_LABEL = "video-capture"; - private static final long VIDEO_SIZE = 10 * 1024 * 1024; - - private final @NonNull Fragment fragment; - private final @NonNull PreviewView previewView; - private final @NonNull CameraController cameraController; - private final @NonNull Callback callback; - private final @NonNull MemoryFileDescriptor memoryFileDescriptor; - private final @NonNull ValueAnimator updateProgressAnimator; - private final @NonNull Debouncer debouncer; - private final @NonNull CameraXModePolicy cameraXModePolicy; - - private ValueAnimator cameraMetricsAnimator; - - private @Nullable Recording activeRecording = null; - - private final Consumer videoSavedListener = new Consumer<>() { - @Override - public void accept(VideoRecordEvent videoRecordEvent) { - Log.d(TAG, "Received recording event: " + videoRecordEvent.getClass().getSimpleName()); - - if (videoRecordEvent instanceof VideoRecordEvent.Finalize) { - VideoRecordEvent.Finalize event = (VideoRecordEvent.Finalize) videoRecordEvent; - - if (event.hasError()) { - Log.w(TAG, "Hit an error while recording! Error code: " + event.getError(), event.getCause()); - debouncer.clear(); - callback.onVideoError(event.getCause()); - } else { - try { - debouncer.clear(); - cameraController.setZoomRatio(getDefaultVideoZoomRatio()); - memoryFileDescriptor.seek(0); - callback.onVideoSaved(memoryFileDescriptor.getFileDescriptor()); - } catch (IOException e) { - callback.onVideoError(e); - } - } - } - } - }; - - CameraXVideoCaptureHelper(@NonNull Fragment fragment, - @NonNull CameraButtonView captureButton, - @NonNull CameraController cameraController, - @NonNull PreviewView previewView, - @NonNull MemoryFileDescriptor memoryFileDescriptor, - @NonNull CameraXModePolicy cameraXModePolicy, - int maxVideoDurationSec, - @NonNull Callback callback) - { - this.fragment = fragment; - this.cameraController = cameraController; - this.previewView = previewView; - this.memoryFileDescriptor = memoryFileDescriptor; - this.callback = callback; - - float animationScale = ContextUtil.getAnimationScale(fragment.requireContext()); - long baseDuration = TimeUnit.SECONDS.toMillis(maxVideoDurationSec); - long scaledDuration = Math.round(animationScale > 0f ? (baseDuration * (1f / animationScale)) : baseDuration); - - this.updateProgressAnimator = ValueAnimator.ofFloat(0f, 1f).setDuration(scaledDuration); - this.debouncer = new Debouncer(TimeUnit.SECONDS.toMillis(maxVideoDurationSec)); - this.cameraXModePolicy = cameraXModePolicy; - - updateProgressAnimator.setInterpolator(new LinearInterpolator()); - updateProgressAnimator.addUpdateListener(anim -> { - captureButton.setProgress(anim.getAnimatedFraction()); - }); - } - - @Override - public void onVideoCaptureStarted() { - Log.d(TAG, "onVideoCaptureStarted"); - - if (canUseCamera() && canRecordAudio()) { - beginCameraRecording(); - } else if (!canRecordAudio()) { - displayAudioRecordingPermissionsDialog(); - } - } - - private boolean canUseCamera() { - return Permissions.hasAll(fragment.requireContext(), Manifest.permission.CAMERA); - } - - private boolean canRecordAudio() { - return Permissions.hasAll(fragment.requireContext(), Manifest.permission.RECORD_AUDIO); - } - - private void displayAudioRecordingPermissionsDialog() { - Permissions.with(fragment) - .request(Manifest.permission.RECORD_AUDIO) - .ifNecessary() - .withRationaleDialog(fragment.getString(R.string.CameraXFragment_allow_access_microphone), fragment.getString(R.string.CameraXFragment_to_capture_videos_with_sound), R.drawable.ic_mic_24) - .withPermanentDenialDialog(fragment.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, fragment.getParentFragmentManager()) - .onAnyDenied(() -> Toast.makeText(fragment.requireContext(), R.string.CameraXFragment_signal_needs_microphone_access_video, Toast.LENGTH_LONG).show()) - .execute(); - } - - @SuppressLint({"RestrictedApi", "MissingPermission"}) - private void beginCameraRecording() { - cameraXModePolicy.setToVideo(cameraController); - this.cameraController.setZoomRatio(getDefaultVideoZoomRatio()); - callback.onVideoRecordStarted(); - shrinkCaptureArea(); - - FileDescriptorOutputOptions outputOptions = new FileDescriptorOutputOptions.Builder(memoryFileDescriptor.getParcelFileDescriptor()).build(); - AudioConfig audioConfig = AudioConfig.create(true); - - cameraController.enableTorch(cameraController.getImageCaptureFlashMode() <= ImageCapture.FLASH_MODE_ON); - activeRecording = cameraController.startRecording(outputOptions, audioConfig, ContextCompat.getMainExecutor(fragment.requireContext()), videoSavedListener); - - updateProgressAnimator.start(); - debouncer.publish(this::onVideoCaptureComplete); - } - - private void shrinkCaptureArea() { - Size screenSize = getScreenSize(); - Size videoRecordingSize = VideoUtil.getVideoRecordingSize(); - float scale = getSurfaceScaleForRecording(); - float targetWidthForAnimation = videoRecordingSize.getWidth() * scale; - float scaleX = targetWidthForAnimation / screenSize.getWidth(); - - if (scaleX == 1f) { - float targetHeightForAnimation = videoRecordingSize.getHeight() * scale; - - if (screenSize.getHeight() == targetHeightForAnimation) { - return; - } - - cameraMetricsAnimator = ValueAnimator.ofFloat(screenSize.getHeight(), targetHeightForAnimation); - } else { - - if (screenSize.getWidth() == targetWidthForAnimation) { - return; - } - - cameraMetricsAnimator = ValueAnimator.ofFloat(screenSize.getWidth(), targetWidthForAnimation); - } - - ViewGroup.LayoutParams params = previewView.getLayoutParams(); - cameraMetricsAnimator.setInterpolator(new LinearInterpolator()); - cameraMetricsAnimator.setDuration(200); - cameraMetricsAnimator.addUpdateListener(animation -> { - if (scaleX == 1f) { - params.height = Math.round((float) animation.getAnimatedValue()); - } else { - params.width = Math.round((float) animation.getAnimatedValue()); - } - previewView.setLayoutParams(params); - }); - cameraMetricsAnimator.start(); - } - - private Size getScreenSize() { - DisplayMetrics metrics = previewView.getResources().getDisplayMetrics(); - return new Size(metrics.widthPixels, metrics.heightPixels); - } - - private float getSurfaceScaleForRecording() { - Size videoRecordingSize = VideoUtil.getVideoRecordingSize(); - Size screenSize = getScreenSize(); - return Math.min(screenSize.getHeight(), screenSize.getWidth()) / (float) Math.min(videoRecordingSize.getHeight(), videoRecordingSize.getWidth()); - } - - @Override - public void onVideoCaptureComplete() { - if (!canRecordAudio()) { - Log.w(TAG, "Can't record audio!"); - return; - } - - if (activeRecording == null) { - Log.w(TAG, "No active recording!"); - return; - } - - Log.d(TAG, "onVideoCaptureComplete"); - activeRecording.close(); - activeRecording = null; - - if (cameraMetricsAnimator != null && cameraMetricsAnimator.isRunning()) { - cameraMetricsAnimator.reverse(); - } - - updateProgressAnimator.cancel(); - debouncer.clear(); - cameraXModePolicy.setToImage(cameraController); - } - - @Override - public void onZoomIncremented(float increment) { - ZoomState zoomState = Objects.requireNonNull(cameraController.getZoomState().getValue()); - float base = getDefaultVideoZoomRatio(); - - if (increment >= 0f) { - float range = zoomState.getMaxZoomRatio() - base; - cameraController.setZoomRatio(base + range * increment); - } else { - float range = base - zoomState.getMinZoomRatio(); - cameraController.setZoomRatio(base + range * increment); - } - } - - @Override - protected void finalize() throws Throwable { - if (activeRecording != null) { - Log.w(TAG, "Dangling recording left open in finalize()! Attempting to close."); - activeRecording.close(); - } - - super.finalize(); - } - - static MemoryFileDescriptor createFileDescriptor(@NonNull Context context) throws MemoryFileDescriptor.MemoryFileException { - return MemoryFileDescriptor.newMemoryFileDescriptor( - context, - VIDEO_DEBUG_LABEL, - VIDEO_SIZE - ); - } - - public float getDefaultVideoZoomRatio() { - if (RemoteConfig.startVideoRecordAt1x()) { - return 1f; - } else { - return Objects.requireNonNull(cameraController.getZoomState().getValue()).getMinZoomRatio(); - } - } - - interface Callback { - void onVideoRecordStarted(); - - void onVideoSaved(@NonNull FileDescriptor fd); - - void onVideoError(@Nullable Throwable cause); - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXModePolicy.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXModePolicy.kt deleted file mode 100644 index e31a2c5931..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXModePolicy.kt +++ /dev/null @@ -1,98 +0,0 @@ -package org.thoughtcrime.securesms.mediasend.camerax - -import android.content.Context -import android.os.Build -import androidx.camera.view.CameraController -import org.signal.core.util.asListContains -import org.thoughtcrime.securesms.mms.MediaConstraints -import org.thoughtcrime.securesms.util.RemoteConfig -import org.thoughtcrime.securesms.video.VideoUtil - -/** - * Describes device capabilities - */ -sealed class CameraXModePolicy { - - abstract val isVideoSupported: Boolean - - abstract val isQrScanEnabled: Boolean - - abstract fun initialize(cameraController: CameraController) - - open fun initialize(cameraController: CameraController, useCaseFlags: Int) { - if (isQrScanEnabled) { - cameraController.setEnabledUseCases(useCaseFlags or CameraController.IMAGE_ANALYSIS) - } else { - cameraController.setEnabledUseCases(useCaseFlags) - } - } - - open fun setToImage(cameraController: CameraController) = Unit - - open fun setToVideo(cameraController: CameraController) = Unit - - /** - * The device supports having Image and Video enabled at the same time - */ - data class Mixed(override val isQrScanEnabled: Boolean) : CameraXModePolicy() { - - override val isVideoSupported: Boolean = true - - override fun initialize(cameraController: CameraController) { - super.initialize(cameraController, CameraController.IMAGE_CAPTURE or CameraController.VIDEO_CAPTURE) - } - } - - /** - * The device supports image and video, but only one mode at a time. - */ - data class Single(override val isQrScanEnabled: Boolean) : CameraXModePolicy() { - - override val isVideoSupported: Boolean = true - - override fun initialize(cameraController: CameraController) { - setToImage(cameraController) - } - - override fun setToImage(cameraController: CameraController) { - super.initialize(cameraController, CameraController.IMAGE_CAPTURE) - } - - override fun setToVideo(cameraController: CameraController) { - super.initialize(cameraController, CameraController.VIDEO_CAPTURE) - } - } - - /** - * The device supports taking images only. - */ - data class ImageOnly(override val isQrScanEnabled: Boolean) : CameraXModePolicy() { - - override val isVideoSupported: Boolean = false - - override fun initialize(cameraController: CameraController) { - super.initialize(cameraController, CameraController.IMAGE_CAPTURE) - } - } - - companion object { - @JvmStatic - fun acquire(context: Context, mediaConstraints: MediaConstraints, isVideoEnabled: Boolean, isQrScanEnabled: Boolean): CameraXModePolicy { - val isVideoSupported = Build.VERSION.SDK_INT >= 26 && - isVideoEnabled && - MediaConstraints.isVideoTranscodeAvailable() && - VideoUtil.getMaxVideoRecordDurationInSeconds(context, mediaConstraints) > 0 - - val isMixedModeSupported = isVideoSupported && - Build.VERSION.SDK_INT >= 26 && - CameraXUtil.isMixedModeSupported(context) && - !RemoteConfig.cameraXMixedModelBlocklist.asListContains(Build.MODEL) - - return when { - isMixedModeSupported -> Mixed(isQrScanEnabled) - isVideoSupported -> Single(isQrScanEnabled) - else -> ImageOnly(isQrScanEnabled) - } - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXModelBlocklist.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXRemoteConfig.kt similarity index 80% rename from app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXModelBlocklist.kt rename to app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXRemoteConfig.kt index 82eb7842f9..ba80b6393b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXModelBlocklist.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXRemoteConfig.kt @@ -8,7 +8,12 @@ import org.thoughtcrime.securesms.util.RemoteConfig * Some phones don't work well with CameraX. This class uses a remote config to decide * which phones should fall back to the legacy camera. */ -object CameraXModelBlocklist { +object CameraXRemoteConfig { + + @JvmStatic + fun isSupported(): Boolean { + return !isBlocklisted() + } @JvmStatic fun isBlocklisted(): Boolean { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXUtil.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXUtil.java deleted file mode 100644 index 2ed5aa0467..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXUtil.java +++ /dev/null @@ -1,262 +0,0 @@ -package org.thoughtcrime.securesms.mediasend.camerax; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.graphics.BitmapRegionDecoder; -import android.graphics.Matrix; -import android.graphics.Rect; -import android.hardware.Camera; -import android.hardware.camera2.CameraAccessException; -import android.hardware.camera2.CameraCharacteristics; -import android.hardware.camera2.CameraManager; -import android.hardware.camera2.CameraMetadata; -import android.os.Build; -import kotlin.Pair; -import android.util.Size; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.RequiresApi; -import androidx.camera.camera2.internal.compat.CameraManagerCompat; -import androidx.camera.core.CameraSelector; -import androidx.camera.core.ImageCapture; -import androidx.camera.core.ImageProxy; - -import org.signal.core.util.Stopwatch; -import org.signal.core.util.logging.Log; -import org.thoughtcrime.securesms.util.BitmapDecodingException; -import org.thoughtcrime.securesms.util.BitmapUtil; - -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.nio.ByteBuffer; -import java.util.Locale; - -public class CameraXUtil { - - private static final String TAG = Log.tag(CameraXUtil.class); - - private static final int[] CAMERA_HARDWARE_LEVEL_ORDERING = new int[]{CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY, - CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED, - CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_FULL}; - - @RequiresApi(24) - private static final int[] CAMERA_HARDWARE_LEVEL_ORDERING_24 = new int[]{CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY, - CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED, - CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_FULL, - CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_3}; - - @RequiresApi(28) - private static final int[] CAMERA_HARDWARE_LEVEL_ORDERING_28 = new int[]{CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY, - CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED, - CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_EXTERNAL, - CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_FULL, - CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_3}; - - @SuppressWarnings("SuspiciousNameCombination") - public static ImageResult toJpeg(@NonNull ImageProxy image, boolean flip) throws IOException { - ImageProxy.PlaneProxy[] planes = image.getPlanes(); - ByteBuffer buffer = planes[0].getBuffer(); - Rect cropRect = shouldCropImage(image) ? image.getCropRect() : null; - byte[] data = new byte[buffer.capacity()]; - int rotation = image.getImageInfo().getRotationDegrees(); - - buffer.get(data); - - try { - Pair dimens = BitmapUtil.getDimensions(new ByteArrayInputStream(data)); - - if (dimens.getFirst() != image.getWidth() && dimens.getSecond() != image.getHeight()) { - Log.w(TAG, String.format(Locale.ENGLISH, "Decoded image dimensions differed from stated dimensions! Stated: %d x %d, Decoded: %d x %d", - image.getWidth(), image.getHeight(), dimens.getFirst(), dimens.getSecond())); - Log.w(TAG, "Ignoring the stated rotation and rotating the crop rect 90 degrees (stated rotation is " + rotation + " degrees)."); - - rotation = 0; - - if (cropRect != null) { - cropRect = new Rect(cropRect.top, cropRect.left, cropRect.bottom, cropRect.right); - } - } - } catch (BitmapDecodingException e) { - Log.w(TAG, "Failed to decode!", e); - } - - if (cropRect != null || rotation != 0 || flip) { - data = transformByteArray(data, cropRect, rotation, flip); - } - - int width = cropRect != null ? (cropRect.right - cropRect.left) : image.getWidth(); - int height = cropRect != null ? (cropRect.bottom - cropRect.top) : image.getHeight(); - - if (rotation == 90 || rotation == 270) { - int swap = width; - - width = height; - height = swap; - } - - return new ImageResult(data, width, height); - } - - public static boolean isSupported() { - return !CameraXModelBlocklist.isBlocklisted(); - } - - public static int toCameraDirectionInt(CameraSelector cameraSelector) { - if (cameraSelector == CameraSelector.DEFAULT_FRONT_CAMERA) { - return Camera.CameraInfo.CAMERA_FACING_FRONT; - } else { - return Camera.CameraInfo.CAMERA_FACING_BACK; - } - } - - public static CameraSelector toCameraSelector(@CameraSelector.LensFacing int cameraDirectionInt) { - if (cameraDirectionInt == Camera.CameraInfo.CAMERA_FACING_FRONT) { - return CameraSelector.DEFAULT_FRONT_CAMERA; - } else { - return CameraSelector.DEFAULT_BACK_CAMERA; - } - } - - public static @ImageCapture.CaptureMode int getOptimalCaptureMode() { - return FastCameraModels.contains(Build.MODEL) ? ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY - : ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY; - } - - private static byte[] transformByteArray(@NonNull byte[] data, @Nullable Rect cropRect, int rotation, boolean flip) throws IOException { - Stopwatch stopwatch = new Stopwatch("transform"); - Bitmap in; - - if (cropRect != null) { - BitmapRegionDecoder decoder = BitmapRegionDecoder.newInstance(data, 0, data.length, false); - in = decoder.decodeRegion(cropRect, new BitmapFactory.Options()); - decoder.recycle(); - stopwatch.split("crop"); - } else { - in = BitmapFactory.decodeByteArray(data, 0, data.length); - } - - Bitmap out = in; - - if (rotation != 0 || flip) { - Matrix matrix = new Matrix(); - matrix.postRotate(rotation); - - if (flip) { - matrix.postScale(-1, 1); - matrix.postTranslate(in.getWidth(), 0); - } - - out = Bitmap.createBitmap(in, 0, 0, in.getWidth(), in.getHeight(), matrix, true); - } - - byte[] transformedData = toJpegBytes(out); - stopwatch.split("transcode"); - - in.recycle(); - out.recycle(); - - stopwatch.stop(TAG); - - return transformedData; - } - - private static boolean shouldCropImage(@NonNull ImageProxy image) { - Size sourceSize = new Size(image.getWidth(), image.getHeight()); - Size targetSize = new Size(image.getCropRect().width(), image.getCropRect().height()); - - return !targetSize.equals(sourceSize); - } - - private static byte[] toJpegBytes(@NonNull Bitmap bitmap) throws IOException { - ByteArrayOutputStream out = new ByteArrayOutputStream(); - - if (!bitmap.compress(Bitmap.CompressFormat.JPEG, 80, out)) { - throw new IOException("Failed to compress bitmap."); - } - - return out.toByteArray(); - } - - public static boolean isMixedModeSupported(@NonNull Context context) { - return getLowestSupportedHardwareLevel(context) != CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY; - } - - public static int getLowestSupportedHardwareLevel(@NonNull Context context) { - @SuppressLint("RestrictedApi") CameraManager cameraManager = CameraManagerCompat.from(context.getApplicationContext()).unwrap(); - - try { - int supported = maxHardwareLevel(); - - for (String cameraId : cameraManager.getCameraIdList()) { - Integer hwLevel = null; - - try { - hwLevel = cameraManager.getCameraCharacteristics(cameraId).get(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL); - } catch (NullPointerException e) { - // redmi device crash, assume lowest - } - - if (hwLevel == null || hwLevel == CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY) { - return CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY; - } - - supported = smallerHardwareLevel(supported, hwLevel); - } - - return supported; - } catch (CameraAccessException e) { - Log.w(TAG, "Failed to enumerate cameras", e); - - return CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY; - } - } - - private static int maxHardwareLevel() { - if (Build.VERSION.SDK_INT >= 24) return CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_3; - else return CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_FULL; - } - - private static int smallerHardwareLevel(int levelA, int levelB) { - - int[] hardwareInfoOrdering = getHardwareInfoOrdering(); - for (int hwInfo : hardwareInfoOrdering) { - if (levelA == hwInfo || levelB == hwInfo) return hwInfo; - } - - return CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY; - } - - private static int[] getHardwareInfoOrdering() { - if (Build.VERSION.SDK_INT >= 28) return CAMERA_HARDWARE_LEVEL_ORDERING_28; - else if (Build.VERSION.SDK_INT >= 24) return CAMERA_HARDWARE_LEVEL_ORDERING_24; - else return CAMERA_HARDWARE_LEVEL_ORDERING; - } - - public static class ImageResult { - private final byte[] data; - private final int width; - private final int height; - - public ImageResult(@NonNull byte[] data, int width, int height) { - this.data = data; - this.width = width; - this.height = height; - } - - public byte[] getData() { - return data; - } - - public int getWidth() { - return width; - } - - public int getHeight() { - return height; - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionNavigator.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionNavigator.kt index a6f9fdde6b..f741a06c16 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionNavigator.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/MediaSelectionNavigator.kt @@ -6,7 +6,7 @@ import androidx.fragment.app.Fragment import androidx.navigation.NavController import org.signal.core.ui.permissions.Permissions import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil +import org.thoughtcrime.securesms.mediasend.camerax.CameraXRemoteConfig import org.thoughtcrime.securesms.util.navigation.safeNavigate class MediaSelectionNavigator( @@ -37,7 +37,7 @@ class MediaSelectionNavigator( fun Fragment.requestPermissionsForCamera( onGranted: () -> Unit ) { - if (CameraXUtil.isSupported()) { + if (CameraXRemoteConfig.isSupported()) { onGranted() } else { Permissions.with(this) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/gallery/MediaGalleryFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/gallery/MediaGalleryFragment.kt index 06ef81f9b5..c3f10faab3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/gallery/MediaGalleryFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/v2/gallery/MediaGalleryFragment.kt @@ -25,7 +25,7 @@ import org.thoughtcrime.securesms.components.recyclerview.GridDividerDecoration import org.thoughtcrime.securesms.conversation.ManageContextMenu import org.thoughtcrime.securesms.databinding.V2MediaGalleryFragmentBinding import org.thoughtcrime.securesms.mediasend.MediaRepository -import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil +import org.thoughtcrime.securesms.mediasend.camerax.CameraXRemoteConfig import org.thoughtcrime.securesms.mediasend.v2.review.MediaGalleryGridItemTouchListener import org.thoughtcrime.securesms.util.Material3OnScrollHelper import org.thoughtcrime.securesms.util.SystemWindowInsetsSetter @@ -101,7 +101,7 @@ class MediaGalleryFragment : Fragment(R.layout.v2_media_gallery_fragment) { if (callbacks.isCameraEnabled()) { binding.mediaGalleryToolbar.setOnMenuItemClickListener { item -> if (item.itemId == R.id.action_camera) { - if (CameraXUtil.isSupported()) { + if (CameraXRemoteConfig.isSupported()) { callbacks.onNavigateToCamera() } else { Permissions.with(this) diff --git a/app/src/main/java/org/thoughtcrime/securesms/mms/MediaConstraints.java b/app/src/main/java/org/thoughtcrime/securesms/mms/MediaConstraints.java index 93eef356ae..693a5a605f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mms/MediaConstraints.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mms/MediaConstraints.java @@ -16,7 +16,7 @@ import org.thoughtcrime.securesms.util.BitmapDecodingException; import org.thoughtcrime.securesms.util.BitmapUtil; import org.thoughtcrime.securesms.util.RemoteConfig; import org.thoughtcrime.securesms.util.MediaUtil; -import org.thoughtcrime.securesms.util.MemoryFileDescriptor; +import org.signal.core.util.MemoryFileDescriptor; import org.thoughtcrime.securesms.video.TranscodingPreset; import java.io.IOException; diff --git a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/transfer/PaymentsTransferQrScanFragment.java b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/transfer/PaymentsTransferQrScanFragment.java index 93ac8147a2..53123232f8 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/transfer/PaymentsTransferQrScanFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/payments/preferences/transfer/PaymentsTransferQrScanFragment.java @@ -15,7 +15,7 @@ import org.signal.core.util.logging.Log; import org.signal.qr.QrScannerView; import org.signal.core.ui.logging.LoggingFragment; import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.mediasend.camerax.CameraXModelBlocklist; +import org.thoughtcrime.securesms.mediasend.camerax.CameraXRemoteConfig; import org.thoughtcrime.securesms.payments.MobileCoinPublicAddress; import org.signal.core.util.concurrent.LifecycleDisposable; import org.thoughtcrime.securesms.util.navigation.SafeNavigation; @@ -53,7 +53,7 @@ public final class PaymentsTransferQrScanFragment extends LoggingFragment { Toolbar toolbar = view.findViewById(R.id.payments_transfer_scan_qr); toolbar.setNavigationOnClickListener(v -> Navigation.findNavController(v).popBackStack()); - scannerView.start(getViewLifecycleOwner(), CameraXModelBlocklist.isBlocklisted()); + scannerView.start(getViewLifecycleOwner(), CameraXRemoteConfig.isBlocklisted()); lifecycleDisposable.bindTo(getViewLifecycleOwner()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/MemoryFileDescriptor.java b/app/src/main/java/org/thoughtcrime/securesms/util/MemoryFileDescriptor.java deleted file mode 100644 index a7b68aaf33..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/util/MemoryFileDescriptor.java +++ /dev/null @@ -1,196 +0,0 @@ -package org.thoughtcrime.securesms.util; - -import android.app.ActivityManager; -import android.content.Context; -import android.os.ParcelFileDescriptor; - -import androidx.annotation.NonNull; - -import org.signal.core.util.logging.Log; - -import java.io.Closeable; -import java.io.FileDescriptor; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.nio.channels.FileChannel; -import java.text.NumberFormat; -import java.util.Locale; -import java.util.concurrent.atomic.AtomicLong; - -public final class MemoryFileDescriptor implements Closeable { - - private static final String TAG = Log.tag(MemoryFileDescriptor.class); - - private static Boolean supported; - - private final ParcelFileDescriptor parcelFileDescriptor; - private final AtomicLong sizeEstimate; - - /** - * Does this device support memory file descriptor. - */ - public synchronized static boolean supported() { - if (supported == null) { - try { - int fileDescriptor = FileUtils.createMemoryFileDescriptor("CHECK"); - - if (fileDescriptor < 0) { - supported = false; - Log.w(TAG, "MemoryFileDescriptor is not available."); - } else { - supported = true; - ParcelFileDescriptor.adoptFd(fileDescriptor).close(); - } - } catch (IOException e) { - Log.w(TAG, e); - } - } - return supported; - } - - /** - * memfd files do not show on the available RAM, so we must track our allocations in addition. - */ - private static long sizeOfAllMemoryFileDescriptors; - - private MemoryFileDescriptor(@NonNull ParcelFileDescriptor parcelFileDescriptor, long sizeEstimate) { - this.parcelFileDescriptor = parcelFileDescriptor; - this.sizeEstimate = new AtomicLong(sizeEstimate); - } - - /** - * @param debugName The name supplied in name is used as a filename and will be displayed - * as the target of the corresponding symbolic link in the directory - * /proc/self/fd/. The displayed name is always prefixed with memfd: - * and serves only for debugging purposes. Names do not affect the - * behavior of the file descriptor, and as such multiple files can have - * the same name without any side effects. - * @param sizeEstimate An estimated upper bound on this file. This is used to check there will be - * enough RAM available and to register with a global counter of reservations. - * Use zero to avoid RAM check. - * @return MemoryFileDescriptor - * @throws MemoryLimitException If there is not enough available RAM to comfortably fit this file. - * @throws MemoryFileCreationException If fails to create a memory file descriptor. - */ - public static MemoryFileDescriptor newMemoryFileDescriptor(@NonNull Context context, - @NonNull String debugName, - long sizeEstimate) - throws MemoryFileException - { - if (sizeEstimate < 0) throw new IllegalArgumentException(); - - if (sizeEstimate > 0) { - ActivityManager activityManager = ServiceUtil.getActivityManager(context); - ActivityManager.MemoryInfo memoryInfo = new ActivityManager.MemoryInfo(); - - synchronized (MemoryFileDescriptor.class) { - activityManager.getMemoryInfo(memoryInfo); - - long remainingRam = memoryInfo.availMem - memoryInfo.threshold - sizeEstimate - sizeOfAllMemoryFileDescriptors; - - if (remainingRam <= 0) { - NumberFormat numberFormat = NumberFormat.getInstance(Locale.US); - Log.w(TAG, String.format("Not enough RAM available without taking the system into a low memory state.%n" + - "Available: %s%n" + - "Low memory threshold: %s%n" + - "Requested: %s%n" + - "Total MemoryFileDescriptor limit: %s%n" + - "Shortfall: %s", - numberFormat.format(memoryInfo.availMem), - numberFormat.format(memoryInfo.threshold), - numberFormat.format(sizeEstimate), - numberFormat.format(sizeOfAllMemoryFileDescriptors), - numberFormat.format(remainingRam) - )); - throw new MemoryLimitException(); - } - - sizeOfAllMemoryFileDescriptors += sizeEstimate; - } - } - - int fileDescriptor = FileUtils.createMemoryFileDescriptor(debugName); - - if (fileDescriptor < 0) { - Log.w(TAG, "Failed to create file descriptor: " + fileDescriptor); - throw new MemoryFileCreationException(); - } - - return new MemoryFileDescriptor(ParcelFileDescriptor.adoptFd(fileDescriptor), sizeEstimate); - } - - @Override - public void close() throws IOException { - try { - clearAndRemoveAllocation(); - } catch (Exception e) { - Log.w(TAG, "Failed to clear data in MemoryFileDescriptor", e); - } finally { - parcelFileDescriptor.close(); - } - } - - private void clearAndRemoveAllocation() throws IOException { - clear(); - - long oldEstimate = sizeEstimate.getAndSet(0); - - synchronized (MemoryFileDescriptor.class) { - sizeOfAllMemoryFileDescriptors -= oldEstimate; - } - } - - /** Rewinds and clears all bytes. */ - private void clear() throws IOException { - long size; - try (FileInputStream fileInputStream = new FileInputStream(getFileDescriptor())) { - FileChannel channel = fileInputStream.getChannel(); - size = channel.size(); - - if (size == 0) return; - - channel.position(0); - } - byte[] zeros = new byte[16 * 1024]; - - try (FileOutputStream output = new FileOutputStream(getFileDescriptor())) { - while (size > 0) { - int limit = (int) Math.min(size, zeros.length); - - output.write(zeros, 0, limit); - - size -= limit; - } - } - } - - public FileDescriptor getFileDescriptor() { - return parcelFileDescriptor.getFileDescriptor(); - } - - public ParcelFileDescriptor getParcelFileDescriptor() { - return parcelFileDescriptor; - } - - public void seek(long position) throws IOException { - try (FileInputStream fileInputStream = new FileInputStream(getFileDescriptor())) { - fileInputStream.getChannel().position(position); - } - } - - public long size() throws IOException { - try (FileInputStream fileInputStream = new FileInputStream(getFileDescriptor())) { - return fileInputStream.getChannel().size(); - } - } - - public static class MemoryFileException extends IOException { - } - - private static final class MemoryLimitException extends MemoryFileException { - } - - private static final class MemoryFileCreationException extends MemoryFileException { - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt index 06b1047139..6e76fac078 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/util/RemoteConfig.kt @@ -817,7 +817,7 @@ object RemoteConfig { /** A comma-separated list of manufacturers that should *not* use CameraX mixed mode. */ val cameraXMixedModelBlocklist: String by remoteString( - key = "android.cameraXMixedModelBlockList.2", + key = "android.cameraXMixedModelBlockList.3", defaultValue = "", hotSwappable = false ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/verify/VerifyScanFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/verify/VerifyScanFragment.kt index 8827d0bc4f..754898b150 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/verify/VerifyScanFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/verify/VerifyScanFragment.kt @@ -13,7 +13,7 @@ import org.signal.qr.QrScannerView import org.signal.qr.kitkat.ScanListener import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.components.ShapeScrim -import org.thoughtcrime.securesms.mediasend.camerax.CameraXModelBlocklist +import org.thoughtcrime.securesms.mediasend.camerax.CameraXRemoteConfig import org.thoughtcrime.securesms.util.ViewUtil import org.thoughtcrime.securesms.util.fragments.findListener @@ -41,7 +41,7 @@ class VerifyScanFragment : Fragment() { ViewUtil.updateLayoutParams(cameraMarks, width, height) } - cameraView.start(viewLifecycleOwner, CameraXModelBlocklist.isBlocklisted()) + cameraView.start(viewLifecycleOwner, CameraXRemoteConfig.isBlocklisted()) lifecycleDisposable.bindTo(viewLifecycleOwner) diff --git a/app/src/main/java/org/thoughtcrime/securesms/video/VideoUtil.java b/app/src/main/java/org/thoughtcrime/securesms/video/VideoUtil.java index 929dc121ce..2b7879453e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/video/VideoUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/video/VideoUtil.java @@ -14,25 +14,10 @@ public final class VideoUtil { private VideoUtil() { } - public static Size getVideoRecordingSize() { - return isPortrait(screenSize()) - ? new Size(VideoConstants.VIDEO_SHORT_EDGE_HD, VideoConstants.VIDEO_LONG_EDGE_HD) - : new Size(VideoConstants.VIDEO_LONG_EDGE_HD, VideoConstants.VIDEO_SHORT_EDGE_HD); - } - 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); } - - private static Size screenSize() { - DisplayMetrics metrics = Resources.getSystem().getDisplayMetrics(); - return new Size(metrics.widthPixels, metrics.heightPixels); - } - - private static boolean isPortrait(Size size) { - return size.getWidth() < size.getHeight(); - } } diff --git a/core/util/src/main/java/org/signal/core/util/MemoryFileDescriptor.kt b/core/util/src/main/java/org/signal/core/util/MemoryFileDescriptor.kt new file mode 100644 index 0000000000..57e94e5d77 --- /dev/null +++ b/core/util/src/main/java/org/signal/core/util/MemoryFileDescriptor.kt @@ -0,0 +1,162 @@ +package org.signal.core.util + +import android.app.ActivityManager +import android.content.Context +import android.os.ParcelFileDescriptor +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.util.FileUtils +import java.io.Closeable +import java.io.FileDescriptor +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.IOException +import java.text.NumberFormat +import java.util.Locale +import java.util.concurrent.atomic.AtomicLong + +class MemoryFileDescriptor private constructor( + private val parcelFileDescriptor: ParcelFileDescriptor, + private val sizeEstimate: AtomicLong +) : Closeable { + + val fileDescriptor: FileDescriptor + get() = parcelFileDescriptor.fileDescriptor + + val parcelFd: ParcelFileDescriptor + get() = parcelFileDescriptor + + @Throws(IOException::class) + fun seek(position: Long) { + FileInputStream(fileDescriptor).use { stream -> + stream.channel.position(position) + } + } + + @Throws(IOException::class) + fun size(): Long { + FileInputStream(fileDescriptor).use { stream -> + return stream.channel.size() + } + } + + @Throws(IOException::class) + override fun close() { + try { + clearAndRemoveAllocation() + } catch (e: Exception) { + Log.w(TAG, "Failed to clear data in MemoryFileDescriptor", e) + } finally { + parcelFileDescriptor.close() + } + } + + @Throws(IOException::class) + private fun clearAndRemoveAllocation() { + clear() + + val oldEstimate = sizeEstimate.getAndSet(0) + + synchronized(MemoryFileDescriptor::class.java) { + sizeOfAllMemoryFileDescriptors -= oldEstimate + } + } + + @Throws(IOException::class) + private fun clear() { + val size: Long + FileInputStream(fileDescriptor).use { stream -> + val channel = stream.channel + size = channel.size() + if (size == 0L) return + channel.position(0) + } + + val zeros = ByteArray(16 * 1024) + var remaining = size + + FileOutputStream(fileDescriptor).use { output -> + while (remaining > 0) { + val limit = remaining.coerceAtMost(zeros.size.toLong()).toInt() + output.write(zeros, 0, limit) + remaining -= limit + } + } + } + + open class MemoryFileException : IOException() + private class MemoryLimitException : MemoryFileException() + private class MemoryFileCreationException : MemoryFileException() + + companion object { + private val TAG = Log.tag(MemoryFileDescriptor::class.java) + + private var sizeOfAllMemoryFileDescriptors: Long = 0 + + @JvmStatic + @Synchronized + fun supported(): Boolean { + return try { + val fd = FileUtils.createMemoryFileDescriptor("CHECK") + if (fd < 0) { + Log.w(TAG, "MemoryFileDescriptor is not available.") + false + } else { + ParcelFileDescriptor.adoptFd(fd).close() + true + } + } catch (e: IOException) { + Log.w(TAG, e) + false + } + } + + @JvmStatic + @Throws(MemoryFileException::class) + fun newMemoryFileDescriptor(context: Context, debugName: String, sizeEstimate: Long): MemoryFileDescriptor { + require(sizeEstimate >= 0) + + if (sizeEstimate > 0) { + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + val memoryInfo = ActivityManager.MemoryInfo() + + synchronized(MemoryFileDescriptor::class.java) { + activityManager.getMemoryInfo(memoryInfo) + + val remainingRam = memoryInfo.availMem - memoryInfo.threshold - sizeEstimate - sizeOfAllMemoryFileDescriptors + + if (remainingRam <= 0) { + val numberFormat = NumberFormat.getInstance(Locale.US) + Log.w( + TAG, + String.format( + "Not enough RAM available without taking the system into a low memory state.%n" + + "Available: %s%n" + + "Low memory threshold: %s%n" + + "Requested: %s%n" + + "Total MemoryFileDescriptor limit: %s%n" + + "Shortfall: %s", + numberFormat.format(memoryInfo.availMem), + numberFormat.format(memoryInfo.threshold), + numberFormat.format(sizeEstimate), + numberFormat.format(sizeOfAllMemoryFileDescriptors), + numberFormat.format(remainingRam) + ) + ) + throw MemoryLimitException() + } + + sizeOfAllMemoryFileDescriptors += sizeEstimate + } + } + + val fd = FileUtils.createMemoryFileDescriptor(debugName) + + if (fd < 0) { + Log.w(TAG, "Failed to create file descriptor: $fd") + throw MemoryFileCreationException() + } + + return MemoryFileDescriptor(ParcelFileDescriptor.adoptFd(fd), AtomicLong(sizeEstimate)) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FileUtils.java b/core/util/src/main/java/org/thoughtcrime/securesms/util/FileUtils.java similarity index 95% rename from app/src/main/java/org/thoughtcrime/securesms/util/FileUtils.java rename to core/util/src/main/java/org/thoughtcrime/securesms/util/FileUtils.java index 865325b5b9..e9fd3137f6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FileUtils.java +++ b/core/util/src/main/java/org/thoughtcrime/securesms/util/FileUtils.java @@ -17,7 +17,7 @@ public final class FileUtils { public static native int getFileDescriptorOwner(FileDescriptor fileDescriptor); - static native int createMemoryFileDescriptor(String name); + public static native int createMemoryFileDescriptor(String name); public static byte[] getFileDigest(FileInputStream fin) throws IOException { try { diff --git a/app/src/main/jniLibs/arm64-v8a/libnative-utils.so b/core/util/src/main/jniLibs/arm64-v8a/libnative-utils.so similarity index 100% rename from app/src/main/jniLibs/arm64-v8a/libnative-utils.so rename to core/util/src/main/jniLibs/arm64-v8a/libnative-utils.so diff --git a/app/src/main/jniLibs/armeabi-v7a/libnative-utils.so b/core/util/src/main/jniLibs/armeabi-v7a/libnative-utils.so similarity index 100% rename from app/src/main/jniLibs/armeabi-v7a/libnative-utils.so rename to core/util/src/main/jniLibs/armeabi-v7a/libnative-utils.so diff --git a/app/src/main/jniLibs/x86/libnative-utils.so b/core/util/src/main/jniLibs/x86/libnative-utils.so similarity index 100% rename from app/src/main/jniLibs/x86/libnative-utils.so rename to core/util/src/main/jniLibs/x86/libnative-utils.so diff --git a/app/src/main/jniLibs/x86_64/libnative-utils.so b/core/util/src/main/jniLibs/x86_64/libnative-utils.so similarity index 100% rename from app/src/main/jniLibs/x86_64/libnative-utils.so rename to core/util/src/main/jniLibs/x86_64/libnative-utils.so diff --git a/feature/camera/build.gradle.kts b/feature/camera/build.gradle.kts index 2ce4034146..8427af8cc2 100644 --- a/feature/camera/build.gradle.kts +++ b/feature/camera/build.gradle.kts @@ -21,7 +21,7 @@ dependencies { lintChecks(project(":lintchecks")) // Signal Core - implementation(project(":core:util-jvm")) + implementation(project(":core:util")) implementation(project(":core:ui")) implementation(project(":lib:glide")) diff --git a/feature/camera/src/main/java/org/signal/camera/CameraCaptureMode.kt b/feature/camera/src/main/java/org/signal/camera/CameraCaptureMode.kt new file mode 100644 index 0000000000..ceee584269 --- /dev/null +++ b/feature/camera/src/main/java/org/signal/camera/CameraCaptureMode.kt @@ -0,0 +1,12 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.camera + +enum class CameraCaptureMode { + ImageAndVideoSimultaneous, + ImageAndVideoExclusive, + ImageOnly +} diff --git a/feature/camera/src/main/java/org/signal/camera/CameraScreen.kt b/feature/camera/src/main/java/org/signal/camera/CameraScreen.kt index 65b966ed1b..ae3061addf 100644 --- a/feature/camera/src/main/java/org/signal/camera/CameraScreen.kt +++ b/feature/camera/src/main/java/org/signal/camera/CameraScreen.kt @@ -76,7 +76,7 @@ fun CameraScreen( modifier: Modifier = Modifier, roundCorners: Boolean = true, contentAlignment: Alignment = Alignment.Center, - enableVideoCapture: Boolean = true, + captureMode: CameraCaptureMode = CameraCaptureMode.ImageAndVideoSimultaneous, enableQrScanning: Boolean = false, content: @Composable BoxScope.() -> Unit = {} ) { @@ -106,7 +106,7 @@ fun CameraScreen( cameraProvider = cameraProvider, surfaceProvider = surfaceProvider, context = context, - enableVideoCapture = enableVideoCapture, + captureMode = captureMode, enableQrScanning = enableQrScanning ) ) diff --git a/feature/camera/src/main/java/org/signal/camera/CameraScreenEvents.kt b/feature/camera/src/main/java/org/signal/camera/CameraScreenEvents.kt index b29a2df02c..9486835683 100644 --- a/feature/camera/src/main/java/org/signal/camera/CameraScreenEvents.kt +++ b/feature/camera/src/main/java/org/signal/camera/CameraScreenEvents.kt @@ -14,7 +14,7 @@ sealed interface CameraScreenEvents { val cameraProvider: ProcessCameraProvider, val surfaceProvider: Preview.SurfaceProvider, val context: Context, - val enableVideoCapture: Boolean = true, + val captureMode: CameraCaptureMode = CameraCaptureMode.ImageAndVideoSimultaneous, val enableQrScanning: Boolean = false ) : CameraScreenEvents diff --git a/feature/camera/src/main/java/org/signal/camera/CameraScreenViewModel.kt b/feature/camera/src/main/java/org/signal/camera/CameraScreenViewModel.kt index 907e8bbbb2..c6cd8310ac 100644 --- a/feature/camera/src/main/java/org/signal/camera/CameraScreenViewModel.kt +++ b/feature/camera/src/main/java/org/signal/camera/CameraScreenViewModel.kt @@ -87,7 +87,7 @@ class CameraScreenViewModel : ViewModel() { private var imageCapture: ImageCapture? = null private var videoCapture: VideoCapture? = null private var recording: Recording? = null - private var isLimitedBinding: Boolean = false + private var captureMode: CameraCaptureMode = CameraCaptureMode.ImageOnly private var brightnessBeforeFlash: Float = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE private var brightnessWindow: WeakReference? = null private var orientationListener: OrientationEventListener? = null @@ -109,6 +109,8 @@ class CameraScreenViewModel : ViewModel() { } fun onEvent(event: CameraScreenEvents) { + logEvent(event) + val currentState = _state.value when (event) { is CameraScreenEvents.BindCamera -> { @@ -138,6 +140,19 @@ class CameraScreenViewModel : ViewModel() { } } + private fun logEvent(event: CameraScreenEvents) { + when (event) { + is CameraScreenEvents.BindCamera -> Log.d(TAG, "[Event] BindCamera(captureMode=${event.captureMode}, enableQrScanning=${event.enableQrScanning})") + is CameraScreenEvents.TapToFocus -> Log.d(TAG, "[Event] TapToFocus(view=${event.viewX},${event.viewY}, surface=${event.surfaceX},${event.surfaceY})") + is CameraScreenEvents.PinchZoom -> Log.d(TAG, "[Event] PinchZoom(factor=${event.zoomFactor})") + is CameraScreenEvents.LinearZoom -> Log.d(TAG, "[Event] LinearZoom(${event.linearZoom})") + is CameraScreenEvents.SwitchCamera -> Log.d(TAG, "[Event] SwitchCamera") + is CameraScreenEvents.SetFlashMode -> Log.d(TAG, "[Event] SetFlashMode(${event.flashMode})") + is CameraScreenEvents.NextFlashMode -> Log.d(TAG, "[Event] NextFlashMode") + is CameraScreenEvents.ClearCaptureError -> Log.d(TAG, "[Event] ClearCaptureError") + } + } + /** * Capture a photo. * If using front camera with flash enabled but no hardware flash available, @@ -240,7 +255,7 @@ class CameraScreenViewModel : ViewModel() { output: VideoOutput, onVideoCaptured: (VideoCaptureResult) -> Unit ) { - val capture = if (isLimitedBinding) rebindForVideoCapture() ?: return else videoCapture ?: return + val capture = videoCapture ?: rebindForVideoCapture() ?: return recordingStartZoomRatio = _state.value.zoomRatio @@ -314,7 +329,7 @@ class CameraScreenViewModel : ViewModel() { // Clear recording recording = null - if (isLimitedBinding) { + if (captureMode == CameraCaptureMode.ImageAndVideoExclusive) { rebindToLastSuccessfulAttempt() } } @@ -452,7 +467,7 @@ class CameraScreenViewModel : ViewModel() { lastSuccessfulAttempt = attempt imageCapture = attempt.imageCapture videoCapture = attempt.videoCapture - isLimitedBinding = event.enableVideoCapture && attempt.videoCapture == null + captureMode = event.captureMode } catch (e: Exception) { Log.e(TAG, "Use case binding failed (attempt ${index + 1} of ${bindingAttempts.size})", e) continue @@ -499,7 +514,7 @@ class CameraScreenViewModel : ViewModel() { .setResolutionSelector(resolutionSelector) .build() - val videoCapture: VideoCapture? = if (event.enableVideoCapture && !FORCE_LIMITED_BINDING) { + val videoCapture: VideoCapture? = if (event.captureMode == CameraCaptureMode.ImageAndVideoSimultaneous && !FORCE_LIMITED_BINDING) { buildVideoCapture() } else { null diff --git a/feature/camera/src/main/java/org/signal/camera/CameraXUtil.kt b/feature/camera/src/main/java/org/signal/camera/CameraXUtil.kt new file mode 100644 index 0000000000..3bed832982 --- /dev/null +++ b/feature/camera/src/main/java/org/signal/camera/CameraXUtil.kt @@ -0,0 +1,113 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.signal.camera + +import android.annotation.SuppressLint +import android.content.Context +import android.hardware.camera2.CameraAccessException +import android.hardware.camera2.CameraCharacteristics +import android.hardware.camera2.CameraMetadata +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.camera.camera2.internal.compat.CameraManagerCompat +import org.signal.core.util.MemoryFileDescriptor +import org.signal.core.util.logging.Log + +object CameraXUtil { + private val TAG = Log.tag(CameraXUtil::class.java) + + private const val VIDEO_DEBUG_LABEL = "video-capture" + private const val VIDEO_SIZE = 10L * 1024 * 1024 + + @Throws(MemoryFileDescriptor.MemoryFileException::class) + fun createVideoFileDescriptor(context: Context): MemoryFileDescriptor { + return MemoryFileDescriptor.newMemoryFileDescriptor(context, VIDEO_DEBUG_LABEL, VIDEO_SIZE) + } + + private val CAMERA_HARDWARE_LEVEL_ORDERING = intArrayOf( + CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY, + CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED, + CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_FULL + ) + + @RequiresApi(24) + private val CAMERA_HARDWARE_LEVEL_ORDERING_24 = intArrayOf( + CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY, + CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED, + CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_FULL, + CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_3 + ) + + @RequiresApi(28) + private val CAMERA_HARDWARE_LEVEL_ORDERING_28 = intArrayOf( + CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY, + CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LIMITED, + CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_EXTERNAL, + CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_FULL, + CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_3 + ) + + fun isMixedModeSupported(context: Context): Boolean { + return getLowestSupportedHardwareLevel(context) != CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY + } + + fun getLowestSupportedHardwareLevel(context: Context): Int { + @SuppressLint("RestrictedApi") + val cameraManager = CameraManagerCompat.from(context.applicationContext).unwrap() + + try { + var supported = maxHardwareLevel() + + for (cameraId in cameraManager.cameraIdList) { + var hwLevel: Int? = null + + try { + hwLevel = cameraManager.getCameraCharacteristics(cameraId).get(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL) + } catch (_: NullPointerException) { + // redmi device crash, assume lowest + } + + if (hwLevel == null || hwLevel == CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY) { + return CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY + } + + supported = smallerHardwareLevel(supported, hwLevel) + } + + return supported + } catch (e: CameraAccessException) { + Log.w(TAG, "Failed to enumerate cameras", e) + return CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY + } + } + + private fun maxHardwareLevel(): Int { + return if (Build.VERSION.SDK_INT >= 24) { + CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_3 + } else { + CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_FULL + } + } + + private fun smallerHardwareLevel(levelA: Int, levelB: Int): Int { + val hardwareInfoOrdering: IntArray = getHardwareInfoOrdering() + for (hwInfo in hardwareInfoOrdering) { + if (levelA == hwInfo || levelB == hwInfo) { + return hwInfo + } + } + + return CameraMetadata.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY + } + + private fun getHardwareInfoOrdering(): IntArray { + return when { + Build.VERSION.SDK_INT >= 28 -> CAMERA_HARDWARE_LEVEL_ORDERING_28 + Build.VERSION.SDK_INT >= 24 -> CAMERA_HARDWARE_LEVEL_ORDERING_24 + else -> CAMERA_HARDWARE_LEVEL_ORDERING + } + } +} diff --git a/feature/camera/src/test/java/org/signal/camera/CameraScreenViewModelTest.kt b/feature/camera/src/test/java/org/signal/camera/CameraScreenViewModelTest.kt index c96cf1ace1..4fd94badfb 100644 --- a/feature/camera/src/test/java/org/signal/camera/CameraScreenViewModelTest.kt +++ b/feature/camera/src/test/java/org/signal/camera/CameraScreenViewModelTest.kt @@ -86,7 +86,7 @@ class CameraScreenViewModelTest { // =========================================================================== private fun bindCamera( - enableVideoCapture: Boolean = true, + captureMode: CameraCaptureMode = CameraCaptureMode.ImageAndVideoSimultaneous, enableQrScanning: Boolean = false ) = viewModel.onEvent( CameraScreenEvents.BindCamera( @@ -94,7 +94,7 @@ class CameraScreenViewModelTest { cameraProvider = mockCameraProvider, surfaceProvider = mockSurfaceProvider, context = RuntimeEnvironment.getApplication(), - enableVideoCapture = enableVideoCapture, + captureMode = captureMode, enableQrScanning = enableQrScanning ) ) @@ -145,7 +145,7 @@ class CameraScreenViewModelTest { fun `binding with all use cases binds video and QR on the first attempt`() { val attempts = captureBindingAttempts() - bindCamera(enableVideoCapture = true, enableQrScanning = true) + bindCamera(captureMode = CameraCaptureMode.ImageAndVideoSimultaneous, enableQrScanning = true) assertThat(attempts.size).isEqualTo(1) assertThat(attempts[0].hasVideoCapture()).isTrue() @@ -156,7 +156,7 @@ class CameraScreenViewModelTest { fun `binding with no optional use cases binds only preview and image capture`() { val attempts = captureBindingAttempts(failCount = 0) - bindCamera(enableVideoCapture = false, enableQrScanning = false) + bindCamera(captureMode = CameraCaptureMode.ImageOnly, enableQrScanning = false) assertThat(attempts.size).isEqualTo(1) assertThat(attempts[0].hasVideoCapture()).isFalse() @@ -171,7 +171,7 @@ class CameraScreenViewModelTest { fun `when first attempt fails with video and QR, second attempt drops video but keeps QR`() { val attempts = captureBindingAttempts(failCount = 1) - bindCamera(enableVideoCapture = true, enableQrScanning = true) + bindCamera(captureMode = CameraCaptureMode.ImageAndVideoSimultaneous, enableQrScanning = true) assertThat(attempts.size).isEqualTo(2) assertThat(attempts[0].hasVideoCapture()).isTrue() @@ -184,7 +184,7 @@ class CameraScreenViewModelTest { fun `when first two attempts fail with video and QR, third attempt drops both`() { val attempts = captureBindingAttempts(failCount = 2) - bindCamera(enableVideoCapture = true, enableQrScanning = true) + bindCamera(captureMode = CameraCaptureMode.ImageAndVideoSimultaneous, enableQrScanning = true) assertThat(attempts.size).isEqualTo(3) assertThat(attempts[2].hasVideoCapture()).isFalse() @@ -195,7 +195,7 @@ class CameraScreenViewModelTest { fun `when all attempts fail, all three use case combinations are tried`() { val attempts = captureBindingAttempts(failCount = Int.MAX_VALUE) - bindCamera(enableVideoCapture = true, enableQrScanning = true) + bindCamera(captureMode = CameraCaptureMode.ImageAndVideoSimultaneous, enableQrScanning = true) assertThat(attempts.size).isEqualTo(3) } @@ -204,7 +204,7 @@ class CameraScreenViewModelTest { fun `with only video requested, fallback drops video and nothing else`() { val attempts = captureBindingAttempts(failCount = Int.MAX_VALUE) - bindCamera(enableVideoCapture = true, enableQrScanning = false) + bindCamera(captureMode = CameraCaptureMode.ImageAndVideoSimultaneous, enableQrScanning = false) assertThat(attempts.size).isEqualTo(2) assertThat(attempts[0].hasVideoCapture()).isTrue() @@ -216,7 +216,7 @@ class CameraScreenViewModelTest { fun `with only QR requested, fallback drops QR and nothing else`() { val attempts = captureBindingAttempts(failCount = Int.MAX_VALUE) - bindCamera(enableVideoCapture = false, enableQrScanning = true) + bindCamera(captureMode = CameraCaptureMode.ImageOnly, enableQrScanning = true) assertThat(attempts.size).isEqualTo(2) assertThat(attempts[0].hasImageAnalysis()).isTrue() @@ -228,7 +228,7 @@ class CameraScreenViewModelTest { fun `each failed binding attempt calls unbindAll before retrying`() { captureBindingAttempts(failCount = 2) - bindCamera(enableVideoCapture = true, enableQrScanning = true) + bindCamera(captureMode = CameraCaptureMode.ImageAndVideoSimultaneous, enableQrScanning = true) // unbindAll called once before each of the 3 attempts verify(exactly = 3) { mockCameraProvider.unbindAll() } @@ -242,7 +242,7 @@ class CameraScreenViewModelTest { fun `when video was dropped during initial binding, startRecording rebinds with video`() { // Initial bind: first attempt (with video) fails, second (without) succeeds → limited mode captureBindingAttempts(failCount = 1) - bindCamera(enableVideoCapture = true, enableQrScanning = false) + bindCamera(captureMode = CameraCaptureMode.ImageAndVideoSimultaneous, enableQrScanning = false) val postInitAttempts = captureBindingAttempts() @@ -259,7 +259,7 @@ class CameraScreenViewModelTest { @Test fun `in normal binding mode, startRecording does not rebind`() { captureBindingAttempts() - bindCamera(enableVideoCapture = true, enableQrScanning = false) + bindCamera(captureMode = CameraCaptureMode.ImageAndVideoSimultaneous, enableQrScanning = false) val postInitAttempts = captureBindingAttempts() @@ -275,7 +275,7 @@ class CameraScreenViewModelTest { @Test fun `when the video rebind fails, restores the last successful use case set`() { captureBindingAttempts(failCount = 1) - bindCamera(enableVideoCapture = true, enableQrScanning = false) + bindCamera(captureMode = CameraCaptureMode.ImageAndVideoSimultaneous, enableQrScanning = false) // Both the failed video rebind and the restore attempt are captured here val postInitAttempts = captureBindingAttempts(failCount = Int.MAX_VALUE)