Rebuild CameraXFragment to use a brand new camera.

This commit is contained in:
Greyson Parrelli
2026-01-28 16:02:51 -05:00
parent 0c102b061c
commit f53ae66fc9
71 changed files with 5232 additions and 678 deletions

View File

@@ -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) {

View File

@@ -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
}

View File

@@ -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<Void> 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;
}
}
}

View File

@@ -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<String>,
grantResults: IntArray
) {
Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults)
}
override fun presentHud(selectedMediaCount: Int) {
this.selectedMediaCount.intValue = selectedMediaCount
}
override fun fadeOutControls(onEndAction: Runnable) {
controlsVisible.value = false
// Post the end action after a short delay to allow animation to complete
view?.postDelayed({ onEndAction.run() }, CONTROLS_ANIMATION_DURATION)
}
override fun fadeInControls() {
controlsVisible.value = true
}
private fun checkPermissions(includeAudio: Boolean) {
if (hasCameraPermission()) {
return
}
if (includeAudio) {
Permissions.with(this)
.request(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO)
.ifNecessary()
.onSomeGranted { permissions ->
// Will trigger recomposition via hasCameraPermission check
}
.onSomePermanentlyDenied { deniedPermissions ->
if (deniedPermissions.containsAll(listOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO))) {
showPermissionFragment(
R.string.CameraXFragment_allow_access_camera_microphone,
R.string.CameraXFragment_to_capture_photos_videos,
false
).show(parentFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
} else if (deniedPermissions.contains(Manifest.permission.CAMERA)) {
showPermissionFragment(
R.string.CameraXFragment_allow_access_camera,
R.string.CameraXFragment_to_capture_photos_videos,
false
).show(parentFragmentManager, BottomSheetUtil.STANDARD_BOTTOM_SHEET_FRAGMENT_TAG)
}
}
.onSomeDenied { deniedPermissions ->
if (deniedPermissions.contains(Manifest.permission.CAMERA)) {
Toast.makeText(
requireContext(),
R.string.CameraXFragment_signal_needs_camera_access_capture_photos,
Toast.LENGTH_LONG
).show()
}
}
.execute()
} else {
Permissions.with(this)
.request(Manifest.permission.CAMERA)
.ifNecessary()
.onAllGranted { /* Will trigger recomposition */ }
.onAnyDenied {
Toast.makeText(
requireContext(),
R.string.CameraXFragment_signal_needs_camera_access_capture_photos,
Toast.LENGTH_LONG
).show()
}
.withPermanentDenialDialog(
getString(R.string.CameraXFragment_signal_needs_camera_access_capture_photos),
null,
R.string.CameraXFragment_allow_access_camera,
R.string.CameraXFragment_to_capture_photos,
parentFragmentManager
)
.execute()
}
}
private fun hasCameraPermission(): Boolean {
return Permissions.hasAll(requireContext(), Manifest.permission.CAMERA)
}
private fun 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
)
}
}

View File

@@ -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));

View File

@@ -315,8 +315,22 @@
<string name="CameraXFragment_to_capture_videos">To capture videos with sound:</string>
<!-- Dialog description that explains the steps needed to give Signal camera permissions -->
<string name="CameraXFragment_to_scan_qr_codes">To scan QR codes:</string>
<!-- Error message shown when we try to take a photo, but fail -->
<string name="CameraXFragment_photo_capture_failed">Failed to capture photo. Please try again.</string>
<!-- Error message shown when we try to take a photo, but fail when trying to process it (convert it into something the user can see). -->
<string name="CameraXFragment_photo_processing_failed">Failed to process photo. Please try again.</string>
<!-- Accessibility label for the switch camera button -->
<string name="CameraXFragment_switch_camera">Switch camera</string>
<!-- Accessibility label for flash button when flash is off -->
<string name="CameraXFragment_flash_off">Flash off</string>
<!-- Accessibility label for flash button when flash is on -->
<string name="CameraXFragment_flash_on">Flash on</string>
<!-- Accessibility label for flash button when flash is set to auto -->
<string name="CameraXFragment_flash_auto">Flash auto</string>
<!-- Accessibility label for the send button in media selection -->
<string name="CameraXFragment_send">Send</string>
<!-- CameraContacts -->
<!-- CameraContacts -->
<string name="CameraContacts_recent_contacts">Recent contacts</string>
<string name="CameraContacts_signal_contacts">Signal contacts</string>
<string name="CameraContacts_signal_groups">Signal groups</string>

View File

@@ -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)),

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M11.605,1.734c1,-1.333 3.12,-0.477 2.913,1.177l-0.777,6.214L19,9.125c1.34,0 2.104,1.529 1.3,2.6l-7.905,10.54c-1,1.334 -3.12,0.478 -2.913,-1.176l0.777,-6.214L5,14.875c-1.339,0 -2.103,-1.529 -1.3,-2.6l7.905,-10.54ZM12.723,3.161L5.25,13.125h6a0.875,0.875 0,0 1,0.868 0.983l-0.841,6.731 7.473,-9.964h-6a0.875,0.875 0,0 1,-0.868 -0.984l0.841,-6.73Z"
android:fillColor="#000"
android:fillType="evenOdd"/>
</vector>

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M14.518,2.91c0.207,-1.653 -1.913,-2.509 -2.913,-1.176L3.7,12.275c-0.803,1.071 -0.04,2.6 1.3,2.6h5.259l-0.777,6.214c-0.207,1.654 1.913,2.51 2.913,1.177L20.3,11.725c0.803,-1.071 0.04,-2.6 -1.3,-2.6h-5.259l0.777,-6.214ZM5.25,13.126l7.473,-9.964 -0.841,6.73a0.875,0.875 0,0 0,0.868 0.984h6l-7.473,9.964 0.841,-6.73a0.875,0.875 0,0 0,-0.868 -0.984h-6ZM19.33,16.49c0.347,-0.807 1.493,-0.807 1.84,0l2.02,4.715a0.75,0.75 0,1 1,-1.38 0.59l-0.287,-0.67h-2.546l-0.288,0.67a0.75,0.75 0,1 1,-1.378 -0.59l2.02,-4.714ZM20.25,18 L20.988,19.875h-1.476L20.25,18Z"
android:fillColor="#000"
android:fillType="evenOdd"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="m12.723,3.16 l-2.659,3.546 -1.25,-1.25 2.791,-3.722c1,-1.333 3.12,-0.477 2.913,1.177l-0.777,6.214H19c1.34,0 2.104,1.529 1.3,2.6l-2.236,2.98 -1.25,-1.25 1.936,-2.58h-4.516l-2.2,-2.2 0.69,-5.514ZM5.936,9.294l1.25,1.25 -1.936,2.581h4.516l2.2,2.2 -0.69,5.514 2.66,-3.545 1.25,1.25 -2.791,3.722c-1,1.333 -3.12,0.477 -2.913,-1.177l0.777,-6.214H5c-1.339,0 -2.103,-1.529 -1.3,-2.6l2.236,-2.98ZM4.119,2.881A0.875,0.875 0,1 0,2.88 4.12l16.97,16.97a0.875,0.875 0,0 0,1.238 -1.237L4.12,2.882Z"
android:fillColor="#000"/>
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M2.137,11.5L1.44,11.5a0.75,0.75 0,0 0,-0.646 1.132l1.562,2.635c0.29,0.49 1,0.49 1.29,0l1.561,-2.635a0.75,0.75 0,0 0,-0.645 -1.132h-0.67a8.125,8.125 0,0 1,13.705 -5.39,0.875 0.875,0 1,0 1.206,-1.27A9.844,9.844 0,0 0,12 2.126c-5.286,0 -9.602,4.154 -9.863,9.375ZM19.439,12.375h0.677A8.125,8.125 0,0 1,6.11 17.596a0.875,0.875 0,0 0,-1.268 1.206A9.85,9.85 0,0 0,12 21.875c5.328,0 9.67,-4.22 9.868,-9.5h0.693a0.75,0.75 0,0 0,0.645 -1.132l-1.56,-2.635a0.75,0.75 0,0 0,-1.291 0l-1.562,2.635a0.75,0.75 0,0 0,0.646 1.132Z"
android:fillColor="#000"/>
</vector>

1
demo/camera/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

View File

@@ -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)
}

21
demo/camera/proguard-rules.pro vendored Normal file
View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-feature android:name="android.hardware.camera" android:required="true" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<application
android:name=".CameraDemoApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.CameraXTest">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.CameraXTest">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -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) {
}
}
}
}

View File

@@ -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()
}
}
}
}
}

View File

@@ -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
}

View File

@@ -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<Screen>
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)
}
}
)
}

View File

@@ -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<Screen>,
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
)
}
}
}
}
}

View File

@@ -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
}

View File

@@ -0,0 +1,7 @@
package org.signal.camera.demo.screens.gallery
data class GalleryScreenState(
val mediaItems: List<MediaItem> = emptyList(),
val isLoading: Boolean = true,
val error: String? = null
)

View File

@@ -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<GalleryScreenState> = mutableStateOf(GalleryScreenState())
val state: State<GalleryScreenState>
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<MediaItem> = 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
}
}

View File

@@ -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
}

View File

@@ -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<Screen>
) {
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
)
}
}
}

View File

@@ -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<Screen>,
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<String?>(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<PermissionState> = emptyList()
override val revokedPermissions: List<PermissionState> = emptyList()
override val shouldShowRationale: Boolean = false
override fun launchMultiplePermissionRequest() {}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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<MainScreenState> = mutableStateOf(MainScreenState())
val state: State<MainScreenState>
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}")
}
}
}

View File

@@ -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<Screen>
) {
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
)
}
}
}

View File

@@ -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)

View File

@@ -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
)
}

View File

@@ -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
)
*/
)

View File

@@ -0,0 +1,171 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View File

@@ -0,0 +1,31 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon
xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon
xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View File

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">CameraXTest</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.CameraXTest" parent="android:Theme.Material.Light.NoActionBar" />
</resources>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>

View File

@@ -0,0 +1,17 @@
package org.signal.camera.demo
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}

View File

@@ -0,0 +1,57 @@
plugins {
id("signal-library")
alias(libs.plugins.compose.compiler)
}
android {
namespace = "org.signal.camera"
buildFeatures {
compose = true
}
}
dependencies {
lintChecks(project(":lintchecks"))
// Signal Core
implementation(project(":core:util-jvm"))
implementation(project(":core:ui"))
implementation(project(":lib:glide"))
// 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)
implementation(libs.androidx.compose.material.icons.extended)
debugImplementation(libs.androidx.compose.ui.tooling.core)
debugImplementation(libs.androidx.compose.ui.test.manifest)
// Core AndroidX
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
// Lifecycle
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
// CameraX
implementation(libs.androidx.camera.core)
implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.camera.video)
implementation(libs.androidx.camera.compose)
// QR Code scanning
implementation(libs.google.zxing.core)
// Testing
testImplementation(testLibs.junit.junit)
androidTestImplementation(testLibs.androidx.test.ext.junit)
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
}

View File

21
feature/camera/proguard-rules.pro vendored Normal file
View File

@@ -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

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature android:name="android.hardware.camera" android:required="true" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.VIBRATE" />
<!-- Permissions for reading media files to show gallery thumbnail -->
<!-- For Android 13 (API 33) and above -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<!-- For Android 12 (API 32) and below -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
</manifest>

View File

