diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 65a33c59fb..26709f4ba3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -526,6 +526,7 @@ dependencies { implementation(project(":core:ui")) implementation(project(":core:models")) implementation(project(":core:models-jvm")) + implementation(project(":feature:camera")) implementation(libs.androidx.fragment.ktx) implementation(libs.androidx.appcompat) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraDisplay.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraDisplay.kt index fa7dc5eb27..42b2a59e96 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraDisplay.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraDisplay.kt @@ -87,22 +87,25 @@ enum class CameraDisplay( toggleBottomMargin = 54 ); + @JvmOverloads @Px - fun getCameraCaptureMarginBottom(resources: Resources): Int { - val positionInfo = if (Stories.isFeatureEnabled()) withTogglePositionInfo else withoutTogglePositionInfo + fun getCameraCaptureMarginBottom(resources: Resources, storiesEnabled: Boolean = Stories.isFeatureEnabled()): Int { + val positionInfo = if (storiesEnabled) withTogglePositionInfo else withoutTogglePositionInfo return positionInfo.cameraCaptureMarginBottomDp.dp - getCameraButtonSizeOffset(resources) } + @JvmOverloads @Px - fun getCameraViewportMarginBottom(): Int { - val positionInfo = if (Stories.isFeatureEnabled()) withTogglePositionInfo else withoutTogglePositionInfo + fun getCameraViewportMarginBottom(storiesEnabled: Boolean = Stories.isFeatureEnabled()): Int { + val positionInfo = if (storiesEnabled) withTogglePositionInfo else withoutTogglePositionInfo return positionInfo.cameraViewportMarginBottomDp.dp } - fun getCameraViewportGravity(): CameraViewportGravity { - val positionInfo = if (Stories.isFeatureEnabled()) withTogglePositionInfo else withoutTogglePositionInfo + @JvmOverloads + fun getCameraViewportGravity(storiesEnabled: Boolean = Stories.isFeatureEnabled()): CameraViewportGravity { + val positionInfo = if (storiesEnabled) withTogglePositionInfo else withoutTogglePositionInfo return positionInfo.cameraViewportGravity } diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.java b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.java deleted file mode 100644 index cedb169670..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.java +++ /dev/null @@ -1,669 +0,0 @@ -package org.thoughtcrime.securesms.mediasend; - -import android.Manifest; -import android.animation.Animator; -import android.annotation.SuppressLint; -import android.app.Activity; -import android.content.Context; -import android.content.pm.ActivityInfo; -import android.graphics.Color; -import android.os.Build; -import android.os.Bundle; -import android.view.GestureDetector; -import android.view.LayoutInflater; -import android.view.MotionEvent; -import android.view.Surface; -import android.view.View; -import android.view.ViewGroup; -import android.view.animation.Animation; -import android.view.animation.AnimationUtils; -import android.view.animation.DecelerateInterpolator; -import android.view.animation.RotateAnimation; -import android.widget.ImageView; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.camera.core.AspectRatio; -import androidx.camera.core.CameraSelector; -import androidx.camera.core.ImageCapture; -import androidx.camera.core.ImageCaptureException; -import androidx.camera.core.ImageProxy; -import androidx.camera.video.FallbackStrategy; -import androidx.camera.video.Quality; -import androidx.camera.video.QualitySelector; -import androidx.camera.view.CameraController; -import androidx.camera.view.LifecycleCameraController; -import androidx.camera.view.PreviewView; -import androidx.constraintlayout.widget.ConstraintLayout; -import androidx.constraintlayout.widget.ConstraintSet; -import androidx.core.content.ContextCompat; - -import com.bumptech.glide.Glide; -import com.google.android.material.button.MaterialButton; -import com.google.android.material.card.MaterialCardView; -import com.google.common.util.concurrent.ListenableFuture; - -import org.signal.core.util.Stopwatch; -import org.signal.core.util.concurrent.SimpleTask; -import org.signal.core.util.logging.Log; -import org.signal.core.models.media.Media; -import org.signal.qr.QrProcessor; -import org.thoughtcrime.securesms.LoggingFragment; -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; -import org.thoughtcrime.securesms.mms.DecryptableUri; -import org.thoughtcrime.securesms.mms.MediaConstraints; -import org.thoughtcrime.securesms.permissions.Permissions; -import org.thoughtcrime.securesms.util.BottomSheetUtil; -import org.thoughtcrime.securesms.util.MemoryFileDescriptor; -import org.thoughtcrime.securesms.util.TextSecurePreferences; -import org.thoughtcrime.securesms.util.ViewUtil; -import org.thoughtcrime.securesms.video.VideoUtil; - -import java.io.FileDescriptor; -import java.io.IOException; -import java.util.List; -import java.util.concurrent.Executor; -import java.util.concurrent.Executors; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.disposables.Disposable; - -import static org.thoughtcrime.securesms.permissions.PermissionDeniedBottomSheet.showPermissionFragment; - -/** - * Camera captured implemented using the CameraX SDK, which uses Camera2 under the hood. Should be - * preferred whenever possible. - */ -public class CameraXFragment extends LoggingFragment implements CameraFragment { - - private static final String TAG = Log.tag(CameraXFragment.class); - private static final String IS_VIDEO_ENABLED = "is_video_enabled"; - private static final String IS_QR_SCAN_ENABLED = "is_qr_scan_enabled"; - - - private static final PreviewView.ScaleType PREVIEW_SCALE_TYPE = PreviewView.ScaleType.FILL_CENTER; - - private PreviewView previewView; - private MaterialCardView cameraParent; - private ViewGroup controlsContainer; - private Controller controller; - private View selfieFlash; - private MemoryFileDescriptor videoFileDescriptor; - private LifecycleCameraController cameraController; - private Disposable mostRecentItemDisposable = Disposable.disposed(); - private CameraXModePolicy cameraXModePolicy; - private CameraScreenBrightnessController cameraScreenBrightnessController; - private boolean isMediaSelected; - private View missingPermissionsContainer; - private TextView missingPermissionsText; - private MaterialButton allowAccessButton; - - private final Executor qrAnalysisExecutor = Executors.newSingleThreadExecutor(); - private final QrProcessor qrProcessor = new QrProcessor(); - - public static CameraXFragment newInstanceForAvatarCapture() { - CameraXFragment fragment = new CameraXFragment(); - Bundle args = new Bundle(); - - args.putBoolean(IS_VIDEO_ENABLED, false); - args.putBoolean(IS_QR_SCAN_ENABLED, false); - fragment.setArguments(args); - - return fragment; - } - - public static CameraXFragment newInstance(boolean qrScanEnabled) { - CameraXFragment fragment = new CameraXFragment(); - - Bundle args = new Bundle(); - args.putBoolean(IS_QR_SCAN_ENABLED, qrScanEnabled); - - fragment.setArguments(args); - - return fragment; - } - - @Override - public void onAttach(@NonNull Context context) { - super.onAttach(context); - - if (getActivity() instanceof Controller) { - this.controller = (Controller) getActivity(); - } else if (getParentFragment() instanceof Controller) { - this.controller = (Controller) getParentFragment(); - } - - if (controller == null) { - throw new IllegalStateException("Parent must implement controller interface."); - } - } - - @Override - public @Nullable View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { - return inflater.inflate(R.layout.camerax_fragment, container, false); - } - - @SuppressLint("MissingPermission") - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - this.cameraParent = view.findViewById(R.id.camerax_camera_parent); - - 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), - requireArguments().getBoolean(IS_QR_SCAN_ENABLED, false)); - this.missingPermissionsContainer = view.findViewById(R.id.missing_permissions_container); - this.missingPermissionsText = view.findViewById(R.id.missing_permissions_text); - this.allowAccessButton = view.findViewById(R.id.allow_access_button); - - checkPermissions(requireArguments().getBoolean(IS_VIDEO_ENABLED, true)); - - Log.d(TAG, "Starting CameraX with mode policy " + cameraXModePolicy.getClass().getSimpleName()); - - - previewView.setScaleType(PREVIEW_SCALE_TYPE); - - final LifecycleCameraController lifecycleCameraController = new LifecycleCameraController(requireContext()); - cameraController = lifecycleCameraController; - lifecycleCameraController.bindToLifecycle(getViewLifecycleOwner()); - lifecycleCameraController.setCameraSelector(CameraXUtil.toCameraSelector(TextSecurePreferences.getDirectCaptureCameraId(requireContext()))); - lifecycleCameraController.setTapToFocusEnabled(true); - lifecycleCameraController.setImageCaptureMode(CameraXUtil.getOptimalCaptureMode()); - lifecycleCameraController.setVideoCaptureQualitySelector(QualitySelector.from(Quality.HD, FallbackStrategy.lowerQualityThan(Quality.HD))); - - previewView.setController(lifecycleCameraController); - cameraXModePolicy.initialize(lifecycleCameraController); - cameraScreenBrightnessController = new CameraScreenBrightnessController( - requireActivity().getWindow(), - new CameraStateProvider(lifecycleCameraController) - ); - - previewView.setScaleType(PREVIEW_SCALE_TYPE); - - lifecycleCameraController.setImageCaptureTargetSize(new CameraController.OutputSize(AspectRatio.RATIO_16_9)); - - controlsContainer.removeAllViews(); - controlsContainer.addView(LayoutInflater.from(getContext()).inflate(R.layout.camera_controls_portrait, controlsContainer, false)); - - initControls(lifecycleCameraController); - - if (requireArguments().getBoolean(IS_QR_SCAN_ENABLED, false)) { - lifecycleCameraController.setImageAnalysisAnalyzer(qrAnalysisExecutor, imageProxy -> { - try (imageProxy) { - String data = qrProcessor.getScannedData(imageProxy); - if (data != null) { - controller.onQrCodeFound(data); - } - } - }); - } - } - - @Override - public void onResume() { - super.onResume(); - - cameraController.bindToLifecycle(getViewLifecycleOwner()); - Log.d(TAG, "Camera init complete from onResume"); - requireActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT); - if (hasCameraPermission()) { - missingPermissionsContainer.setVisibility(View.GONE); - } - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - mostRecentItemDisposable.dispose(); - closeVideoFileDescriptor(); - requireActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED); - } - - @Override - public void fadeOutControls(@NonNull Runnable onEndAction) { - controlsContainer.setEnabled(false); - controlsContainer.animate() - .setDuration(250) - .alpha(0f) - .setInterpolator(MediaAnimations.getInterpolator()) - .setListener(new AnimationCompleteListener() { - @Override - public void onAnimationEnd(Animator animation) { - controlsContainer.setEnabled(true); - onEndAction.run(); - } - }); - } - - @Override - public void fadeInControls() { - controlsContainer.setEnabled(false); - controlsContainer.animate() - .setDuration(250) - .alpha(1f) - .setInterpolator(MediaAnimations.getInterpolator()) - .setListener(new AnimationCompleteListener() { - @Override - public void onAnimationEnd(Animator animation) { - controlsContainer.setEnabled(true); - } - }); - } - - @Override - public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { - Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); - } - - private void checkPermissions(boolean includeAudio) { - if (hasCameraPermission()) { - missingPermissionsContainer.setVisibility(View.GONE); - } else { - boolean hasAudioPermission = Permissions.hasAll(requireContext(), Manifest.permission.RECORD_AUDIO); - missingPermissionsContainer.setVisibility(View.VISIBLE); - int textResId = (!includeAudio || hasAudioPermission) ? R.string.CameraXFragment_to_capture_photos_and_video_allow_camera : R.string.CameraXFragment_to_capture_photos_and_video_allow_camera_microphone; - missingPermissionsText.setText(textResId); - allowAccessButton.setOnClickListener(v -> requestPermissions(includeAudio)); - } - } - - private void requestPermissions(boolean includeAudio) { - if (includeAudio) { - Permissions.with(this) - .request(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO) - .ifNecessary() - .onSomeGranted(permissions -> { - if (permissions.contains(Manifest.permission.CAMERA)) { - missingPermissionsContainer.setVisibility(View.GONE); - } - }) - .onSomePermanentlyDenied(deniedPermissions -> { - if (deniedPermissions.containsAll(List.of(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO))) { - showPermissionFragment(R.string.CameraXFragment_allow_access_camera_microphone, R.string.CameraXFragment_to_capture_photos_videos, false).show(getParentFragmentManager(), BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG); - } else if (deniedPermissions.contains(Manifest.permission.CAMERA)) { - showPermissionFragment(R.string.CameraXFragment_allow_access_camera, R.string.CameraXFragment_to_capture_photos_videos, false).show(getParentFragmentManager(), BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG); - } - }) - .onSomeDenied(deniedPermissions -> { - if (deniedPermissions.contains(Manifest.permission.CAMERA)) { - Toast.makeText(requireContext(), R.string.CameraXFragment_signal_needs_camera_access_capture_photos, Toast.LENGTH_LONG).show(); - } - }) - .execute(); - } else { - Permissions.with(this) - .request(Manifest.permission.CAMERA) - .ifNecessary() - .onAllGranted (() -> missingPermissionsContainer.setVisibility(View.GONE)) - .onAnyDenied(() -> Toast.makeText(requireContext(), R.string.CameraXFragment_signal_needs_camera_access_capture_photos, Toast.LENGTH_LONG).show()) - .withPermanentDenialDialog(getString(R.string.CameraXFragment_signal_needs_camera_access_capture_photos), null, R.string.CameraXFragment_allow_access_camera, R.string.CameraXFragment_to_capture_photos, getParentFragmentManager()) - .execute(); - } - } - - private boolean hasCameraPermission() { - return Permissions.hasAll(requireContext(), Manifest.permission.CAMERA); - } - - private void presentRecentItemThumbnail(@Nullable Media media) { - View thumbBackground = controlsContainer.findViewById(R.id.camera_gallery_button_background); - ImageView thumbnail = controlsContainer.findViewById(R.id.camera_gallery_button); - - if (media != null) { - thumbBackground.setBackgroundResource(R.drawable.circle_tintable); - thumbnail.clearColorFilter(); - thumbnail.setScaleType(ImageView.ScaleType.FIT_CENTER); - Glide.with(this) - .load(new DecryptableUri(media.getUri())) - .centerCrop() - .into(thumbnail); - } else { - thumbBackground.setBackgroundResource(R.drawable.media_selection_camera_switch_background); - thumbnail.setImageResource(R.drawable.symbol_album_tilt_24); - thumbnail.setColorFilter(Color.WHITE); - thumbnail.setScaleType(ImageView.ScaleType.CENTER_INSIDE); - } - } - - @Override - public void presentHud(int selectedMediaCount) { - MediaCountIndicatorButton countButton = controlsContainer.findViewById(R.id.camera_review_button); - - if (selectedMediaCount > 0) { - countButton.setVisibility(View.VISIBLE); - countButton.setCount(selectedMediaCount); - } else { - countButton.setVisibility(View.GONE); - } - - isMediaSelected = selectedMediaCount > 0; - updateGalleryVisibility(); - } - - private void updateGalleryVisibility() { - View cameraGalleryContainer = controlsContainer.findViewById(R.id.camera_gallery_button_background); - - if (isMediaSelected) { - cameraGalleryContainer.setVisibility(View.GONE); - } else { - cameraGalleryContainer.setVisibility(View.VISIBLE); - } - } - - private void initializeViewFinderAndControlsPositioning() { - MaterialCardView cameraCard = requireView().findViewById(R.id.camerax_camera_parent); - View controls = requireView().findViewById(R.id.camerax_controls_container); - CameraDisplay cameraDisplay = CameraDisplay.getDisplay(requireActivity()); - - if (!cameraDisplay.getRoundViewFinderCorners()) { - cameraCard.setRadius(0f); - } - - ViewUtil.setBottomMargin(controls, cameraDisplay.getCameraCaptureMarginBottom(getResources())); - - if (cameraDisplay.getCameraViewportGravity() == CameraDisplay.CameraViewportGravity.CENTER) { - ConstraintSet constraintSet = new ConstraintSet(); - constraintSet.clone((ConstraintLayout) requireView()); - constraintSet.connect(R.id.camerax_camera_parent, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP); - constraintSet.applyTo((ConstraintLayout) requireView()); - ViewUtil.setTopMargin(cameraCard, ViewUtil.getStatusBarHeight(requireView())); - ViewUtil.setBottomMargin(cameraCard, ViewUtil.getNavigationBarHeight(requireView())); - } else { - ViewUtil.setBottomMargin(cameraCard, cameraDisplay.getCameraViewportMarginBottom()); - } - } - - @SuppressLint({ "ClickableViewAccessibility", "MissingPermission" }) - private void initControls(LifecycleCameraController lifecycleCameraController) { - View flipButton = requireView().findViewById(R.id.camera_flip_button); - CameraButtonView captureButton = requireView().findViewById(R.id.camera_capture_button); - View galleryButton = requireView().findViewById(R.id.camera_gallery_button); - View countButton = requireView().findViewById(R.id.camera_review_button); - CameraXFlashToggleView flashButton = requireView().findViewById(R.id.camera_flash_button); - - initializeViewFinderAndControlsPositioning(); - - mostRecentItemDisposable.dispose(); - mostRecentItemDisposable = controller.getMostRecentMediaItem() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(item -> presentRecentItemThumbnail(item.orElse(null))); - - selfieFlash = requireView().findViewById(R.id.camera_selfie_flash); - - final ListenableFuture cameraInitFuture = lifecycleCameraController.getInitializationFuture(); - captureButton.setOnClickListener(v -> { - if (hasCameraPermission() && cameraInitFuture.isDone()) { - captureButton.setEnabled(false); - flipButton.setEnabled(false); - flashButton.setEnabled(false); - onCaptureClicked(); - } else { - Log.i(TAG, "Camera capture button clicked but the camera controller is not yet initialized."); - } - }); - - previewView.setScaleType(PREVIEW_SCALE_TYPE); - - cameraInitFuture.addListener(() -> initializeFlipButton(flipButton, flashButton), ContextCompat.getMainExecutor(requireContext())); - - flashButton.setAutoFlashEnabled(lifecycleCameraController.getImageCaptureFlashMode() >= ImageCapture.FLASH_MODE_AUTO); - flashButton.setFlash(lifecycleCameraController.getImageCaptureFlashMode()); - flashButton.setOnFlashModeChangedListener(mode -> { - cameraController.setImageCaptureFlashMode(mode); - cameraScreenBrightnessController.onCameraFlashChanged(mode == ImageCapture.FLASH_MODE_ON); - }); - - galleryButton.setOnClickListener(v -> controller.onGalleryClicked()); - countButton.setOnClickListener(v -> controller.onCameraCountButtonClicked()); - - if (Build.VERSION.SDK_INT >= 26 && cameraXModePolicy.isVideoSupported()) { - try { - closeVideoFileDescriptor(); - videoFileDescriptor = CameraXVideoCaptureHelper.createFileDescriptor(requireContext()); - - Animation inAnimation = AnimationUtils.loadAnimation(requireContext(), R.anim.fade_in); - Animation outAnimation = AnimationUtils.loadAnimation(requireContext(), R.anim.fade_out); - - int maxDuration = VideoUtil.getMaxVideoRecordDurationInSeconds(requireContext(), controller.getMediaConstraints()); - if (controller.getMaxVideoDuration() > 0) { - maxDuration = controller.getMaxVideoDuration(); - } - - Log.d(TAG, "Max duration: " + maxDuration + " sec"); - - captureButton.setVideoCaptureListener(new CameraXVideoCaptureHelper( - this, - captureButton, - lifecycleCameraController, - previewView, - videoFileDescriptor, - cameraXModePolicy, - maxDuration, - new CameraXVideoCaptureHelper.Callback() { - @Override - public void onVideoRecordStarted() { - hideAndDisableControlsForVideoRecording(captureButton, flashButton, flipButton, outAnimation); - } - - @Override - public void onVideoSaved(@NonNull FileDescriptor fd) { - showAndEnableControlsAfterVideoRecording(captureButton, flashButton, flipButton, inAnimation); - controller.onVideoCaptured(fd); - } - - @Override - public void onVideoError(@Nullable Throwable cause) { - showAndEnableControlsAfterVideoRecording(captureButton, flashButton, flipButton, inAnimation); - controller.onVideoCaptureError(); - } - } - )); - displayVideoRecordingTooltipIfNecessary(captureButton); - } catch (IOException e) { - Log.w(TAG, "Video capture is not supported on this device.", e); - } - } else { - captureButton.setOnLongClickListener(unused -> { - CameraFragment.toastVideoRecordingNotAvailable(requireContext()); - return true; - }); - - Log.i(TAG, "Video capture not supported. " + - "API: " + Build.VERSION.SDK_INT + ", " + - "MFD: " + MemoryFileDescriptor.supported() + ", " + - "Camera: " + CameraXUtil.getLowestSupportedHardwareLevel(requireContext()) + ", " + - "MaxDuration: " + VideoUtil.getMaxVideoRecordDurationInSeconds(requireContext(), controller.getMediaConstraints()) + " sec"); - } - } - - private void displayVideoRecordingTooltipIfNecessary(CameraButtonView captureButton) { - if (shouldDisplayVideoRecordingTooltip()) { - int displayRotation = requireActivity().getWindowManager().getDefaultDisplay().getRotation(); - - TooltipPopup.forTarget(captureButton) - .setOnDismissListener(this::neverDisplayVideoRecordingTooltipAgain) - .setBackgroundTint(ContextCompat.getColor(requireContext(), R.color.core_ultramarine)) - .setTextColor(ContextCompat.getColor(requireContext(), R.color.signal_text_toolbar_title)) - .setText(R.string.CameraXFragment_tap_for_photo_hold_for_video) - .show(displayRotation == Surface.ROTATION_0 || displayRotation == Surface.ROTATION_180 ? TooltipPopup.POSITION_ABOVE : TooltipPopup.POSITION_START); - } - } - - private boolean shouldDisplayVideoRecordingTooltip() { - return !TextSecurePreferences.hasSeenVideoRecordingTooltip(requireContext()) && MediaConstraints.isVideoTranscodeAvailable(); - } - - private void neverDisplayVideoRecordingTooltipAgain() { - Context context = getContext(); - if (context != null) { - TextSecurePreferences.setHasSeenVideoRecordingTooltip(requireContext(), true); - } - } - - private void hideAndDisableControlsForVideoRecording(@NonNull View captureButton, - @NonNull View flashButton, - @NonNull View flipButton, - @NonNull Animation outAnimation) - { - captureButton.setEnabled(false); - flashButton.startAnimation(outAnimation); - flashButton.setVisibility(View.INVISIBLE); - flipButton.startAnimation(outAnimation); - flipButton.setVisibility(View.INVISIBLE); - } - - private void showAndEnableControlsAfterVideoRecording(@NonNull View captureButton, - @NonNull View flashButton, - @NonNull View flipButton, - @NonNull Animation inAnimation) - { - Activity activity = getActivity(); - - if (activity != null) { - activity.runOnUiThread(() -> { - captureButton.setEnabled(true); - flashButton.startAnimation(inAnimation); - flashButton.setVisibility(View.VISIBLE); - flipButton.startAnimation(inAnimation); - flipButton.setVisibility(View.VISIBLE); - }); - } - } - - private void onCaptureClicked() { - Stopwatch stopwatch = new Stopwatch("Capture"); - - CameraXSelfieFlashHelper flashHelper = new CameraXSelfieFlashHelper( - requireActivity().getWindow(), - cameraController, - selfieFlash - ); - - flashHelper.onWillTakePicture(); - cameraController.takePicture(ContextCompat.getMainExecutor(requireContext()), new ImageCapture.OnImageCapturedCallback() { - @Override - public void onCaptureSuccess(@NonNull ImageProxy image) { - flashHelper.endFlash(); - - final boolean flip = cameraController.getCameraSelector() == CameraSelector.DEFAULT_FRONT_CAMERA; - SimpleTask.run(CameraXFragment.this.getViewLifecycleOwner().getLifecycle(), () -> { - stopwatch.split("captured"); - try { - return CameraXUtil.toJpeg(image, flip); - } catch (IOException e) { - Log.w(TAG, "Failed to encode captured image.", e); - return null; - } finally { - image.close(); - } - }, result -> { - stopwatch.split("transformed"); - stopwatch.stop(TAG); - - if (result != null) { - controller.onImageCaptured(result.getData(), result.getWidth(), result.getHeight()); - } else { - controller.onCameraError(); - } - }); - } - - @Override - public void onError(@NonNull ImageCaptureException exception) { - Log.w(TAG, "Failed to capture image due to error " + exception.getImageCaptureError(), exception.getCause()); - flashHelper.endFlash(); - controller.onCameraError(); - } - }); - - flashHelper.startFlash(); - } - - private void closeVideoFileDescriptor() { - if (videoFileDescriptor != null) { - try { - videoFileDescriptor.close(); - videoFileDescriptor = null; - } catch (IOException e) { - Log.w(TAG, "Failed to close video file descriptor", e); - } - } - } - - @SuppressLint({ "MissingPermission" }) - private void initializeFlipButton(@NonNull View flipButton, @NonNull CameraXFlashToggleView flashButton) { - if (getContext() == null) { - Log.w(TAG, "initializeFlipButton called either before or after fragment was attached."); - return; - } - - if (!getLifecycle().getCurrentState().isAtLeast(androidx.lifecycle.Lifecycle.State.STARTED)) { - return; - } - - getViewLifecycleOwner().getLifecycle().addObserver(cameraScreenBrightnessController); - if (cameraController.hasCamera(CameraSelector.DEFAULT_FRONT_CAMERA) && cameraController.hasCamera(CameraSelector.DEFAULT_BACK_CAMERA)) { - flipButton.setVisibility(View.VISIBLE); - flipButton.setOnClickListener(v -> { - CameraSelector cameraSelector = cameraController.getCameraSelector() == CameraSelector.DEFAULT_FRONT_CAMERA - ? CameraSelector.DEFAULT_BACK_CAMERA - : CameraSelector.DEFAULT_FRONT_CAMERA; - cameraController.setCameraSelector(cameraSelector); - TextSecurePreferences.setDirectCaptureCameraId(getContext(), CameraXUtil.toCameraDirectionInt(cameraController.getCameraSelector())); - - Animation animation = new RotateAnimation(0, -180, RotateAnimation.RELATIVE_TO_SELF, 0.5f, RotateAnimation.RELATIVE_TO_SELF, 0.5f); - animation.setDuration(200); - animation.setInterpolator(new DecelerateInterpolator()); - flipButton.startAnimation(animation); - flashButton.setAutoFlashEnabled(cameraController.getImageCaptureFlashMode() >= ImageCapture.FLASH_MODE_AUTO); - flashButton.setFlash(cameraController.getImageCaptureFlashMode()); - cameraScreenBrightnessController.onCameraDirectionChanged(cameraSelector == CameraSelector.DEFAULT_FRONT_CAMERA); - }); - - GestureDetector gestureDetector = new GestureDetector(requireContext(), new GestureDetector.SimpleOnGestureListener() { - @Override - public boolean onDoubleTap(@NonNull MotionEvent e) { - if (flipButton.isEnabled()) { - flipButton.performClick(); - } - return true; - } - }); - - previewView.setOnTouchListener((v, event) -> gestureDetector.onTouchEvent(event)); - - } else { - flipButton.setVisibility(View.GONE); - } - } - - private static class CameraStateProvider implements CameraScreenBrightnessController.CameraStateProvider { - - private final CameraController cameraController; - - private CameraStateProvider(CameraController cameraController) { - this.cameraController = cameraController; - } - - @Override - public boolean isFrontFacingCameraSelected() { - return cameraController.getCameraSelector() == CameraSelector.DEFAULT_FRONT_CAMERA; - } - - @Override - public boolean isFlashEnabled() { - return cameraController.getImageCaptureFlashMode() == ImageCapture.FLASH_MODE_ON; - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.kt new file mode 100644 index 0000000000..df1a799853 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/mediasend/CameraXFragment.kt @@ -0,0 +1,670 @@ +package org.thoughtcrime.securesms.mediasend + +import android.Manifest +import android.content.Context +import android.content.pm.ActivityInfo +import android.graphics.Bitmap +import android.os.Build +import android.os.Bundle +import android.os.ParcelFileDescriptor +import android.widget.Toast +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import org.signal.camera.CameraScreen +import org.signal.camera.CameraScreenEvents +import org.signal.camera.CameraScreenViewModel +import org.signal.camera.VideoCaptureResult +import org.signal.camera.VideoOutput +import org.signal.camera.hud.StringResources +import org.signal.camera.hud.StandardCameraHud +import org.signal.camera.hud.StandardCameraHudEvents +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.compose.ComposeFragment +import org.thoughtcrime.securesms.mediasend.camerax.CameraXModePolicy +import org.thoughtcrime.securesms.permissions.PermissionDeniedBottomSheet.Companion.showPermissionFragment +import org.thoughtcrime.securesms.permissions.Permissions +import org.thoughtcrime.securesms.stories.Stories +import org.thoughtcrime.securesms.util.BottomSheetUtil +import org.thoughtcrime.securesms.util.MemoryFileDescriptor +import org.thoughtcrime.securesms.video.VideoUtil +import java.io.ByteArrayOutputStream +import java.io.IOException + +private val TAG = Log.tag(CameraXFragment::class.java) + +/** + * Camera capture implemented using a Compose-based CameraScreen with CameraX SDK under the hood. + * This is the preferred camera implementation when supported. + */ +class CameraXFragment : ComposeFragment(), CameraFragment { + companion object { + private const val IS_VIDEO_ENABLED = "is_video_enabled" + private const val IS_QR_SCAN_ENABLED = "is_qr_scan_enabled" + private const val CONTROLS_ANIMATION_DURATION = 250L + + @JvmStatic + fun newInstanceForAvatarCapture(): CameraXFragment { + return CameraXFragment().apply { + arguments = Bundle().apply { + putBoolean(IS_VIDEO_ENABLED, false) + putBoolean(IS_QR_SCAN_ENABLED, false) + } + } + } + + @JvmStatic + fun newInstance(qrScanEnabled: Boolean): CameraXFragment { + return CameraXFragment().apply { + arguments = Bundle().apply { + putBoolean(IS_QR_SCAN_ENABLED, qrScanEnabled) + } + } + } + } + + private var controller: CameraFragment.Controller? = null + private var videoFileDescriptor: MemoryFileDescriptor? = null + private var cameraXModePolicy: CameraXModePolicy? = null + + private val isVideoEnabled: Boolean + get() = requireArguments().getBoolean(IS_VIDEO_ENABLED, true) + + private val isQrScanEnabled: Boolean + get() = requireArguments().getBoolean(IS_QR_SCAN_ENABLED, false) + + // Compose state holders for HUD visibility + private var controlsVisible = mutableStateOf(true) + private var selectedMediaCount = mutableIntStateOf(0) + + override fun onAttach(context: Context) { + super.onAttach(context) + controller = when { + activity is CameraFragment.Controller -> activity as CameraFragment.Controller + parentFragment is CameraFragment.Controller -> parentFragment as CameraFragment.Controller + else -> throw IllegalStateException("Parent must implement Controller interface.") + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + cameraXModePolicy = CameraXModePolicy.acquire( + requireContext(), + controller!!.mediaConstraints, + isVideoEnabled, + isQrScanEnabled + ) + + Log.d(TAG, "Starting CameraX with mode policy ${cameraXModePolicy?.javaClass?.simpleName}") + } + + @Composable + override fun FragmentContent() { + CameraXScreen( + controller = controller, + isVideoEnabled = isVideoEnabled && Build.VERSION.SDK_INT >= 26, + isQrScanEnabled = isQrScanEnabled, + controlsVisible = controlsVisible.value, + selectedMediaCount = selectedMediaCount.intValue, + onCheckPermissions = { checkPermissions(isVideoEnabled) }, + hasCameraPermission = { hasCameraPermission() }, + createVideoFileDescriptor = { createVideoFileDescriptor() }, + getMaxVideoDurationInSeconds = { getMaxVideoDurationInSeconds() }, + cameraDisplay = CameraDisplay.getDisplay(requireActivity()) + ) + } + + override fun onResume() { + super.onResume() + requireActivity().requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + } + + override fun onDestroyView() { + super.onDestroyView() + closeVideoFileDescriptor() + requireActivity().requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults) + } + + override fun presentHud(selectedMediaCount: Int) { + this.selectedMediaCount.intValue = selectedMediaCount + } + + override fun fadeOutControls(onEndAction: Runnable) { + controlsVisible.value = false + // Post the end action after a short delay to allow animation to complete + view?.postDelayed({ onEndAction.run() }, CONTROLS_ANIMATION_DURATION) + } + + override fun fadeInControls() { + controlsVisible.value = true + } + + private fun checkPermissions(includeAudio: Boolean) { + if (hasCameraPermission()) { + return + } + + if (includeAudio) { + Permissions.with(this) + .request(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO) + .ifNecessary() + .onSomeGranted { permissions -> + // Will trigger recomposition via hasCameraPermission check + } + .onSomePermanentlyDenied { deniedPermissions -> + if (deniedPermissions.containsAll(listOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO))) { + showPermissionFragment( + R.string.CameraXFragment_allow_access_camera_microphone, + R.string.CameraXFragment_to_capture_photos_videos, + false + ).show(parentFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) + } else if (deniedPermissions.contains(Manifest.permission.CAMERA)) { + showPermissionFragment( + R.string.CameraXFragment_allow_access_camera, + R.string.CameraXFragment_to_capture_photos_videos, + false + ).show(parentFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG) + } + } + .onSomeDenied { deniedPermissions -> + if (deniedPermissions.contains(Manifest.permission.CAMERA)) { + Toast.makeText( + requireContext(), + R.string.CameraXFragment_signal_needs_camera_access_capture_photos, + Toast.LENGTH_LONG + ).show() + } + } + .execute() + } else { + Permissions.with(this) + .request(Manifest.permission.CAMERA) + .ifNecessary() + .onAllGranted { /* Will trigger recomposition */ } + .onAnyDenied { + Toast.makeText( + requireContext(), + R.string.CameraXFragment_signal_needs_camera_access_capture_photos, + Toast.LENGTH_LONG + ).show() + } + .withPermanentDenialDialog( + getString(R.string.CameraXFragment_signal_needs_camera_access_capture_photos), + null, + R.string.CameraXFragment_allow_access_camera, + R.string.CameraXFragment_to_capture_photos, + parentFragmentManager + ) + .execute() + } + } + + private fun hasCameraPermission(): Boolean { + return Permissions.hasAll(requireContext(), Manifest.permission.CAMERA) + } + + private fun createVideoFileDescriptor(): ParcelFileDescriptor? { + if (Build.VERSION.SDK_INT < 26) { + throw IllegalStateException("Video capture requires API 26 or higher") + } + + return try { + closeVideoFileDescriptor() + videoFileDescriptor = CameraXVideoCaptureHelper.createFileDescriptor(requireContext()) + videoFileDescriptor?.parcelFileDescriptor + } catch (e: IOException) { + Log.w(TAG, "Failed to create video file descriptor", e) + null + } + } + + private fun closeVideoFileDescriptor() { + videoFileDescriptor?.let { + try { + it.close() + } catch (e: IOException) { + Log.w(TAG, "Failed to close video file descriptor", e) + } + videoFileDescriptor = null + } + } + + private fun getMaxVideoDurationInSeconds(): Int { + var maxDuration = VideoUtil.getMaxVideoRecordDurationInSeconds(requireContext(), controller!!.mediaConstraints) + val controllerMaxDuration = controller?.maxVideoDuration ?: 0 + if (controllerMaxDuration > 0) { + maxDuration = controllerMaxDuration + } + return maxDuration + } +} + +@Composable +private fun CameraXScreen( + controller: CameraFragment.Controller?, + isVideoEnabled: Boolean, + isQrScanEnabled: Boolean, + controlsVisible: Boolean, + selectedMediaCount: Int, + onCheckPermissions: () -> Unit, + hasCameraPermission: () -> Boolean, + createVideoFileDescriptor: () -> ParcelFileDescriptor?, + getMaxVideoDurationInSeconds: () -> Int, + cameraDisplay: CameraDisplay, + storiesEnabled: Boolean = Stories.isFeatureEnabled() +) { + val context = LocalContext.current + val cameraViewModel: CameraScreenViewModel = viewModel() + val cameraState by cameraViewModel.state + var hasPermission by remember { mutableStateOf(hasCameraPermission()) } + + LaunchedEffect(Unit) { + if (!hasPermission) { + onCheckPermissions() + } + } + + LaunchedEffect(cameraViewModel, isQrScanEnabled) { + if (isQrScanEnabled) { + cameraViewModel.qrCodeDetected.collect { qrCode -> + controller?.onQrCodeFound(qrCode) + } + } + } + + LaunchedEffect(Unit) { + while (true) { + kotlinx.coroutines.delay(500) + val newHasPermission = hasCameraPermission() + if (newHasPermission != hasPermission) { + hasPermission = newHasPermission + } + } + } + + val resources = LocalContext.current.resources + + val hudBottomMargin = with(LocalDensity.current) { + cameraDisplay.getCameraCaptureMarginBottom(resources, storiesEnabled).toDp() + } + + val viewportGravity = cameraDisplay.getCameraViewportGravity(storiesEnabled) + val cameraAlignment = when (viewportGravity) { + CameraDisplay.CameraViewportGravity.CENTER -> Alignment.Center + CameraDisplay.CameraViewportGravity.BOTTOM -> Alignment.BottomCenter + } + + val viewportBottomMargin = if (viewportGravity == CameraDisplay.CameraViewportGravity.BOTTOM) { + with(LocalDensity.current) { cameraDisplay.getCameraViewportMarginBottom(storiesEnabled).toDp() } + } else { + 0.dp + } + + BoxWithConstraints( + modifier = Modifier.fillMaxSize() + ) { + // We have to do a bunch of match to figure out how to place the camera buttons because + // the logic relies on positining things from the edge of the screen, which doesn't jive + // with how the composables are arranged. When this screen is re-written, we should simplify + // this whole setup. For now, I'm just doing my best to match current behavior. + val cameraAspectRatio = 9f / 16f + val availableHeight = maxHeight - viewportBottomMargin + val availableAspectRatio = maxWidth / availableHeight + val matchHeightFirst = availableAspectRatio > cameraAspectRatio + + val viewportHeight = if (matchHeightFirst) { + availableHeight + } else { + maxWidth / cameraAspectRatio + } + + val bottomGapFromAlignment = when (viewportGravity) { + CameraDisplay.CameraViewportGravity.CENTER -> (availableHeight - viewportHeight) / 2 + CameraDisplay.CameraViewportGravity.BOTTOM -> 0.dp + } + + val totalBottomOffset = viewportBottomMargin + bottomGapFromAlignment + val hudBottomPaddingInsideViewport = maxOf(0.dp, hudBottomMargin - totalBottomOffset) + + if (hasPermission) { + CameraScreen( + state = cameraState, + emitter = { event -> cameraViewModel.onEvent(event) }, + roundCorners = cameraDisplay.roundViewFinderCorners, + contentAlignment = cameraAlignment, + modifier = Modifier.padding(bottom = viewportBottomMargin) + ) { + AnimatedVisibility( + visible = controlsVisible, + enter = fadeIn(animationSpec = tween(durationMillis = 150)), + exit = fadeOut(animationSpec = tween(durationMillis = 150)) + ) { + Box(modifier = Modifier.fillMaxSize()) { + StandardCameraHud( + state = cameraState, + modifier = Modifier.padding(bottom = hudBottomPaddingInsideViewport), + maxRecordingDurationMs = getMaxVideoDurationInSeconds() * 1000L, + mediaSelectionCount = selectedMediaCount, + emitter = { event -> + handleHudEvent( + event = event, + context = context, + cameraViewModel = cameraViewModel, + controller = controller, + isVideoEnabled = isVideoEnabled, + createVideoFileDescriptor = createVideoFileDescriptor + ) + }, + stringResources = StringResources( + photoCaptureFailed = R.string.CameraXFragment_photo_capture_failed, + photoProcessingFailed = R.string.CameraXFragment_photo_processing_failed + ) + ) + } + } + } + } else { + PermissionMissingContent( + isVideoEnabled = isVideoEnabled, + onRequestPermissions = onCheckPermissions + ) + } + } +} + +@Composable +private fun PermissionMissingContent( + isVideoEnabled: Boolean, + onRequestPermissions: () -> Unit +) { + val context = LocalContext.current + val hasAudioPermission = remember { Permissions.hasAll(context, Manifest.permission.RECORD_AUDIO) } + + val textResId = if (!isVideoEnabled || hasAudioPermission) { + R.string.CameraXFragment_to_capture_photos_and_video_allow_camera + } else { + R.string.CameraXFragment_to_capture_photos_and_video_allow_camera_microphone + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(16.dp) + ) { + Text( + text = stringResource(textResId), + color = Color.White, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = onRequestPermissions) { + Text(text = stringResource(R.string.CameraXFragment_allow_access)) + } + } + } +} + +private fun handleHudEvent( + event: StandardCameraHudEvents, + context: Context, + cameraViewModel: CameraScreenViewModel, + controller: CameraFragment.Controller?, + isVideoEnabled: Boolean, + createVideoFileDescriptor: () -> ParcelFileDescriptor? +) { + when (event) { + is StandardCameraHudEvents.PhotoCaptureTriggered -> { + cameraViewModel.capturePhoto( + context = context, + onPhotoCaptured = { bitmap -> + handlePhotoCaptured(bitmap, controller) + } + ) + } + + is StandardCameraHudEvents.VideoCaptureStarted -> { + if (Build.VERSION.SDK_INT >= 26 && isVideoEnabled) { + val fileDescriptor = createVideoFileDescriptor() + if (fileDescriptor != null) { + cameraViewModel.startRecording( + context = context, + output = VideoOutput.FileDescriptorOutput(fileDescriptor), + onVideoCaptured = { result -> + handleVideoCaptured(result, controller) + } + ) + } else { + CameraFragment.toastVideoRecordingNotAvailable(context) + } + } else { + CameraFragment.toastVideoRecordingNotAvailable(context) + } + } + + is StandardCameraHudEvents.VideoCaptureStopped -> { + cameraViewModel.stopRecording() + } + + is StandardCameraHudEvents.GalleryClick -> { + controller?.onGalleryClicked() + } + + is StandardCameraHudEvents.MediaSelectionClick -> { + controller?.onCameraCountButtonClicked() + } + + is StandardCameraHudEvents.ToggleFlash -> { + cameraViewModel.onEvent(CameraScreenEvents.NextFlashMode) + } + + is StandardCameraHudEvents.ClearCaptureError -> { + cameraViewModel.onEvent(CameraScreenEvents.ClearCaptureError) + } + + is StandardCameraHudEvents.SwitchCamera -> { + cameraViewModel.onEvent(CameraScreenEvents.SwitchCamera(context)) + } + + is StandardCameraHudEvents.SetZoomLevel -> { + cameraViewModel.onEvent(CameraScreenEvents.LinearZoom(event.zoomLevel)) + } + } +} + +private fun handlePhotoCaptured(bitmap: Bitmap, controller: CameraFragment.Controller?) { + // Convert bitmap to JPEG byte array + val outputStream = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.JPEG, 90, outputStream) + val data = outputStream.toByteArray() + + controller?.onImageCaptured(data, bitmap.width, bitmap.height) +} + +private fun handleVideoCaptured(result: VideoCaptureResult, controller: CameraFragment.Controller?) { + when (result) { + is VideoCaptureResult.Success -> { + result.fileDescriptor?.let { parcelFd -> + try { + // Seek to beginning before reading + android.system.Os.lseek(parcelFd.fileDescriptor, 0, android.system.OsConstants.SEEK_SET) + controller?.onVideoCaptured(parcelFd.fileDescriptor) + } catch (e: Exception) { + Log.w(TAG, "Failed to seek video file descriptor", e) + controller?.onVideoCaptureError() + } + } ?: controller?.onVideoCaptureError() + } + + is VideoCaptureResult.Error -> { + Log.w(TAG, "Video capture failed: ${result.message}", result.throwable) + controller?.onVideoCaptureError() + } + } +} + +@androidx.compose.ui.tooling.preview.Preview( + name = "20:9 Display", + showBackground = true, + widthDp = 360, + heightDp = 800 +) +@Composable +private fun CameraXScreenPreview_20_9() { + org.signal.core.ui.compose.Previews.Preview { + CameraXScreen( + controller = null, + isVideoEnabled = true, + isQrScanEnabled = false, + controlsVisible = true, + selectedMediaCount = 0, + onCheckPermissions = {}, + hasCameraPermission = { true }, + createVideoFileDescriptor = { null }, + getMaxVideoDurationInSeconds = { 60 }, + cameraDisplay = CameraDisplay.DISPLAY_20_9, + storiesEnabled = true + ) + } +} + +@androidx.compose.ui.tooling.preview.Preview( + name = "19:9 Display", + showBackground = true, + widthDp = 360, + heightDp = 760 +) +@Composable +private fun CameraXScreenPreview_19_9() { + org.signal.core.ui.compose.Previews.Preview { + CameraXScreen( + controller = null, + isVideoEnabled = true, + isQrScanEnabled = false, + controlsVisible = true, + selectedMediaCount = 0, + onCheckPermissions = {}, + hasCameraPermission = { true }, + createVideoFileDescriptor = { null }, + getMaxVideoDurationInSeconds = { 60 }, + cameraDisplay = CameraDisplay.DISPLAY_19_9, + storiesEnabled = true + ) + } +} + +@androidx.compose.ui.tooling.preview.Preview( + name = "18:9 Display", + showBackground = true, + widthDp = 360, + heightDp = 720 +) +@Composable +private fun CameraXScreenPreview_18_9() { + org.signal.core.ui.compose.Previews.Preview { + CameraXScreen( + controller = null, + isVideoEnabled = true, + isQrScanEnabled = false, + controlsVisible = true, + selectedMediaCount = 0, + onCheckPermissions = {}, + hasCameraPermission = { true }, + createVideoFileDescriptor = { null }, + getMaxVideoDurationInSeconds = { 60 }, + cameraDisplay = CameraDisplay.DISPLAY_18_9, + storiesEnabled = true + ) + } +} + +@androidx.compose.ui.tooling.preview.Preview( + name = "16:9 Display", + showBackground = true, + widthDp = 360, + heightDp = 640 +) +@Composable +private fun CameraXScreenPreview_16_9() { + org.signal.core.ui.compose.Previews.Preview { + CameraXScreen( + controller = null, + isVideoEnabled = true, + isQrScanEnabled = false, + controlsVisible = true, + selectedMediaCount = 0, + onCheckPermissions = {}, + hasCameraPermission = { true }, + createVideoFileDescriptor = { null }, + getMaxVideoDurationInSeconds = { 60 }, + cameraDisplay = CameraDisplay.DISPLAY_16_9, + storiesEnabled = true + ) + } +} + +@androidx.compose.ui.tooling.preview.Preview( + name = "6:5 Display (Tablet)", + showBackground = true, + widthDp = 480, + heightDp = 576 +) +@Composable +private fun CameraXScreenPreview_6_5() { + org.signal.core.ui.compose.Previews.Preview { + CameraXScreen( + controller = null, + isVideoEnabled = true, + isQrScanEnabled = false, + controlsVisible = true, + selectedMediaCount = 0, + onCheckPermissions = {}, + hasCameraPermission = { true }, + createVideoFileDescriptor = { null }, + getMaxVideoDurationInSeconds = { 60 }, + cameraDisplay = CameraDisplay.DISPLAY_6_5, + storiesEnabled = true + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCardViewHolder.java b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCardViewHolder.java index 203db28598..fd83d113f6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCardViewHolder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/profiles/spoofing/ReviewCardViewHolder.java @@ -158,7 +158,7 @@ class ReviewCardViewHolder extends RecyclerView.ViewHolder { private void presentSignalConnection(@NonNull TextView line, @NonNull ImageView icon, @NonNull Context context, @NonNull ReviewCard reviewCard) { Preconditions.checkArgument(reviewCard.getReviewRecipient().isProfileSharing()); - Drawable chevron = ContextCompat.getDrawable(context, R.drawable.symbol_chevron_right_24); + Drawable chevron = ContextCompat.getDrawable(context, org.signal.core.ui.R.drawable.symbol_chevron_right_24); Preconditions.checkNotNull(chevron); chevron.setTint(ContextCompat.getColor(context, R.color.core_grey_45)); diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 82c04c4c08..d4ceb3171e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -315,8 +315,22 @@ To capture videos with sound: To scan QR codes: + + Failed to capture photo. Please try again. + + Failed to process photo. Please try again. + + Switch camera + + Flash off + + Flash on + + Flash auto + + Send - + Recent contacts Signal contacts Signal groups diff --git a/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt b/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt index 7ce45d5508..a2d42fcc79 100644 --- a/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt +++ b/core/ui/src/main/java/org/signal/core/ui/compose/SignalIcons.kt @@ -35,10 +35,15 @@ enum class SignalIcons(private val icon: SignalIcon) : SignalIcon by icon { At(icon(R.drawable.symbol_at_24)), Backup(icon(R.drawable.symbol_backup_24)), Camera(icon(R.drawable.symbol_camera_24)), + CameraSwitch(icon(R.drawable.symbol_switch_24)), CheckCircle(icon(R.drawable.symbol_check_circle_24)), + ChevronRight(icon(R.drawable.symbol_chevron_right_24)), Copy(icon(R.drawable.symbol_copy_android_24)), Edit(icon(R.drawable.symbol_edit_24)), ErrorCircle(icon(R.drawable.symbol_error_circle_fill_24)), + FlashAuto(icon(R.drawable.symbol_flash_auto_24)), + FlashOff(icon(R.drawable.symbol_flash_slash_24)), + FlashOn(icon(R.drawable.symbol_flash_24)), Forward(icon(R.drawable.symbol_forward_24)), Info(icon(R.drawable.symbol_info_24)), Keyboard(icon(R.drawable.ic_keyboard_24)), diff --git a/app/src/main/res/drawable/symbol_chevron_right_24.xml b/core/ui/src/main/res/drawable/symbol_chevron_right_24.xml similarity index 100% rename from app/src/main/res/drawable/symbol_chevron_right_24.xml rename to core/ui/src/main/res/drawable/symbol_chevron_right_24.xml diff --git a/core/ui/src/main/res/drawable/symbol_flash_24.xml b/core/ui/src/main/res/drawable/symbol_flash_24.xml new file mode 100644 index 0000000000..eddb38304e --- /dev/null +++ b/core/ui/src/main/res/drawable/symbol_flash_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/ui/src/main/res/drawable/symbol_flash_auto_24.xml b/core/ui/src/main/res/drawable/symbol_flash_auto_24.xml new file mode 100644 index 0000000000..991ebf937f --- /dev/null +++ b/core/ui/src/main/res/drawable/symbol_flash_auto_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/core/ui/src/main/res/drawable/symbol_flash_slash_24.xml b/core/ui/src/main/res/drawable/symbol_flash_slash_24.xml new file mode 100644 index 0000000000..17fafd85b2 --- /dev/null +++ b/core/ui/src/main/res/drawable/symbol_flash_slash_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/drawable/symbol_switch_24.xml b/core/ui/src/main/res/drawable/symbol_switch_24.xml new file mode 100644 index 0000000000..39abfdcae0 --- /dev/null +++ b/core/ui/src/main/res/drawable/symbol_switch_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/demo/camera/.gitignore b/demo/camera/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/demo/camera/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/demo/camera/build.gradle.kts b/demo/camera/build.gradle.kts new file mode 100644 index 0000000000..96620aec66 --- /dev/null +++ b/demo/camera/build.gradle.kts @@ -0,0 +1,102 @@ +plugins { + id("signal-sample-app") + alias(libs.plugins.compose.compiler) + alias(libs.plugins.kotlinx.serialization) +} + +android { + namespace = "org.signal.camera.demo" + compileSdkVersion = libs.versions.compileSdk.get() + + defaultConfig { + applicationId = "org.signal.camera.demo" + minSdk = 23 + targetSdk = libs.versions.targetSdk.get().toInt() + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.toVersion(libs.versions.javaVersion.get()) + targetCompatibility = JavaVersion.toVersion(libs.versions.javaVersion.get()) + } + + kotlinOptions { + jvmTarget = libs.versions.kotlinJvmTarget.get() + } + + buildFeatures { + compose = true + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + // Camera feature module + implementation(project(":feature:camera")) + + // Core modules + implementation(project(":core:ui")) + + // Core AndroidX + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.fragment.ktx) + implementation(libs.androidx.activity.compose) + + // Compose BOM + platform(libs.androidx.compose.bom).let { composeBom -> + implementation(composeBom) + androidTestImplementation(composeBom) + } + + // Compose dependencies + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.ui.tooling.preview) + debugImplementation(libs.androidx.compose.ui.tooling.core) + debugImplementation(libs.androidx.compose.ui.test.manifest) + implementation(libs.androidx.compose.material.icons.extended) + + // Lifecycle + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.lifecycle.viewmodel.compose) + + // Navigation 3 + implementation(libs.androidx.navigation3.runtime) + implementation(libs.androidx.navigation3.ui) + + // Kotlinx Serialization + implementation(libs.kotlinx.serialization.json) + + // Permissions + implementation(libs.accompanist.permissions) + + // Image loading via Glide + implementation(libs.glide.glide) + implementation(project(":lib:glide")) + + // Media3 for video playback + implementation(libs.bundles.media3) + + // Testing + androidTestImplementation(testLibs.junit.junit) + androidTestImplementation(testLibs.androidx.test.runner) + androidTestImplementation(testLibs.androidx.test.ext.junit.ktx) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) +} diff --git a/demo/camera/proguard-rules.pro b/demo/camera/proguard-rules.pro new file mode 100644 index 0000000000..481bb43481 --- /dev/null +++ b/demo/camera/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/demo/camera/src/androidTest/java/org/signal/camera/demo/ExampleInstrumentedTest.kt b/demo/camera/src/androidTest/java/org/signal/camera/demo/ExampleInstrumentedTest.kt new file mode 100644 index 0000000000..842a738a45 --- /dev/null +++ b/demo/camera/src/androidTest/java/org/signal/camera/demo/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package org.signal.camera.demo + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("org.signal.camera.demo", appContext.packageName) + } +} \ No newline at end of file diff --git a/demo/camera/src/main/AndroidManifest.xml b/demo/camera/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..eb22a526a8 --- /dev/null +++ b/demo/camera/src/main/AndroidManifest.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/demo/camera/src/main/java/org/signal/camera/demo/CameraDemoApplication.kt b/demo/camera/src/main/java/org/signal/camera/demo/CameraDemoApplication.kt new file mode 100644 index 0000000000..fde97c7370 --- /dev/null +++ b/demo/camera/src/main/java/org/signal/camera/demo/CameraDemoApplication.kt @@ -0,0 +1,25 @@ +package org.signal.camera.demo + +import android.app.Application +import android.content.Context +import com.bumptech.glide.Glide +import com.bumptech.glide.Registry +import org.signal.core.util.logging.AndroidLogger +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.mms.RegisterGlideComponents +import org.thoughtcrime.securesms.mms.SignalGlideModule + +/** + * Application class for the camera demo. + */ +class CameraDemoApplication : Application() { + override fun onCreate() { + super.onCreate() + + Log.initialize(AndroidLogger) + SignalGlideModule.registerGlideComponents = object : RegisterGlideComponents { + override fun registerComponents(context: Context, glide: Glide, registry: Registry) { + } + } + } +} diff --git a/demo/camera/src/main/java/org/signal/camera/demo/MainActivity.kt b/demo/camera/src/main/java/org/signal/camera/demo/MainActivity.kt new file mode 100644 index 0000000000..165ecaf447 --- /dev/null +++ b/demo/camera/src/main/java/org/signal/camera/demo/MainActivity.kt @@ -0,0 +1,27 @@ +package org.signal.camera.demo + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + setContent { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize() + ) { + NavGraph() + } + } + } + } +} \ No newline at end of file diff --git a/demo/camera/src/main/java/org/signal/camera/demo/MediaSelectionHolder.kt b/demo/camera/src/main/java/org/signal/camera/demo/MediaSelectionHolder.kt new file mode 100644 index 0000000000..95bb1603f2 --- /dev/null +++ b/demo/camera/src/main/java/org/signal/camera/demo/MediaSelectionHolder.kt @@ -0,0 +1,11 @@ +package org.signal.camera.demo + +import org.signal.camera.demo.screens.gallery.MediaItem + +/** + * Simple singleton to hold the currently selected media item for viewing. + * This is used to pass data between Gallery screen and Viewer screens. + */ +object MediaSelectionHolder { + var selectedMedia: MediaItem? = null +} diff --git a/demo/camera/src/main/java/org/signal/camera/demo/NavGraph.kt b/demo/camera/src/main/java/org/signal/camera/demo/NavGraph.kt new file mode 100644 index 0000000000..91e4ebe5a6 --- /dev/null +++ b/demo/camera/src/main/java/org/signal/camera/demo/NavGraph.kt @@ -0,0 +1,122 @@ +package org.signal.camera.demo + +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.runtime.Composable +import androidx.navigation3.runtime.NavKey +import androidx.compose.ui.Modifier +import androidx.navigation3.runtime.NavBackStack +import androidx.navigation3.runtime.entryProvider +import androidx.navigation3.runtime.rememberNavBackStack +import androidx.navigation3.scene.Scene +import androidx.navigation3.ui.NavDisplay +import org.signal.camera.demo.screens.gallery.GalleryScreen +import org.signal.camera.demo.screens.imageviewer.ImageViewerScreen +import org.signal.camera.demo.screens.main.MainScreen +import org.signal.camera.demo.screens.videoviewer.VideoViewerScreen + +/** + * Navigation destinations as an enum (automatically Parcelable). + * + * To add a new destination: + * 1. Add a new enum value here + * 2. Add a corresponding entry provider in NavGraph.kt + */ +enum class Screen : NavKey { + Main, + Gallery, + ImageViewer, + VideoViewer +} + +@Composable +fun NavGraph( + modifier: Modifier = Modifier +) { + val backStack = rememberNavBackStack(Screen.Main) + + @Suppress("UNCHECKED_CAST") + val typedBackStack = backStack as NavBackStack + + NavDisplay( + backStack = backStack, + modifier = modifier, + transitionSpec = { + // Gallery slides up from bottom + slideInHorizontally( + initialOffsetX = { fullWidth -> fullWidth }, + animationSpec = tween(500) + ) togetherWith + // Camera stays in place and fades out + slideOutHorizontally ( + targetOffsetX = { fullWidth -> -fullWidth }, + animationSpec = tween(500) + ) + }, + popTransitionSpec = { + // Camera slides back in from left + slideInHorizontally( + initialOffsetX = { fullWidth -> -fullWidth }, + animationSpec = tween(500) + ) togetherWith + // Gallery slides out to right + slideOutHorizontally( + targetOffsetX = { fullWidth -> fullWidth }, + animationSpec = tween(500) + ) + }, + predictivePopTransitionSpec = { progress -> + // Camera slides back in from left (predictive with progress) + slideInHorizontally( + initialOffsetX = { fullWidth -> -fullWidth }, + animationSpec = tween(500) + ) togetherWith + // Gallery slides out to right (predictive with progress) + slideOutHorizontally( + targetOffsetX = { fullWidth -> fullWidth }, + animationSpec = tween(500) + ) + }, + entryProvider = entryProvider { + addEntryProvider( + key = Screen.Main, + contentKey = Screen.Main, + metadata = emptyMap() + ) { screen: Screen -> + MainScreen(backStack = typedBackStack) + } + + addEntryProvider( + key = Screen.Gallery, + contentKey = Screen.Gallery, + metadata = emptyMap() + ) { screen: Screen -> + GalleryScreen(backStack = typedBackStack) + } + + addEntryProvider( + key = Screen.ImageViewer, + contentKey = Screen.ImageViewer, + metadata = emptyMap() + ) { screen: Screen -> + ImageViewerScreen(backStack = typedBackStack) + } + + addEntryProvider( + key = Screen.VideoViewer, + contentKey = Screen.VideoViewer, + metadata = emptyMap() + ) { screen: Screen -> + VideoViewerScreen(backStack = typedBackStack) + } + } + ) +} diff --git a/demo/camera/src/main/java/org/signal/camera/demo/screens/gallery/GalleryScreen.kt b/demo/camera/src/main/java/org/signal/camera/demo/screens/gallery/GalleryScreen.kt new file mode 100644 index 0000000000..7b1138440a --- /dev/null +++ b/demo/camera/src/main/java/org/signal/camera/demo/screens/gallery/GalleryScreen.kt @@ -0,0 +1,222 @@ +package org.signal.camera.demo.screens.gallery + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation3.runtime.NavBackStack +import org.signal.camera.demo.Screen +import org.signal.glide.compose.GlideImage +import org.signal.glide.compose.GlideImageScaleType + +@Composable +fun GalleryScreen( + backStack: NavBackStack, + viewModel: GalleryScreenViewModel = viewModel() +) { + val context = LocalContext.current + val state = viewModel.state.value + var showDeleteDialog by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + viewModel.loadMedia(context) + } + + // Delete confirmation dialog + if (showDeleteDialog) { + AlertDialog( + onDismissRequest = { showDeleteDialog = false }, + title = { Text("Delete All Media?") }, + text = { + Text("This will permanently delete all ${state.mediaItems.size} photos and videos from your gallery. This action cannot be undone.") + }, + confirmButton = { + TextButton( + onClick = { + showDeleteDialog = false + viewModel.deleteAllMedia(context) + }, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Text("Delete All") + } + }, + dismissButton = { + TextButton(onClick = { showDeleteDialog = false }) { + Text("Cancel") + } + } + ) + } + + Box( + modifier = Modifier + .fillMaxSize() + .padding(WindowInsets.systemBars.asPaddingValues()) + .padding(start = 16.dp, end = 16.dp) + ) { + when { + state.isLoading -> { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center) + ) + } + state.error != null -> { + Text( + text = "Error: ${state.error}", + modifier = Modifier + .align(Alignment.Center) + .padding(16.dp), + color = MaterialTheme.colorScheme.error + ) + } + state.mediaItems.isEmpty() -> { + Column( + modifier = Modifier + .align(Alignment.Center) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "No photos or videos yet", + style = MaterialTheme.typography.titleMedium + ) + Text( + text = "Capture some photos to see them here", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + else -> { + LazyVerticalGrid( + columns = GridCells.Fixed(3), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxSize() + ) { + items(state.mediaItems, key = { it.file.absolutePath }) { mediaItem -> + MediaThumbnail( + mediaItem = mediaItem, + onClick = { + org.signal.camera.demo.MediaSelectionHolder.selectedMedia = mediaItem + when (mediaItem) { + is MediaItem.Image -> backStack.add(Screen.ImageViewer) + is MediaItem.Video -> backStack.add(Screen.VideoViewer) + } + } + ) + } + } + } + } + + // Delete all button at bottom (only show when there are items) + if (state.mediaItems.isNotEmpty()) { + Button( + onClick = { showDeleteDialog = true }, + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(16.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("Delete All (${state.mediaItems.size})") + } + } + } +} + +@Composable +private fun MediaThumbnail( + mediaItem: MediaItem, + onClick: () -> Unit +) { + Card( + modifier = Modifier + .aspectRatio(1f) + .clickable(onClick = onClick), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Box(modifier = Modifier.fillMaxSize()) { + GlideImage( + model = mediaItem.file, + scaleType = GlideImageScaleType.CENTER_CROP, + modifier = Modifier.fillMaxSize() + ) + + if (mediaItem.isVideo) { + Box( + modifier = Modifier + .align(Alignment.Center) + .fillMaxWidth(0.3f) + .aspectRatio(1f) + .background( + color = Color.Black.copy(alpha = 0.5f), + shape = CircleShape + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.PlayArrow, + contentDescription = "Video", + modifier = Modifier.fillMaxSize(0.7f), + tint = Color.White + ) + } + } + } + } +} diff --git a/demo/camera/src/main/java/org/signal/camera/demo/screens/gallery/GalleryScreenEvents.kt b/demo/camera/src/main/java/org/signal/camera/demo/screens/gallery/GalleryScreenEvents.kt new file mode 100644 index 0000000000..5b920eb885 --- /dev/null +++ b/demo/camera/src/main/java/org/signal/camera/demo/screens/gallery/GalleryScreenEvents.kt @@ -0,0 +1,6 @@ +package org.signal.camera.demo.screens.gallery + +sealed interface GalleryScreenEvents { + data class OnMediaItemClick(val mediaItem: MediaItem) : GalleryScreenEvents + data object OnRefresh : GalleryScreenEvents +} diff --git a/demo/camera/src/main/java/org/signal/camera/demo/screens/gallery/GalleryScreenState.kt b/demo/camera/src/main/java/org/signal/camera/demo/screens/gallery/GalleryScreenState.kt new file mode 100644 index 0000000000..2b0c50a9bb --- /dev/null +++ b/demo/camera/src/main/java/org/signal/camera/demo/screens/gallery/GalleryScreenState.kt @@ -0,0 +1,7 @@ +package org.signal.camera.demo.screens.gallery + +data class GalleryScreenState( + val mediaItems: List = emptyList(), + val isLoading: Boolean = true, + val error: String? = null +) diff --git a/demo/camera/src/main/java/org/signal/camera/demo/screens/gallery/GalleryScreenViewModel.kt b/demo/camera/src/main/java/org/signal/camera/demo/screens/gallery/GalleryScreenViewModel.kt new file mode 100644 index 0000000000..eac4dbeaf2 --- /dev/null +++ b/demo/camera/src/main/java/org/signal/camera/demo/screens/gallery/GalleryScreenViewModel.kt @@ -0,0 +1,102 @@ +package org.signal.camera.demo.screens.gallery + +import android.content.Context +import android.util.Log +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File + +private const val TAG = "GalleryScreenViewModel" +private const val GALLERY_FOLDER = "gallery" + +class GalleryScreenViewModel : ViewModel() { + private val _state: MutableState = mutableStateOf(GalleryScreenState()) + val state: State + get() = _state + + fun loadMedia(context: Context) { + _state.value = _state.value.copy(isLoading = true, error = null) + + viewModelScope.launch { + try { + val mediaItems = loadMediaFromInternalStorage(context) + _state.value = _state.value.copy( + mediaItems = mediaItems, + isLoading = false + ) + Log.d(TAG, "Loaded ${mediaItems.size} media items") + } catch (e: Exception) { + Log.e(TAG, "Failed to load media: ${e.message}", e) + _state.value = _state.value.copy( + isLoading = false, + error = e.message ?: "Unknown error" + ) + } + } + } + + fun deleteAllMedia(context: Context) { + viewModelScope.launch { + try { + val count = deleteAllMediaFromInternalStorage(context) + Log.d(TAG, "Deleted $count media items") + // Reload to update UI + loadMedia(context) + } catch (e: Exception) { + Log.e(TAG, "Failed to delete media: ${e.message}", e) + _state.value = _state.value.copy( + error = e.message ?: "Failed to delete media" + ) + } + } + } + + private suspend fun loadMediaFromInternalStorage(context: Context): List = withContext(Dispatchers.IO) { + val galleryDir = File(context.filesDir, GALLERY_FOLDER) + + if (!galleryDir.exists()) { + return@withContext emptyList() + } + + galleryDir.listFiles() + ?.filter { it.isFile } + ?.mapNotNull { file -> + when { + file.extension.lowercase() in listOf("jpg", "jpeg", "png", "webp") -> { + MediaItem.Image(file) + } + file.extension.lowercase() in listOf("mp4", "mkv", "webm", "mov") -> { + MediaItem.Video(file) + } + else -> null + } + } + ?.sortedByDescending { it.lastModified } + ?: emptyList() + } + + private suspend fun deleteAllMediaFromInternalStorage(context: Context): Int = withContext(Dispatchers.IO) { + val galleryDir = File(context.filesDir, GALLERY_FOLDER) + + if (!galleryDir.exists()) { + return@withContext 0 + } + + val files = galleryDir.listFiles() ?: return@withContext 0 + var deletedCount = 0 + + files.forEach { file -> + if (file.isFile && file.delete()) { + deletedCount++ + } + } + + deletedCount + } +} diff --git a/demo/camera/src/main/java/org/signal/camera/demo/screens/gallery/MediaItem.kt b/demo/camera/src/main/java/org/signal/camera/demo/screens/gallery/MediaItem.kt new file mode 100644 index 0000000000..95fefdb62c --- /dev/null +++ b/demo/camera/src/main/java/org/signal/camera/demo/screens/gallery/MediaItem.kt @@ -0,0 +1,24 @@ +package org.signal.camera.demo.screens.gallery + +import java.io.File + +sealed class MediaItem { + abstract val file: File + abstract val name: String + abstract val lastModified: Long + + data class Image( + override val file: File, + override val name: String = file.name, + override val lastModified: Long = file.lastModified() + ) : MediaItem() + + data class Video( + override val file: File, + override val name: String = file.name, + override val lastModified: Long = file.lastModified() + ) : MediaItem() + + val isImage: Boolean get() = this is Image + val isVideo: Boolean get() = this is Video +} diff --git a/demo/camera/src/main/java/org/signal/camera/demo/screens/imageviewer/ImageViewerScreen.kt b/demo/camera/src/main/java/org/signal/camera/demo/screens/imageviewer/ImageViewerScreen.kt new file mode 100644 index 0000000000..ce1e2a1dee --- /dev/null +++ b/demo/camera/src/main/java/org/signal/camera/demo/screens/imageviewer/ImageViewerScreen.kt @@ -0,0 +1,101 @@ +package org.signal.camera.demo.screens.imageviewer + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.dp +import androidx.navigation3.runtime.NavBackStack +import org.signal.camera.demo.Screen +import org.signal.camera.demo.screens.gallery.MediaItem +import org.signal.glide.compose.GlideImage +import org.signal.glide.compose.GlideImageScaleType + +@Composable +fun ImageViewerScreen( + backStack: NavBackStack +) { + val selectedMedia = org.signal.camera.demo.MediaSelectionHolder.selectedMedia + + if (selectedMedia == null || selectedMedia !is MediaItem.Image) { + // No image selected, go back + backStack.removeLastOrNull() + return + } + + val imageFile = selectedMedia.file + + var scale by remember { mutableFloatStateOf(1f) } + var offset by remember { mutableStateOf(Offset.Zero) } + + Box( + modifier = Modifier + .fillMaxSize() + .padding(WindowInsets.systemBars.asPaddingValues()) + .background(Color.Black) + ) { + // Image with pinch-to-zoom + Box( + modifier = Modifier + .fillMaxSize() + .pointerInput(Unit) { + detectTransformGestures { _, pan, zoom, _ -> + scale = (scale * zoom).coerceIn(1f, 5f) + if (scale > 1f) { + offset += pan + } else { + offset = Offset.Zero + } + } + }, + contentAlignment = Alignment.Center + ) { + GlideImage( + model = imageFile, + scaleType = GlideImageScaleType.FIT_CENTER, + modifier = Modifier + .fillMaxSize() + .graphicsLayer( + scaleX = scale, + scaleY = scale, + translationX = offset.x, + translationY = offset.y + ) + ) + } + + // Back button + IconButton( + onClick = { backStack.removeLastOrNull() }, + modifier = Modifier + .align(Alignment.TopStart) + .padding(8.dp) + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = Color.White + ) + } + } +} diff --git a/demo/camera/src/main/java/org/signal/camera/demo/screens/main/MainScreen.kt b/demo/camera/src/main/java/org/signal/camera/demo/screens/main/MainScreen.kt new file mode 100644 index 0000000000..437cd04546 --- /dev/null +++ b/demo/camera/src/main/java/org/signal/camera/demo/screens/main/MainScreen.kt @@ -0,0 +1,220 @@ +@file:OptIn(ExperimentalPermissionsApi::class) + +package org.signal.camera.demo.screens.main + +import android.Manifest +import android.os.Build +import android.widget.Toast +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.MultiplePermissionsState +import com.google.accompanist.permissions.PermissionState +import com.google.accompanist.permissions.rememberMultiplePermissionsState +import androidx.compose.foundation.background +import androidx.compose.ui.graphics.Color +import androidx.navigation3.runtime.NavBackStack +import org.signal.camera.demo.Screen +import org.signal.camera.hud.StandardCameraHudEvents +import org.signal.camera.CameraScreen +import org.signal.camera.CameraScreenEvents +import org.signal.camera.CameraScreenViewModel +import org.signal.camera.hud.StandardCameraHud + +@Composable +fun MainScreen( + backStack: NavBackStack, + viewModel: MainScreenViewModel = viewModel(), +) { + val cameraViewModel: CameraScreenViewModel = viewModel() + + val permissions = buildList { + add(Manifest.permission.CAMERA) + add(Manifest.permission.RECORD_AUDIO) + + if (Build.VERSION.SDK_INT >= 33) { + add(Manifest.permission.READ_MEDIA_IMAGES) + add(Manifest.permission.READ_MEDIA_VIDEO) + } else { + add(Manifest.permission.READ_EXTERNAL_STORAGE) + } + } + + val permissionsState = rememberMultiplePermissionsState(permissions = permissions) + + val context = LocalContext.current + var qrCodeContent by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + permissionsState.launchMultiplePermissionRequest() + } + + // Observe save status and show toasts + LaunchedEffect(viewModel.state.value.saveStatus) { + viewModel.state.value.saveStatus?.let { status -> + val message = when (status) { + is SaveStatus.Saving -> null + is SaveStatus.Success -> "Saved to gallery!" + is SaveStatus.Error -> "Failed to save: ${status.message}" + } + message?.let { + Toast.makeText(context, it, Toast.LENGTH_SHORT).show() + viewModel.onEvent(MainScreenEvents.ClearSaveStatus) + } + } + } + + // Observe QR code detections from the camera view model + LaunchedEffect(cameraViewModel) { + cameraViewModel.qrCodeDetected.collect { qrCode -> + qrCodeContent = qrCode + } + } + + when { + permissionsState.allPermissionsGranted -> { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black), + contentAlignment = Alignment.Center + ) { + CameraScreen( + state = cameraViewModel.state.value, + emitter = { event -> cameraViewModel.onEvent(event) } + ) { + StandardCameraHud( + state = cameraViewModel.state.value, + emitter = { event -> + when (event) { + is StandardCameraHudEvents.PhotoCaptureTriggered -> { + cameraViewModel.capturePhoto( + context = context, + onPhotoCaptured = { bitmap -> + viewModel.onEvent(MainScreenEvents.SavePhoto(context, bitmap)) + } + ) + } + is StandardCameraHudEvents.VideoCaptureStarted -> { + cameraViewModel.startRecording( + context = context, + output = viewModel.createVideoOutput(context), + onVideoCaptured = { result -> + viewModel.onEvent(MainScreenEvents.VideoSaved(result)) + } + ) + } + is StandardCameraHudEvents.VideoCaptureStopped-> { + cameraViewModel.stopRecording() + } + is StandardCameraHudEvents.GalleryClick -> { + backStack.add(Screen.Gallery) + } + is StandardCameraHudEvents.ToggleFlash -> { + cameraViewModel.onEvent(CameraScreenEvents.NextFlashMode) + } + is StandardCameraHudEvents.ClearCaptureError -> { + cameraViewModel.onEvent(CameraScreenEvents.ClearCaptureError) + } + is StandardCameraHudEvents.SwitchCamera -> { + cameraViewModel.onEvent(CameraScreenEvents.SwitchCamera(context)) + } + is StandardCameraHudEvents.SetZoomLevel -> { + cameraViewModel.onEvent(CameraScreenEvents.LinearZoom(event.zoomLevel)) + } + is StandardCameraHudEvents.MediaSelectionClick -> { + // Doesn't need to be handled + } + } + } + ) + } + } + + // QR Code Dialog + if (qrCodeContent != null) { + AlertDialog( + onDismissRequest = { qrCodeContent = null }, + title = { Text("QR Code Detected") }, + text = { Text(qrCodeContent ?: "") }, + confirmButton = { + TextButton(onClick = { qrCodeContent = null }) { + Text("OK") + } + } + ) + } + } + else -> { + PermissionDeniedContent(permissionsState) + } + } +} + +@Composable +fun PermissionDeniedContent(permissionsState: MultiplePermissionsState) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "Camera, microphone, and media permissions are required", + style = MaterialTheme.typography.bodyLarge + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Media permissions allow showing your recent photos in the gallery button", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(16.dp)) + Button(onClick = { permissionsState.launchMultiplePermissionRequest() }) { + Text("Grant Permissions") + } + } + } +} + +@PreviewLightDark +@Composable +fun PreviewPermissionDeniedLight() { + MaterialTheme { + Surface(modifier = Modifier.fillMaxSize()) { + PermissionDeniedContent(permissionsState = PreviewMultiplePermissionsState()) + } + } +} + +private class PreviewMultiplePermissionsState : MultiplePermissionsState { + override val allPermissionsGranted: Boolean = false + override val permissions: List = emptyList() + override val revokedPermissions: List = emptyList() + override val shouldShowRationale: Boolean = false + override fun launchMultiplePermissionRequest() {} +} diff --git a/demo/camera/src/main/java/org/signal/camera/demo/screens/main/MainScreenEvents.kt b/demo/camera/src/main/java/org/signal/camera/demo/screens/main/MainScreenEvents.kt new file mode 100644 index 0000000000..460d243a7d --- /dev/null +++ b/demo/camera/src/main/java/org/signal/camera/demo/screens/main/MainScreenEvents.kt @@ -0,0 +1,12 @@ +package org.signal.camera.demo.screens.main + +import android.content.Context +import android.graphics.Bitmap + +sealed interface MainScreenEvents { + data class SavePhoto(val context: Context, val bitmap: Bitmap) : MainScreenEvents + + data class VideoSaved(val result: org.signal.camera.VideoCaptureResult) : MainScreenEvents + + data object ClearSaveStatus : MainScreenEvents +} diff --git a/demo/camera/src/main/java/org/signal/camera/demo/screens/main/MainScreenState.kt b/demo/camera/src/main/java/org/signal/camera/demo/screens/main/MainScreenState.kt new file mode 100644 index 0000000000..5adf403fc8 --- /dev/null +++ b/demo/camera/src/main/java/org/signal/camera/demo/screens/main/MainScreenState.kt @@ -0,0 +1,11 @@ +package org.signal.camera.demo.screens.main + +data class MainScreenState( + val saveStatus: SaveStatus? = null +) + +sealed interface SaveStatus { + data object Saving : SaveStatus + data object Success : SaveStatus + data class Error(val message: String?) : SaveStatus +} diff --git a/demo/camera/src/main/java/org/signal/camera/demo/screens/main/MainScreenViewModel.kt b/demo/camera/src/main/java/org/signal/camera/demo/screens/main/MainScreenViewModel.kt new file mode 100644 index 0000000000..95f6808232 --- /dev/null +++ b/demo/camera/src/main/java/org/signal/camera/demo/screens/main/MainScreenViewModel.kt @@ -0,0 +1,126 @@ +package org.signal.camera.demo.screens.main + +import android.content.Context +import android.graphics.Bitmap +import android.os.ParcelFileDescriptor +import android.util.Log +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import org.signal.camera.VideoCaptureResult +import org.signal.camera.VideoOutput +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.text.SimpleDateFormat +import java.util.Locale + +private const val TAG = "MainScreenViewModel" +private const val GALLERY_FOLDER = "gallery" + +class MainScreenViewModel : ViewModel() { + private val _state: MutableState = mutableStateOf(MainScreenState()) + val state: State + get() = _state + + fun onEvent(event: MainScreenEvents) { + val currentState = _state.value + when (event) { + is MainScreenEvents.SavePhoto -> { + handleSavePhotoEvent(currentState, event) + } + is MainScreenEvents.VideoSaved -> { + handleVideoSavedEvent(currentState, event) + } + is MainScreenEvents.ClearSaveStatus -> { + handleClearSaveStatusEvent(currentState, event) + } + } + } + + fun createVideoOutput(context: Context): VideoOutput { + // Create gallery directory in internal storage + val galleryDir = File(context.filesDir, GALLERY_FOLDER) + if (!galleryDir.exists()) { + galleryDir.mkdirs() + } + + // Generate filename with timestamp + val name = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss-SSS", Locale.US) + .format(System.currentTimeMillis()) + val file = File(galleryDir, "$name.mp4") + + // Open the file as a ParcelFileDescriptor + val fileDescriptor = ParcelFileDescriptor.open( + file, + ParcelFileDescriptor.MODE_READ_WRITE or ParcelFileDescriptor.MODE_CREATE + ) + + return VideoOutput.FileDescriptorOutput(fileDescriptor) + } + + private fun handleSavePhotoEvent( + state: MainScreenState, + event: MainScreenEvents.SavePhoto + ) { + _state.value = state.copy(saveStatus = SaveStatus.Saving) + + viewModelScope.launch { + try { + saveBitmapToMediaStore(event) + _state.value = _state.value.copy(saveStatus = SaveStatus.Success) + } catch (e: Exception) { + Log.e(TAG, "Failed to save photo: ${e.message}", e) + _state.value = _state.value.copy(saveStatus = SaveStatus.Error(e.message)) + } + } + } + + private fun handleVideoSavedEvent( + state: MainScreenState, + event: MainScreenEvents.VideoSaved + ) { + when (val result = event.result) { + is VideoCaptureResult.Success -> { + // Close the file descriptor now that recording is complete + result.fileDescriptor?.close() + + Log.d(TAG, "Video saved successfully") + _state.value = state.copy(saveStatus = SaveStatus.Success) + } + is VideoCaptureResult.Error -> { + Log.e(TAG, "Failed to save video: ${result.message}", result.throwable) + _state.value = state.copy(saveStatus = SaveStatus.Error(result.message)) + } + } + } + + private fun handleClearSaveStatusEvent( + state: MainScreenState, + event: MainScreenEvents.ClearSaveStatus + ) { + _state.value = state.copy(saveStatus = null) + } + + private suspend fun saveBitmapToMediaStore(event: MainScreenEvents.SavePhoto) = withContext(Dispatchers.IO) { + // Create gallery directory in internal storage + val galleryDir = File(event.context.filesDir, "gallery") + if (!galleryDir.exists()) { + galleryDir.mkdirs() + } + + // Generate filename with timestamp + val name = SimpleDateFormat("yyyy-MM-dd-HH-mm-ss-SSS", Locale.US) + .format(System.currentTimeMillis()) + val file = File(galleryDir, "$name.jpg") + + // Save bitmap to file + file.outputStream().use { outputStream -> + event.bitmap.compress(Bitmap.CompressFormat.JPEG, 95, outputStream) + Log.d(TAG, "Photo saved to internal storage: ${file.absolutePath}") + } + } +} diff --git a/demo/camera/src/main/java/org/signal/camera/demo/screens/videoviewer/VideoViewerScreen.kt b/demo/camera/src/main/java/org/signal/camera/demo/screens/videoviewer/VideoViewerScreen.kt new file mode 100644 index 0000000000..00898295ac --- /dev/null +++ b/demo/camera/src/main/java/org/signal/camera/demo/screens/videoviewer/VideoViewerScreen.kt @@ -0,0 +1,99 @@ +package org.signal.camera.demo.screens.videoviewer + +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.annotation.OptIn +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.ui.PlayerView +import androidx.navigation3.runtime.NavBackStack +import org.signal.camera.demo.Screen +import java.io.File + +@OptIn(UnstableApi::class) +@Composable +fun VideoViewerScreen( + backStack: NavBackStack +) { + val selectedMedia = org.signal.camera.demo.MediaSelectionHolder.selectedMedia + + if (selectedMedia == null || selectedMedia !is org.signal.camera.demo.screens.gallery.MediaItem.Video) { + // No video selected, go back + backStack.removeAt(backStack.lastIndex) + return + } + + val context = LocalContext.current + val videoFile = selectedMedia.file + + val exoPlayer = remember { + ExoPlayer.Builder(context).build().apply { + val mediaItem = MediaItem.fromUri(videoFile.toURI().toString()) + setMediaItem(mediaItem) + prepare() + playWhenReady = true + repeatMode = Player.REPEAT_MODE_OFF + } + } + + DisposableEffect(Unit) { + onDispose { + exoPlayer.release() + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + ) { + // Video player + AndroidView( + factory = { ctx -> + PlayerView(ctx).apply { + player = exoPlayer + layoutParams = FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + useController = true + controllerShowTimeoutMs = 3000 + } + }, + modifier = Modifier.fillMaxSize() + ) + + // Back button + IconButton( + onClick = { backStack.removeAt(backStack.lastIndex) }, + modifier = Modifier + .align(Alignment.TopStart) + .padding(8.dp) + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = Color.White + ) + } + } +} diff --git a/demo/camera/src/main/java/org/signal/camera/demo/ui/theme/Color.kt b/demo/camera/src/main/java/org/signal/camera/demo/ui/theme/Color.kt new file mode 100644 index 0000000000..6b6b9d7fb8 --- /dev/null +++ b/demo/camera/src/main/java/org/signal/camera/demo/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package org.signal.camera.demo.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/demo/camera/src/main/java/org/signal/camera/demo/ui/theme/Theme.kt b/demo/camera/src/main/java/org/signal/camera/demo/ui/theme/Theme.kt new file mode 100644 index 0000000000..1bd4fa7a3c --- /dev/null +++ b/demo/camera/src/main/java/org/signal/camera/demo/ui/theme/Theme.kt @@ -0,0 +1,58 @@ +package org.signal.camera.demo.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun CameraXTestTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/demo/camera/src/main/java/org/signal/camera/demo/ui/theme/Type.kt b/demo/camera/src/main/java/org/signal/camera/demo/ui/theme/Type.kt new file mode 100644 index 0000000000..0044f2904a --- /dev/null +++ b/demo/camera/src/main/java/org/signal/camera/demo/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package org.signal.camera.demo.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/demo/camera/src/main/res/drawable/ic_launcher_background.xml b/demo/camera/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000000..c71b77f4d2 --- /dev/null +++ b/demo/camera/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,171 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/demo/camera/src/main/res/drawable/ic_launcher_foreground.xml b/demo/camera/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000000..18c0565852 --- /dev/null +++ b/demo/camera/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/demo/camera/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/demo/camera/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000000..7efa3313d2 --- /dev/null +++ b/demo/camera/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/demo/camera/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/demo/camera/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000000..7efa3313d2 --- /dev/null +++ b/demo/camera/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/demo/camera/src/main/res/mipmap-hdpi/ic_launcher.webp b/demo/camera/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000000..c209e78ecd Binary files /dev/null and b/demo/camera/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/demo/camera/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/demo/camera/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..b2dfe3d1ba Binary files /dev/null and b/demo/camera/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/demo/camera/src/main/res/mipmap-mdpi/ic_launcher.webp b/demo/camera/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000000..4f0f1d64e5 Binary files /dev/null and b/demo/camera/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/demo/camera/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/demo/camera/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..62b611da08 Binary files /dev/null and b/demo/camera/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/demo/camera/src/main/res/mipmap-xhdpi/ic_launcher.webp b/demo/camera/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000000..948a3070fe Binary files /dev/null and b/demo/camera/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/demo/camera/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/demo/camera/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..1b9a6956b3 Binary files /dev/null and b/demo/camera/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/demo/camera/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/demo/camera/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000000..28d4b77f9f Binary files /dev/null and b/demo/camera/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/demo/camera/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/demo/camera/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..9287f50836 Binary files /dev/null and b/demo/camera/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/demo/camera/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/demo/camera/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000000..aa7d6427e6 Binary files /dev/null and b/demo/camera/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/demo/camera/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/demo/camera/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..9126ae37cb Binary files /dev/null and b/demo/camera/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/demo/camera/src/main/res/values/colors.xml b/demo/camera/src/main/res/values/colors.xml new file mode 100644 index 0000000000..09837df62f --- /dev/null +++ b/demo/camera/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/demo/camera/src/main/res/values/strings.xml b/demo/camera/src/main/res/values/strings.xml new file mode 100644 index 0000000000..056fb0c3ab --- /dev/null +++ b/demo/camera/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + CameraXTest + \ No newline at end of file diff --git a/demo/camera/src/main/res/values/themes.xml b/demo/camera/src/main/res/values/themes.xml new file mode 100644 index 0000000000..3d43b6f3c9 --- /dev/null +++ b/demo/camera/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +