diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.java index 1a3f25f7cf..15de7c43bd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.java @@ -49,6 +49,7 @@ import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.animation.AnimationCompleteListener; import org.thoughtcrime.securesms.components.TooltipPopup; import org.thoughtcrime.securesms.mediasend.camerax.CameraXFlashToggleView; +import org.thoughtcrime.securesms.mediasend.camerax.CameraXModePolicy; import org.thoughtcrime.securesms.mediasend.camerax.CameraXUtil; import org.thoughtcrime.securesms.mediasend.v2.MediaAnimations; import org.thoughtcrime.securesms.mediasend.v2.MediaCountIndicatorButton; @@ -87,6 +88,7 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment { private MemoryFileDescriptor videoFileDescriptor; private LifecycleCameraController cameraController; private Disposable mostRecentItemDisposable = Disposable.disposed(); + private CameraXModePolicy cameraXModePolicy; private boolean isThumbAvailable; private boolean isMediaSelected; @@ -136,13 +138,18 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment { this.previewView = view.findViewById(R.id.camerax_camera); this.controlsContainer = view.findViewById(R.id.camerax_controls_container); + this.cameraXModePolicy = CameraXModePolicy.acquire(requireContext(), + controller.getMediaConstraints(), + requireArguments().getBoolean(IS_VIDEO_ENABLED, true)); + + Log.d(TAG, "Starting CameraX with mode policy " + cameraXModePolicy.getClass().getSimpleName()); cameraController = new LifecycleCameraController(requireContext()); cameraController.bindToLifecycle(getViewLifecycleOwner()); cameraController.setCameraSelector(CameraXUtil.toCameraSelector(TextSecurePreferences.getDirectCaptureCameraId(requireContext()))); cameraController.setTapToFocusEnabled(true); cameraController.setImageCaptureMode(CameraXUtil.getOptimalCaptureMode()); - cameraController.setEnabledUseCases(getSupportedUseCases()); + cameraXModePolicy.initialize(cameraController); previewView.setScaleType(PREVIEW_SCALE_TYPE); previewView.setController(cameraController); @@ -335,7 +342,7 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment { galleryButton.setOnClickListener(v -> controller.onGalleryClicked()); countButton.setOnClickListener(v -> controller.onCameraCountButtonClicked()); - if (isVideoRecordingSupported(requireContext())) { + if (Build.VERSION.SDK_INT >= 26 && cameraXModePolicy.isVideoSupported()) { try { closeVideoFileDescriptor(); videoFileDescriptor = CameraXVideoCaptureHelper.createFileDescriptor(requireContext()); @@ -356,6 +363,7 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment { cameraController, previewView, videoFileDescriptor, + cameraXModePolicy, maxDuration, new CameraXVideoCaptureHelper.Callback() { @Override @@ -389,23 +397,6 @@ public class CameraXFragment extends LoggingFragment implements CameraFragment { } } - @CameraController.UseCases - private int getSupportedUseCases() { - if (isVideoRecordingSupported(requireContext())) { - return CameraController.IMAGE_CAPTURE | CameraController.VIDEO_CAPTURE; - } else { - return CameraController.IMAGE_CAPTURE; - } - } - - private boolean isVideoRecordingSupported(@NonNull Context context) { - return Build.VERSION.SDK_INT >= 26 && - requireArguments().getBoolean(IS_VIDEO_ENABLED, true) && - MediaConstraints.isVideoTranscodeAvailable() && - CameraXUtil.isMixedModeSupported(context) && - VideoUtil.getMaxVideoRecordDurationInSeconds(context, controller.getMediaConstraints()) > 0; - } - private void displayVideoRecordingTooltipIfNecessary(CameraButtonView captureButton) { if (shouldDisplayVideoRecordingTooltip()) { int displayRotation = requireActivity().getWindowManager().getDefaultDisplay().getRotation(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXVideoCaptureHelper.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXVideoCaptureHelper.java index 56bc38dcf9..4da3e0e3c1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXVideoCaptureHelper.java +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXVideoCaptureHelper.java @@ -26,6 +26,7 @@ import com.bumptech.glide.util.Executors; import org.signal.core.util.logging.Log; import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.mediasend.camerax.CameraXModePolicy; import org.thoughtcrime.securesms.permissions.Permissions; import org.thoughtcrime.securesms.util.Debouncer; import org.thoughtcrime.securesms.util.MemoryFileDescriptor; @@ -51,6 +52,7 @@ class CameraXVideoCaptureHelper implements CameraButtonView.VideoCaptureListener private final @NonNull MemoryFileDescriptor memoryFileDescriptor; private final @NonNull ValueAnimator updateProgressAnimator; private final @NonNull Debouncer debouncer; + private final @NonNull CameraXModePolicy cameraXModePolicy; private ValueAnimator cameraMetricsAnimator; @@ -81,6 +83,7 @@ class CameraXVideoCaptureHelper implements CameraButtonView.VideoCaptureListener @NonNull CameraController cameraController, @NonNull PreviewView previewView, @NonNull MemoryFileDescriptor memoryFileDescriptor, + @NonNull CameraXModePolicy cameraXModePolicy, int maxVideoDurationSec, @NonNull Callback callback) { @@ -91,6 +94,7 @@ class CameraXVideoCaptureHelper implements CameraButtonView.VideoCaptureListener this.callback = callback; this.updateProgressAnimator = ValueAnimator.ofFloat(0f, 1f).setDuration(TimeUnit.SECONDS.toMillis(maxVideoDurationSec)); this.debouncer = new Debouncer(TimeUnit.SECONDS.toMillis(maxVideoDurationSec)); + this.cameraXModePolicy = cameraXModePolicy; updateProgressAnimator.setInterpolator(new LinearInterpolator()); updateProgressAnimator.addUpdateListener(anim -> captureButton.setProgress(anim.getAnimatedFraction())); @@ -123,6 +127,7 @@ class CameraXVideoCaptureHelper implements CameraButtonView.VideoCaptureListener @SuppressLint("RestrictedApi") private void beginCameraRecording() { + cameraXModePolicy.setToVideo(cameraController); this.cameraController.setZoomRatio(Objects.requireNonNull(this.cameraController.getZoomState().getValue()).getMinZoomRatio()); callback.onVideoRecordStarted(); shrinkCaptureArea(); @@ -196,6 +201,7 @@ class CameraXVideoCaptureHelper implements CameraButtonView.VideoCaptureListener updateProgressAnimator.cancel(); debouncer.clear(); + cameraXModePolicy.setToImage(cameraController); } @Override 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 new file mode 100644 index 0000000000..a4bc5d808b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/camerax/CameraXModePolicy.kt @@ -0,0 +1,92 @@ +package org.thoughtcrime.securesms.mediasend.camerax + +import android.content.Context +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.camera.view.CameraController +import androidx.camera.view.video.ExperimentalVideo +import org.signal.core.util.asListContains +import org.thoughtcrime.securesms.mms.MediaConstraints +import org.thoughtcrime.securesms.util.FeatureFlags +import org.thoughtcrime.securesms.video.VideoUtil + +/** + * Describes device capabilities + */ +@RequiresApi(21) +@ExperimentalVideo +sealed class CameraXModePolicy { + + abstract val isVideoSupported: Boolean + + abstract fun initialize(cameraController: CameraController) + + open fun setToImage(cameraController: CameraController) = Unit + + open fun setToVideo(cameraController: CameraController) = Unit + + /** + * The device supports having Image and Video enabled at the same time + */ + object Mixed : CameraXModePolicy() { + + override val isVideoSupported: Boolean = true + + override fun initialize(cameraController: CameraController) { + cameraController.setEnabledUseCases(CameraController.IMAGE_CAPTURE or CameraController.VIDEO_CAPTURE) + } + } + + /** + * The device supports image and video, but only one mode at a time. + */ + object Single : CameraXModePolicy() { + + override val isVideoSupported: Boolean = true + + override fun initialize(cameraController: CameraController) { + setToImage(cameraController) + } + + override fun setToImage(cameraController: CameraController) { + cameraController.setEnabledUseCases(CameraController.IMAGE_CAPTURE) + } + + override fun setToVideo(cameraController: CameraController) { + cameraController.setEnabledUseCases(CameraController.VIDEO_CAPTURE) + } + } + + /** + * The device supports taking images only. + */ + object ImageOnly : CameraXModePolicy() { + + override val isVideoSupported: Boolean = false + + override fun initialize(cameraController: CameraController) { + cameraController.setEnabledUseCases(CameraController.IMAGE_CAPTURE) + } + } + + companion object { + @JvmStatic + fun acquire(context: Context, mediaConstraints: MediaConstraints, isVideoEnabled: 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) && + !FeatureFlags.cameraXMixedModelBlocklist().asListContains(Build.MODEL) + + return when { + isMixedModeSupported -> Mixed + isVideoSupported -> Single + else -> ImageOnly + } + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java index 431e127e07..23559335b3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/FeatureFlags.java @@ -97,6 +97,7 @@ public final class FeatureFlags { private static final String TELECOM_MANUFACTURER_ALLOWLIST = "android.calling.telecomAllowList"; private static final String TELECOM_MODEL_BLOCKLIST = "android.calling.telecomModelBlockList"; private static final String CAMERAX_MODEL_BLOCKLIST = "android.cameraXModelBlockList"; + private static final String CAMERAX_MIXED_MODEL_BLOCKLIST = "android.cameraXMixedModelBlockList"; private static final String RECIPIENT_MERGE_V2 = "android.recipientMergeV2"; private static final String CDS_V2_LOAD_TEST = "android.cdsV2LoadTest"; private static final String SMS_EXPORTER = "android.sms.exporter"; @@ -153,6 +154,7 @@ public final class FeatureFlags { TELECOM_MANUFACTURER_ALLOWLIST, TELECOM_MODEL_BLOCKLIST, CAMERAX_MODEL_BLOCKLIST, + CAMERAX_MIXED_MODEL_BLOCKLIST, RECIPIENT_MERGE_V2, CDS_V2_LOAD_TEST, SMS_EXPORTER, @@ -492,6 +494,11 @@ public final class FeatureFlags { return getString(CAMERAX_MODEL_BLOCKLIST, ""); } + /** A comma-separated list of manufacturers that should *not* use CameraX mixed mode. */ + public static @NonNull String cameraXMixedModelBlocklist() { + return getString(CAMERAX_MIXED_MODEL_BLOCKLIST, ""); + } + /** Whether or not hardware AEC should be used for calling on devices older than API 29. */ public static boolean useHardwareAecIfOlderThanApi29() { return getBoolean(USE_HARDWARE_AEC_IF_OLD, false);