@@ -0,0 +1,320 @@
package org.signal.camera
import android.content.res.Configuration
import androidx.camera.compose.CameraXViewfinder
import androidx.camera.core.SurfaceRequest
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.core.Preview as CameraPreview
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
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.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.shape.RoundedCornerShape
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.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.Previews
/**
* A camera screen that handles core camera functionality, such as:
* - Tap to focus
* - Pinch to zoom
* - Camera switching
*
* among other things.
*
* This composable is state-driven and emits events through [emitter]. The parent
* composable is responsible for handling these events, typically by forwarding them
* to a [CameraScreenViewModel].
*
* Use the [content] parameter to overlay custom HUD elements on top of the camera.
* For a ready-to-use HUD, see [org.signal.camera.hud.StandardCameraHud].
*
* @param state The camera screen state, typically from a [CameraScreenViewModel].
* @param emitter Callback for events that need to be handled by the parent, likely via [CameraScreenViewModel].
* @param modifier Modifier to apply to the camera container.
* @param roundCorners Whether to apply rounded corners to the camera viewfinder. Defaults to true.
* @param contentAlignment The alignment of the camera viewfinder within the available space. Defaults to center.
* @param content Composable content to overlay on top of the camera surface. The content is placed in a Box
* with the same size and position as the camera surface.
*/
@Composable
fun CameraScreen(
state: CameraScreenState,
emitter: (CameraScreenEvents) -> Unit,
modifier: Modifier = Modifier,
roundCorners: Boolean = true,
contentAlignment: Alignment = Alignment.Center,
content: @Composable BoxScope.() -> Unit = {}
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val configuration = LocalConfiguration.current
val isInPreview = LocalInspectionMode.current
// State to hold the surface request from CameraX Preview
var surfaceRequest by remember { mutableStateOf<SurfaceRequest?>(null) }
// Determine aspect ratio based on orientation
val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
val aspectRatio = if (isLandscape) 16f / 9f else 9f / 16f
// Bind camera and setup surface provider
LaunchedEffect(lifecycleOwner, state.lensFacing) {
val cameraProvider = ProcessCameraProvider.getInstance(context).get()
val surfaceProvider = CameraPreview.SurfaceProvider { request ->
surfaceRequest = request
}
emitter(
CameraScreenEvents.BindCamera(
lifecycleOwner = lifecycleOwner,
cameraProvider = cameraProvider,
surfaceProvider = surfaceProvider,
context = context
)
)
}
BoxWithConstraints(
contentAlignment = contentAlignment,
modifier = modifier.fillMaxSize()
) {
// Determine whether to match height constraints first based on available space.
val availableAspectRatio = maxWidth / maxHeight
val matchHeightFirst = availableAspectRatio > aspectRatio
Box(
modifier = Modifier
.aspectRatio(aspectRatio, matchHeightConstraintsFirst = matchHeightFirst)
) {
val cornerShape = if (roundCorners) RoundedCornerShape(16.dp) else RoundedCornerShape(0.dp)
if (isInPreview) {
// Preview placeholder - shows a dark box with border to represent camera viewfinder
Box(
modifier = Modifier
.fillMaxSize()
.clip(cornerShape)
.drawBehind {
drawRect(Color(0xFF1A1A1A))
}
)
} else if (surfaceRequest != null) {
CameraXViewfinder(
surfaceRequest = surfaceRequest!!,
modifier = Modifier
.fillMaxSize()
.clip(cornerShape)
.pointerInput(Unit) {
detectTapGestures { offset ->
emitter(
CameraScreenEvents.TapToFocus(
x = offset.x,
y = offset.y,
width = size.width.toFloat(),
height = size.height.toFloat()
)
)
}
}
.pointerInput(Unit) {
detectTransformGestures { _, _, zoom, _ ->
emitter(CameraScreenEvents.PinchZoom(zoom))
}
}
)
}
if (state.showFocusIndicator && state.focusPoint != null) {
FocusIndicator(
focusPoint = state.focusPoint,
modifier = Modifier.fillMaxSize()
)
}
// Selfie flash overlay (white screen for front camera)
SelfieFlashOverlay(visible = state.showSelfieFlash)
// Content overlay (HUD elements, buttons, etc. from parent)
content()
}
}
}
@Composable
private fun FocusIndicator(
focusPoint: Offset,
modifier: Modifier = Modifier
) {
val scale = remember { Animatable(1.5f) }
val alpha = remember { Animatable(1f) }
LaunchedEffect(focusPoint) {
// Reset animations
scale.snapTo(1.5f)
alpha.snapTo(1f)
// Animate scale down with spring
launch {
scale.animateTo(
targetValue = 0.8f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
)
)
}
// Fade out after delay
launch {
delay(400L)
alpha.animateTo(
targetValue = 0f,
animationSpec = tween(durationMillis = 400)
)
}
}
Box(
modifier = modifier
.drawBehind {
val radius = 40.dp.toPx() * scale.value
drawCircle(
color = Color.White.copy(alpha = alpha.value),
radius = radius,
center = focusPoint,
style = Stroke(width = 2.dp.toPx())
)
}
)
}
/**
* White overlay used as a selfie flash for front camera photos.
* Fades in quickly when shown, fades out when hidden.
*/
@Composable
private fun SelfieFlashOverlay(visible: Boolean) {
AnimatedVisibility(
visible = visible,
enter = fadeIn(animationSpec = tween(durationMillis = 100)),
exit = fadeOut(animationSpec = tween(durationMillis = 200))
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.White.copy(alpha = 0.95f))
)
}
}
@Preview(name = "Phone", showBackground = true, backgroundColor = 0xFF000000)
@Composable
private fun CameraScreenPreview() {
Previews.Preview {
CameraScreen(
state = CameraScreenState(),
emitter = {}
)
}
}
@Preview(
name = "Phone - Small",
showBackground = true,
backgroundColor = 0xFF000000,
widthDp = 320,
heightDp = 568
)
@Composable
private fun CameraScreenPreviewSmallPhone() {
Previews.Preview {
CameraScreen(
state = CameraScreenState(),
emitter = {}
)
}
}
@Preview(
name = "Tablet",
showBackground = true,
backgroundColor = 0xFF000000,
widthDp = 600,
heightDp = 960
)
@Composable
private fun CameraScreenPreviewTablet() {
Previews.Preview {
CameraScreen(
state = CameraScreenState(),
emitter = {}
)
}
}
@Preview(
name = "Landscape",
showBackground = true,
backgroundColor = 0xFF000000,
widthDp = 840,
heightDp = 400
)
@Composable
private fun CameraScreenPreviewLandscape() {
Previews.Preview {
CameraScreen(
state = CameraScreenState(),
emitter = {}
)
}
}
@Preview(
name = "Foldable",
showBackground = true,
backgroundColor = 0xFF000000,
widthDp = 673,
heightDp = 841
)
@Composable
private fun CameraScreenPreviewFoldable() {
Previews.Preview {
CameraScreen(
state = CameraScreenState(),
emitter = {}
)
}
}

View File

@@ -0,0 +1,44 @@
package org.signal.camera
import android.content.Context
import androidx.annotation.FloatRange
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.lifecycle.LifecycleOwner
sealed interface CameraScreenEvents {
/** Binds a camera to a sruface provider. */
data class BindCamera(
val lifecycleOwner: LifecycleOwner,
val cameraProvider: ProcessCameraProvider,
val surfaceProvider: Preview.SurfaceProvider,
val context: Context
) : CameraScreenEvents
/** Focuses the camera on a point. */
data class TapToFocus(
val x: Float,
val y: Float,
val width: Float,
val height: Float
) : CameraScreenEvents
/** Zoom that happens when you pinch your fingers. */
data class PinchZoom(val zoomFactor: Float) : CameraScreenEvents
/** Zoom that happens when you move your finger up and down during recording. */
data class LinearZoom(@param:FloatRange(from = 0.0, to = 1.0) val linearZoom: Float) : CameraScreenEvents
/** Switches between available cameras (i.e. front and rear cameras). */
data class SwitchCamera(val context: Context) : CameraScreenEvents
/** Sets the flash to a specific mode. */
data class SetFlashMode(val flashMode: FlashMode) : CameraScreenEvents
/** Moves the flash to the next available mode. */
data object NextFlashMode : CameraScreenEvents
/** Indicates the capture error has been handled and can be cleared. */
data object ClearCaptureError : CameraScreenEvents
}

View File

@@ -0,0 +1,48 @@
package org.signal.camera
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageCapture
import androidx.compose.ui.geometry.Offset
/**
* State for CameraScreen.
* Contains UI-related state for camera functionality.
*/
data class CameraScreenState(
val focusPoint: Offset? = null,
val showFocusIndicator: Boolean = false,
val lensFacing: Int = CameraSelector.LENS_FACING_BACK,
val zoomRatio: Float = 1f,
val flashMode: FlashMode = FlashMode.Off,
val isRecording: Boolean = false,
val recordingDuration: Long = 0L,
val showShutter: Boolean = false,
val showSelfieFlash: Boolean = false,
val captureError: CaptureError? = null
)
sealed interface CaptureError {
data class PhotoCaptureFailed(val message: String?) : CaptureError
data class PhotoProcessingFailed(val message: String?) : CaptureError
}
/**
* Flash mode for the camera.
*/
enum class FlashMode(val cameraxMode: Int) {
Off(ImageCapture.FLASH_MODE_OFF),
On(ImageCapture.FLASH_MODE_ON),
Auto(ImageCapture.FLASH_MODE_AUTO);
/**
* Returns the next flash mode in the cycle: OFF -> ON -> AUTO -> OFF
*/
fun next(): FlashMode {
return when (this) {
Off -> On
On -> Auto
Auto -> Off
}
}
}

View File

