Rebuild CameraXFragment to use a brand new camera.
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)),
|
||||
|
||||
10
core/ui/src/main/res/drawable/symbol_flash_24.xml
Normal 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>
|
||||
10
core/ui/src/main/res/drawable/symbol_flash_auto_24.xml
Normal 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>
|
||||
9
core/ui/src/main/res/drawable/symbol_flash_slash_24.xml
Normal 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>
|
||||
9
core/ui/src/main/res/drawable/symbol_switch_24.xml
Normal 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
@@ -0,0 +1 @@
|
||||
/build
|
||||
102
demo/camera/build.gradle.kts
Normal 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
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
39
demo/camera/src/main/AndroidManifest.xml
Normal 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>
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
122
demo/camera/src/main/java/org/signal/camera/demo/NavGraph.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() {}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
*/
|
||||
)
|
||||
171
demo/camera/src/main/res/drawable/ic_launcher_background.xml
Normal 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>
|
||||
31
demo/camera/src/main/res/drawable/ic_launcher_foreground.xml
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
BIN
demo/camera/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
demo/camera/src/main/res/mipmap-hdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
demo/camera/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 982 B |
BIN
demo/camera/src/main/res/mipmap-mdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
demo/camera/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
demo/camera/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
demo/camera/src/main/res/mipmap-xxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
demo/camera/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
demo/camera/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
demo/camera/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
10
demo/camera/src/main/res/values/colors.xml
Normal 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>
|
||||
3
demo/camera/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">CameraXTest</string>
|
||||
</resources>
|
||||
5
demo/camera/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="Theme.CameraXTest" parent="android:Theme.Material.Light.NoActionBar" />
|
||||
</resources>
|
||||
13
demo/camera/src/main/res/xml/backup_rules.xml
Normal 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>
|
||||
19
demo/camera/src/main/res/xml/data_extraction_rules.xml
Normal 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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
57
feature/camera/build.gradle.kts
Normal 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)
|
||||
}
|
||||
0
feature/camera/consumer-rules.pro
Normal file
21
feature/camera/proguard-rules.pro
vendored
Normal 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
|
||||
17
feature/camera/src/main/AndroidManifest.xml
Normal 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>
|
||||
320
feature/camera/src/main/java/org/signal/camera/CameraScreen.kt
Normal 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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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")
|
||||
|
||||