@@ -0,0 +1,576 @@
package org.signal.camera
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Matrix
import android.os.Build
import android.os.VibrationEffect
import android.os.Vibrator
import androidx.camera.core.AspectRatio
import androidx.camera.core.Camera
import androidx.camera.core.CameraSelector
import androidx.camera.core.FocusMeteringAction
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCaptureException
import androidx.camera.core.ImageProxy
import androidx.camera.core.Preview
import androidx.camera.core.SurfaceOrientedMeteringPointFactory
import androidx.camera.core.resolutionselector.AspectRatioStrategy
import androidx.camera.core.resolutionselector.ResolutionSelector
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.video.Recorder
import androidx.camera.video.Recording
import androidx.camera.video.VideoCapture
import androidx.camera.video.VideoRecordEvent
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.geometry.Offset
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.zxing.BarcodeFormat
import com.google.zxing.BinaryBitmap
import com.google.zxing.DecodeHintType
import com.google.zxing.MultiFormatReader
import com.google.zxing.NotFoundException
import com.google.zxing.PlanarYUVLuminanceSource
import com.google.zxing.common.HybridBinarizer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.signal.core.util.logging.Log
import org.signal.core.util.throttleLatest
import java.util.EnumMap
import java.util.concurrent.Executors
import kotlin.time.Duration.Companion.seconds
private const val TAG = "CameraScreenViewModel"
class CameraScreenViewModel : ViewModel() {
companion object {
private val imageAnalysisExecutor = Executors.newSingleThreadExecutor()
}
private val _state: MutableState<CameraScreenState> = mutableStateOf(CameraScreenState())
val state: State<CameraScreenState>
get() = _state
private var camera: Camera? = null
private var lifecycleOwner: LifecycleOwner? = null
private var cameraProvider: ProcessCameraProvider? = null
private var imageCapture: ImageCapture? = null
private var videoCapture: VideoCapture<Recorder>? = null
private var recording: Recording? = null
private val _qrCodeDetected = MutableSharedFlow<String>(extraBufferCapacity = 1)
/**
* Flow of detected QR codes. Observers can collect from this flow to receive QR code detections.
* The flow filters consecutive duplicates and is throttled to avoid rapid-fire detections.
*/
val qrCodeDetected: Flow<String> = _qrCodeDetected.throttleLatest(2.seconds)
private val qrCodeReader = MultiFormatReader().apply {
val hints = EnumMap<DecodeHintType, Any>(DecodeHintType::class.java)
hints[DecodeHintType.POSSIBLE_FORMATS] = listOf(BarcodeFormat.QR_CODE)
hints[DecodeHintType.TRY_HARDER] = true
setHints(hints)
}
fun onEvent(event: CameraScreenEvents) {
val currentState = _state.value
when (event) {
is CameraScreenEvents.BindCamera -> {
handleBindCameraEvent(currentState, event)
}
is CameraScreenEvents.TapToFocus -> {
handleTapToFocusEvent(currentState, event)
}
is CameraScreenEvents.PinchZoom -> {
handlePinchZoomEvent(currentState, event)
}
is CameraScreenEvents.LinearZoom -> {
handleSetLinearZoomEvent(currentState, event.linearZoom)
}
is CameraScreenEvents.SwitchCamera -> {
handleSwitchCameraEvent(currentState)
}
is CameraScreenEvents.SetFlashMode -> {
handleSetFlashModeEvent(currentState, event.flashMode)
}
is CameraScreenEvents.NextFlashMode -> {
handleSetFlashModeEvent(currentState, currentState.flashMode.next())
}
is CameraScreenEvents.ClearCaptureError -> {
handleClearCaptureErrorEvent(currentState)
}
}
}
/**
* Capture a photo.
* If using front camera with flash enabled but no hardware flash available,
* uses a selfie flash (white screen overlay) to illuminate the subject.
*/
@androidx.annotation.OptIn(markerClass = [androidx.camera.core.ExperimentalGetImage::class])
fun capturePhoto(
context: Context,
onPhotoCaptured: (Bitmap) -> Unit,
) {
val state = _state.value
val capture = imageCapture ?: run {
_state.value = state.copy(captureError = CaptureError.PhotoCaptureFailed("Camera not ready"))
return
}
val needsSelfieFlash = state.lensFacing == CameraSelector.LENS_FACING_FRONT &&
state.flashMode == FlashMode.On
if (needsSelfieFlash) {
captureWithSelfieFlash(context, capture, state, onPhotoCaptured)
} else {
capturePhotoInternal(context, capture, state, onPhotoCaptured)
}
}
private fun captureWithSelfieFlash(
context: Context,
capture: ImageCapture,
state: CameraScreenState,
onPhotoCaptured: (Bitmap) -> Unit
) {
// Show selfie flash
_state.value = state.copy(showSelfieFlash = true)
// Wait for screen to brighten, then capture
viewModelScope.launch {
delay(150L) // Give screen time to brighten
capturePhotoInternal(context, capture, _state.value, onPhotoCaptured)
}
}
@androidx.annotation.OptIn(markerClass = [androidx.camera.core.ExperimentalGetImage::class])
private fun capturePhotoInternal(
context: Context,
capture: ImageCapture,
state: CameraScreenState,
onPhotoCaptured: (Bitmap) -> Unit
) {
// Vibrate for haptic feedback
vibrate(context)
capture.takePicture(
ContextCompat.getMainExecutor(context),
object : ImageCapture.OnImageCapturedCallback() {
override fun onCaptureSuccess(imageProxy: ImageProxy) {
viewModelScope.launch {
try {
// Convert ImageProxy to Bitmap, mirroring for front camera to match preview
val mirrorImage = state.lensFacing == CameraSelector.LENS_FACING_FRONT
val bitmap = imageProxy.toBitmapWithTransforms(mirrorHorizontally = mirrorImage)
// Pass bitmap to callback
triggerShutter(state)
onPhotoCaptured(bitmap)
} catch (e: Exception) {
Log.e(TAG, "Failed to process image: ${e.message}", e)
_state.value = state.copy(captureError = CaptureError.PhotoCaptureFailed(e.message))
} finally {
imageProxy.close()
hideSelfieFlash()
}
}
}
override fun onError(e: ImageCaptureException) {
Log.e(TAG, "Photo capture failed: ${e.message}", e)
_state.value = state.copy(captureError = CaptureError.PhotoCaptureFailed(e.message))
hideSelfieFlash()
}
}
)
}
private fun hideSelfieFlash() {
if (_state.value.showSelfieFlash) {
_state.value = _state.value.copy(showSelfieFlash = false)
}
}
/**
* Start video recording.
*/
@androidx.annotation.OptIn(markerClass = [androidx.camera.core.ExperimentalGetImage::class])
@android.annotation.SuppressLint("MissingPermission", "RestrictedApi", "NewApi")
fun startRecording(
context: Context,
output: VideoOutput,
onVideoCaptured: (VideoCaptureResult) -> Unit
) {
val capture = videoCapture ?: return
// Prepare recording based on configuration
val pendingRecording = when (output) {
is VideoOutput.FileOutput -> {
val fileOutputOptions = androidx.camera.video.FileOutputOptions.Builder(output.file).build()
capture.output.prepareRecording(context, fileOutputOptions)
}
is VideoOutput.FileDescriptorOutput -> {
val fileDescriptorOutputOptions = androidx.camera.video.FileDescriptorOutputOptions.Builder(
output.fileDescriptor
).build()
capture.output.prepareRecording(context, fileDescriptorOutputOptions)
}
}
val activeRecording = pendingRecording
.withAudioEnabled()
.start(ContextCompat.getMainExecutor(context)) { recordEvent ->
when (recordEvent) {
is VideoRecordEvent.Start -> {
Log.d(TAG, "Video recording started")
startRecordingTimer()
vibrate(context)
}
is VideoRecordEvent.Finalize -> {
val result = if (!recordEvent.hasError()) {
Log.d(TAG, "Video recording succeeded")
when (output) {
is VideoOutput.FileOutput -> {
VideoCaptureResult.Success(outputFile = output.file)
}
is VideoOutput.FileDescriptorOutput -> {
VideoCaptureResult.Success(fileDescriptor = output.fileDescriptor)
}
}
} else {
Log.e(TAG, "Video recording failed: ${recordEvent.error}")
val fileDescriptor = (output as? VideoOutput.FileDescriptorOutput)?.fileDescriptor
VideoCaptureResult.Error(
message = "Video recording failed",
throwable = recordEvent.cause,
fileDescriptor = fileDescriptor
)
}
// Call the callback
onVideoCaptured(result)
stopRecordingTimer()
// Clear recording
recording = null
}
}
}
recording = activeRecording
}
/**
* Stop video recording.
*/
fun stopRecording() {
recording?.stop()
recording = null
}
override fun onCleared() {
super.onCleared()
stopRecording()
}
private fun handleBindCameraEvent(
state: CameraScreenState,
event: CameraScreenEvents.BindCamera
) {
val resolutionSelector = ResolutionSelector.Builder()
.setAspectRatioStrategy(AspectRatioStrategy.RATIO_16_9_FALLBACK_AUTO_STRATEGY)
.build()
// Preview with 16:9 aspect ratio - uses Compose Viewfinder
val preview = Preview.Builder()
.setResolutionSelector(resolutionSelector)
.build()
.also { it.surfaceProvider = event.surfaceProvider }
// Image capture with 16:9 aspect ratio (optimized for speed)
val imageCaptureUseCase = ImageCapture.Builder()
.setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
.setResolutionSelector(resolutionSelector)
.build()
// Video capture (16:9 is default for video)
val recorder = Recorder.Builder()
.setAspectRatio(AspectRatio.RATIO_16_9)
.setQualitySelector(
androidx.camera.video.QualitySelector.from(
androidx.camera.video.Quality.HIGHEST,
androidx.camera.video.FallbackStrategy.higherQualityOrLowerThan(androidx.camera.video.Quality.HD)
)
)
.build()
val videoCaptureUseCase = VideoCapture.withOutput(recorder)
// Image analysis for QR code detection
val imageAnalysisUseCase = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.also {
it.setAnalyzer(imageAnalysisExecutor) { imageProxy ->
processImageForQrCode(imageProxy)
}
}
// Select camera based on lensFacing
val cameraSelector = CameraSelector.Builder()
.requireLensFacing(state.lensFacing)
.build()
try {
// Unbind use cases before rebinding
event.cameraProvider.unbindAll()
// Bind use cases to camera
camera = event.cameraProvider.bindToLifecycle(
event.lifecycleOwner,
cameraSelector,
preview,
imageCaptureUseCase,
videoCaptureUseCase,
imageAnalysisUseCase
)
lifecycleOwner = event.lifecycleOwner
cameraProvider = event.cameraProvider
imageCapture = imageCaptureUseCase
videoCapture = videoCaptureUseCase
} catch (e: Exception) {
Log.e(TAG, "Use case binding failed", e)
}
}
private fun handleTapToFocusEvent(
state: CameraScreenState,
event: CameraScreenEvents.TapToFocus
) {
val currentCamera = camera ?: return
val factory = SurfaceOrientedMeteringPointFactory(event.width, event.height)
val point = factory.createPoint(event.x, event.y)
val action = FocusMeteringAction.Builder(point).build()
currentCamera.cameraControl.startFocusAndMetering(action)
_state.value = state.copy(
focusPoint = Offset(event.x, event.y),
showFocusIndicator = true
)
// Hide indicator after animation
viewModelScope.launch {
delay(800L) // Duration for spring animation + fade out
_state.value = _state.value.copy(showFocusIndicator = false)
}
}
private fun handlePinchZoomEvent(
state: CameraScreenState,
event: CameraScreenEvents.PinchZoom
) {
val currentCamera = camera ?: return
// Get current zoom ratio and calculate new zoom
val currentZoom = state.zoomRatio
val newZoom = (currentZoom * event.zoomFactor).coerceIn(
currentCamera.cameraInfo.zoomState.value?.minZoomRatio ?: 1f,
currentCamera.cameraInfo.zoomState.value?.maxZoomRatio ?: 1f
)
// Apply zoom to camera
currentCamera.cameraControl.setZoomRatio(newZoom)
// Update state
_state.value = state.copy(zoomRatio = newZoom)
}
private fun handleSwitchCameraEvent(state: CameraScreenState) {
// Toggle between front and back camera
val newLensFacing = if (state.lensFacing == CameraSelector.LENS_FACING_BACK) {
CameraSelector.LENS_FACING_FRONT
} else {
CameraSelector.LENS_FACING_BACK
}
_state.value = state.copy(lensFacing = newLensFacing)
}
private fun handleSetFlashModeEvent(
state: CameraScreenState,
flashMode: FlashMode
) {
_state.value = state.copy(flashMode = flashMode)
imageCapture?.flashMode = flashMode.cameraxMode
}
private fun handleSetLinearZoomEvent(
state: CameraScreenState,
linearZoom: Float
) {
val currentCamera = camera ?: return
// Clamp linear zoom to valid range
val clampedLinearZoom = linearZoom.coerceIn(0f, 1f)
// CameraX setLinearZoom takes 0.0-1.0 and maps to min-max zoom ratio
currentCamera.cameraControl.setLinearZoom(clampedLinearZoom)
// Calculate the actual zoom ratio for state tracking
val minZoom = currentCamera.cameraInfo.zoomState.value?.minZoomRatio ?: 1f
val maxZoom = currentCamera.cameraInfo.zoomState.value?.maxZoomRatio ?: 1f
val newZoomRatio = minZoom + (maxZoom - minZoom) * clampedLinearZoom
_state.value = state.copy(zoomRatio = newZoomRatio)
}
private fun triggerShutter(state: CameraScreenState) {
_state.value = state.copy(showShutter = true)
// Hide flash after animation
viewModelScope.launch {
delay(200L)
_state.value = _state.value.copy(showShutter = false)
}
}
private fun handleClearCaptureErrorEvent(state: CameraScreenState) {
_state.value = state.copy(captureError = null)
}
private fun startRecordingTimer() {
_state.value = _state.value.copy(isRecording = true, recordingDuration = 0L)
viewModelScope.launch {
while (_state.value.isRecording) {
delay(100L)
_state.value = _state.value.copy(recordingDuration = _state.value.recordingDuration + 100L)
}
}
}
private fun stopRecordingTimer() {
_state.value = _state.value.copy(isRecording = false, recordingDuration = 0L)
}
@androidx.annotation.OptIn(markerClass = [androidx.camera.core.ExperimentalGetImage::class])
private suspend fun ImageProxy.toBitmapWithTransforms(mirrorHorizontally: Boolean = false): Bitmap = withContext(Dispatchers.Default) {
val imageProxy = this@toBitmapWithTransforms
val bitmap = imageProxy.toBitmap()
val needsRotation = imageProxy.imageInfo.rotationDegrees != 0
if (needsRotation || mirrorHorizontally) {
val matrix = Matrix()
if (needsRotation) {
matrix.postRotate(imageProxy.imageInfo.rotationDegrees.toFloat())
}
if (mirrorHorizontally) {
// Mirror horizontally (flip around vertical axis)
matrix.postScale(-1f, 1f, bitmap.width / 2f, bitmap.height / 2f)
}
Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
} else {
bitmap
}
}
@androidx.annotation.OptIn(markerClass = [androidx.camera.core.ExperimentalGetImage::class])
private fun processImageForQrCode(imageProxy: ImageProxy) {
val mediaImage = imageProxy.image
if (mediaImage == null) {
imageProxy.close()
return
}
try {
// Get the Y plane (luminance)
val yPlane = mediaImage.planes[0]
val yBuffer = yPlane.buffer
val ySize = yBuffer.remaining()
val yData = ByteArray(ySize)
yBuffer.get(yData)
// Create a planar YUV source with proper stride handling
val width = mediaImage.width
val height = mediaImage.height
val rowStride = yPlane.rowStride
val pixelStride = yPlane.pixelStride
// If row stride equals width and pixel stride is 1, we can use the data directly
val source = if (rowStride == width && pixelStride == 1) {
PlanarYUVLuminanceSource(
yData,
width,
height,
0,
0,
width,
height,
false
)
} else {
// Need to account for stride - copy row by row
val adjustedData = ByteArray(width * height)
var outputPos = 0
for (row in 0 until height) {
val inputPos = row * rowStride
for (col in 0 until width) {
adjustedData[outputPos++] = yData[inputPos + col * pixelStride]
}
}
PlanarYUVLuminanceSource(
adjustedData,
width,
height,
0,
0,
width,
height,
false
)
}
val binaryBitmap = BinaryBitmap(HybridBinarizer(source))
try {
val result = qrCodeReader.decodeWithState(binaryBitmap)
qrCodeReader.reset() // Reset state after successful decode
_qrCodeDetected.tryEmit(result.text)
} catch (_: NotFoundException) {
// No QR code found in this frame, which is normal
qrCodeReader.reset() // Reset state for next attempt
}
} catch (e: Exception) {
Log.e(TAG, "Error processing image for QR code: ${e.message}", e)
}
imageProxy.close()
}
private fun vibrate(context: Context) {
val vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
vibrator?.let {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
it.vibrate(VibrationEffect.createOneShot(50, 75))
} else {
@Suppress("DEPRECATION")
it.vibrate(50)
}
}
}
}

View File

@@ -0,0 +1,48 @@
package org.signal.camera
import android.os.ParcelFileDescriptor
import java.io.File
/**
* Configuration for video output.
* Allows the consumer to specify where videos should be saved.
*/
sealed class VideoOutput {
/**
* Save video to a specific file.
* The consumer is responsible for creating the file and managing its lifecycle.
*/
data class FileOutput(val file: File) : VideoOutput()
/**
* Save video to a file descriptor.
* The consumer provides the file descriptor and is responsible for closing it.
* This is useful for writing to pipes, sockets, or any other file-descriptor-backed output.
*/
data class FileDescriptorOutput(val fileDescriptor: ParcelFileDescriptor) : VideoOutput()
}
/**
* Result of video capture.
*/
sealed class VideoCaptureResult {
/**
* Video was successfully captured and saved.
* @param outputFile The file where the video was saved (for FileOutput)
* @param fileDescriptor The file descriptor used (for FileDescriptorOutput)
*/
data class Success(
val outputFile: File? = null,
val fileDescriptor: ParcelFileDescriptor? = null
) : VideoCaptureResult()
/**
* Video capture failed.
* @param fileDescriptor The file descriptor that was being used (for cleanup)
*/
data class Error(
val message: String?,
val throwable: Throwable?,
val fileDescriptor: ParcelFileDescriptor? = null
) : VideoCaptureResult()
}

View File

@@ -0,0 +1,387 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.camera.hud
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
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.geometry.Offset
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlin.math.min
/**
* Capture button colors matching CameraButtonView.java
*/
private object CaptureButtonColors {
/** Background fill: white at 30% alpha (0x4CFFFFFF) */
val Background = Color(0x4CFFFFFF)
/** Outer ring stroke: pure white (0xFFFFFFFF) */
val Arc = Color.White
/** Inner fill: pure white (0xFFFFFFFF) */
val CaptureFill = Color.White
/** Outer stroke while recording: black at 15% alpha (0x26000000) */
val Outline = Color(0x26000000)
/** Record indicator: Material red (0xFFF44336) */
val Record = Color(0xFFF44336)
/** Progress arc: pure white (0xFFFFFFFF) */
val Progress = Color.White
}
/**
* Stroke widths matching CameraButtonView.java
*/
private object CaptureButtonDimensions {
/** Stroke width for the capture arc in image mode: 3.5dp */
val CaptureArcStrokeWidth = 3.5.dp
/** Stroke width for the outline in video mode: 4dp */
val OutlineStrokeWidth = 4.dp
/** Stroke width for the progress arc: 4dp */
val ProgressArcStrokeWidth = 4.dp
/** Protection margin for capture fill circle: 10dp */
val CaptureFillProtection = 10.dp
/** Default button size */
val DefaultButtonSize = 80.dp
/** Default image capture size (inner area) */
val DefaultImageCaptureSize = 60.dp
/** Default record indicator size (red dot) */
val DefaultRecordSize = 24.dp
}
/**
* Drag distance multiplier for zoom calculation.
* Matches DRAG_DISTANCE_MULTIPLIER = 3 from CameraButtonView.
*/
private const val DRAG_DISTANCE_MULTIPLIER = 3
/**
* Deadzone reduction percentage.
* Matches DEADZONE_REDUCTION_PERCENT = 0.35f from CameraButtonView.
*/
private const val DEADZONE_REDUCTION_PERCENT = 0.35f
/**
* A capture button that supports both photo capture (tap) and video recording (long press).
*
* This composable mimics the behavior and appearance of [CameraButtonView] from the legacy
* camera implementation. It displays:
* - In idle state: A white-filled circle with a white arc outline
* - In recording state: A larger circle with a red recording indicator and progress arc
*
* @param modifier Modifier to be applied to the button
* @param isRecording Whether video recording is currently active
* @param recordingProgress Progress of the recording from 0f to 1f (for progress arc display)
* @param imageCaptureSize Size of the inner capture circle in image mode
* @param recordSize Size of the red recording indicator circle
* @param onTap Callback for tap gesture (photo capture)
* @param onLongPressStart Callback when long press begins (video recording start)
* @param onLongPressEnd Callback when long press ends (video recording stop)
* @param onZoomChange Callback for zoom level changes during recording (0f to 1f)
*/
@Composable
fun CaptureButton(
modifier: Modifier = Modifier,
isRecording: Boolean,
recordingProgress: Float = 0f,
imageCaptureSize: Dp = CaptureButtonDimensions.DefaultImageCaptureSize,
recordSize: Dp = CaptureButtonDimensions.DefaultRecordSize,
onTap: () -> Unit,
onLongPressStart: () -> Unit,
onLongPressEnd: () -> Unit,
onZoomChange: (Float) -> Unit
) {
var isPressed by remember { mutableStateOf(false) }
val scale = remember { Animatable(1f) }
// Scale animation for press feedback and recording state
LaunchedEffect(isPressed, isRecording) {
val targetScale = when {
isRecording -> 1.3f
isPressed -> 0.9f
else -> 1f
}
scale.animateTo(
targetValue = targetScale,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = if (isRecording) Spring.StiffnessLow else Spring.StiffnessMedium
)
)
}
val density = LocalDensity.current
val imageCaptureRadius = with(density) { imageCaptureSize.toPx() / 2f }
val recordRadius = with(density) { recordSize.toPx() / 2f }
val captureArcStroke = with(density) { CaptureButtonDimensions.CaptureArcStrokeWidth.toPx() }
val outlineStroke = with(density) { CaptureButtonDimensions.OutlineStrokeWidth.toPx() }
val progressStroke = with(density) { CaptureButtonDimensions.ProgressArcStrokeWidth.toPx() }
val fillProtection = with(density) { CaptureButtonDimensions.CaptureFillProtection.toPx() }
Box(
modifier = modifier
.size(CaptureButtonDimensions.DefaultButtonSize)
.graphicsLayer {
scaleX = scale.value
scaleY = scale.value
}
.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
isPressed = true
var longPressTriggered = false
var startY = down.position.y
val pressStartTime = System.currentTimeMillis()
val longPressTimeoutMs = viewConfiguration.longPressTimeoutMillis
// Calculate deadzone for zoom gestures
val deadzoneTop = size.height * DEADZONE_REDUCTION_PERCENT / 2f
val maxRange = size.height * DRAG_DISTANCE_MULTIPLIER
try {
while (true) {
val event = withTimeoutOrNull(50) { awaitPointerEvent() }
if (event != null) {
val currentPointer = event.changes.firstOrNull { it.id == down.id }
if (currentPointer == null || !currentPointer.pressed) {
// Finger lifted
if (!longPressTriggered) {
onTap()
} else {
onLongPressEnd()
}
break
}
// Check for long press timeout
val elapsed = System.currentTimeMillis() - pressStartTime
if (!longPressTriggered && elapsed >= longPressTimeoutMs) {
longPressTriggered = true
startY = currentPointer.position.y
onLongPressStart()
}
// Handle zoom during recording
if (longPressTriggered) {
val isAboveDeadzone = currentPointer.position.y < deadzoneTop
if (isAboveDeadzone) {
val deltaY = (deadzoneTop - currentPointer.position.y).coerceAtLeast(0f)
val zoomPercent = (deltaY / maxRange).coerceIn(0f, 1f)
// Apply decelerate interpolation like CameraButtonView
val interpolatedZoom = decelerateInterpolation(zoomPercent)
onZoomChange(interpolatedZoom)
}
}
currentPointer.consume()
} else {
// Timeout - check for long press
val elapsed = System.currentTimeMillis() - pressStartTime
if (!longPressTriggered && elapsed >= longPressTimeoutMs) {
longPressTriggered = true
startY = down.position.y
onLongPressStart()
}
}
}
} finally {
isPressed = false
}
}
},
contentAlignment = Alignment.Center
) {
Canvas(modifier = Modifier.matchParentSize()) {
if (isRecording) {
drawForVideoCapture(
recordRadius = recordRadius,
outlineStroke = outlineStroke,
progressStroke = progressStroke,
progressPercent = recordingProgress
)
} else {
drawForImageCapture(
captureRadius = imageCaptureRadius,
arcStroke = captureArcStroke,
fillProtection = fillProtection
)
}
}
}
}
/**
* Decelerate interpolation matching DecelerateInterpolator from Android.
* Formula: 1.0 - (1.0 - input)^2
*/
private fun decelerateInterpolation(input: Float): Float {
return 1f - (1f - input) * (1f - input)
}
/**
* Draw the button in image capture mode.
*/
private fun DrawScope.drawForImageCapture(
captureRadius: Float,
arcStroke: Float,
fillProtection: Float
) {
val centerX = size.width / 2f
val centerY = size.height / 2f
val radius = min(centerX, centerY)
// Background circle
drawCircle(
color = CaptureButtonColors.Background,
radius = radius,
center = Offset(centerX, centerY)
)
// Arc outline
drawCircle(
color = CaptureButtonColors.Arc,
radius = radius,
center = Offset(centerX, centerY),
style = Stroke(width = arcStroke)
)
// Inner fill circle (smaller to create the ring effect)
drawCircle(
color = CaptureButtonColors.CaptureFill,
radius = radius - fillProtection,
center = Offset(centerX, centerY)
)
}
/**
* Draw the button in video capture mode.
*/
private fun DrawScope.drawForVideoCapture(
recordRadius: Float,
outlineStroke: Float,
progressStroke: Float,
progressPercent: Float
) {
val centerX = size.width / 2f
val centerY = size.height / 2f
val radius = min(centerX, centerY)
// Background circle
drawCircle(
color = CaptureButtonColors.Background,
radius = radius,
center = Offset(centerX, centerY)
)
// Outline stroke
drawCircle(
color = CaptureButtonColors.Outline,
radius = radius,
center = Offset(centerX, centerY),
style = Stroke(width = outlineStroke)
)
// Red record indicator
drawCircle(
color = CaptureButtonColors.Record,
radius = recordRadius,
center = Offset(centerX, centerY)
)
// Progress arc (only if there's progress to show)
if (progressPercent > 0f) {
val strokeHalf = progressStroke / 2f
drawArc(
color = CaptureButtonColors.Progress,
startAngle = -90f, // Start from top
sweepAngle = 360f * progressPercent,
useCenter = false,
topLeft = Offset(strokeHalf, strokeHalf),
size = Size(size.width - progressStroke, size.height - progressStroke),
style = Stroke(width = progressStroke, cap = StrokeCap.Round)
)
}
}
@Preview(name = "Idle State", showBackground = true, backgroundColor = 0xFF444444)
@Composable
private fun CaptureButtonIdlePreview() {
Box(modifier = Modifier.size(120.dp), contentAlignment = Alignment.Center) {
CaptureButton(
isRecording = false,
onTap = {},
onLongPressStart = {},
onLongPressEnd = {},
onZoomChange = {}
)
}
}
@Preview(name = "Recording State", showBackground = true, backgroundColor = 0xFF444444)
@Composable
private fun CaptureButtonRecordingPreview() {
Box(modifier = Modifier.size(120.dp), contentAlignment = Alignment.Center) {
CaptureButton(
isRecording = true,
recordingProgress = 0f,
onTap = {},
onLongPressStart = {},
onLongPressEnd = {},
onZoomChange = {}
)
}
}
@Preview(name = "Recording with Progress", showBackground = true, backgroundColor = 0xFF444444)
@Composable
private fun CaptureButtonRecordingWithProgressPreview() {
Box(modifier = Modifier.size(120.dp), contentAlignment = Alignment.Center) {
CaptureButton(
isRecording = true,
recordingProgress = 0.65f,
onTap = {},
onLongPressStart = {},
onLongPressEnd = {},
onZoomChange = {}
)
}
}

View File

@@ -0,0 +1,230 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.camera.hud
import android.Manifest
import android.content.ContentUris
import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
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.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import org.signal.glide.compose.GlideImage
import org.signal.glide.compose.GlideImageScaleType
import kotlinx.coroutines.withContext
/**
* A button that displays a thumbnail of the most recent image or video from the gallery.
* Shows a circular thumbnail with a white border that opens the gallery when clicked.
*
* @param modifier Modifier to apply to the button
* @param onClick Callback when the button is clicked
*/
@Composable
fun GalleryThumbnailButton(
modifier: Modifier = Modifier,
onClick: () -> Unit
) {
val context = LocalContext.current
var thumbnailUri by remember { mutableStateOf<Uri?>(null) }
// Load the most recent media item
LaunchedEffect(Unit) {
thumbnailUri = getLatestMediaUri(context)
}
Box(
modifier = modifier
.size(52.dp)
.clip(CircleShape)
.border(2.dp, Color.White, CircleShape)
.background(Color.Black.copy(alpha = 0.3f), CircleShape)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
if (thumbnailUri != null) {
GlideImage(
model = thumbnailUri,
scaleType = GlideImageScaleType.CENTER_CROP,
modifier = Modifier
.size(52.dp)
.clip(CircleShape)
)
} else {
// Fallback to a simple icon if no media found
Box(
modifier = Modifier
.size(52.dp)
.background(Color.Gray.copy(alpha = 0.5f), CircleShape)
)
}
}
}
/**
* Queries MediaStore to get the URI of the most recent image or video.
* Checks both images and videos and returns whichever is more recent.
*/
private suspend fun getLatestMediaUri(context: Context): Uri? = withContext(Dispatchers.IO) {
try {
val imageUri = getLatestImageUri(context)
val videoUri = getLatestVideoUri(context)
// Compare timestamps if both exist, otherwise return whichever is available
when {
imageUri != null && videoUri != null -> {
val imageTime = getMediaTimestamp(context, imageUri) ?: 0L
val videoTime = getMediaTimestamp(context, videoUri) ?: 0L
if (imageTime >= videoTime) imageUri else videoUri
}
imageUri != null -> imageUri
videoUri != null -> videoUri
else -> null
}
} catch (e: SecurityException) {
// Permission denied - return null
null
} catch (e: Exception) {
// Other error - return null
null
}
}
/**
* Gets the most recent image URI from MediaStore.
*/
private fun getLatestImageUri(context: Context): Uri? {
val projection = arrayOf(
MediaStore.Images.Media._ID,
MediaStore.Images.Media.DATE_ADDED
)
val sortOrder = "${MediaStore.Images.Media.DATE_ADDED} DESC"
context.contentResolver.query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
projection,
null,
null,
sortOrder
)?.use { cursor ->
if (cursor.moveToFirst()) {
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
val id = cursor.getLong(idColumn)
return ContentUris.withAppendedId(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
id
)
}
}
return null
}
/**
* Gets the most recent video URI from MediaStore.
*/
private fun getLatestVideoUri(context: Context): Uri? {
val projection = arrayOf(
MediaStore.Video.Media._ID,
MediaStore.Video.Media.DATE_ADDED
)
val sortOrder = "${MediaStore.Video.Media.DATE_ADDED} DESC"
context.contentResolver.query(
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
projection,
null,
null,
sortOrder
)?.use { cursor ->
if (cursor.moveToFirst()) {
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media._ID)
val id = cursor.getLong(idColumn)
return ContentUris.withAppendedId(
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
id
)
}
}
return null
}
/**
* Gets the timestamp of a media item.
*/
private fun getMediaTimestamp(context: Context, uri: Uri): Long? {
val projection = arrayOf(MediaStore.MediaColumns.DATE_ADDED)
context.contentResolver.query(
uri,
projection,
null,
null,
null
)?.use { cursor ->
if (cursor.moveToFirst()) {
val dateColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_ADDED)
return cursor.getLong(dateColumn)
}
}
return null
}
/**
* Checks if the app has permission to read media files.
* For Android 13+ (API 33+), we need READ_MEDIA_IMAGES and READ_MEDIA_VIDEO.
* For older versions, we need READ_EXTERNAL_STORAGE.
*/
fun hasMediaPermissions(context: Context): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// Android 13+
context.checkSelfPermission(Manifest.permission.READ_MEDIA_IMAGES) ==
PackageManager.PERMISSION_GRANTED ||
context.checkSelfPermission(Manifest.permission.READ_MEDIA_VIDEO) ==
PackageManager.PERMISSION_GRANTED
} else {
// Older Android versions
context.checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) ==
PackageManager.PERMISSION_GRANTED
}
}
/**
* Returns the list of permissions needed to read media files based on the Android version.
*/
fun getMediaPermissions(): Array<String> {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
arrayOf(
Manifest.permission.READ_MEDIA_IMAGES,
Manifest.permission.READ_MEDIA_VIDEO
)
} else {
arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE)
}
}

View File

@@ -0,0 +1,492 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.camera.hud
import android.content.res.Configuration
import android.widget.Toast
import androidx.annotation.StringRes
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.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import org.signal.camera.CameraScreenState
import org.signal.camera.CaptureError
import org.signal.camera.FlashMode
import org.signal.core.ui.compose.SignalIcons
import java.util.Locale
/** Default maximum recording duration: 60 seconds */
const val DEFAULT_MAX_RECORDING_DURATION_MS = 60_000L
data class StringResources(
@param:StringRes val photoCaptureFailed: Int = 0,
@param:StringRes val photoProcessingFailed: Int = 0,
@param:StringRes val switchCamera: Int = 0,
@param:StringRes val flashOff: Int = 0,
@param:StringRes val flashOn: Int = 0,
@param:StringRes val flashAuto: Int = 0,
@param:StringRes val send: Int = 0
)
/**
* A standard camera HUD that provides common camera controls:
* - Flash toggle button
* - Capture button (tap for photo, long press for video)
* - Camera switch button
* - Gallery button
* - Recording duration display
* - Flash overlay animation
*
* This composable is designed to be used as the content of [org.signal.camera.CameraScreen]:
*
* ```kotlin
* CameraScreen(
* state = viewModel.state.value,
* emitter = { viewModel.onEvent(it) }
* ) {
* StandardCameraHud(
* state = viewModel.state.value,
* maxRecordingDurationMs = 30_000L,
* emitter = { event ->
* when (event) {
* is CameraHudEvents.PhotoCaptured -> savePhoto(event.bitmap)
* is CameraHudEvents.VideoCaptured -> handleVideo(event.result)
* is CameraHudEvents.GalleryClick -> openGallery()
* }
* }
* )
* }
* ```
*
* @param state The current camera screen state
* @param maxRecordingDurationMs Maximum video recording duration in milliseconds (for progress indicator)
* @param mediaSelectionCount Number of media items currently selected (shows count indicator when > 0)
* @param emitter Callback for HUD events (photo captured, video captured, gallery click)
*/
@Composable
fun BoxScope.StandardCameraHud(
state: CameraScreenState,
emitter: (StandardCameraHudEvents) -> Unit,
modifier: Modifier = Modifier,
maxRecordingDurationMs: Long = DEFAULT_MAX_RECORDING_DURATION_MS,
mediaSelectionCount: Int = 0,
stringResources: StringResources = StringResources(0, 0)
) {
val context = LocalContext.current
LaunchedEffect(state.captureError) {
state.captureError?.let { error ->
val message = when (error) {
is CaptureError.PhotoCaptureFailed -> stringResources.photoCaptureFailed
is CaptureError.PhotoProcessingFailed -> stringResources.photoProcessingFailed
}
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
emitter(StandardCameraHudEvents.ClearCaptureError)
}
}
LaunchedEffect(state.isRecording, state.recordingDuration, maxRecordingDurationMs) {
if (state.isRecording && maxRecordingDurationMs > 0 && state.recordingDuration >= maxRecordingDurationMs) {
emitter(StandardCameraHudEvents.VideoCaptureStopped)
}
}
StandardCameraHudContent(
state = state,
emitter = emitter,
modifier = modifier,
maxRecordingDurationMs = maxRecordingDurationMs,
mediaSelectionCount = mediaSelectionCount,
stringResources = stringResources
)
}
@Composable
private fun BoxScope.StandardCameraHudContent(
state: CameraScreenState,
emitter: (StandardCameraHudEvents) -> Unit,
modifier: Modifier = Modifier,
maxRecordingDurationMs: Long = DEFAULT_MAX_RECORDING_DURATION_MS,
mediaSelectionCount: Int = 0,
stringResources: StringResources = StringResources()
) {
val configuration = LocalConfiguration.current
val isLandscape = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
ShutterOverlay(state.showShutter)
FlashToggleButton(
flashMode = state.flashMode,
onToggle = { emitter(StandardCameraHudEvents.ToggleFlash) },
stringResources = stringResources,
modifier = Modifier
.align(if (isLandscape) Alignment.TopStart else Alignment.TopEnd)
.padding(16.dp)
)
if (state.isRecording) {
RecordingDurationDisplay(
durationMillis = state.recordingDuration,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 16.dp)
)
}
CameraControls(
isLandscape = isLandscape,
isRecording = state.isRecording,
recordingProgress = if (maxRecordingDurationMs > 0) {
(state.recordingDuration.toFloat() / maxRecordingDurationMs).coerceIn(0f, 1f)
} else {
0f
},
mediaSelectionCount = mediaSelectionCount,
emitter = emitter,
stringResources = stringResources,
modifier = modifier.align(if (isLandscape) Alignment.CenterEnd else Alignment.BottomCenter)
)
}
@Composable
private fun ShutterOverlay(showFlash: Boolean) {
AnimatedVisibility(
visible = showFlash,
enter = fadeIn(animationSpec = tween(50)),
exit = fadeOut(animationSpec = tween(200))
) {
Box(
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(16.dp))
.background(Color.Black)
)
}
}
/**
* Camera control buttons layout with center element always truly centered
* and side elements at fixed distances from edges.
*/
@Composable
private fun CameraControls(
isLandscape: Boolean,
isRecording: Boolean,
recordingProgress: Float,
mediaSelectionCount: Int,
emitter: (StandardCameraHudEvents) -> Unit,
stringResources: StringResources,
modifier: Modifier = Modifier
) {
val galleryOrMediaCount: @Composable () -> Unit = {
if (mediaSelectionCount > 0) {
MediaCountIndicator(
count = mediaSelectionCount,
onClick = { emitter(StandardCameraHudEvents.MediaSelectionClick) },
stringResources = stringResources
)
} else {
GalleryThumbnailButton(onClick = { emitter(StandardCameraHudEvents.GalleryClick) })
}
}
val captureButton: @Composable () -> Unit = {
CaptureButton(
isRecording = isRecording,
recordingProgress = recordingProgress,
onTap = { emitter(StandardCameraHudEvents.PhotoCaptureTriggered) },
onLongPressStart = { emitter(StandardCameraHudEvents.VideoCaptureStarted) },
onLongPressEnd = { emitter(StandardCameraHudEvents.VideoCaptureStopped) },
onZoomChange = { emitter(StandardCameraHudEvents.SetZoomLevel(it)) }
)
}
val cameraSwitchButton: @Composable () -> Unit = {
CameraSwitchButton(
onClick = { emitter(StandardCameraHudEvents.SwitchCamera) },
stringResources = stringResources
)
}
if (isLandscape) {
Box(
modifier = modifier
.fillMaxHeight()
.padding(end = 16.dp, top = 40.dp, bottom = 40.dp)
) {
Box(modifier = Modifier.align(Alignment.TopCenter)) {
galleryOrMediaCount()
}
Box(modifier = Modifier.align(Alignment.Center)) {
captureButton()
}
Box(modifier = Modifier.align(Alignment.BottomCenter)) {
cameraSwitchButton()
}
}
} else {
Box(
modifier = modifier
.fillMaxWidth()
.padding(bottom = 16.dp, start = 40.dp, end = 40.dp)
) {
Box(modifier = Modifier.align(Alignment.CenterStart)) {
cameraSwitchButton()
}
Box(modifier = Modifier.align(Alignment.Center)) {
captureButton()
}
Box(modifier = Modifier.align(Alignment.CenterEnd)) {
galleryOrMediaCount()
}
}
}
}
@Composable
private fun RecordingDurationDisplay(
durationMillis: Long,
modifier: Modifier = Modifier
) {
val seconds = (durationMillis / 1000) % 60
val minutes = (durationMillis / 1000) / 60
val timeText = String.format(Locale.US, "%02d:%02d", minutes, seconds)
Box(
modifier = modifier
.background(Color.Black.copy(alpha = 0.5f), shape = CircleShape)
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Text(
text = timeText,
color = Color.White,
fontSize = 20.sp,
fontWeight = FontWeight.Bold
)
}
}
@Composable
private fun CameraSwitchButton(
onClick: () -> Unit,
stringResources: StringResources,
modifier: Modifier = Modifier
) {
val contentDescription = if (stringResources.switchCamera != 0) {
LocalContext.current.getString(stringResources.switchCamera)
} else {
null
}
IconButton(
onClick = onClick,
modifier = modifier
.size(52.dp)
.border(2.dp, Color.White, CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.38f), shape = CircleShape)
) {
Icon(
painter = SignalIcons.CameraSwitch.painter,
contentDescription = contentDescription,
tint = Color.White,
modifier = Modifier.size(28.dp)
)
}
}
@Composable
private fun FlashToggleButton(
flashMode: FlashMode,
onToggle: () -> Unit,
stringResources: StringResources,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val icon = when (flashMode) {
FlashMode.Off -> SignalIcons.FlashOff
FlashMode.On -> SignalIcons.FlashOn
FlashMode.Auto -> SignalIcons.FlashAuto
}
val contentDescriptionRes = when (flashMode) {
FlashMode.Off -> stringResources.flashOff
FlashMode.On -> stringResources.flashOn
FlashMode.Auto -> stringResources.flashAuto
}
val contentDescription = if (contentDescriptionRes != 0) {
context.getString(contentDescriptionRes)
} else {
null
}
IconButton(
onClick = onToggle,
modifier = modifier
.background(Color.Black.copy(alpha = 0.5f), shape = CircleShape)
) {
Icon(
painter = icon.painter,
contentDescription = contentDescription,
tint = Color.White,
modifier = Modifier
.padding(6.dp)
.size(24.dp)
)
}
}
/** Signal ultramarine blue color for the count badge */
private val UltramarineBlue = Color(0xFF2C6BED)
/**
* Media count indicator that shows the number of selected media items.
* Displays a pill-shaped button with the count in a blue badge and a chevron icon.
*/
@Composable
private fun MediaCountIndicator(
count: Int,
onClick: () -> Unit,
stringResources: StringResources,
modifier: Modifier = Modifier
) {
val contentDescription = if (stringResources.send != 0) {
LocalContext.current.getString(stringResources.send)
} else {
null
}
Row(
modifier = modifier
.height(44.dp)
.background(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(32.dp)
)
.clip(RoundedCornerShape(32.dp))
.clickable(onClick = onClick)
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
val size = with (LocalDensity.current) {
22.sp.toDp()
}
Box(
modifier = Modifier
.background(
color = UltramarineBlue,
shape = CircleShape
)
.size(size),
contentAlignment = Alignment.Center
) {
Text(
text = if (count > 99) "99+" else count.toString(),
color = Color.White,
fontSize = 13.sp,
fontWeight = FontWeight.Medium
)
}
Icon(
painter = SignalIcons.ChevronRight.painter,
contentDescription = contentDescription,
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.padding(start = 3.dp)
.size(24.dp)
)
}
}
@Preview(name = "Default", showBackground = true, backgroundColor = 0xFF444444, widthDp = 360, heightDp = 640)
@Composable
private fun StandardCameraHudPreview() {
Box(modifier = Modifier.fillMaxSize()) {
StandardCameraHudContent(
state = CameraScreenState(),
emitter = {}
)
}
}
@Preview(name = "Recording", showBackground = true, backgroundColor = 0xFF444444, widthDp = 360, heightDp = 640)
@Composable
private fun StandardCameraHudRecordingPreview() {
Box(modifier = Modifier.fillMaxSize()) {
StandardCameraHudContent(
state = CameraScreenState(
isRecording = true,
recordingDuration = 18_000L,
flashMode = FlashMode.On
),
maxRecordingDurationMs = 30_000L,
emitter = {}
)
}
}
@Preview(name = "With Media Selected", showBackground = true, backgroundColor = 0xFF444444, widthDp = 360, heightDp = 640)
@Composable
private fun StandardCameraHudWithMediaPreview() {
Box(modifier = Modifier.fillMaxSize()) {
StandardCameraHudContent(
state = CameraScreenState(),
mediaSelectionCount = 1,
emitter = {}
)
}
}
@Preview(
name = "Landscape",
showBackground = true,
backgroundColor = 0xFF444444,
widthDp = 640,
heightDp = 360,
device = "spec:width=640dp,height=360dp,orientation=landscape"
)
@Composable
private fun StandardCameraHudLandscapePreview() {
Box(modifier = Modifier.fillMaxSize()) {
StandardCameraHudContent(
state = CameraScreenState(),
emitter = {}
)
}
}

View File

@@ -0,0 +1,40 @@
package org.signal.camera.hud
import androidx.annotation.FloatRange
/**
* Events emitted by camera HUD components like [StandardCameraHud].
* The parent composable handles these events to respond to user actions.
*/
sealed interface StandardCameraHudEvents {
data object PhotoCaptureTriggered : StandardCameraHudEvents
data object VideoCaptureStarted : StandardCameraHudEvents
data object VideoCaptureStopped : StandardCameraHudEvents
data object SwitchCamera : StandardCameraHudEvents
data class SetZoomLevel(@param:FloatRange(from = 0.0, to = 1.0) val zoomLevel: Float) : StandardCameraHudEvents
/**
* Emitted when the gallery button is clicked.
*/
data object GalleryClick : StandardCameraHudEvents
/**
* Emitted when the media selection indicator is clicked to advance to the next screen.
*/
data object MediaSelectionClick : StandardCameraHudEvents
/**
* Emitted when the flash toggle button is clicked.
*/
data object ToggleFlash : StandardCameraHudEvents
/**
* Emitted when a capture error should be cleared (after displaying to user).
*/
data object ClearCaptureError : StandardCameraHudEvents
}

View File

@@ -17,7 +17,7 @@ android-gradle-plugin = "8.13.2"
# Other versions
androidx-appcompat = "1.7.0"
androidx-activity = "1.12.0"
androidx-camera = "1.3.4"
androidx-camera = "1.5.2"
androidx-fragment = "1.8.5"
androidx-lifecycle = "2.8.7"
androidx-lifecycle-navigation3 = "2.10.0"
@@ -58,6 +58,7 @@ androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
androidx-compose-material3-adaptive = { module = "androidx.compose.material3.adaptive:adaptive"}
androidx-compose-material3-adaptive-layout = { module = "androidx.compose.material3.adaptive:adaptive-layout"}
androidx-compose-material3-adaptive-navigation = { module = "androidx.compose.material3.adaptive:adaptive-navigation"}
androidx-compose-material-icons-extended = "androidx.compose.material:material-icons-extended:1.7.8"
androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
androidx-compose-ui-tooling-core = { module = "androidx.compose.ui:ui-tooling" }
androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
@@ -126,6 +127,8 @@ androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.r
androidx-camera-extensions = { module = "androidx.camera:camera-extensions", version.ref = "androidx-camera" }
androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "androidx-camera" }
androidx-camera-view = { module = "androidx.camera:camera-view", version.ref = "androidx-camera" }
androidx-camera-video = { module = "androidx.camera:camera-video", version.ref = "androidx-camera" }
androidx-camera-compose = { module = "androidx.camera:camera-compose", version.ref = "androidx-camera" }
androidx-concurrent-futures = "androidx.concurrent:concurrent-futures:1.2.0"
androidx-autofill = "androidx.autofill:autofill:1.1.0"
androidx-biometric = "androidx.biometric:biometric:1.1.0"

View File

@@ -644,6 +644,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="f8d5e1ced08b9fa732a4331d87c1f8a998811944fd8aa356ba0a7d968ce52abd" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.camera" name="camera-camera2" version="1.5.2">
<artifact name="camera-camera2-1.5.2.aar">
<sha256 value="855d4aebfa1ad9bd6156ce0182ff74c5cd62ed1f76352e6a4aab2b794d85244b" origin="Generated by Gradle"/>
</artifact>
<artifact name="camera-camera2-1.5.2.module">
<sha256 value="e5ddc8080d07f60e9cc690a0819069e0d008825d4b791d6f5d62f42418745308" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.camera" name="camera-compose" version="1.5.2">
<artifact name="camera-compose-1.5.2.aar">
<sha256 value="49e5be1069454dc5e63c55ce810072dbed4aa6e90ea575a0497815096bdeefc0" origin="Generated by Gradle"/>
</artifact>
<artifact name="camera-compose-1.5.2.module">
<sha256 value="bae40fe875d08d355e0c37e952d5b3d348c69b463eaf3cd7a1a07331dd7a02d8" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.camera" name="camera-core" version="1.3.4">
<artifact name="camera-core-1.3.4.aar">
<md5 value="dcc5f65c776a434a4617c5ebf1b96e6f" origin="Generated by Gradle"/>
@@ -656,6 +672,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="f74e6f591fca0a6391943de43c3fc24627987e46fcdf8c1999e20c933823790b" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.camera" name="camera-core" version="1.5.2">
<artifact name="camera-core-1.5.2.aar">
<sha256 value="803d1a61481149431740ad1ff354c36066dff2a05500f0b179c61d77ad88ae16" origin="Generated by Gradle"/>
</artifact>
<artifact name="camera-core-1.5.2.module">
<sha256 value="cf774b5545cede39d726c776028c3fba3d7aa9ed3995eedc5ab6880195d1e20b" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.camera" name="camera-extensions" version="1.3.4">
<artifact name="camera-extensions-1.3.4.aar">
<md5 value="e2bd23e46a0e23a397961ccce7701d83" origin="Generated by Gradle"/>
@@ -668,6 +692,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="154d964f4608f84759b8d416d35cabb7fcaf3a86be61a7471cab782c04ba9362" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.camera" name="camera-extensions" version="1.5.2">
<artifact name="camera-extensions-1.5.2.aar">
<sha256 value="ca8e1c3aa34869385d7261ebb42ef1ab2bf1e4d8addefc2b073f5c2357a5289d" origin="Generated by Gradle"/>
</artifact>
<artifact name="camera-extensions-1.5.2.module">
<sha256 value="50462867dfe073d4b28fa068ffeddf2059134d959007993ca74e10efbdb9c7d6" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.camera" name="camera-lifecycle" version="1.3.4">
<artifact name="camera-lifecycle-1.3.4.aar">
<md5 value="586e0294e4b1b83f336116e77336b690" origin="Generated by Gradle"/>
@@ -680,6 +712,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="1d418947ed0b9f410f3f4648fc7ae76612b4b0e338a00628fa145e5387e1e0ec" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.camera" name="camera-lifecycle" version="1.5.2">
<artifact name="camera-lifecycle-1.5.2.aar">
<sha256 value="463d2d0c75a4db796b6edd6a4d39d289c16550b7dc6afa78f83cfae948a23c51" origin="Generated by Gradle"/>
</artifact>
<artifact name="camera-lifecycle-1.5.2.module">
<sha256 value="b529c137b3c56ed76e4d54d86cfbf322cc0b55d223e7f0828f30226395da083a" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.camera" name="camera-video" version="1.3.4">
<artifact name="camera-video-1.3.4.aar">
<md5 value="f446a8691018f3c3509e263c66d2168e" origin="Generated by Gradle"/>
@@ -692,6 +732,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="fa8826a211864ef98bb39e5705b22618a4a4c3d316c0dfa7ad73788b8c1d487b" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.camera" name="camera-video" version="1.5.2">
<artifact name="camera-video-1.5.2.aar">
<sha256 value="7ca93388eff071dcb61d1b8b3d6afca6a1d282ffd2f8b7ce7dfde832889adcaa" origin="Generated by Gradle"/>
</artifact>
<artifact name="camera-video-1.5.2.module">
<sha256 value="a5417e5da258053729d2945ca49eb5ce72cda6e60d5af060d856b48ef5b9c62c" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.camera" name="camera-view" version="1.3.4">
<artifact name="camera-view-1.3.4.aar">
<md5 value="bc61b844861874626dce2dcee7b206c5" origin="Generated by Gradle"/>
@@ -704,6 +752,38 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="f291f5459da6f75de2b54c365d49db70cbaf23a242a78eaf40e414b8f3609362" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.camera" name="camera-view" version="1.5.2">
<artifact name="camera-view-1.5.2.aar">
<sha256 value="4505a6deac7dc6b0e9b60e94487bc1ad104cede6d1c9fbce89dd9c19c566484b" origin="Generated by Gradle"/>
</artifact>
<artifact name="camera-view-1.5.2.module">
<sha256 value="fbfa7508d1aa4e9aaf62ebe8f54f76d8330754997e8bf20c3028b1b0c5b2f837" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.camera.featurecombinationquery" name="featurecombinationquery" version="1.5.2">
<artifact name="featurecombinationquery-1.5.2.aar">
<sha256 value="67e7030f6840bcdd17ec0c50c0acc29bddcab5c206543bed7f5c76baa76330c6" origin="Generated by Gradle"/>
</artifact>
<artifact name="featurecombinationquery-1.5.2.module">
<sha256 value="50ec3769ccee233ef8224f38dcfd84357e7e81d6ee9a55780c757374e0cf1839" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.camera.viewfinder" name="viewfinder-compose" version="1.5.2">
<artifact name="viewfinder-compose-1.5.2.aar">
<sha256 value="6152d31e75a427968b9ed3ad7648be16feebe502b45e05712455d7e7dcffaa29" origin="Generated by Gradle"/>
</artifact>
<artifact name="viewfinder-compose-1.5.2.module">
<sha256 value="0ad93f0b0f2bdacd4f2cf842756e6135b86aee17b4c262e0f6aa0c62cab2f192" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.camera.viewfinder" name="viewfinder-core" version="1.5.2">
<artifact name="viewfinder-core-1.5.2.aar">
<sha256 value="df2ebe3b2c45489eef002b2af72dfc2563850ccb3abc3a4759dc6ac5e8e7995a" origin="Generated by Gradle"/>
</artifact>
<artifact name="viewfinder-core-1.5.2.module">
<sha256 value="ed322d889069b275e09ac5d07e9fdc91d3debb31aa061b04fc5ac90a9b373575" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.cardview" name="cardview" version="1.0.0">
<artifact name="cardview-1.0.0.aar">
<md5 value="1d3ef7a9d0706960569ad8815a750b67" origin="Generated by Gradle"/>
@@ -1363,6 +1443,54 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="d0dfae931cb227d075da520f5012b84926e22ef9547c20b6138b7e383d7acc34" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.material" name="material-icons-core" version="1.7.8">
<artifact name="material-icons-core-1.7.8.module">
<sha256 value="f9d63655bac19ff7f27abf68a9c0f38f5e42c85e365655b990e6e1a317f92e2f" origin="Generated by Gradle"/>
</artifact>
<artifact name="material-icons-core-metadata-1.7.8.jar">
<sha256 value="951f2a3a6c0913819dfaae7c69cb8cdf977f7c79bd53fef03e4faf459ee30a0f" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.material" name="material-icons-core-android" version="1.7.8">
<artifact name="material-icons-core-android-1.7.8.module">
<sha256 value="99a1ca83e54261a65eb96d44ea02fae43588be45ade5e97963d73e8489ea4a54" origin="Generated by Gradle"/>
</artifact>
<artifact name="material-icons-core-release.aar">
<sha256 value="332c06b25e662cc417fb087e76b8faa5cb249f4992ffa3360084a3d4ab882284" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.material" name="material-icons-core-desktop" version="1.7.8">
<artifact name="material-icons-core-desktop-1.7.8.jar">
<sha256 value="b5729220e242132b22b0c0317a304ff167a05cc685c3e9e6483d5dfca3495f56" origin="Generated by Gradle"/>
</artifact>
<artifact name="material-icons-core-desktop-1.7.8.module">
<sha256 value="6593704fdf2912efa250d32c44e5fdabe484c3e052e0f5387e09991dcd32e1ee" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.material" name="material-icons-extended" version="1.7.8">
<artifact name="material-icons-extended-1.7.8.module">
<sha256 value="db90152cc18a7f2c3d0931f2032d2c3016f35f82471bf4c9f5620702a0cded95" origin="Generated by Gradle"/>
</artifact>
<artifact name="material-icons-extended-metadata-1.7.8.jar">
<sha256 value="714e2bfc4095b291e0dbcbd7626bc0e420fc2f6a78f2416398596504debd9117" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.material" name="material-icons-extended-android" version="1.7.8">
<artifact name="material-icons-extended-android-1.7.8.module">
<sha256 value="d4d502935e175255fd7730b1c2dce4261799c3a70ae427e1a6a845079fe297f1" origin="Generated by Gradle"/>
</artifact>
<artifact name="material-icons-extended-release.aar">
<sha256 value="64e86269f1106848981dd76f0046f81b46f3bd92efb22645de8fd044c0402b61" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.material" name="material-icons-extended-desktop" version="1.7.8">
<artifact name="material-icons-extended-desktop-1.7.8.jar">
<sha256 value="0ade0b7d55cb008136d7b58b71100ce017dedb84be20af6da2e76b58b090f699" origin="Generated by Gradle"/>
</artifact>
<artifact name="material-icons-extended-desktop-1.7.8.module">
<sha256 value="d9bad8628c4b705f232676aa67aee1c4d0782621482e75330d1fcde6e3893618" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.material" name="material-ripple" version="1.9.0">
<artifact name="material-ripple-1.9.0.module">
<sha256 value="a0a809cfcb2ad667875a0fb1a7d5a247a2c798be219a613d6d380f9109917d6b" origin="Generated by Gradle"/>
@@ -2224,6 +2352,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="13024b3997eab97818f5d3601bf75fb263e757cac5778440c685873902b7c232" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.ui" name="ui" version="1.7.8">
<artifact name="ui-1.7.8.module">
<sha256 value="afb4a970a9d61a96b7dae6c7c86c6586a54aa044045cb0050cdbe16b05ebf7df" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.compose.ui" name="ui" version="1.9.0">
<artifact name="ui-1.9.0.module">
<sha256 value="8360e8f459820174829ffe5857aa45dad429786a60128f83c2c407f5e5820ce0" origin="Generated by Gradle"/>
@@ -3520,6 +3653,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="afd7094d3a07422be2e991bc87048542ae9724a5c42bd54b5cae3029c7970a1d" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.concurrent" name="concurrent-futures-ktx" version="1.1.0">
<artifact name="concurrent-futures-ktx-1.1.0.jar">
<sha256 value="1968bf52039e38636aa6f114cd17d7256919d1e8997417716fef9d1da1f24d85" origin="Generated by Gradle"/>
</artifact>
<artifact name="concurrent-futures-ktx-1.1.0.module">
<sha256 value="69b79724566d49140846700690b8d2165231c577e93e66726a443e8f976bbe19" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.concurrent" name="concurrent-futures-ktx" version="1.2.0">
<artifact name="concurrent-futures-ktx-1.2.0.jar">
<sha256 value="e1f3e17bb4358ccd6c77ca45f70635c9aba237261f19eaa4f64a0218c00e2a3e" origin="Generated by Gradle"/>
</artifact>
<artifact name="concurrent-futures-ktx-1.2.0.module">
<sha256 value="823f469acd984adfd30b3d0a577eb4f7796a03742a526ef70c1583b594b43b80" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.constraintlayout" name="constraintlayout" version="2.2.0">
<artifact name="constraintlayout-2.2.0.aar">
<md5 value="412320999a34ddd61efbfc3dd0c6e2a7" origin="Generated by Gradle"/>
@@ -4474,6 +4623,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="1d83bd5f3ccc1298eda25b9ed128e8c187f830e7f3af8d0294be688abb03c35d" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.lifecycle" name="lifecycle-livedata-core" version="2.3.1">
<artifact name="lifecycle-livedata-core-2.3.1.module">
<sha256 value="b1e095d550d39a7d6c815761d6dbe3fd64eb31a39dc28146e78bbf2c01ba8f41" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="androidx.lifecycle" name="lifecycle-livedata-core" version="2.6.1">
<artifact name="lifecycle-livedata-core-2.6.1.aar">
<md5 value="1e5c954ed476e2d2edabf3120aa5b156" origin="Generated by Gradle"/>
@@ -11432,6 +11586,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="e518a59856273a1fce18ac7d13ddfc690defa6122d7e5cd00cae19b62d0347d9" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.squareup.okio" name="okio" version="3.9.1">
<artifact name="okio-3.9.1.module">
<sha256 value="9b90b4274a5ad602dd574d6d4b48903663b2de9a60b9fc3402248293d843e121" origin="Generated by Gradle"/>
</artifact>
<artifact name="okio-metadata-3.9.1.jar">
<sha256 value="99c8ebbc4995f29dd58b05d75583fcc8b957db21eeeb8c5d89733f0d34955897" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.squareup.okio" name="okio-bom" version="3.0.0">
<artifact name="okio-bom-3.0.0.module">
<md5 value="dc582829b1b4f65e0fd9c8cb02f6adda" origin="Generated by Gradle"/>
@@ -11479,6 +11641,14 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="cf97284ec61bb51e6dfcbc7f2b6d9556c9b7b3e3cb22cc0e2d4f0eefbf36195b" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.squareup.okio" name="okio-jvm" version="3.9.1">
<artifact name="okio-jvm-3.9.1.jar">
<sha256 value="fe6fe91378f9bfa7b08c3864828ce418005cf28acca12e8847eb65c565c37500" origin="Generated by Gradle"/>
</artifact>
<artifact name="okio-jvm-3.9.1.module">
<sha256 value="b0afa9192c42d7c463de38d6325936c6901d9e3b7149735ffd18a11df31034ab" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="com.squareup.okio" name="okio-multiplatform" version="2.8.0">
<artifact name="okio-multiplatform-2.8.0.module">
<md5 value="3114d775341474ed8a480ba8482b88e2" origin="Generated by Gradle"/>
@@ -13111,6 +13281,318 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="7b0f19724082cbfcbc66e5abea2b9bc92cf08a1ea11e191933ed43801eb3cd05" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.androidx.lifecycle" name="lifecycle-common" version="2.8.0">
<artifact name="lifecycle-common-2.8.0.jar">
<sha256 value="b6f04afae504913dfa75e3cf66914b7547ec1b7f2470ab1f3cfbaabb77fd4f3a" origin="Generated by Gradle"/>
</artifact>
<artifact name="lifecycle-common-2.8.0.module">
<sha256 value="202c42241dc3b482dbbdbe02001c8a5e901b881322e1cca4a8a1786bd134d478" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.androidx.lifecycle" name="lifecycle-common" version="2.9.5">
<artifact name="lifecycle-common-2.9.5.jar">
<sha256 value="b21a149fe28cccc7ece37b55098d10027aba3d2e0db31d12d3cdc907ded2368a" origin="Generated by Gradle"/>
</artifact>
<artifact name="lifecycle-common-2.9.5.module">
<sha256 value="1525fbe1eec1ecab1ac8973c99371694df03a3a8147836f998b96fd0588a93ff" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.androidx.lifecycle" name="lifecycle-runtime" version="2.8.0">
<artifact name="lifecycle-runtime-2.8.0.jar">
<sha256 value="0503661bf9b83ad7b4d8e56e7919a0d28f6bb6e166d6c24a1299e7d290792d99" origin="Generated by Gradle"/>
</artifact>
<artifact name="lifecycle-runtime-2.8.0.module">
<sha256 value="88b767af86569678b7eb1dc7e36769602590bad9d31ba8b184848cb6cc50e70f" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.androidx.lifecycle" name="lifecycle-runtime" version="2.9.5">
<artifact name="lifecycle-runtime-2.9.5.jar">
<sha256 value="9dd583b709136e0aafe60e1b90249bf63bc80cd7ba6e0641ce74a1ab3613c64f" origin="Generated by Gradle"/>
</artifact>
<artifact name="lifecycle-runtime-2.9.5.module">
<sha256 value="d4db6e5057e6afa0156e49b5f15dcd248bb488afcdf772bc7f804c12348381be" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.androidx.lifecycle" name="lifecycle-runtime-compose" version="2.8.0">
<artifact name="lifecycle-runtime-compose-2.8.0.jar">
<sha256 value="1d92334096a50aaa1bbfbbe27d9afee76a331c1cd76e7ed5c6bc34fa6f3dfcb2" origin="Generated by Gradle"/>
</artifact>
<artifact name="lifecycle-runtime-compose-2.8.0.module">
<sha256 value="11de85d74af9cefaea9b6290cbf6d2c05486c2037c0f9c2f88570c738aa20eef" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.androidx.lifecycle" name="lifecycle-runtime-compose" version="2.9.5">
<artifact name="lifecycle-runtime-compose-2.9.5.jar">
<sha256 value="64f4a647f40b64cd728612712a86f88e13dd9d663bc68afaacc226b2b904ff24" origin="Generated by Gradle"/>
</artifact>
<artifact name="lifecycle-runtime-compose-2.9.5.module">
<sha256 value="c122317b9012d3296fa32710f8d3471ba1c597d9f659ede443890b35f19b85d9" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.androidx.lifecycle" name="lifecycle-runtime-compose-desktop" version="2.8.0">
<artifact name="lifecycle-runtime-compose-desktop-2.8.0.jar">
<sha256 value="a38464368e327e792acceb5ebcd26defdae9d62438ea9f5b24bb16cab29a3eca" origin="Generated by Gradle"/>
</artifact>
<artifact name="lifecycle-runtime-compose-desktop-2.8.0.module">
<sha256 value="7b5323476d2ef4a1f53ac7fa07b7c79ba0331883e849b68c5eb11ee48b080ef6" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.androidx.lifecycle" name="lifecycle-runtime-compose-desktop" version="2.9.5">
<artifact name="lifecycle-runtime-compose-desktop-2.9.5.jar">
<sha256 value="31c3c4b6dea38f078c46829dee2c43abba2bf8757b7ee8a0f660f31217364898" origin="Generated by Gradle"/>
</artifact>
<artifact name="lifecycle-runtime-compose-desktop-2.9.5.module">
<sha256 value="445b633f4d0fe0423001788a98f84232126758643e4ad24350b4b7449bf42caa" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.androidx.lifecycle" name="lifecycle-viewmodel" version="2.8.0">
<artifact name="lifecycle-viewmodel-2.8.0.jar">
<sha256 value="fb0e8dbfcddce8c7658d547a32dcdfaf6da10c878d02a69333918a8285e0398d" origin="Generated by Gradle"/>
</artifact>
<artifact name="lifecycle-viewmodel-2.8.0.module">
<sha256 value="d7be94e676336231006f09e7a34922aba93e8e2b23f3882bef7eb449a1e79f68" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.androidx.lifecycle" name="lifecycle-viewmodel" version="2.9.5">
<artifact name="lifecycle-viewmodel-2.9.5.jar">
<sha256 value="9ecd2bc61365fb4fa3f6f284aba92148dbfd3d1aca38662ad54bcecac2ab5546" origin="Generated by Gradle"/>
</artifact>
<artifact name="lifecycle-viewmodel-2.9.5.module">
<sha256 value="2e4e850091eaa36e3f91064c6d530fc4157412013f7e5621289d83d6a06cc5dc" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.compose.animation" name="animation" version="1.6.11">
<artifact name="animation-1.6.11.jar">
<sha256 value="131a9e7ad73bf8d30a003df72a70ea8647a25e56b4f2436c2499d315048dec50" origin="Generated by Gradle"/>
</artifact>
<artifact name="animation-1.6.11.module">
<sha256 value="10c6b23611b1bf10e10d52f59372022ac84235d6590c9c625a8f5073bb2bc4af" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.compose.animation" name="animation-core" version="1.6.11">
<artifact name="animation-core-1.6.11.jar">
<sha256 value="08b5ee1368b16bc706cd96762bc6e4d638ca950add17f869339394a39331681e" origin="Generated by Gradle"/>
</artifact>
<artifact name="animation-core-1.6.11.module">
<sha256 value="dc888721d0f1f11ddb972575d523205161e5d3fd355fc763bd948f451057fc97" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.compose.animation" name="animation-core-desktop" version="1.6.11">
<artifact name="animation-core-desktop-1.6.11.jar">
<sha256 value="6487c4c965c887e9f7d3d658b3c291dd3d3c0bb82f1805fce2b4ef9d7089cd66" origin="Generated by Gradle"/>
</artifact>
<artifact name="animation-core-desktop-1.6.11.module">
<sha256 value="8e1a9e007b58dd803bbf4e0711cfa03d08e1d146b1432cea9ea792b9ec28cc67" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.compose.animation" name="animation-desktop" version="1.6.11">
<artifact name="animation-desktop-1.6.11.jar">
<sha256 value="149a98d2702fdf2bbb4e2039cd5f5769284ee86b67ee346087a49ade5048f9f7" origin="Generated by Gradle"/>
</artifact>
<artifact name="animation-desktop-1.6.11.module">
<sha256 value="d8caddde3d56795447c9bb4edb96db830801f6ca5abf257bcf87f2083a11cf29" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.compose.annotation-internal" name="annotation" version="1.6.11">
<artifact name="annotation-1.6.11.jar">
<sha256 value="5ab3c5d9690591cc368b3fd41c906c73f6631b921477c66913a759abc8174082" origin="Generated by Gradle"/>
</artifact>
<artifact name="annotation-1.6.11.module">
<sha256 value="f6dc495d925509edc6c0c55c751da86b2b4d0cbd95633eda2317b85c1b0e379e" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.compose.collection-internal" name="collection" version="1.6.11">
<artifact name="collection-1.6.11.jar">
<sha256 value="94021efb94a4aa283b038bf1675c834540f0e38e133c50584fe8a6a38e7b1366" origin="Generated by Gradle"/>
</artifact>
<artifact name="collection-1.6.11.module">
<sha256 value="93b27f90a568d84c788ac2e62f5e0fef035c669304ea3e55a33702b8ffd983a7" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.compose.foundation" name="foundation" version="1.6.11">
<artifact name="foundation-1.6.11.jar">
<sha256 value="27530c4cee9f29f59845307a4ca58260a791bdb4bd4e62dbd92d616c4606e234" origin="Generated by Gradle"/>
</artifact>
<artifact name="foundation-1.6.11.module">
<sha256 value="afc86382a9751d392f0b84a27f0d7ff582363bc3d4ee66624bc4658b35ea6efa" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.compose.foundation" name="foundation-desktop" version="1.6.11">
<artifact name="foundation-desktop-1.6.11.jar">
<sha256 value="b255c7c56ae0a401d03783e5ccdd3087de9ed56632159c2bd658d4314cfa2431" origin="Generated by Gradle"/>
</artifact>
<artifact name="foundation-desktop-1.6.11.module">
<sha256 value="7198e808839395293f7034c190523d03608c6e7ea947776685c9d28e9ce98bba" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.compose.foundation" name="foundation-layout" version="1.6.11">
<artifact name="foundation-layout-1.6.11.jar">
<sha256 value="c2b87b45fec9f85e394d35ccde08dec262306ce0c2d230461cb71d86e33edffe" origin="Generated by Gradle"/>
</artifact>
<artifact name="foundation-layout-1.6.11.module">
<sha256 value="f2b2014bfc234a70a0887b5a5fce461fdc71a5881769f227a988b8ff9031146b" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.compose.foundation" name="foundation-layout-desktop" version="1.6.11">
<artifact name="foundation-layout-desktop-1.6.11.jar">
<sha256 value="3180587e0f170b01b02ac0fb61b98e53772103def930f1ca6fb273cb93400465" origin="Generated by Gradle"/>
</artifact>
<artifact name="foundation-layout-desktop-1.6.11.module">
<sha256 value="05f7d63de5ae6f76ffd6cf55dbb60dd74864512a330dc688f4c342e0d3c800f5" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.compose.runtime" name="runtime" version="1.6.11">
<artifact name="runtime-1.6.11.jar">
<sha256 value="484e41582d425a86a57ead9ebe4a2573ae9d67a83b168b70ab0b190b067a4544" origin="Generated by Gradle"/>
</artifact>
<artifact name="runtime-1.6.11.module">
<sha256 value="865780c0ac4b4945709e64840d1b8d8ab707815c4b0c0f97795e7118ddca19eb" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.compose.runtime" name="runtime" version="1.9.1">
<artifact name="runtime-1.9.1.jar">
<sha256 value="b5d00a3d220d2cf62f8ac6f7a046597e7995963604f0196fe777dd2ff226156a" origin="Generated by Gradle"/>
</artifact>
<artifact name="runtime-1.9.1.module">
<sha256 value="682e531dca1f72a85e4ac32d339c57d0afbc00bcb7207c280551bc68a3c64c0b" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.compose.runtime" name="runtime-desktop" version="1.6.11">
<artifact name="runtime-desktop-1.6.11.jar">
<sha256 value="66c97d0d48ac8852ed2780de5a747ea94a26c29b37196e23e6225502a2a09c96" origin="Generated by Gradle"/>
</artifact>
<artifact name="runtime-desktop-1.6.11.module">
<sha256 value="dedad07d1fedcf51cd43822da42f7b8417e813c110350c943e113464e53e64fc" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.compose.runtime" name="runtime-desktop" version="1.9.1">
<artifact name="runtime-desktop-1.9.1.jar">
<sha256 value="05f5570c0a7ea8addd6fe9507a70f293efa7f1450d8b9b16dc3c8fee21cc6127" origin="Generated by Gradle"/>
</artifact>
<artifact name="runtime-desktop-1.9.1.module">
<sha256 value="cc24e02ed252be109790ab96677464ab54ef4d9700b672c0825117ef923e2d7a" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.compose.runtime" name="runtime-saveable" version="1.6.11">
<artifact name="runtime-saveable-1.6.11.jar">
<sha256 value="92cc01b07410bb1090432f458d0853b127f4c4e1f7036f48fb40bc3a98c07b32" origin="Generated by Gradle"/>
</artifact>
<artifact name="runtime-saveable-1.6.11.module">
<sha256 value="a413c46dac8920e574b7ef5ba169c4172a71690192049f4fb047ef94e6b3524a" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.compose.runtime" name="runtime-saveable-desktop" version="1.6.11">
<artifact name="runtime-saveable-desktop-1.6.11.jar">
<sha256 value="5e57a42a37457112eb560d1021e66e544da031559607e4408e3aa7f865f34c83" origin="Generated by Gradle"/>
</artifact>
<artifact name="runtime-saveable-desktop-1.6.11.module">
<sha256 value="a79e311ea6b27497d74928753469457ef94667e43a88e329996face6bfde5484" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.compose.ui" name="ui" version="1.6.11">
<artifact name="ui-1.6.11.jar">
<sha256 value="e5041f7ad5efd1aa69e15e9aaaded3c210fb103583b6abe6d5de245bbf13d044" origin="Generated by Gradle"/>
</artifact>
<artifact name="ui-1.6.11.module">
<sha256 value="67996493e0bf7ce1f0978494cfd84cf242845107e4b587311e93bbd9730b2452" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.compose.ui" name="ui-desktop" version="1.6.11">
<artifact name="ui-desktop-1.6.11.jar">
<sha256 value="01e5ba2526eb71b8eca4e91cebb7109e103823623b4ab11bb84faedf46d60c8c" origin="Generated by Gradle"/>
</artifact>
<artifact name="ui-desktop-1.6.11.module">
<sha256 value="dbb024910cd1b436986f2f9ff84c085db9c0348df5c2ab8835e0d708811fd56e" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.compose.ui" name="ui-geometry" version="1.6.11">
<artifact name="ui-geometry-1.6.11.jar">
<sha256 value="322a153fa7f1368707a8da192bb36093a08b29a414582c60e2bf670f8373a1c4" origin="Generated by Gradle"/>
</artifact>
<artifact name="ui-geometry-1.6.11.module">
<sha256 value="71aa8a46dceb85047c95c69c3545ab5ca2b0bbc25816dbe52179310c8a3ce8c4" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.compose.ui" name="ui-geometry-desktop" version="1.6.11">
<artifact name="ui-geometry-desktop-1.6.11.jar">
<sha256 value="b962f1fc129a05a8ac1513eeaf50367a19f142fd4938fa83de519d0c8c5a8f1f" origin="Generated by Gradle"/>
</artifact>
<artifact name="ui-geometry-desktop-1.6.11.module">
<sha256 value="50ac74a654ac4186166a13e0f4a828bbe9da1fcde82d420e0ff40f3403524316" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.compose.ui" name="ui-graphics" version="1.6.11">
<artifact name="ui-graphics-1.6.11.jar">
<sha256 value="a42f49ac9f9423fcf652e50a65e6a041e86537374d87aee0bc86a708527da418" origin="Generated by Gradle"/>
</artifact>
<artifact name="ui-graphics-1.6.11.module">
<sha256 value="37ac1a1b1009d9f362d3676a892b8704c63ecff78e2740b9bac400e6896882d4" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.compose.ui" name="ui-graphics-desktop" version="1.6.11">
<artifact name="ui-graphics-desktop-1.6.11.jar">
<sha256 value="5c816f1e3ff0c67d50941915fb8232347a94598ce4cf904230cbabec43950a2a" origin="Generated by Gradle"/>
</artifact>
<artifact name="ui-graphics-desktop-1.6.11.module">
<sha256 value="ced9fb7e419c736dbeace45e9af2c9c600e809b1a14b117a530ef3f7ee934d13" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.compose.ui" name="ui-text" version="1.6.11">
<artifact name="ui-text-1.6.11.jar">
<sha256 value="1e7b7237a53eff503c3867d865b990a1ca0229b466a18f33fb0d79b54d748147" origin="Generated by Gradle"/>
</artifact>
<artifact name="ui-text-1.6.11.module">
<sha256 value="26d6290d13cbcce9d5534e67c7930fcf2c68bfb6896f6a8cc949ae789d7dd086" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.compose.ui" name="ui-text-desktop" version="1.6.11">
<artifact name="ui-text-desktop-1.6.11.jar">
<sha256 value="c014073d865f784fb94c22fb6bb9d056a996af63c93a176e90118a58ff0facc6" origin="Generated by Gradle"/>
</artifact>
<artifact name="ui-text-desktop-1.6.11.module">
<sha256 value="85f90dfac5fca84eefbd36877ec391f511f74801c6391aa8f77f8c153f2cb410" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.compose.ui" name="ui-uikit" version="1.6.11">
<artifact name="ui-uikit-1.6.11.module">
<sha256 value="4350820ba2b3123d63ee401614726deafb83c929e357a6c0ddd59c0404b4ceef" origin="Generated by Gradle"/>
</artifact>
<artifact name="ui-uikit-metadata-1.6.11.jar">
<sha256 value="1a13b39adf3ad2767f967f764ba0a0d2ed2a2d09eef310f22d2c0304d7bda6cb" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.compose.ui" name="ui-unit" version="1.6.11">
<artifact name="ui-unit-1.6.11.jar">
<sha256 value="162fbeaff522254b8c9afe994a5f8300a994c62183f79c73dfdbbb5f5a1bdba2" origin="Generated by Gradle"/>
</artifact>
<artifact name="ui-unit-1.6.11.module">
<sha256 value="77bbda9ff59082f25b1f50385825c687e3c27d6f25a385074b3709c8924fdf09" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.compose.ui" name="ui-unit-desktop" version="1.6.11">
<artifact name="ui-unit-desktop-1.6.11.jar">
<sha256 value="9bf8c0ee2d60a0a74df6e4153f109a265bdc17d0040d27412b3817244505ca26" origin="Generated by Gradle"/>
</artifact>
<artifact name="ui-unit-desktop-1.6.11.module">
<sha256 value="abbaf339def70cd4c39f3c9aebd0296b2df2c4c1eadbf61c392fbb3244e65cdb" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.compose.ui" name="ui-util" version="1.6.11">
<artifact name="ui-util-1.6.11.jar">
<sha256 value="46deb5ec4459351af47a7faf3cc7a0e2484c114adfe7ae759fae322cb062b562" origin="Generated by Gradle"/>
</artifact>
<artifact name="ui-util-1.6.11.module">
<sha256 value="82ba9d787c557bbbc3019e3833155cf182bdc168f66337596b70a6d378e9a23a" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.compose.ui" name="ui-util-desktop" version="1.6.11">
<artifact name="ui-util-desktop-1.6.11.jar">
<sha256 value="05b16a745c7fbdb637f0df23bce033fc0dced122deaf794712750c9127a833ce" origin="Generated by Gradle"/>
</artifact>
<artifact name="ui-util-desktop-1.6.11.module">
<sha256 value="0fa70cf2475fa57acdb0e87992c5ee2e884c60f4fa6bf9c40f1275beba8440e8" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.intellij.deps" name="trove4j" version="1.0.20200330">
<artifact name="trove4j-1.0.20200330.jar">
<md5 value="bb75697e375d588a9d3f8f2653b30f77" origin="Generated by Gradle"/>
@@ -14696,6 +15178,11 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="7db8660ebe4b91bb478edb3616c4e3a50ba59c07dca517d1e1284c03fe86ac57" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlinx" name="atomicfu" version="0.23.2">
<artifact name="atomicfu-0.23.2.module">
<sha256 value="51530de284967a15e211c98563ca9d2356899b8cb331704560b0645adeac6dc0" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlinx" name="atomicfu" version="0.27.0">
<artifact name="atomicfu-0.27.0.module">
<md5 value="30b60dbe02064ecf4bf35a79412d7f86" origin="Generated by Gradle"/>
@@ -14720,6 +15207,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="35070f923ce69f87c6f90e5305720e2704409b69a2374492ac45be70075ee49a" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlinx" name="atomicfu-jvm" version="0.23.2">
<artifact name="atomicfu-jvm-0.23.2.jar">
<sha256 value="101ff43ff563fca167af5ade98c3b69bf569010eab745013b84d8955034d3b3c" origin="Generated by Gradle"/>
</artifact>
<artifact name="atomicfu-jvm-0.23.2.module">
<sha256 value="f5fac91c373a0098e5ccae6221e89bb71a1490cf6a8949ee348d46ee1ca8d3a6" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlinx" name="atomicfu-jvm" version="0.27.0">
<artifact name="atomicfu-jvm-0.27.0.jar">
<sha256 value="2b68464170070a8b085d8a7224c7c002dbd65ea14e1f8b97a9605115a252f7fb" origin="Generated by Gradle"/>
</artifact>
<artifact name="atomicfu-jvm-0.27.0.module">
<sha256 value="ce2e86b752713ffe67014bdd1e12ef290c702e89bfacb87ab27fbfdd7e13bac9" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.kotlinx" name="kotlinx-collections-immutable" version="0.3.5">
<artifact name="kotlinx-collections-immutable-0.3.5.module">
<md5 value="453d3d952da5f4c976459860da772bee" origin="Generated by Gradle"/>
@@ -15284,6 +15787,22 @@ https://docs.gradle.org/current/userguide/dependency_verification.html
<sha256 value="bbadf8c76bab3fcd88fce45b3bbb63dfb1487f38021e1bdd83bfb9310ef7e69a" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.skiko" name="skiko" version="0.8.4">
<artifact name="skiko-0.8.4.module">
<sha256 value="3732d99caa6b6ca5c8dc51089549b5aa9da868b02aaaed582592cec8125760fd" origin="Generated by Gradle"/>
</artifact>
<artifact name="skiko-metadata-0.8.4.jar">
<sha256 value="6649c2bc2ae9e4d67611c1d2394af4402eb6e6f18def80ae5df54c17467bc656" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jetbrains.skiko" name="skiko-awt" version="0.8.4">
<artifact name="skiko-awt-0.8.4.jar">
<sha256 value="a9da8815e049ed1f350bde4f418b188113aaf20076a8c8c5cff177089f78d174" origin="Generated by Gradle"/>
</artifact>
<artifact name="skiko-awt-0.8.4.module">
<sha256 value="87038aa8eccf2193f244da2a5a95436b711b32645288198baf0bccd9f9749132" origin="Generated by Gradle"/>
</artifact>
</component>
<component group="org.jlleitschuh.gradle" name="ktlint-gradle" version="12.1.1">
<artifact name="ktlint-gradle-12.1.1.jar">
<md5 value="240ad99bfbaa74dacfb13724695d0164" origin="Generated by Gradle"/>

View File

@@ -89,6 +89,7 @@ include(":lib:debuglogs-viewer")
// Feature modules
include(":feature:registration")
include(":feature:camera")
// Demo apps
include(":demo:paging")
@@ -101,6 +102,7 @@ include(":demo:video")
include(":demo:image-editor")
include(":demo:debuglogs-viewer")
include(":demo:registration")
include(":demo:camera")
// Testing/Lint modules
include(":lintchecks")