diff --git a/camera/Package.swift b/camera/Package.swift
index ebdf4a670..4309df3e4 100644
--- a/camera/Package.swift
+++ b/camera/Package.swift
@@ -19,7 +19,10 @@ let package = Package(
.product(name: "Capacitor", package: "capacitor-swift-pm"),
.product(name: "Cordova", package: "capacitor-swift-pm")
],
- path: "ios/Sources/CameraPlugin"),
+ path: "ios/Sources/CameraPlugin",
+ resources: [
+ .process("Resources")
+ ]),
.testTarget(
name: "CameraPluginTests",
dependencies: ["CameraPlugin"],
diff --git a/camera/README.md b/camera/README.md
index 9ad234eee..f0709a22b 100644
--- a/camera/README.md
+++ b/camera/README.md
@@ -336,11 +336,12 @@ Request camera and photo album permissions
#### CameraSource
-| Members | Value | Description |
-| ------------ | --------------------- | ------------------------------------------------------------------ |
-| **`Prompt`** | 'PROMPT'
| Prompts the user to select either the photo album or take a photo. |
-| **`Camera`** | 'CAMERA'
| Take a new photo using the camera. |
-| **`Photos`** | 'PHOTOS'
| Pick an existing photo from the gallery or photo album. |
+| Members | Value | Description |
+| ----------------- | --------------------------- | ----------------------------------------------------------------------------- |
+| **`Prompt`** | 'PROMPT'
| Prompts the user to select either the photo album or take a photo. |
+| **`Camera`** | 'CAMERA'
| Take a new photo using the camera. |
+| **`CameraMulti`** | 'CAMERA_MULTI'
| Take multiple photos in a row using the camera. Available on Android and iOS. |
+| **`Photos`** | 'PHOTOS'
| Pick an existing photo from the gallery or photo album. |
#### CameraDirection
diff --git a/camera/android/build.gradle b/camera/android/build.gradle
index 3b2b8665f..3d58ac676 100644
--- a/camera/android/build.gradle
+++ b/camera/android/build.gradle
@@ -6,6 +6,7 @@ ext {
androidxExifInterfaceVersion = project.hasProperty('androidxExifInterfaceVersion') ? rootProject.ext.androidxExifInterfaceVersion : '1.3.7'
androidxJunitVersion = project.hasProperty('androidxJunitVersion') ? rootProject.ext.androidxJunitVersion : '1.2.1'
androidxMaterialVersion = project.hasProperty('androidxMaterialVersion') ? rootProject.ext.androidxMaterialVersion : '1.12.0'
+ cameraxVersion = project.hasProperty('cameraxVersion') ? rootProject.ext.cameraxVersion : '1.3.1'
}
buildscript {
@@ -77,6 +78,9 @@ dependencies {
implementation "androidx.exifinterface:exifinterface:$androidxExifInterfaceVersion"
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "com.google.android.material:material:$androidxMaterialVersion"
+ implementation "androidx.camera:camera-camera2:${cameraxVersion}"
+ implementation "androidx.camera:camera-view:${cameraxVersion}"
+ implementation "androidx.camera:camera-lifecycle:${cameraxVersion}"
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
diff --git a/camera/android/src/main/AndroidManifest.xml b/camera/android/src/main/AndroidManifest.xml
index 2898a91dd..f19d7ab65 100644
--- a/camera/android/src/main/AndroidManifest.xml
+++ b/camera/android/src/main/AndroidManifest.xml
@@ -4,4 +4,5 @@
+
diff --git a/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraFragment.java b/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraFragment.java
new file mode 100644
index 000000000..b50e4ad4e
--- /dev/null
+++ b/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraFragment.java
@@ -0,0 +1,2735 @@
+package com.capacitorjs.plugins.camera;
+
+import static com.capacitorjs.plugins.camera.DeviceUtils.dpToPx;
+
+import android.view.ViewTreeObserver;
+
+import android.annotation.SuppressLint;
+import android.app.AlertDialog;
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.res.ColorStateList;
+import android.content.res.Configuration;
+import android.database.Cursor;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Color;
+import android.graphics.drawable.GradientDrawable;
+import android.media.MediaActionSound;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.provider.MediaStore;
+import android.util.DisplayMetrics;
+import android.util.Log;
+import android.util.Size;
+import android.view.Gravity;
+import android.view.HapticFeedbackConstants;
+import android.view.LayoutInflater;
+import android.view.MotionEvent;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.Window;
+import android.view.WindowInsets;
+import android.view.WindowInsetsController;
+import android.view.DisplayCutout;
+import android.view.animation.AccelerateDecelerateInterpolator;
+import android.widget.FrameLayout;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.ProgressBar;
+import android.widget.RelativeLayout;
+import android.widget.TextView;
+import androidx.annotation.ColorInt;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.camera.core.CameraSelector;
+import androidx.camera.core.ImageCapture;
+import androidx.camera.core.ImageCaptureException;
+import androidx.camera.core.ZoomState;
+import androidx.camera.view.LifecycleCameraController;
+import androidx.camera.view.PreviewView;
+import androidx.cardview.widget.CardView;
+import androidx.core.content.ContextCompat;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentActivity;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+import com.getcapacitor.Logger;
+import com.google.android.material.floatingactionbutton.FloatingActionButton;
+import com.google.android.material.tabs.TabLayout;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.text.DecimalFormat;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+@SuppressWarnings("FieldCanBeLocal")
+/**
+ * CameraFragment provides a full-screen camera interface with safe area inset awareness.
+ *
+ * Safe Area Inset Implementation:
+ * - Detects display cutouts and system window insets (Android API 28+)
+ * - Automatically adjusts UI layout to avoid camera cutouts, especially in landscape right mode
+ * - Calculates safe margins for shutter button and controls positioning
+ * - Dynamically updates layout when orientation changes
+ * - Provides minimum safe margins even on devices without cutouts
+ *
+ * Key methods for safe area handling:
+ * - calculateSafeAreaInsets(): Detects and calculates safe insets
+ * - getSafeControlMargin(): Returns appropriate margin based on orientation
+ * - logSafeAreaStatus(): Logs current safe area status for debugging
+ */
+public class CameraFragment extends Fragment {
+
+ // Constants
+ private final String TAG = "CameraFragment";
+ private final String FILENAME = "yyyy-MM-dd-HH-mm-ss-SSS";
+ private final String PHOTO_TYPE = "image/jpeg";
+
+ private final String CONFIRM_CANCEL_TITLE = "Discard Photos?";
+ private final String CONFIRM_CANCEL_MESSAGE = "Are you sure you want to discard all photos?";
+ private final String CONFIRM_CANCEL_POSITIVE = "Yes";
+ private final String CONFIRM_CANCEL_NEGATIVE = "No";
+
+ @ColorInt
+ private final int ZOOM_TAB_LAYOUT_BACKGROUND_COLOR = 0x80000000;
+
+ @ColorInt
+ private final int ZOOM_BUTTON_COLOR_SELECTED = 0xFFFFFFFF;
+
+ @ColorInt
+ private final int ZOOM_BUTTON_COLOR_UNSELECTED = 0x80FFFFFF;
+
+ private final AtomicBoolean isSnappingZoom = new AtomicBoolean(false);
+ // View related variables
+ private RelativeLayout relativeLayout;
+ private RelativeLayout bottomBar;
+ private PreviewView previewView;
+ private ImageView focusIndicator;
+ private ThumbnailAdapter thumbnailAdapter;
+ private RecyclerView filmstripView;
+ private TabLayout zoomTabLayout;
+ private LinearLayout verticalZoomContainer; // For vertical zoom buttons in landscape mode
+ private CardView zoomTabCardView;
+ private FloatingActionButton takePictureButton;
+ private FloatingActionButton flipCameraButton;
+ private FloatingActionButton doneButton;
+ private FloatingActionButton closeButton;
+ private FloatingActionButton flashButton;
+ private RelativeLayout.LayoutParams bottomBarLayoutParams;
+ private RelativeLayout.LayoutParams cardViewLayoutParams;
+ private RelativeLayout.LayoutParams tabLayoutParams;
+ private RelativeLayout.LayoutParams takePictureLayoutParams;
+ private RelativeLayout.LayoutParams flipButtonLayoutParams;
+ private RelativeLayout.LayoutParams doneButtonLayoutParams;
+ private RelativeLayout.LayoutParams closeButtonLayoutParams;
+ private RelativeLayout.LayoutParams flashButtonLayoutParams;
+ private DisplayMetrics displayMetrics;
+ private boolean isLandscape = false;
+ private RelativeLayout controlsContainer; // Container for camera controls in landscape mode
+
+ // Safe area insets for camera cutout awareness
+ private int safeInsetTop = 0;
+ private int safeInsetBottom = 0;
+ private int safeInsetLeft = 0;
+ private int safeInsetRight = 0;
+ // Camera variables
+ private int lensFacing = CameraSelector.LENS_FACING_BACK;
+ private int flashMode = ImageCapture.FLASH_MODE_AUTO;
+
+ @SuppressWarnings("unused")
+ private ZoomState zoomRatio = null;
+
+ private float minZoom = 0f;
+
+ @SuppressWarnings("unused")
+ private float maxZoom = 1f;
+
+ private ExecutorService cameraExecutor;
+ private LifecycleCameraController cameraController;
+ // Utility variables
+ private HashMap imageCache;
+ private ArrayList zoomTabs;
+
+ private Handler zoomHandler = null;
+ private Runnable zoomRunnable = null;
+ private MediaActionSound mediaActionSound;
+
+ // Callbacks
+ private OnImagesCapturedCallback imagesCapturedCallback;
+
+ // Camera settings
+ private CameraSettings cameraSettings;
+
+ // Processing spinner overlay
+ private View processingOverlay;
+ private ProgressBar processingSpinner;
+ private TextView processingText;
+ private Handler processingHandler;
+ private Runnable processingRunnable;
+
+ // ViewTreeObserver listener references for proper cleanup
+ private ViewTreeObserver.OnGlobalLayoutListener relativeLayoutListener;
+ private ViewTreeObserver.OnGlobalLayoutListener previewViewListener;
+ private ViewTreeObserver.OnGlobalLayoutListener previewViewSecondaryListener;
+ private ViewTreeObserver.OnGlobalLayoutListener zoomTabCardViewListener;
+ private ViewTreeObserver.OnGlobalLayoutListener zoomTabCardViewSecondaryListener;
+ private ViewTreeObserver.OnGlobalLayoutListener filmstripViewListener;
+ private ViewTreeObserver.OnGlobalLayoutListener filmstripViewSecondaryListener;
+
+ @NonNull
+ private static ColorStateList createButtonColorList() {
+ int[][] states = new int[][] {
+ new int[] { android.R.attr.state_enabled }, // enabled
+ new int[] { -android.R.attr.state_enabled }, // disabled
+ new int[] { -android.R.attr.state_checked }, // unchecked
+ new int[] { android.R.attr.state_pressed } // pressed
+ };
+
+ int[] colors = new int[] { Color.DKGRAY, Color.TRANSPARENT, Color.TRANSPARENT, Color.LTGRAY };
+ return new ColorStateList(states, colors);
+ }
+
+ @Override
+ public void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ // Initialize simple HashMap for image storage
+ imageCache = new HashMap<>();
+
+ zoomTabs = new ArrayList<>();
+ zoomHandler = new Handler(requireActivity().getMainLooper());
+ mediaActionSound = new MediaActionSound();
+ mediaActionSound.load(MediaActionSound.SHUTTER_CLICK);
+
+ // Register for configuration changes (like orientation changes)
+ setRetainInstance(true);
+ }
+
+ @Override
+ public void onDestroy() {
+ super.onDestroy();
+ // Restore the original system UI settings
+ Window window = requireActivity().getWindow();
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ final WindowInsetsController insetsController = window.getInsetsController();
+ if (insetsController != null) {
+ insetsController.show(android.view.WindowInsets.Type.statusBars() | android.view.WindowInsets.Type.navigationBars());
+ insetsController.setSystemBarsBehavior(WindowInsetsController.BEHAVIOR_DEFAULT);
+ }
+ } else {
+ View decorView = window.getDecorView();
+ int flags = View.SYSTEM_UI_FLAG_VISIBLE;
+ decorView.setSystemUiVisibility(flags);
+ }
+ window.setStatusBarColor(requireActivity().getResources().getColor(android.R.color.transparent));
+ window.setNavigationBarColor(requireActivity().getResources().getColor(android.R.color.transparent));
+
+ // Clean up any ViewTreeObserver listeners that might still be active
+ cleanupViewTreeObservers();
+
+ // Clear image cache to free memory
+ if (imageCache != null) {
+ try {
+ // Manually recycle all bitmaps before clearing cache
+ for (Bitmap bitmap : imageCache.values()) {
+ if (bitmap != null && !bitmap.isRecycled()) {
+ bitmap.recycle();
+ }
+ }
+ imageCache.clear();
+ imageCache = null;
+ } catch (Exception e) {
+ Logger.error(TAG, "Error clearing image cache", e);
+ imageCache = null;
+ }
+ }
+
+ if (mediaActionSound != null) {
+ mediaActionSound.release();
+ mediaActionSound = null;
+ }
+
+ // Clean up processing handler and runnable
+ if (processingHandler != null && processingRunnable != null) {
+ processingHandler.removeCallbacks(processingRunnable);
+ processingHandler = null;
+ processingRunnable = null;
+ }
+
+ if (cameraExecutor != null) {
+ cameraExecutor.shutdown();
+ try {
+ // Wait for tasks to complete with timeout
+ if (!cameraExecutor.awaitTermination(500, TimeUnit.MILLISECONDS)) {
+ cameraExecutor.shutdownNow();
+ }
+ } catch (InterruptedException e) {
+ cameraExecutor.shutdownNow();
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
+
+ /**
+ * Cleans up any ViewTreeObserver listeners to prevent memory leaks
+ */
+ private void cleanupViewTreeObservers() {
+ try {
+ // Clean up ViewTreeObserver listeners for all views that might have them
+ if (relativeLayout != null && relativeLayout.getViewTreeObserver().isAlive() && relativeLayoutListener != null) {
+ relativeLayout.getViewTreeObserver().removeOnGlobalLayoutListener(relativeLayoutListener);
+ relativeLayoutListener = null;
+ }
+
+ if (previewView != null && previewView.getViewTreeObserver().isAlive()) {
+ if (previewViewListener != null) {
+ previewView.getViewTreeObserver().removeOnGlobalLayoutListener(previewViewListener);
+ previewViewListener = null;
+ }
+ if (previewViewSecondaryListener != null) {
+ previewView.getViewTreeObserver().removeOnGlobalLayoutListener(previewViewSecondaryListener);
+ previewViewSecondaryListener = null;
+ }
+ }
+
+ if (zoomTabCardView != null && zoomTabCardView.getViewTreeObserver().isAlive()) {
+ if (zoomTabCardViewListener != null) {
+ zoomTabCardView.getViewTreeObserver().removeOnGlobalLayoutListener(zoomTabCardViewListener);
+ zoomTabCardViewListener = null;
+ }
+ if (zoomTabCardViewSecondaryListener != null) {
+ zoomTabCardView.getViewTreeObserver().removeOnGlobalLayoutListener(zoomTabCardViewSecondaryListener);
+ zoomTabCardViewSecondaryListener = null;
+ }
+ }
+
+ if (filmstripView != null && filmstripView.getViewTreeObserver().isAlive()) {
+ if (filmstripViewListener != null) {
+ filmstripView.getViewTreeObserver().removeOnGlobalLayoutListener(filmstripViewListener);
+ filmstripViewListener = null;
+ }
+ if (filmstripViewSecondaryListener != null) {
+ filmstripView.getViewTreeObserver().removeOnGlobalLayoutListener(filmstripViewSecondaryListener);
+ filmstripViewSecondaryListener = null;
+ }
+ }
+ } catch (Exception e) {
+ // Log but don't crash if there's an issue cleaning up listeners
+ Logger.error(TAG, "Error cleaning up ViewTreeObserver listeners", e);
+ }
+ }
+
+ @Nullable
+ /**
+ * Checks if the device is currently in landscape mode
+ *
+ * @param context The context to check orientation
+ * @return true if in landscape mode, false otherwise
+ */
+ private boolean isLandscapeMode(Context context) {
+ return context.getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE;
+ }
+
+ /**
+ * Calculates safe area insets to avoid camera cutouts and other display features
+ * This is particularly important for landscape right mode where the shutter button
+ * could be positioned near the camera cutout.
+ */
+ private void calculateSafeAreaInsets() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ Window window = requireActivity().getWindow();
+ WindowInsets rootInsets = window.getDecorView().getRootWindowInsets();
+
+ if (rootInsets != null) {
+ DisplayCutout displayCutout = rootInsets.getDisplayCutout();
+
+ if (displayCutout != null) {
+ // Get cutout insets
+ safeInsetTop = Math.max(safeInsetTop, displayCutout.getSafeInsetTop());
+ safeInsetBottom = Math.max(safeInsetBottom, displayCutout.getSafeInsetBottom());
+ safeInsetLeft = Math.max(safeInsetLeft, displayCutout.getSafeInsetLeft());
+ safeInsetRight = Math.max(safeInsetRight, displayCutout.getSafeInsetRight());
+
+ Logger.debug(TAG, "Display cutout detected - Top: " + safeInsetTop +
+ ", Bottom: " + safeInsetBottom +
+ ", Left: " + safeInsetLeft +
+ ", Right: " + safeInsetRight);
+ } else {
+ Logger.debug(TAG, "No display cutout detected");
+ }
+
+ // Also check for system window insets as fallback
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ android.graphics.Insets systemInsets = rootInsets.getInsets(WindowInsets.Type.systemBars());
+ safeInsetTop = Math.max(safeInsetTop, systemInsets.top);
+ safeInsetBottom = Math.max(safeInsetBottom, systemInsets.bottom);
+ safeInsetLeft = Math.max(safeInsetLeft, systemInsets.left);
+ safeInsetRight = Math.max(safeInsetRight, systemInsets.right);
+ }
+ }
+ }
+
+ // Apply minimum safe margins even if no cutout is detected
+ int minSafeMargin = dpToPx(requireContext(), 16);
+ safeInsetTop = Math.max(safeInsetTop, minSafeMargin);
+ safeInsetBottom = Math.max(safeInsetBottom, minSafeMargin);
+ safeInsetLeft = Math.max(safeInsetLeft, minSafeMargin);
+ safeInsetRight = Math.max(safeInsetRight, minSafeMargin);
+
+ Logger.debug(TAG, "Final safe area insets - Top: " + safeInsetTop +
+ ", Bottom: " + safeInsetBottom +
+ ", Left: " + safeInsetLeft +
+ ", Right: " + safeInsetRight);
+
+ // Log safe area status for debugging
+ logSafeAreaStatus();
+ }
+
+ /**
+ * Gets the appropriate margin for controls based on orientation and safe area insets
+ * In landscape right mode, we need extra margin to avoid camera cutouts
+ */
+ private int getSafeControlMargin(int baseMargin) {
+ if (!isLandscape) {
+ return baseMargin;
+ }
+
+ // In landscape mode, especially landscape right, we need to account for cutouts
+ // The right side is where the controls container is positioned
+ return Math.max(baseMargin, safeInsetRight);
+ }
+
+ /**
+ * Logs current orientation and safe area inset information for debugging
+ */
+ private void logSafeAreaStatus() {
+ String orientation = isLandscape ? "LANDSCAPE" : "PORTRAIT";
+ Logger.debug(TAG, "Safe Area Status - Orientation: " + orientation +
+ ", Safe Insets - Top: " + safeInsetTop +
+ ", Bottom: " + safeInsetBottom +
+ ", Left: " + safeInsetLeft +
+ ", Right: " + safeInsetRight);
+
+ if (isLandscape && safeInsetRight > dpToPx(requireContext(), 16)) {
+ Logger.info(TAG, "Landscape mode with significant right inset detected - likely camera cutout area");
+ }
+ }
+
+ @Override
+ public void onConfigurationChanged(@NonNull Configuration newConfig) {
+ super.onConfigurationChanged(newConfig);
+
+ // Check if device is in landscape mode with the new configuration
+ boolean wasLandscape = isLandscape;
+ isLandscape = newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE;
+
+ // Recalculate safe area insets for the new orientation
+ calculateSafeAreaInsets();
+
+ // Recreate the layout when orientation changes
+ if (relativeLayout != null) {
+ // Save the current camera state
+ int currentLensFacing = lensFacing;
+ int currentFlashMode = flashMode;
+
+ // Completely recreate the camera controller when switching orientations
+ if (cameraController != null) {
+ cameraController.unbind();
+ cameraController = null;
+ }
+
+ // Clear zoom tabs when recreating UI for orientation change
+ if (!zoomTabs.isEmpty()) {
+ if (zoomTabLayout != null) {
+ zoomTabLayout.removeAllTabs();
+ }
+ if (verticalZoomContainer != null) {
+ verticalZoomContainer.removeAllViews();
+ }
+ zoomTabs.clear();
+ }
+
+ // Remove all views
+ relativeLayout.removeAllViews();
+
+ // Recreate the UI with the new orientation
+ FragmentActivity fragmentActivity = requireActivity();
+ int margin = (int) (20 * displayMetrics.density);
+ int barHeight = (int) (100 * displayMetrics.density);
+
+ ColorStateList buttonColors = createButtonColorList();
+
+ // Create a black background that fills the entire screen
+ View blackBackground = new View(fragmentActivity);
+ blackBackground.setId(View.generateViewId());
+ blackBackground.setBackgroundColor(Color.BLACK);
+ RelativeLayout.LayoutParams blackBgParams = new RelativeLayout.LayoutParams(
+ RelativeLayout.LayoutParams.MATCH_PARENT,
+ RelativeLayout.LayoutParams.MATCH_PARENT
+ );
+ blackBackground.setLayoutParams(blackBgParams);
+ relativeLayout.addView(blackBackground, 0); // Add at index 0 to be behind everything
+
+ // Create the preview view first
+ createPreviewView(fragmentActivity);
+
+ createFocusIndicator(fragmentActivity);
+
+ if (isLandscape) {
+ // In landscape mode, create a container for controls on the right side
+ // Use safe margin calculation for orientation change
+ int safeMargin = getSafeControlMargin(margin);
+ createControlsContainerForLandscape(fragmentActivity, buttonColors, safeMargin);
+ } else {
+ // In portrait mode, create the bottom bar and buttons
+ createBottomBar(fragmentActivity, barHeight, margin, buttonColors);
+
+ // Set preview view to be above bottom bar in portrait mode
+ RelativeLayout.LayoutParams previewParams = (RelativeLayout.LayoutParams) previewView.getLayoutParams();
+ previewParams.addRule(RelativeLayout.ABOVE, bottomBar.getId());
+ previewView.setLayoutParams(previewParams);
+
+ // Zoom bar is above the bottom bar/buttons
+ createZoomTabLayout(fragmentActivity, margin);
+
+ // Thumbnail images in the filmstrip are above the zoom buttons
+ createFilmstripView(fragmentActivity);
+
+ // Close button and flash are top left/right corners
+ createCloseButton(fragmentActivity, margin, buttonColors);
+ createFlashButton(fragmentActivity, margin, buttonColors);
+ }
+
+ // Create a new camera controller
+ cameraController = new LifecycleCameraController(requireActivity());
+ cameraController.bindToLifecycle(requireActivity());
+ previewView.setController(cameraController);
+
+ // Restore camera settings
+ lensFacing = currentLensFacing;
+ flashMode = currentFlashMode;
+
+ // Make sure the camera selector is set correctly
+ CameraSelector cameraSelector = new CameraSelector.Builder().requireLensFacing(lensFacing).build();
+ cameraController.setCameraSelector(cameraSelector);
+ cameraController.setImageCaptureFlashMode(flashMode);
+
+ // Setup camera to initialize zoom state and other camera features
+ setupCamera();
+
+ // Force layout update
+ relativeLayout.requestLayout();
+ relativeLayout.invalidate();
+ previewView.requestLayout();
+
+ // Use ViewTreeObserver to efficiently handle layout updates
+ relativeLayoutListener = new ViewTreeObserver.OnGlobalLayoutListener() {
+ @Override
+ public void onGlobalLayout() {
+ // Remove the listener to prevent multiple callbacks
+ relativeLayout.getViewTreeObserver().removeOnGlobalLayoutListener(this);
+ relativeLayoutListener = null;
+
+ if (isLandscape) {
+ // Force the preview view to take up the correct width in landscape mode
+ int containerWidth = (int) (displayMetrics.widthPixels * 0.2);
+ RelativeLayout.LayoutParams previewParams = (RelativeLayout.LayoutParams) previewView.getLayoutParams();
+
+ // Clear any existing rules that might be interfering
+ previewParams.removeRule(RelativeLayout.ABOVE);
+ previewParams.removeRule(RelativeLayout.BELOW);
+ previewParams.removeRule(RelativeLayout.RIGHT_OF);
+ previewParams.removeRule(RelativeLayout.LEFT_OF);
+ previewParams.removeRule(RelativeLayout.CENTER_IN_PARENT);
+ previewParams.removeRule(RelativeLayout.CENTER_HORIZONTAL);
+ previewParams.removeRule(RelativeLayout.CENTER_VERTICAL);
+
+ // Set explicit width to 80% of screen width
+ previewParams.width = displayMetrics.widthPixels - containerWidth;
+ previewParams.height = RelativeLayout.LayoutParams.MATCH_PARENT;
+
+ // Set the correct rules for landscape mode
+ previewParams.addRule(RelativeLayout.ALIGN_PARENT_LEFT);
+ previewParams.addRule(RelativeLayout.ALIGN_PARENT_TOP);
+ previewParams.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM);
+ if (controlsContainer != null) {
+ previewParams.addRule(RelativeLayout.LEFT_OF, controlsContainer.getId());
+ }
+
+ previewParams.setMargins(0, 0, 0, 0);
+ previewView.setLayoutParams(previewParams);
+
+ // Force update the scale type
+ previewView.setScaleType(PreviewView.ScaleType.FILL_CENTER);
+
+ // Add a second layout listener for fine-tuning after the initial layout
+ previewViewSecondaryListener = new ViewTreeObserver.OnGlobalLayoutListener() {
+ @Override
+ public void onGlobalLayout() {
+ // Remove this listener after execution
+ previewView.getViewTreeObserver().removeOnGlobalLayoutListener(this);
+ previewViewSecondaryListener = null;
+
+ if (previewView != null && isLandscape) {
+ previewView.setScaleType(PreviewView.ScaleType.FILL_CENTER);
+ }
+ }
+ };
+ previewView.getViewTreeObserver().addOnGlobalLayoutListener(previewViewSecondaryListener);
+ }
+ }
+ };
+ relativeLayout.getViewTreeObserver().addOnGlobalLayoutListener(relativeLayoutListener);
+ }
+ }
+
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
+ FragmentActivity fragmentActivity = requireActivity();
+ displayMetrics = fragmentActivity.getResources().getDisplayMetrics();
+ int margin = (int) (20 * displayMetrics.density);
+ int barHeight = (int) (100 * displayMetrics.density);
+
+ // Check if device is in landscape mode
+ isLandscape = isLandscapeMode(fragmentActivity);
+
+ // Calculate safe area insets for camera cutout awareness
+ calculateSafeAreaInsets();
+
+ relativeLayout = new RelativeLayout(fragmentActivity);
+
+ // Create a black background that fills the entire screen
+ View blackBackground = new View(fragmentActivity);
+ blackBackground.setId(View.generateViewId());
+ blackBackground.setBackgroundColor(Color.BLACK);
+ RelativeLayout.LayoutParams blackBgParams = new RelativeLayout.LayoutParams(
+ RelativeLayout.LayoutParams.MATCH_PARENT,
+ RelativeLayout.LayoutParams.MATCH_PARENT
+ );
+ blackBackground.setLayoutParams(blackBgParams);
+ relativeLayout.addView(blackBackground); // Add the background first
+
+ ColorStateList buttonColors = createButtonColorList();
+
+ // Create the preview view first
+ createPreviewView(fragmentActivity);
+
+ createFocusIndicator(fragmentActivity);
+
+ if (isLandscape) {
+ // In landscape mode, create a container for controls on the right side
+ // Use safe margin calculation for initial creation
+ int safeMargin = getSafeControlMargin(margin);
+ createControlsContainerForLandscape(fragmentActivity, buttonColors, safeMargin);
+ } else {
+ // In portrait mode, create the bottom bar and buttons
+ createBottomBar(fragmentActivity, barHeight, margin, buttonColors);
+
+ // Set preview view to be above bottom bar in portrait mode
+ RelativeLayout.LayoutParams previewParams = (RelativeLayout.LayoutParams) previewView.getLayoutParams();
+ previewParams.addRule(RelativeLayout.ABOVE, bottomBar.getId());
+ previewView.setLayoutParams(previewParams);
+
+ // Zoom bar is above the bottom bar/buttons
+ createZoomTabLayout(fragmentActivity, margin);
+
+ // Thumbnail images in the filmstrip are above the zoom buttons
+ createFilmstripView(fragmentActivity);
+
+ // Close button and flash are top left/right corners
+ createCloseButton(fragmentActivity, margin, buttonColors);
+ createFlashButton(fragmentActivity, margin, buttonColors);
+ }
+
+ // Set a transparent navigation bar
+ Window window = requireActivity().getWindow();
+ window.setStatusBarColor(Color.BLACK);
+ window.setNavigationBarColor(Color.BLACK);
+
+ // Enable immersive fullscreen mode (hide status and navigation bars)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ final WindowInsetsController insetsController = window.getInsetsController();
+ if (insetsController != null) {
+ insetsController.hide(android.view.WindowInsets.Type.statusBars() | android.view.WindowInsets.Type.navigationBars());
+ insetsController.setSystemBarsBehavior(WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
+ }
+ } else {
+ View decorView = window.getDecorView();
+ int flags = View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY | View.SYSTEM_UI_FLAG_FULLSCREEN | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION;
+ decorView.setSystemUiVisibility(flags);
+ }
+
+ // Set up window insets listener to handle safe area changes dynamically
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ relativeLayout.setOnApplyWindowInsetsListener((v, insets) -> {
+ // Recalculate safe area insets when window insets change
+ calculateSafeAreaInsets();
+ return insets;
+ });
+ }
+
+ // Remove edge-to-edge insets handling for true fullscreen
+ requireActivity().getWindow().setDecorFitsSystemWindows(true);
+
+ // Create processing overlay
+ createProcessingOverlay(fragmentActivity);
+
+ return relativeLayout;
+ }
+
+ @Override
+ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+
+ // Request focus for the root view to ensure it can receive touch events immediately.
+ view.setFocusableInTouchMode(true);
+ view.requestFocus();
+
+ cameraController = new LifecycleCameraController(requireActivity());
+ cameraController.bindToLifecycle(requireActivity());
+ previewView.setController(cameraController);
+ cameraExecutor = Executors.newSingleThreadExecutor();
+
+ // Use ViewTreeObserver to ensure layout is complete before setting up camera
+ previewViewListener = new ViewTreeObserver.OnGlobalLayoutListener() {
+ @Override
+ public void onGlobalLayout() {
+ // Remove the listener to prevent multiple callbacks
+ previewView.getViewTreeObserver().removeOnGlobalLayoutListener(this);
+ previewViewListener = null;
+ setupCamera();
+ }
+ };
+ previewView.getViewTreeObserver().addOnGlobalLayoutListener(previewViewListener);
+ }
+
+ /**
+ * Safely adds an image to the cache
+ *
+ * @param uri The URI of the image
+ * @param bitmap The bitmap to cache
+ */
+ private void addImageToCache(Uri uri, Bitmap bitmap) {
+ if (uri != null && bitmap != null && !bitmap.isRecycled() && imageCache != null) {
+ try {
+ imageCache.put(uri, bitmap);
+ } catch (Exception e) {
+ Logger.error(TAG, "Error adding image to cache", e);
+ }
+ } else {
+ Logger.warn(TAG, "Failed to add image to cache - uri: " + uri + ", bitmap: " + bitmap + ", imageCache: " + imageCache);
+ }
+ }
+
+ /**
+ * Safely retrieves an image from the cache
+ *
+ * @param uri The URI of the image to retrieve
+ * @return The cached bitmap, or null if not found
+ */
+ private Bitmap getImageFromCache(Uri uri) {
+ return imageCache != null ? imageCache.get(uri) : null;
+ }
+
+ /**
+ * Gets all image URIs currently in the cache
+ *
+ * @return A HashMap of all cached images
+ */
+ private HashMap getAllCachedImages() {
+ HashMap result = new HashMap<>();
+ if (imageCache != null) {
+ // Copy all entries from the cache
+ for (Map.Entry entry : imageCache.entrySet()) {
+ result.put(entry.getKey(), entry.getValue());
+ }
+ }
+ return result;
+ }
+
+ private void cancel() {
+ // Stop any ongoing processing and cleanup resources
+ hideProcessingOverlay();
+
+ // Cancel any ongoing background processing tasks
+ if (cameraExecutor != null && !cameraExecutor.isShutdown()) {
+ // Remove any loading thumbnails to stop processing
+ if (thumbnailAdapter != null) {
+ thumbnailAdapter.removeLoadingThumbnails();
+ }
+ }
+
+ // When the user cancels the camera session, it should clean up all the photos that were
+ // taken.
+ int failedDeletions = 0;
+ for (Map.Entry image : getAllCachedImages().entrySet()) {
+ if (!deleteFile(image.getKey())) {
+ failedDeletions++;
+ Logger.warn(TAG, "Failed to delete image during cancel: " + image.getKey());
+ }
+ }
+
+ if (failedDeletions > 0) {
+ Logger.warn(TAG, "Failed to delete " + failedDeletions + " images during cancel");
+ // We still proceed with cancellation even if some deletions failed
+ }
+
+ if (imagesCapturedCallback != null) {
+ imagesCapturedCallback.onCaptureCanceled();
+ }
+ closeFragment();
+ }
+
+ private void done() {
+ // Check if there are still images being processed
+ if (thumbnailAdapter != null && thumbnailAdapter.hasLoadingThumbnails()) {
+ Logger.debug(TAG, "Images still processing, showing spinner overlay");
+ // Show non-dismissable spinner while processing
+ showProcessingOverlay();
+
+ // Check periodically if processing is complete
+ processingHandler = new Handler(Looper.getMainLooper());
+ processingRunnable = new Runnable() {
+ @Override
+ public void run() {
+ if (thumbnailAdapter != null && thumbnailAdapter.hasLoadingThumbnails()) {
+ // Still processing, check again in 500ms
+ if (processingHandler != null) {
+ processingHandler.postDelayed(this, 500);
+ }
+ } else {
+ // Processing complete, hide spinner and proceed
+ hideProcessingOverlay();
+ finalizeDone();
+ }
+ }
+ };
+ processingHandler.post(processingRunnable);
+ } else {
+ Logger.debug(TAG, "No images processing, proceeding immediately");
+ // No processing needed, proceed immediately
+ finalizeDone();
+ }
+ }
+
+ private void finalizeDone() {
+ if (imagesCapturedCallback != null) {
+ imagesCapturedCallback.onCaptureSuccess(getAllCachedImages());
+ }
+ closeFragment();
+ }
+
+ /**
+ * Safely closes the fragment, handling any potential exceptions
+ */
+ private void closeFragment() {
+ try {
+ if (getActivity() != null && !getActivity().isFinishing() && isAdded()) {
+ requireActivity().getSupportFragmentManager().beginTransaction().remove(this).commit();
+ } else {
+ Logger.warn(TAG, "Cannot close fragment: activity is null, finishing, or fragment not added");
+ }
+ } catch (IllegalStateException e) {
+ // This can happen if the activity is being destroyed
+ Logger.error(TAG, "Error closing fragment", e);
+ } catch (Exception e) {
+ Logger.error(TAG, "Unexpected error closing fragment", e);
+ }
+ }
+
+ /**
+ * Creates the processing overlay with spinner and text
+ */
+ private void createProcessingOverlay(FragmentActivity fragmentActivity) {
+ // Create the main overlay container with semi-transparent background
+ processingOverlay = new RelativeLayout(fragmentActivity);
+ processingOverlay.setId(View.generateViewId());
+ processingOverlay.setBackgroundColor(0x80000000); // Semi-transparent black
+ processingOverlay.setVisibility(View.GONE);
+
+ RelativeLayout.LayoutParams overlayParams = new RelativeLayout.LayoutParams(
+ RelativeLayout.LayoutParams.MATCH_PARENT,
+ RelativeLayout.LayoutParams.MATCH_PARENT
+ );
+ processingOverlay.setLayoutParams(overlayParams);
+
+ // Create content container for centering
+ RelativeLayout contentContainer = new RelativeLayout(fragmentActivity);
+ contentContainer.setId(View.generateViewId());
+ RelativeLayout.LayoutParams contentParams = new RelativeLayout.LayoutParams(
+ RelativeLayout.LayoutParams.WRAP_CONTENT,
+ RelativeLayout.LayoutParams.WRAP_CONTENT
+ );
+ contentParams.addRule(RelativeLayout.CENTER_IN_PARENT);
+ contentContainer.setLayoutParams(contentParams);
+
+ // Create spinner
+ processingSpinner = new ProgressBar(fragmentActivity);
+ processingSpinner.setId(View.generateViewId());
+ int spinnerSize = (int) (48 * displayMetrics.density);
+ RelativeLayout.LayoutParams spinnerParams = new RelativeLayout.LayoutParams(spinnerSize, spinnerSize);
+ spinnerParams.addRule(RelativeLayout.CENTER_HORIZONTAL);
+ processingSpinner.setLayoutParams(spinnerParams);
+
+ // Create text
+ processingText = new TextView(fragmentActivity);
+ processingText.setId(View.generateViewId());
+ processingText.setText("Processing images...");
+ processingText.setTextColor(Color.WHITE);
+ processingText.setTextSize(16);
+ processingText.setGravity(Gravity.CENTER);
+ RelativeLayout.LayoutParams textParams = new RelativeLayout.LayoutParams(
+ RelativeLayout.LayoutParams.WRAP_CONTENT,
+ RelativeLayout.LayoutParams.WRAP_CONTENT
+ );
+ textParams.addRule(RelativeLayout.CENTER_HORIZONTAL);
+ textParams.addRule(RelativeLayout.BELOW, processingSpinner.getId());
+ textParams.setMargins(0, (int) (16 * displayMetrics.density), 0, 0);
+ processingText.setLayoutParams(textParams);
+
+ // Add spinner and text to content container
+ contentContainer.addView(processingSpinner);
+ contentContainer.addView(processingText);
+
+ // Add content container to the main overlay
+ ((RelativeLayout) processingOverlay).addView(contentContainer);
+
+ // Add the overlay to main layout
+ relativeLayout.addView(processingOverlay);
+ }
+
+ /**
+ * Shows the processing overlay
+ */
+ private void showProcessingOverlay() {
+ Logger.debug(TAG, "Showing processing overlay");
+ if (processingOverlay != null) {
+ processingOverlay.setVisibility(View.VISIBLE);
+ processingOverlay.bringToFront();
+ }
+ }
+
+ /**
+ * Hides the processing overlay and cleans up resources
+ */
+ private void hideProcessingOverlay() {
+ if (processingOverlay != null) {
+ processingOverlay.setVisibility(View.GONE);
+ }
+
+ // Clean up processing handler and runnable
+ if (processingHandler != null && processingRunnable != null) {
+ processingHandler.removeCallbacks(processingRunnable);
+ processingHandler = null;
+ processingRunnable = null;
+ }
+ }
+
+ public void setImagesCapturedCallback(OnImagesCapturedCallback imagesCapturedCallback) {
+ this.imagesCapturedCallback = imagesCapturedCallback;
+ }
+
+ public void setCameraSettings(CameraSettings settings) {
+ this.cameraSettings = settings;
+ }
+
+ /**
+ * Process bitmap according to camera settings (quality, resize, orientation)
+ */
+ private Bitmap processBitmap(Bitmap originalBitmap, Uri imageUri) {
+ if (originalBitmap == null || originalBitmap.isRecycled()) {
+ Logger.warn(TAG, "Cannot process null or recycled bitmap");
+ return null;
+ }
+
+ // If no settings are available, return original bitmap
+ if (cameraSettings == null) {
+ Logger.debug(TAG, "No camera settings available, returning original bitmap");
+ return originalBitmap;
+ }
+
+ Bitmap processedBitmap = originalBitmap;
+
+ try {
+ ExifWrapper exif = ImageUtils.getExifData(getContext(), processedBitmap, imageUri);
+ boolean wasProcessed = false;
+
+ // Apply resizing
+ if (cameraSettings.isShouldResize() && cameraSettings.getWidth() > 0 && cameraSettings.getHeight() > 0) {
+ Bitmap resizedBitmap = ImageUtils.resize(processedBitmap, cameraSettings.getWidth(), cameraSettings.getHeight());
+ if (resizedBitmap != processedBitmap && resizedBitmap != null) {
+ Logger.debug(TAG, "Applied resizing to " + cameraSettings.getWidth() + "x" + cameraSettings.getHeight());
+ if (processedBitmap != originalBitmap) {
+ processedBitmap.recycle();
+ }
+ processedBitmap = resizedBitmap;
+ wasProcessed = true;
+ }
+ }
+
+ // Apply orientation correction (only if explicitly enabled)
+ if (cameraSettings.isShouldCorrectOrientation()) {
+ Bitmap correctedBitmap = ImageUtils.correctOrientation(getContext(), processedBitmap, imageUri, exif);
+ if (correctedBitmap != processedBitmap && correctedBitmap != null) {
+ Logger.debug(TAG, "Applied orientation correction");
+ if (processedBitmap != originalBitmap) {
+ processedBitmap.recycle();
+ }
+ processedBitmap = correctedBitmap;
+ wasProcessed = true;
+ }
+ }
+
+ if (wasProcessed) {
+ Logger.debug(TAG, "Bitmap processed: " + originalBitmap.getWidth() + "x" + originalBitmap.getHeight() +
+ " -> " + processedBitmap.getWidth() + "x" + processedBitmap.getHeight());
+ }
+
+ return processedBitmap;
+ } catch (Exception e) {
+ Logger.error(TAG, "Error processing bitmap", e);
+ // If processing fails, return original bitmap and don't crash
+ return originalBitmap;
+ }
+ }
+
+ private void createBottomBar(FragmentActivity fragmentActivity, int barHeight, int margin, ColorStateList buttonColors) {
+ bottomBar = new RelativeLayout(fragmentActivity);
+ bottomBar.setId(View.generateViewId());
+ bottomBar.setBackgroundColor(Color.BLACK);
+ bottomBarLayoutParams = new RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, barHeight);
+ bottomBarLayoutParams.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM);
+ bottomBar.setLayoutParams(bottomBarLayoutParams);
+ relativeLayout.addView(bottomBar);
+
+ createTakePictureButton(fragmentActivity, margin, buttonColors);
+ createFlipButton(fragmentActivity, margin, buttonColors);
+ createDoneButton(fragmentActivity, margin, buttonColors);
+ }
+
+ private void createTakePictureButtonForLandscape(FragmentActivity fragmentActivity, int margin, ColorStateList buttonColors) {
+ takePictureButton = new FloatingActionButton(fragmentActivity);
+ takePictureButton.setId(View.generateViewId());
+ takePictureButton.setImageResource(R.drawable.ic_shutter_circle);
+ takePictureButton.setBackgroundColor(Color.TRANSPARENT);
+ takePictureButton.setBackgroundTintList(buttonColors);
+
+ int fabSize = dpToPx(fragmentActivity, 84);
+ int iconSize = (int) (fabSize * 0.9);
+ takePictureButton.setCustomSize(fabSize);
+ takePictureButton.setMaxImageSize(iconSize);
+
+ takePictureLayoutParams = new RelativeLayout.LayoutParams(
+ RelativeLayout.LayoutParams.WRAP_CONTENT,
+ RelativeLayout.LayoutParams.WRAP_CONTENT
+ );
+
+ // Position in the center of the right side controls container
+ // Add extra margin to ensure it's not too close to potential camera cutouts
+ takePictureLayoutParams.addRule(RelativeLayout.CENTER_VERTICAL);
+ takePictureLayoutParams.addRule(RelativeLayout.CENTER_HORIZONTAL);
+
+ // Add safe area margins to ensure the button is positioned away from cutouts
+ int safeMarginHorizontal = Math.max(margin, safeInsetRight / 2);
+ int safeMarginVertical = Math.max(margin, Math.max(safeInsetTop, safeInsetBottom) / 4);
+ takePictureLayoutParams.setMargins(safeMarginHorizontal, safeMarginVertical, safeMarginHorizontal, safeMarginVertical);
+
+ takePictureButton.setLayoutParams(takePictureLayoutParams);
+ takePictureButton.setStateListAnimator(android.animation.AnimatorInflater.loadStateListAnimator(fragmentActivity, R.animator.button_press_animation));
+ takePictureButton.setOnClickListener(
+ v -> {
+ v.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY);
+ mediaActionSound.play(MediaActionSound.SHUTTER_CLICK);
+
+ // Add loading thumbnail immediately for visual feedback
+ if (thumbnailAdapter != null) {
+ thumbnailAdapter.addLoadingThumbnail();
+ // Scroll to show the new loading thumbnail
+ if (filmstripView != null) {
+ filmstripView.scrollToPosition(thumbnailAdapter.getItemCount() - 1);
+ }
+ }
+
+ var name = new SimpleDateFormat(FILENAME, Locale.US).format(System.currentTimeMillis());
+ var contentValues = new ContentValues();
+ contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, name);
+ contentValues.put(MediaStore.MediaColumns.MIME_TYPE, PHOTO_TYPE);
+ var outputOptions = new ImageCapture.OutputFileOptions.Builder(
+ requireContext().getContentResolver(),
+ MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
+ contentValues
+ )
+ .build();
+
+ cameraController.takePicture(
+ outputOptions,
+ cameraExecutor,
+ new ImageCapture.OnImageSavedCallback() {
+ @Override
+ public void onImageSaved(@NonNull ImageCapture.OutputFileResults outputFileResults) {
+ Uri savedImageUri = outputFileResults.getSavedUri();
+ if (savedImageUri != null) {
+ InputStream stream = null;
+ try {
+ stream = requireContext().getContentResolver().openInputStream(savedImageUri);
+ if (stream == null) {
+ Logger.error(TAG, "Failed to open input stream for saved image: " + savedImageUri, null);
+ showErrorToast("Failed to process captured image");
+ return;
+ }
+
+ BitmapFactory.Options options = new BitmapFactory.Options();
+ options.inPreferredConfig = Bitmap.Config.ARGB_8888;
+ Bitmap bmp = BitmapFactory.decodeStream(stream, null, options);
+
+ if (bmp == null) {
+ Logger.error(TAG, "Failed to decode bitmap from saved image: " + savedImageUri, null);
+ showErrorToast("Failed to process captured image");
+ return;
+ }
+
+ // Process bitmap with quality and size settings
+ Bitmap processedBmp = processBitmap(bmp, savedImageUri);
+ if (processedBmp != bmp && bmp != null) {
+ bmp.recycle(); // Recycle original if it was replaced
+ }
+
+ addImageToCache(savedImageUri, processedBmp);
+
+ // Generate thumbnail on a background thread to avoid UI jank
+ if (cameraExecutor != null && !cameraExecutor.isShutdown()) {
+ cameraExecutor.execute(() -> {
+ final Bitmap thumbnail = getThumbnail(savedImageUri);
+ // Update UI on main thread
+ requireActivity().runOnUiThread(() -> {
+ if (thumbnailAdapter != null) {
+ thumbnailAdapter.replaceLoadingThumbnail(savedImageUri, thumbnail);
+ }
+ });
+ });
+ }
+ } catch (FileNotFoundException e) {
+ Logger.error(TAG, "File not found for saved image: " + savedImageUri, e);
+ showErrorToast("Image file not found");
+ } catch (OutOfMemoryError e) {
+ Logger.error(TAG, "Out of memory when processing image: " + savedImageUri, e);
+ showErrorToast("Not enough memory to process image");
+ // Try to recover by clearing the cache
+ if (imageCache != null) {
+ imageCache.clear();
+ }
+ System.gc(); // Request garbage collection
+ } catch (Exception e) {
+ Logger.error(TAG, "Error processing saved image: " + savedImageUri, e);
+ showErrorToast("Error processing image");
+ } finally {
+ if (stream != null) {
+ try {
+ stream.close();
+ } catch (IOException e) {
+ Logger.error(TAG, "Error closing input stream", e);
+ }
+ }
+ }
+ } else {
+ Logger.error(TAG, "Saved image URI is null", null);
+ showErrorToast("Failed to save image");
+ }
+ }
+
+ @Override
+ public void onError(@NonNull ImageCaptureException exception) {
+ int errorCode = exception.getImageCaptureError();
+ String errorMessage;
+
+ switch (errorCode) {
+ case ImageCapture.ERROR_CAMERA_CLOSED:
+ errorMessage = "Camera was closed during capture";
+ break;
+ case ImageCapture.ERROR_CAPTURE_FAILED:
+ errorMessage = "Image capture failed";
+ break;
+ case ImageCapture.ERROR_FILE_IO:
+ errorMessage = "File write operation failed";
+ break;
+ case ImageCapture.ERROR_INVALID_CAMERA:
+ errorMessage = "Selected camera cannot be found";
+ break;
+ default:
+ errorMessage = "Unknown error during image capture";
+ break;
+ }
+
+ Logger.error(TAG, "Image capture error: " + errorMessage, exception);
+
+ // Remove any loading thumbnails since capture failed
+ requireActivity().runOnUiThread(() -> {
+ if (thumbnailAdapter != null && thumbnailAdapter.hasLoadingThumbnails()) {
+ thumbnailAdapter.removeLoadingThumbnails();
+ }
+ });
+
+ showErrorToast(errorMessage);
+ }
+ }
+ );
+ }
+ );
+ controlsContainer.addView(takePictureButton);
+ }
+
+ private void createFlipButtonForLandscape(FragmentActivity fragmentActivity, int margin, ColorStateList buttonColors) {
+ flipCameraButton = new FloatingActionButton(fragmentActivity);
+ flipCameraButton.setId(View.generateViewId());
+ flipCameraButton.setImageResource(R.drawable.flip_camera_android_24px);
+ flipCameraButton.setColorFilter(Color.WHITE);
+ flipCameraButton.setBackgroundTintList(buttonColors);
+ flipButtonLayoutParams = new RelativeLayout.LayoutParams(
+ RelativeLayout.LayoutParams.WRAP_CONTENT,
+ RelativeLayout.LayoutParams.WRAP_CONTENT
+ );
+ // Position at the bottom center of controls container with safe area margins
+ flipButtonLayoutParams.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM);
+ flipButtonLayoutParams.addRule(RelativeLayout.CENTER_HORIZONTAL);
+
+ // Use safe area insets to ensure button is not too close to bottom cutouts
+ int safeBottomMargin = Math.max(margin * 2, safeInsetBottom + margin);
+ flipButtonLayoutParams.setMargins(0, 0, 0, safeBottomMargin);
+ flipCameraButton.setLayoutParams(flipButtonLayoutParams);
+ flipCameraButton.setOnClickListener(
+ v -> {
+ v.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY);
+
+ // Clean up any loading thumbnails since camera swap will cancel ongoing captures
+ if (thumbnailAdapter != null && thumbnailAdapter.hasLoadingThumbnails()) {
+ thumbnailAdapter.removeLoadingThumbnails();
+ showErrorToast("Capture cancelled due to camera switch");
+ }
+
+ Logger.debug(TAG, "Switching camera from " + (lensFacing == CameraSelector.LENS_FACING_FRONT ? "FRONT" : "BACK"));
+ lensFacing = lensFacing == CameraSelector.LENS_FACING_FRONT ? CameraSelector.LENS_FACING_BACK : CameraSelector.LENS_FACING_FRONT;
+ Logger.debug(TAG, "Switched camera to " + (lensFacing == CameraSelector.LENS_FACING_FRONT ? "FRONT" : "BACK"));
+
+ flashButton.setVisibility(lensFacing == CameraSelector.LENS_FACING_BACK ? View.VISIBLE : View.GONE);
+ if (!zoomTabs.isEmpty()) {
+ Logger.debug(TAG, "Clearing " + zoomTabs.size() + " zoom tabs");
+ if (zoomTabLayout != null) {
+ zoomTabLayout.removeAllTabs();
+ }
+ if (verticalZoomContainer != null) {
+ verticalZoomContainer.removeAllViews();
+ }
+ zoomTabs.clear();
+ }
+
+ // Set the camera selector before setting up camera to ensure correct zoom capabilities
+ CameraSelector cameraSelector = new CameraSelector.Builder().requireLensFacing(lensFacing).build();
+ cameraController.setCameraSelector(cameraSelector);
+
+ setupCamera();
+ }
+ );
+ controlsContainer.addView(flipCameraButton);
+ }
+
+ private void createDoneButtonForLandscape(FragmentActivity fragmentActivity, int margin, ColorStateList buttonColors) {
+ doneButton = new FloatingActionButton(fragmentActivity);
+ doneButton.setId(View.generateViewId());
+ doneButton.setImageResource(R.drawable.done_24px);
+ doneButton.setColorFilter(Color.WHITE);
+ doneButton.setBackgroundTintList(buttonColors);
+ doneButtonLayoutParams = new RelativeLayout.LayoutParams(
+ RelativeLayout.LayoutParams.WRAP_CONTENT,
+ RelativeLayout.LayoutParams.WRAP_CONTENT
+ );
+ // Position at the top center of controls container with safe area margins
+ doneButtonLayoutParams.addRule(RelativeLayout.ALIGN_PARENT_TOP);
+ doneButtonLayoutParams.addRule(RelativeLayout.CENTER_HORIZONTAL);
+
+ // Use safe area insets to ensure button is not too close to top cutouts
+ int safeTopMargin = Math.max(margin * 2, safeInsetTop + margin);
+ doneButtonLayoutParams.setMargins(0, safeTopMargin, 0, 0);
+ doneButton.setLayoutParams(doneButtonLayoutParams);
+ doneButton.setOnClickListener(
+ view -> {
+ view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY);
+ done();
+ }
+ );
+ controlsContainer.addView(doneButton);
+ }
+
+ private void createCloseButtonForLandscape(FragmentActivity fragmentActivity, int margin, ColorStateList buttonColors) {
+ closeButton = new FloatingActionButton(fragmentActivity);
+ closeButton.setId(View.generateViewId());
+ closeButton.setImageResource(R.drawable.close_24px);
+ closeButton.setBackgroundTintList(buttonColors);
+ closeButton.setColorFilter(Color.WHITE);
+ closeButtonLayoutParams = new RelativeLayout.LayoutParams(
+ RelativeLayout.LayoutParams.WRAP_CONTENT,
+ RelativeLayout.LayoutParams.WRAP_CONTENT
+ );
+ // Position at the top left of the preview area with safe area margins
+ closeButtonLayoutParams.addRule(RelativeLayout.ALIGN_PARENT_TOP);
+ closeButtonLayoutParams.addRule(RelativeLayout.ALIGN_PARENT_LEFT);
+
+ // Use safe area insets to ensure button is positioned away from cutouts
+ int safeLeftMargin = Math.max(margin * 2, safeInsetLeft + margin);
+ int safeTopMargin = Math.max(margin * 2, safeInsetTop + margin);
+ closeButtonLayoutParams.setMargins(safeLeftMargin, safeTopMargin, 0, 0);
+ closeButton.setLayoutParams(closeButtonLayoutParams);
+ closeButton.setOnClickListener(
+ view -> {
+ view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY);
+ if (imageCache != null && imageCache.size() > 0) {
+ new AlertDialog.Builder(requireContext())
+ .setTitle(CONFIRM_CANCEL_TITLE)
+ .setMessage(CONFIRM_CANCEL_MESSAGE)
+ .setPositiveButton(CONFIRM_CANCEL_POSITIVE, (dialogInterface, i) -> cancel())
+ .setNegativeButton(CONFIRM_CANCEL_NEGATIVE, (dialogInterface, i) -> dialogInterface.dismiss())
+ .create()
+ .show();
+ } else {
+ cancel();
+ }
+ }
+ );
+
+ // Add to the main layout instead of the controls container
+ relativeLayout.addView(closeButton);
+ }
+
+ private void createFlashButtonForLandscape(FragmentActivity fragmentActivity, int margin, ColorStateList buttonColors) {
+ flashButton = new FloatingActionButton(fragmentActivity);
+ flashButton.setId(View.generateViewId());
+ flashButton.setImageResource(R.drawable.flash_auto_24px);
+ flashButton.setBackgroundTintList(buttonColors);
+ flashButton.setColorFilter(Color.WHITE);
+ flashButtonLayoutParams = new RelativeLayout.LayoutParams(
+ RelativeLayout.LayoutParams.WRAP_CONTENT,
+ RelativeLayout.LayoutParams.WRAP_CONTENT
+ );
+ // Position at the bottom left of the preview area with safe area margins
+ flashButtonLayoutParams.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM);
+ flashButtonLayoutParams.addRule(RelativeLayout.ALIGN_PARENT_LEFT);
+
+ // Use safe area insets to ensure button is positioned away from cutouts
+ int safeLeftMargin = Math.max(margin * 2, safeInsetLeft + margin);
+ int safeBottomMargin = Math.max(margin * 2, safeInsetBottom + margin);
+ flashButtonLayoutParams.setMargins(safeLeftMargin, 0, 0, safeBottomMargin);
+ flashButton.setLayoutParams(flashButtonLayoutParams);
+ flashButton.setOnClickListener(
+ view -> {
+ view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY);
+ flashMode = cameraController.getImageCaptureFlashMode();
+ switch (flashMode) {
+ case ImageCapture.FLASH_MODE_OFF:
+ {
+ flashMode = ImageCapture.FLASH_MODE_ON;
+ flashButton.setImageResource(R.drawable.flash_on_24px);
+ flashButton.setColorFilter(Color.WHITE);
+ break;
+ }
+ case ImageCapture.FLASH_MODE_ON:
+ {
+ flashMode = ImageCapture.FLASH_MODE_AUTO;
+ flashButton.setImageResource(R.drawable.flash_auto_24px);
+ flashButton.setColorFilter(Color.WHITE);
+ break;
+ }
+ case ImageCapture.FLASH_MODE_AUTO:
+ {
+ flashMode = ImageCapture.FLASH_MODE_OFF;
+ flashButton.setImageResource(R.drawable.flash_off_24px);
+ flashButton.setColorFilter(Color.WHITE);
+ break;
+ }
+ default:
+ throw new IllegalStateException("Unexpected flash mode: " + flashMode);
+ }
+ cameraController.setImageCaptureFlashMode(flashMode);
+ }
+ );
+
+ // Add to the main layout instead of the controls container
+ relativeLayout.addView(flashButton);
+ }
+
+ private void createFlashButton(FragmentActivity fragmentActivity, int margin, ColorStateList buttonColors) {
+ flashButton = new FloatingActionButton(fragmentActivity);
+ flashButton.setId(View.generateViewId());
+ flashButton.setImageResource(R.drawable.flash_auto_24px);
+ flashButton.setBackgroundTintList(buttonColors);
+ flashButton.setColorFilter(Color.WHITE);
+ flashButtonLayoutParams = new RelativeLayout.LayoutParams(
+ RelativeLayout.LayoutParams.WRAP_CONTENT,
+ RelativeLayout.LayoutParams.WRAP_CONTENT
+ );
+ flashButtonLayoutParams.addRule(RelativeLayout.ALIGN_PARENT_TOP);
+ flashButtonLayoutParams.addRule(RelativeLayout.ALIGN_PARENT_RIGHT);
+ int topMargin = (int) (margin * 2.5);
+ flashButtonLayoutParams.setMargins(0, topMargin, margin, 0);
+ flashButton.setLayoutParams(flashButtonLayoutParams);
+ flashButton.setOnClickListener(
+ view -> {
+ view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY);
+ flashMode = cameraController.getImageCaptureFlashMode();
+ switch (flashMode) {
+ case ImageCapture.FLASH_MODE_OFF:
+ {
+ flashMode = ImageCapture.FLASH_MODE_ON;
+ flashButton.setImageResource(R.drawable.flash_on_24px);
+ flashButton.setColorFilter(Color.WHITE);
+ break;
+ }
+ case ImageCapture.FLASH_MODE_ON:
+ {
+ flashMode = ImageCapture.FLASH_MODE_AUTO;
+ flashButton.setImageResource(R.drawable.flash_auto_24px);
+ flashButton.setColorFilter(Color.WHITE);
+ break;
+ }
+ case ImageCapture.FLASH_MODE_AUTO:
+ {
+ flashMode = ImageCapture.FLASH_MODE_OFF;
+ flashButton.setImageResource(R.drawable.flash_off_24px);
+ flashButton.setColorFilter(Color.WHITE);
+ break;
+ }
+ default:
+ throw new IllegalStateException("Unexpected flash mode: " + flashMode);
+ }
+ cameraController.setImageCaptureFlashMode(flashMode);
+ }
+ );
+ relativeLayout.addView(flashButton);
+ }
+
+ private void createTakePictureButton(FragmentActivity fragmentActivity, int margin, ColorStateList buttonColors) {
+ takePictureButton = new FloatingActionButton(fragmentActivity);
+ takePictureButton.setId(View.generateViewId());
+ takePictureButton.setImageResource(R.drawable.ic_shutter_circle);
+ takePictureButton.setBackgroundColor(Color.TRANSPARENT);
+ takePictureButton.setBackgroundTintList(buttonColors);
+
+ int fabSize = dpToPx(fragmentActivity, 84);
+ int iconSize = (int) (fabSize * 0.9);
+ takePictureButton.setCustomSize(fabSize);
+ takePictureButton.setMaxImageSize(iconSize);
+
+ takePictureLayoutParams = new RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.WRAP_CONTENT);
+ takePictureLayoutParams.addRule(RelativeLayout.CENTER_IN_PARENT);
+ takePictureButton.setLayoutParams(takePictureLayoutParams);
+ takePictureButton.setStateListAnimator(android.animation.AnimatorInflater.loadStateListAnimator(fragmentActivity, R.animator.button_press_animation));
+ takePictureButton.setOnClickListener(
+ v -> {
+ v.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY);
+ mediaActionSound.play(MediaActionSound.SHUTTER_CLICK);
+
+ // Add loading thumbnail immediately for visual feedback
+ if (thumbnailAdapter != null) {
+ thumbnailAdapter.addLoadingThumbnail();
+ // Scroll to show the new loading thumbnail
+ if (filmstripView != null) {
+ filmstripView.scrollToPosition(thumbnailAdapter.getItemCount() - 1);
+ }
+ }
+
+ var name = new SimpleDateFormat(FILENAME, Locale.US).format(System.currentTimeMillis());
+ var contentValues = new ContentValues();
+ contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, name);
+ contentValues.put(MediaStore.MediaColumns.MIME_TYPE, PHOTO_TYPE);
+ var outputOptions = new ImageCapture.OutputFileOptions.Builder(
+ requireContext().getContentResolver(),
+ MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
+ contentValues
+ )
+ .build();
+
+ cameraController.takePicture(
+ outputOptions,
+ cameraExecutor,
+ new ImageCapture.OnImageSavedCallback() {
+ @Override
+ public void onImageSaved(@NonNull ImageCapture.OutputFileResults outputFileResults) {
+ Uri savedImageUri = outputFileResults.getSavedUri();
+ if (savedImageUri != null) {
+ InputStream stream = null;
+ try {
+ stream = requireContext().getContentResolver().openInputStream(savedImageUri);
+ if (stream == null) {
+ Logger.error(TAG, "Failed to open input stream for saved image: " + savedImageUri, null);
+ showErrorToast("Failed to process captured image");
+ return;
+ }
+
+ BitmapFactory.Options options = new BitmapFactory.Options();
+ options.inPreferredConfig = Bitmap.Config.ARGB_8888;
+ Bitmap bmp = BitmapFactory.decodeStream(stream, null, options);
+
+ if (bmp == null) {
+ Logger.error(TAG, "Failed to decode bitmap from saved image: " + savedImageUri, null);
+ showErrorToast("Failed to process captured image");
+ return;
+ }
+
+ // Process bitmap with quality and size settings
+ Bitmap processedBmp = processBitmap(bmp, savedImageUri);
+ if (processedBmp != bmp && bmp != null) {
+ bmp.recycle(); // Recycle original if it was replaced
+ }
+
+ addImageToCache(savedImageUri, processedBmp);
+
+ // Generate thumbnail on a background thread to avoid UI jank
+ if (cameraExecutor != null && !cameraExecutor.isShutdown()) {
+ cameraExecutor.execute(() -> {
+ final Bitmap thumbnail = getThumbnail(savedImageUri);
+ // Update UI on main thread
+ requireActivity().runOnUiThread(() -> {
+ if (thumbnailAdapter != null) {
+ thumbnailAdapter.replaceLoadingThumbnail(savedImageUri, thumbnail);
+ }
+ });
+ });
+ }
+ } catch (FileNotFoundException e) {
+ Logger.error(TAG, "File not found for saved image: " + savedImageUri, e);
+ showErrorToast("Image file not found");
+ } catch (OutOfMemoryError e) {
+ Logger.error(TAG, "Out of memory when processing image: " + savedImageUri, e);
+ showErrorToast("Not enough memory to process image");
+ // Try to recover by clearing the cache
+ if (imageCache != null) {
+ imageCache.clear();
+ }
+ System.gc(); // Request garbage collection
+ } catch (Exception e) {
+ Logger.error(TAG, "Error processing saved image: " + savedImageUri, e);
+ showErrorToast("Error processing image");
+ } finally {
+ if (stream != null) {
+ try {
+ stream.close();
+ } catch (IOException e) {
+ Logger.error(TAG, "Error closing input stream", e);
+ }
+ }
+ }
+ } else {
+ Logger.error(TAG, "Saved image URI is null", null);
+ showErrorToast("Failed to save image");
+ }
+ }
+
+ @Override
+ public void onError(@NonNull ImageCaptureException exception) {
+ int errorCode = exception.getImageCaptureError();
+ String errorMessage;
+
+ switch (errorCode) {
+ case ImageCapture.ERROR_CAMERA_CLOSED:
+ errorMessage = "Camera was closed during capture";
+ break;
+ case ImageCapture.ERROR_CAPTURE_FAILED:
+ errorMessage = "Image capture failed";
+ break;
+ case ImageCapture.ERROR_FILE_IO:
+ errorMessage = "File write operation failed";
+ break;
+ case ImageCapture.ERROR_INVALID_CAMERA:
+ errorMessage = "Selected camera cannot be found";
+ break;
+ default:
+ errorMessage = "Unknown error during image capture";
+ break;
+ }
+
+ Logger.error(TAG, "Image capture error: " + errorMessage, exception);
+
+ // Remove any loading thumbnails since capture failed
+ requireActivity().runOnUiThread(() -> {
+ if (thumbnailAdapter != null && thumbnailAdapter.hasLoadingThumbnails()) {
+ thumbnailAdapter.removeLoadingThumbnails();
+ }
+ });
+
+ showErrorToast(errorMessage);
+ }
+ }
+ );
+ }
+ );
+ bottomBar.addView(takePictureButton);
+ }
+
+ private void createFlipButton(FragmentActivity fragmentActivity, int margin, ColorStateList buttonColors) {
+ flipCameraButton = new FloatingActionButton(fragmentActivity);
+ flipCameraButton.setId(View.generateViewId());
+ flipCameraButton.setImageResource(R.drawable.flip_camera_android_24px);
+ flipCameraButton.setColorFilter(Color.WHITE);
+ flipCameraButton.setBackgroundTintList(buttonColors);
+ flipButtonLayoutParams = new RelativeLayout.LayoutParams(
+ RelativeLayout.LayoutParams.WRAP_CONTENT,
+ RelativeLayout.LayoutParams.WRAP_CONTENT
+ );
+ flipButtonLayoutParams.addRule(RelativeLayout.ALIGN_PARENT_START);
+ flipButtonLayoutParams.addRule(RelativeLayout.CENTER_VERTICAL);
+ flipButtonLayoutParams.setMargins(margin, 0, 0, 0);
+ flipCameraButton.setLayoutParams(flipButtonLayoutParams);
+ flipCameraButton.setOnClickListener(
+ v -> {
+ v.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY);
+
+ // Clean up any loading thumbnails since camera swap will cancel ongoing captures
+ if (thumbnailAdapter != null && thumbnailAdapter.hasLoadingThumbnails()) {
+ thumbnailAdapter.removeLoadingThumbnails();
+ showErrorToast("Capture cancelled due to camera switch");
+ }
+
+ Logger.debug(TAG, "Switching camera from " + (lensFacing == CameraSelector.LENS_FACING_FRONT ? "FRONT" : "BACK"));
+ lensFacing = lensFacing == CameraSelector.LENS_FACING_FRONT ? CameraSelector.LENS_FACING_BACK : CameraSelector.LENS_FACING_FRONT;
+ Logger.debug(TAG, "Switched camera to " + (lensFacing == CameraSelector.LENS_FACING_FRONT ? "FRONT" : "BACK"));
+
+ flashButton.setVisibility(lensFacing == CameraSelector.LENS_FACING_BACK ? View.VISIBLE : View.GONE);
+ if (!zoomTabs.isEmpty()) {
+ Logger.debug(TAG, "Clearing " + zoomTabs.size() + " zoom tabs");
+ if (zoomTabLayout != null) {
+ zoomTabLayout.removeAllTabs();
+ }
+ if (verticalZoomContainer != null) {
+ verticalZoomContainer.removeAllViews();
+ }
+ zoomTabs.clear();
+ }
+
+ // Set the camera selector before setting up camera to ensure correct zoom capabilities
+ CameraSelector cameraSelector = new CameraSelector.Builder().requireLensFacing(lensFacing).build();
+ cameraController.setCameraSelector(cameraSelector);
+
+ setupCamera();
+ }
+ );
+ bottomBar.addView(flipCameraButton);
+ }
+
+ /**
+ * Creates a container for camera controls in landscape mode
+ */
+ private void createControlsContainerForLandscape(FragmentActivity fragmentActivity, ColorStateList buttonColors, int margin) {
+ // Create a container for controls on the right side
+ controlsContainer = new RelativeLayout(fragmentActivity);
+ controlsContainer.setId(View.generateViewId());
+ controlsContainer.setBackgroundColor(Color.BLACK);
+
+ // Calculate safe control margin to avoid camera cutouts
+ int safeMargin = getSafeControlMargin(margin);
+
+ // Set the container to take up the right portion of the screen, accounting for safe area
+ // Base width is 20% of screen, but we add safe area insets to ensure controls are visible
+ int baseContainerWidth = (int) (displayMetrics.widthPixels * 0.2);
+ int containerWidth = Math.max(baseContainerWidth, safeInsetRight + dpToPx(fragmentActivity, 80)); // 80dp min for controls
+
+ RelativeLayout.LayoutParams containerParams = new RelativeLayout.LayoutParams(
+ containerWidth,
+ RelativeLayout.LayoutParams.MATCH_PARENT
+ );
+ containerParams.addRule(RelativeLayout.ALIGN_PARENT_RIGHT);
+
+ // Add top and bottom margins to account for safe area insets
+ containerParams.setMargins(0, safeInsetTop, 0, safeInsetBottom);
+
+ controlsContainer.setLayoutParams(containerParams);
+
+ // Add the container to the main layout
+ relativeLayout.addView(controlsContainer);
+
+ // First remove any existing layout rules from the preview view
+ RelativeLayout.LayoutParams previewParams = (RelativeLayout.LayoutParams) previewView.getLayoutParams();
+ previewParams.width = displayMetrics.widthPixels - containerWidth;
+ previewParams.height = RelativeLayout.LayoutParams.MATCH_PARENT;
+
+ // Clear any existing rules that might be interfering
+ previewParams.removeRule(RelativeLayout.ABOVE);
+ previewParams.removeRule(RelativeLayout.BELOW);
+ previewParams.removeRule(RelativeLayout.RIGHT_OF);
+ previewParams.removeRule(RelativeLayout.LEFT_OF);
+ previewParams.removeRule(RelativeLayout.CENTER_IN_PARENT);
+ previewParams.removeRule(RelativeLayout.CENTER_HORIZONTAL);
+ previewParams.removeRule(RelativeLayout.CENTER_VERTICAL);
+
+ // Set the correct rules for landscape mode
+ previewParams.addRule(RelativeLayout.ALIGN_PARENT_LEFT);
+ previewParams.addRule(RelativeLayout.ALIGN_PARENT_TOP);
+ previewParams.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM);
+ previewParams.addRule(RelativeLayout.LEFT_OF, controlsContainer.getId());
+
+ // Add left margin to account for safe area insets on the left side
+ previewParams.setMargins(safeInsetLeft, 0, 0, 0);
+ previewView.setLayoutParams(previewParams);
+
+ // Force the scale type to FILL_CENTER to ensure it fills the available space
+ previewView.setScaleType(PreviewView.ScaleType.FILL_CENTER);
+
+ // Ensure the preview view has a black background to prevent transparency
+ previewView.setBackgroundColor(Color.BLACK);
+
+ // Create camera control buttons for landscape mode that go in the right container
+ createTakePictureButtonForLandscape(fragmentActivity, safeMargin, buttonColors);
+ createFlipButtonForLandscape(fragmentActivity, safeMargin, buttonColors);
+ createDoneButtonForLandscape(fragmentActivity, safeMargin, buttonColors);
+
+ // Create buttons that go directly on the main layout (left side)
+ createCloseButtonForLandscape(fragmentActivity, safeMargin, buttonColors);
+ createFlashButtonForLandscape(fragmentActivity, safeMargin, buttonColors);
+
+ // Create zoom tabs for landscape mode
+ createZoomTabLayoutForLandscape(fragmentActivity, safeMargin);
+
+ // Create filmstrip for landscape mode
+ createFilmstripViewForLandscape(fragmentActivity);
+ }
+
+ @SuppressLint("ClickableViewAccessibility")
+ private void createPreviewView(FragmentActivity fragmentActivity) {
+ previewView = new PreviewView(fragmentActivity);
+ previewView.setId(View.generateViewId());
+
+ RelativeLayout.LayoutParams previewLayoutParams;
+
+ if (isLandscape) {
+ // In landscape mode, explicitly set width to account for controls container
+ int containerWidth = (int) (displayMetrics.widthPixels * 0.2);
+ previewLayoutParams = new RelativeLayout.LayoutParams(
+ RelativeLayout.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT
+ );
+ // Initially set to match_parent, we'll adjust the width after the controls container is created
+ previewLayoutParams.addRule(RelativeLayout.ALIGN_PARENT_LEFT);
+ previewLayoutParams.addRule(RelativeLayout.ALIGN_PARENT_TOP);
+ previewLayoutParams.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM);
+ previewLayoutParams.setMargins(0, 0, 0, 0);
+ } else {
+ // In portrait mode, use match_parent for width
+ previewLayoutParams = new RelativeLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT
+ );
+ // We'll set the ABOVE rule after bottomBar is created
+ }
+
+ previewView.setLayoutParams(previewLayoutParams);
+
+ // Set background color to black to prevent transparency
+ previewView.setBackgroundColor(Color.BLACK);
+
+ // Use FILL_CENTER for both orientations to ensure the preview fills the available space
+ previewView.setScaleType(PreviewView.ScaleType.FILL_CENTER);
+
+ previewView.setOnTouchListener(
+ (v, event) -> {
+ if (focusIndicator != null) {
+ // Position the focus indicator at the touch point
+ focusIndicator.setX(event.getX() - (focusIndicator.getWidth() / 2f));
+ focusIndicator.setY(event.getY() - (focusIndicator.getHeight() / 2f));
+ }
+
+ // Let the PreviewView handle the rest of the touch event.
+ // Returning false allows the default tap-to-focus behavior to trigger.
+ return false;
+ }
+ );
+
+ relativeLayout.addView(previewView);
+ }
+
+ private void createFocusIndicator(Context context) {
+ focusIndicator = new ImageView(context);
+ focusIndicator.setImageResource(R.drawable.center_focus_24px);
+
+ int size = dpToPx(context, 72);
+ RelativeLayout.LayoutParams layoutParams = new RelativeLayout.LayoutParams(size, size);
+ focusIndicator.setLayoutParams(layoutParams);
+
+ focusIndicator.setColorFilter(Color.WHITE);
+ focusIndicator.setVisibility(View.INVISIBLE); // Initially hidden
+
+ relativeLayout.addView(focusIndicator);
+ }
+
+ private void createZoomTabLayout(FragmentActivity fragmentActivity, int margin) {
+ zoomTabCardView = new CardView(fragmentActivity);
+ zoomTabCardView.setId(View.generateViewId());
+
+ // Make the card view rounded corners
+ GradientDrawable backgroundDrawable = new GradientDrawable();
+ backgroundDrawable.setShape(GradientDrawable.RECTANGLE);
+ backgroundDrawable.setColor(ZOOM_TAB_LAYOUT_BACKGROUND_COLOR);
+ backgroundDrawable.setCornerRadius(dpToPx(requireContext(), 56 / 2));
+ zoomTabCardView.setBackground(backgroundDrawable);
+
+ // Define the LayoutParams for the cardView
+ cardViewLayoutParams = new RelativeLayout.LayoutParams(
+ RelativeLayout.LayoutParams.WRAP_CONTENT,
+ RelativeLayout.LayoutParams.WRAP_CONTENT
+ );
+ cardViewLayoutParams.addRule(RelativeLayout.ABOVE, bottomBar.getId());
+ cardViewLayoutParams.addRule(RelativeLayout.CENTER_HORIZONTAL);
+ cardViewLayoutParams.setMargins(margin, margin, margin, margin); // Adjust bottom margin as needed
+ zoomTabCardView.setLayoutParams(cardViewLayoutParams);
+
+ relativeLayout.addView(zoomTabCardView);
+
+ zoomTabLayout = new TabLayout(fragmentActivity);
+ zoomTabLayout.setId(View.generateViewId());
+ tabLayoutParams = new RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.WRAP_CONTENT);
+ zoomTabLayout.setLayoutParams(tabLayoutParams);
+
+ // Set TabLayout parameters
+ zoomTabLayout.setTabGravity(TabLayout.GRAVITY_FILL);
+ zoomTabLayout.setTabMode(TabLayout.MODE_FIXED);
+ zoomTabLayout.setSelectedTabIndicatorColor(Color.TRANSPARENT); // No indicator color
+ zoomTabLayout.setSelectedTabIndicator(null);
+ zoomTabLayout.setBackgroundColor(Color.TRANSPARENT); // Transparent background to let card view color show
+ zoomTabLayout.setBackground(null);
+
+ // Set the listener for tab selection to change the text color and background
+ zoomTabLayout.addOnTabSelectedListener(
+ new TabLayout.OnTabSelectedListener() {
+ @Override
+ public void onTabSelected(TabLayout.Tab tab) {
+ ZoomTab zoomTab = zoomTabs.get(tab.getPosition());
+ zoomTab.setSelected(true);
+ if (!isSnappingZoom.get()) {
+ zoomTab.setTransientZoomLevel(null);
+ if (cameraController != null) {
+ cameraController.setZoomRatio(zoomTab.getZoomLevel());
+ }
+ }
+ }
+
+ @Override
+ public void onTabUnselected(TabLayout.Tab tab) {
+ ZoomTab zoomTab = zoomTabs.get(tab.getPosition());
+ zoomTab.setSelected(false);
+ zoomTab.setTransientZoomLevel(null);
+ }
+
+ @Override
+ public void onTabReselected(TabLayout.Tab tab) {
+ ZoomTab zoomTab = zoomTabs.get(tab.getPosition());
+ zoomTab.setSelected(true);
+ if (!isSnappingZoom.get()) {
+ zoomTab.setTransientZoomLevel(null);
+ if (cameraController != null) {
+ cameraController.setZoomRatio(zoomTab.getZoomLevel());
+ }
+ }
+ }
+ }
+ );
+
+ zoomTabCardView.addView(zoomTabLayout);
+
+ // Use ViewTreeObserver to ensure zoom tab layout is properly laid out
+ zoomTabCardViewListener = new ViewTreeObserver.OnGlobalLayoutListener() {
+ @Override
+ public void onGlobalLayout() {
+ // Remove the listener to prevent multiple callbacks
+ zoomTabCardView.getViewTreeObserver().removeOnGlobalLayoutListener(this);
+ zoomTabCardViewListener = null;
+
+ // Ensure the tab layout has the correct width
+ if (zoomTabLayout.getWidth() > 0) {
+ // Distribute tab width evenly
+ TabLayout.Tab tab = zoomTabLayout.getTabAt(0);
+ if (tab != null && tab.view != null) {
+ int tabWidth = zoomTabLayout.getWidth() / zoomTabLayout.getTabCount();
+ for (int i = 0; i < zoomTabLayout.getTabCount(); i++) {
+ TabLayout.Tab currentTab = zoomTabLayout.getTabAt(i);
+ if (currentTab != null && currentTab.view != null) {
+ ViewGroup.LayoutParams layoutParams = currentTab.view.getLayoutParams();
+ layoutParams.width = tabWidth;
+ currentTab.view.setLayoutParams(layoutParams);
+ }
+ }
+ }
+ }
+ }
+ };
+ zoomTabCardView.getViewTreeObserver().addOnGlobalLayoutListener(zoomTabCardViewListener);
+ }
+
+ private void createZoomTabLayoutForLandscape(FragmentActivity fragmentActivity, int margin) {
+ zoomTabCardView = new CardView(fragmentActivity);
+ zoomTabCardView.setId(View.generateViewId());
+
+ // Make the card view rounded corners
+ GradientDrawable backgroundDrawable = new GradientDrawable();
+ backgroundDrawable.setShape(GradientDrawable.RECTANGLE);
+ backgroundDrawable.setColor(ZOOM_TAB_LAYOUT_BACKGROUND_COLOR);
+ backgroundDrawable.setCornerRadius(dpToPx(requireContext(), 56 / 2));
+ zoomTabCardView.setBackground(backgroundDrawable);
+
+ // Define the LayoutParams for the cardView in landscape mode
+ cardViewLayoutParams = new RelativeLayout.LayoutParams(
+ RelativeLayout.LayoutParams.WRAP_CONTENT,
+ RelativeLayout.LayoutParams.WRAP_CONTENT
+ );
+ // Position it to the left of the controls container, centered vertically in the preview area
+ cardViewLayoutParams.addRule(RelativeLayout.LEFT_OF, controlsContainer.getId());
+ cardViewLayoutParams.addRule(RelativeLayout.CENTER_VERTICAL);
+ cardViewLayoutParams.setMargins(margin, margin, margin, margin);
+ zoomTabCardView.setLayoutParams(cardViewLayoutParams);
+
+ // Add to the main layout (preview area) instead of the controls container
+ relativeLayout.addView(zoomTabCardView);
+
+ // Create a LinearLayout with vertical orientation instead of TabLayout for landscape mode
+ LinearLayout verticalZoomContainer = new LinearLayout(fragmentActivity);
+ verticalZoomContainer.setId(View.generateViewId());
+ verticalZoomContainer.setOrientation(LinearLayout.VERTICAL);
+ verticalZoomContainer.setGravity(Gravity.CENTER);
+
+ // Use WRAP_CONTENT for both width and height to make the container compact
+ LinearLayout.LayoutParams containerParams = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.WRAP_CONTENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ );
+ verticalZoomContainer.setLayoutParams(containerParams);
+
+ // Add padding between buttons
+ int buttonSpacing = dpToPx(fragmentActivity, 8);
+ verticalZoomContainer.setPadding(buttonSpacing, buttonSpacing, buttonSpacing, buttonSpacing);
+
+ zoomTabCardView.addView(verticalZoomContainer);
+
+ // Store the vertical container for use in createZoomTabsForLandscape
+ zoomTabLayout = null; // We're not using TabLayout in landscape mode
+ this.verticalZoomContainer = verticalZoomContainer;
+ }
+
+ private void createZoomTabs(FragmentActivity fragmentActivity, TabLayout tabLayout) {
+ // This method is for portrait mode with TabLayout
+ createZoomTabsInternal(fragmentActivity, tabLayout, null);
+ }
+
+ private void createZoomTabsForLandscape(FragmentActivity fragmentActivity, LinearLayout verticalContainer) {
+ // This method is for landscape mode with vertical LinearLayout
+ createZoomTabsInternal(fragmentActivity, null, verticalContainer);
+ }
+
+ private void createZoomTabsInternal(FragmentActivity fragmentActivity, TabLayout tabLayout, LinearLayout verticalContainer) {
+ float[] zoomLevels;
+
+ Logger.debug(TAG, "Creating zoom tabs for camera facing: " + (lensFacing == CameraSelector.LENS_FACING_FRONT ? "FRONT" : "BACK") +
+ ", minZoom: " + minZoom + ", maxZoom: " + maxZoom);
+
+ // For front camera, don't include ultra-wide (minZoom like 0.6x) as it's not useful
+ if (lensFacing == CameraSelector.LENS_FACING_FRONT) {
+ zoomLevels = new float[]{ 1f, 2f };
+ } else {
+ // For back camera, include minZoom (like 0.6x ultra-wide) if it's less than 1f
+ if (minZoom < 1f) {
+ zoomLevels = new float[]{ minZoom, 1f, 2f, 5f };
+ } else {
+ zoomLevels = new float[]{ 1f, 2f, 5f };
+ }
+ }
+
+ Logger.debug(TAG, "Zoom levels to create: " + java.util.Arrays.toString(zoomLevels));
+
+ int selectedTabIndex = -1;
+ for (int i = 0; i < zoomLevels.length; i++) {
+ float zoomLevel = zoomLevels[i];
+ // Skip zoom levels that exceed the maximum supported zoom
+ if (zoomLevel > maxZoom) {
+ Logger.debug(TAG, "Skipping zoom level " + zoomLevel + " because it exceeds maxZoom " + maxZoom);
+ continue;
+ }
+
+ // Use smaller circle size for landscape mode
+ int circleSize = (verticalContainer != null) ? 32 : 40;
+ ZoomTab zoomTab = new ZoomTab(fragmentActivity, zoomLevel, circleSize, i);
+ zoomTabs.add(zoomTab);
+
+ if (tabLayout != null) {
+ // Portrait mode - add to TabLayout
+ TabLayout.Tab tab = tabLayout.newTab();
+ tab.setCustomView(zoomTab.getView());
+ tabLayout.addTab(tab);
+ } else if (verticalContainer != null) {
+ // Landscape mode - add to vertical LinearLayout
+ View zoomView = zoomTab.getView();
+
+ // Create layout params with margin for vertical spacing
+ LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.WRAP_CONTENT,
+ LinearLayout.LayoutParams.WRAP_CONTENT
+ );
+ int margin = dpToPx(fragmentActivity, 4);
+ params.setMargins(0, margin, 0, margin);
+ zoomView.setLayoutParams(params);
+
+ // Add click listener for landscape mode
+ final int finalIndex = zoomTabs.size() - 1;
+ zoomView.setOnClickListener(v -> {
+ // Unselect all tabs
+ for (ZoomTab tab : zoomTabs) {
+ tab.setSelected(false);
+ }
+ // Select this tab
+ zoomTab.setSelected(true);
+ // Set zoom level
+ if (!isSnappingZoom.get()) {
+ zoomTab.setTransientZoomLevel(null);
+ if (cameraController != null) {
+ cameraController.setZoomRatio(zoomTab.getZoomLevel());
+ }
+ }
+ });
+
+ verticalContainer.addView(zoomView);
+ }
+
+ // Track which should be the default selected tab (1x zoom)
+ if (Math.abs(zoomLevel - 1f) < 0.01f) {
+ selectedTabIndex = zoomTabs.size() - 1;
+ }
+ }
+
+ // Select the 1x zoom tab
+ if (selectedTabIndex >= 0) {
+ if (tabLayout != null && selectedTabIndex < tabLayout.getTabCount()) {
+ tabLayout.selectTab(tabLayout.getTabAt(selectedTabIndex));
+ } else if (verticalContainer != null && selectedTabIndex < zoomTabs.size()) {
+ // For landscape mode, manually select the 1x zoom tab
+ zoomTabs.get(selectedTabIndex).setSelected(true);
+ }
+ }
+ }
+
+ private void createFilmstripView(FragmentActivity fragmentActivity) {
+ filmstripView = new RecyclerView(fragmentActivity);
+ RelativeLayout.LayoutParams filmstripLayoutParams = new RelativeLayout.LayoutParams(
+ RelativeLayout.LayoutParams.MATCH_PARENT,
+ RelativeLayout.LayoutParams.WRAP_CONTENT
+ );
+ filmstripLayoutParams.addRule(RelativeLayout.CENTER_HORIZONTAL);
+ filmstripLayoutParams.addRule(RelativeLayout.ABOVE, zoomTabCardView.getId());
+ filmstripView.setLayoutParams(filmstripLayoutParams);
+
+ // Add padding to the filmstrip to prevent clipping of the remove button
+ int padding = dpToPx(fragmentActivity, 12);
+ filmstripView.setPadding(padding, padding, padding, padding);
+ filmstripView.setClipToPadding(false);
+
+ LinearLayoutManager layoutManager = new LinearLayoutManager(fragmentActivity, LinearLayoutManager.HORIZONTAL, false);
+ filmstripView.setLayoutManager(layoutManager);
+
+ // If we already have an adapter with thumbnails, preserve them
+ ThumbnailAdapter existingAdapter = thumbnailAdapter;
+ thumbnailAdapter = new ThumbnailAdapter();
+
+ // If we had an existing adapter with thumbnails, transfer them to the new adapter
+ if (existingAdapter != null && existingAdapter.getItemCount() > 0) {
+ for (int i = 0; i < existingAdapter.getItemCount(); i++) {
+ ThumbnailAdapter.ThumbnailItem item = existingAdapter.getThumbnailItem(i);
+ if (item != null) {
+ if (!item.isLoading()) {
+ thumbnailAdapter.addThumbnail(item.getUri(), item.getBitmap());
+ }
+ }
+ }
+ }
+ filmstripView.setAdapter(thumbnailAdapter);
+ relativeLayout.addView(filmstripView);
+
+ // Use ViewTreeObserver to ensure filmstrip is properly laid out
+ filmstripViewListener = new ViewTreeObserver.OnGlobalLayoutListener() {
+ @Override
+ public void onGlobalLayout() {
+ // Remove the listener to prevent multiple callbacks
+ filmstripView.getViewTreeObserver().removeOnGlobalLayoutListener(this);
+ filmstripViewListener = null;
+
+ // Scroll to the end of the filmstrip to show the most recent thumbnails
+ if (thumbnailAdapter.getItemCount() > 0) {
+ filmstripView.scrollToPosition(thumbnailAdapter.getItemCount() - 1);
+ }
+ }
+ };
+ filmstripView.getViewTreeObserver().addOnGlobalLayoutListener(filmstripViewListener);
+
+ thumbnailAdapter.setOnThumbnailsChangedCallback(
+ new ThumbnailAdapter.OnThumbnailsChangedCallback() {
+ @Override
+ public void onThumbnailRemoved(Uri uri, Bitmap bmp) {
+ Bitmap bitmap = getImageFromCache(uri);
+ if (imageCache != null) {
+ imageCache.remove(uri);
+ }
+
+ if (!deleteFile(uri)) {
+ Logger.warn(TAG, "Failed to delete file after thumbnail removal: " + uri);
+ // Even if deletion fails, we've already removed it from the UI and cache,
+ // so we don't need to show an error to the user
+ }
+ }
+ }
+ );
+
+ // Set click listener for thumbnails to show preview
+ thumbnailAdapter.setOnThumbnailClickListener(new ThumbnailAdapter.OnThumbnailClickListener() {
+ @Override
+ public void onThumbnailClick(Uri uri, Bitmap bitmap) {
+ showImagePreview(uri);
+ }
+ });
+ }
+
+ private void createFilmstripViewForLandscape(FragmentActivity fragmentActivity) {
+ filmstripView = new RecyclerView(fragmentActivity);
+ RelativeLayout.LayoutParams filmstripLayoutParams = new RelativeLayout.LayoutParams(
+ RelativeLayout.LayoutParams.MATCH_PARENT,
+ RelativeLayout.LayoutParams.WRAP_CONTENT
+ );
+
+ // Position the filmstrip at the bottom of the preview area, but to the right of the flash button
+ filmstripLayoutParams.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM);
+ filmstripLayoutParams.addRule(RelativeLayout.RIGHT_OF, flashButton.getId());
+
+ // Calculate width to fill the remaining space (minus controls container and some margin for the flash button)
+ int flashButtonWidth = dpToPx(fragmentActivity, 56); // Approximate width of FAB
+ int margin = dpToPx(fragmentActivity, 20);
+ // Calculate width to use more of the available space
+ // We're keeping the 20% for controls container but reducing other margins
+ filmstripLayoutParams.width = displayMetrics.widthPixels -
+ (int)(displayMetrics.widthPixels * 0.2) - // Controls container
+ flashButtonWidth - // Flash button width
+ margin; // Reduced margin to allow more thumbnails
+
+ // Add left margin to create space between flash button and filmstrip
+ filmstripLayoutParams.setMargins(margin, 0, 0, margin);
+
+ filmstripView.setLayoutParams(filmstripLayoutParams);
+
+ // Add padding to the filmstrip - reduced for landscape mode
+ int padding = dpToPx(fragmentActivity, 6);
+ filmstripView.setPadding(padding, padding, padding, padding);
+ filmstripView.setClipToPadding(false);
+
+ LinearLayoutManager layoutManager = new LinearLayoutManager(fragmentActivity, LinearLayoutManager.HORIZONTAL, false);
+ filmstripView.setLayoutManager(layoutManager);
+
+ // Create a custom adapter with smaller thumbnails for landscape mode
+ // If we already have an adapter with thumbnails, preserve them
+ ThumbnailAdapter existingAdapter = thumbnailAdapter;
+ thumbnailAdapter = new ThumbnailAdapter() {
+ @Override
+ public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ Context context = parent.getContext();
+ int thumbnailSize = dpToPx(context, 60); // Smaller thumbnail size for landscape mode (was 80dp)
+ int margin = dpToPx(context, 3); // Smaller margin for landscape mode (was 4dp)
+
+ FrameLayout frameLayout = new FrameLayout(context);
+ FrameLayout.LayoutParams layoutParams = new FrameLayout.LayoutParams(thumbnailSize, thumbnailSize);
+ layoutParams.setMargins(margin, margin, margin, margin);
+ frameLayout.setLayoutParams(layoutParams);
+
+ ImageView imageView = new ImageView(context);
+ imageView.setScaleType(ImageView.ScaleType.CENTER_CROP);
+ frameLayout.addView(imageView);
+
+ ImageView removeButton = new ImageView(context);
+ int buttonSize = dpToPx(context, 20); // Smaller remove button (was 24dp)
+ FrameLayout.LayoutParams buttonParams = new FrameLayout.LayoutParams(buttonSize, buttonSize);
+ buttonParams.gravity = Gravity.TOP | Gravity.END;
+ removeButton.setLayoutParams(buttonParams);
+ removeButton.setImageResource(R.drawable.ic_cancel_white_24dp);
+ frameLayout.addView(removeButton);
+
+ // Add progress bar for loading state
+ ProgressBar progressBar = new ProgressBar(context);
+ int progressSize = dpToPx(context, 24); // Smaller progress bar for landscape mode
+ FrameLayout.LayoutParams progressParams = new FrameLayout.LayoutParams(progressSize, progressSize);
+ progressParams.gravity = Gravity.CENTER;
+ progressBar.setLayoutParams(progressParams);
+ progressBar.setVisibility(View.GONE); // Initially hidden
+ frameLayout.addView(progressBar);
+
+ return new ViewHolder(frameLayout, imageView, removeButton, progressBar);
+ }
+ };
+
+ // If we had an existing adapter with thumbnails, transfer them to the new adapter
+ if (existingAdapter != null && existingAdapter.getItemCount() > 0) {
+ for (int i = 0; i < existingAdapter.getItemCount(); i++) {
+ ThumbnailAdapter.ThumbnailItem item = existingAdapter.getThumbnailItem(i);
+ if (item != null) {
+ if (!item.isLoading()) {
+ thumbnailAdapter.addThumbnail(item.getUri(), item.getBitmap());
+ }
+ }
+ }
+ }
+ filmstripView.setAdapter(thumbnailAdapter);
+ relativeLayout.addView(filmstripView);
+
+ // Use ViewTreeObserver to ensure filmstrip is properly laid out in landscape mode
+ filmstripViewSecondaryListener = new ViewTreeObserver.OnGlobalLayoutListener() {
+ @Override
+ public void onGlobalLayout() {
+ // Remove the listener to prevent multiple callbacks
+ filmstripView.getViewTreeObserver().removeOnGlobalLayoutListener(this);
+ filmstripViewSecondaryListener = null;
+
+ // Scroll to the end of the filmstrip to show the most recent thumbnails
+ if (thumbnailAdapter.getItemCount() > 0) {
+ filmstripView.scrollToPosition(thumbnailAdapter.getItemCount() - 1);
+ }
+ }
+ };
+ filmstripView.getViewTreeObserver().addOnGlobalLayoutListener(filmstripViewSecondaryListener);
+
+ thumbnailAdapter.setOnThumbnailsChangedCallback(
+ new ThumbnailAdapter.OnThumbnailsChangedCallback() {
+ @Override
+ public void onThumbnailRemoved(Uri uri, Bitmap bmp) {
+ if (imageCache != null) {
+ imageCache.remove(uri);
+ }
+
+ if (!deleteFile(uri)) {
+ Logger.warn(TAG, "Failed to delete file after thumbnail removal in landscape mode: " + uri);
+ // Even if deletion fails, we've already removed it from the UI and cache,
+ // so we don't need to show an error to the user
+ }
+ }
+ }
+ );
+
+ // Set click listener for thumbnails to show preview
+ thumbnailAdapter.setOnThumbnailClickListener(new ThumbnailAdapter.OnThumbnailClickListener() {
+ @Override
+ public void onThumbnailClick(Uri uri, Bitmap bitmap) {
+ showImagePreview(uri);
+ }
+ });
+ }
+
+ private void createDoneButton(FragmentActivity fragmentActivity, int margin, ColorStateList buttonColors) {
+ doneButton = new FloatingActionButton(fragmentActivity);
+ doneButton.setId(View.generateViewId());
+ doneButton.setImageResource(R.drawable.done_24px);
+ doneButton.setColorFilter(Color.WHITE);
+ doneButton.setBackgroundTintList(buttonColors);
+ doneButtonLayoutParams = new RelativeLayout.LayoutParams(
+ RelativeLayout.LayoutParams.WRAP_CONTENT,
+ RelativeLayout.LayoutParams.WRAP_CONTENT
+ );
+ doneButtonLayoutParams.addRule(RelativeLayout.ALIGN_PARENT_END);
+ doneButtonLayoutParams.addRule(RelativeLayout.CENTER_VERTICAL);
+ doneButtonLayoutParams.setMargins(0, 0, margin, 0);
+ doneButton.setLayoutParams(doneButtonLayoutParams);
+ doneButton.setOnClickListener(
+ view -> {
+ view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY);
+ done();
+ }
+ );
+ bottomBar.addView(doneButton);
+ }
+
+ private void createCloseButton(FragmentActivity fragmentActivity, int margin, ColorStateList buttonColors) {
+ closeButton = new FloatingActionButton(fragmentActivity);
+ closeButton.setId(View.generateViewId());
+ closeButton.setImageResource(R.drawable.close_24px);
+ closeButton.setBackgroundTintList(buttonColors);
+ closeButton.setColorFilter(Color.WHITE);
+ closeButtonLayoutParams = new RelativeLayout.LayoutParams(
+ RelativeLayout.LayoutParams.WRAP_CONTENT,
+ RelativeLayout.LayoutParams.WRAP_CONTENT
+ );
+ closeButtonLayoutParams.addRule(RelativeLayout.ALIGN_PARENT_TOP);
+ closeButtonLayoutParams.addRule(RelativeLayout.ALIGN_PARENT_LEFT);
+ // Increase top margin for immersive mode (e.g., 2.5x the original margin)
+ int topMargin = (int) (margin * 2.5);
+ closeButtonLayoutParams.setMargins(margin, topMargin, 0, 0);
+ closeButton.setLayoutParams(closeButtonLayoutParams);
+ closeButton.setOnClickListener(
+ view -> {
+ view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY);
+ if (imageCache != null && imageCache.size() > 0) {
+ new AlertDialog.Builder(requireContext())
+ .setTitle(CONFIRM_CANCEL_TITLE)
+ .setMessage(CONFIRM_CANCEL_MESSAGE)
+ .setPositiveButton(CONFIRM_CANCEL_POSITIVE, (dialogInterface, i) -> cancel())
+ .setNegativeButton(CONFIRM_CANCEL_NEGATIVE, (dialogInterface, i) -> dialogInterface.dismiss())
+ .create()
+ .show();
+ } else {
+ cancel();
+ }
+ }
+ );
+ relativeLayout.addView(closeButton);
+ }
+
+ /**
+ * Deletes a file from the device storage
+ *
+ * @param fileUri The URI of the file to delete
+ * @return true if deletion was successful, false otherwise
+ */
+ private boolean deleteFile(Uri fileUri) {
+ if (fileUri == null) {
+ Logger.error(TAG, "Cannot delete null URI", null);
+ return false;
+ }
+
+ try {
+ ContentResolver contentResolver = requireContext().getContentResolver();
+ int deleted = contentResolver.delete(fileUri, null, null);
+
+ if (deleted == 0) {
+ // File deletion failed
+ Logger.error(TAG, "Failed to delete file: " + fileUri, null);
+ return false;
+ } else {
+ // File deletion successful
+ Logger.info(TAG, "File deleted: " + fileUri);
+ return true;
+ }
+ } catch (SecurityException e) {
+ // Handle permission issues
+ Logger.error(TAG, "Security exception when deleting file: " + fileUri, e);
+ showErrorToast("Permission denied to delete image");
+ return false;
+ } catch (IllegalArgumentException e) {
+ // Handle invalid URI
+ Logger.error(TAG, "Invalid URI when deleting file: " + fileUri, e);
+ return false;
+ } catch (Exception e) {
+ // Handle any other exceptions
+ Logger.error(TAG, "Error deleting file: " + fileUri, e);
+ return false;
+ }
+ }
+
+ /**
+ * Shows a full-screen preview of the image when a thumbnail is clicked
+ *
+ * @param uri The URI of the image to display in the preview
+ */
+ private void showImagePreview(Uri uri) {
+ if (thumbnailAdapter == null) {
+ // Fallback to single image preview if no adapter
+ ImagePreviewFragment previewFragment = ImagePreviewFragment.newInstance(uri);
+ previewFragment.show(requireActivity().getSupportFragmentManager(), "image_preview");
+ return;
+ }
+
+ // Gather all non-loading image URIs and find the current position
+ List imageUris = new ArrayList<>();
+ int currentPosition = 0;
+
+ for (int i = 0; i < thumbnailAdapter.getItemCount(); i++) {
+ ThumbnailAdapter.ThumbnailItem item = thumbnailAdapter.getThumbnailItem(i);
+ if (item != null && !item.isLoading()) {
+ if (uri.equals(item.getUri())) {
+ currentPosition = imageUris.size();
+ }
+ imageUris.add(item.getUri());
+ }
+ }
+
+ if (imageUris.isEmpty()) {
+ // Fallback to single image preview if no images found
+ ImagePreviewFragment previewFragment = ImagePreviewFragment.newInstance(uri);
+ previewFragment.show(requireActivity().getSupportFragmentManager(), "image_preview");
+ return;
+ }
+
+ // Show preview with swipe navigation
+ ImagePreviewFragment previewFragment = ImagePreviewFragment.newInstance(imageUris, currentPosition);
+ previewFragment.show(requireActivity().getSupportFragmentManager(), "image_preview");
+ }
+
+ private void setupCamera() throws IllegalStateException {
+ cameraController
+ .getInitializationFuture()
+ .addListener(() -> {
+ if (!hasFrontFacingCamera()) {
+ flipCameraButton.setVisibility(View.GONE);
+ }
+ }, ContextCompat.getMainExecutor(requireContext()));
+
+ cameraController
+ .getZoomState()
+ .observe(
+ requireActivity(),
+ zoomState -> {
+ zoomRatio = zoomState;
+ minZoom = zoomState.getMinZoomRatio();
+ maxZoom = zoomState.getMaxZoomRatio();
+
+ Logger.debug(TAG, "Zoom state changed - minZoom: " + minZoom + ", maxZoom: " + maxZoom + ", current zoom tabs: " + zoomTabs.size());
+
+ if (zoomTabs.isEmpty()) {
+ Logger.debug(TAG, "Creating zoom tabs because zoomTabs is empty");
+ if (isLandscape && verticalZoomContainer != null) {
+ createZoomTabsForLandscape(requireActivity(), verticalZoomContainer);
+ } else if (zoomTabLayout != null) {
+ createZoomTabs(requireActivity(), zoomTabLayout);
+ }
+ } else {
+ Logger.debug(TAG, "Not creating zoom tabs because zoomTabs is not empty (" + zoomTabs.size() + " tabs exist)");
+ }
+
+ if (zoomRunnable != null) {
+ zoomHandler.removeCallbacks(zoomRunnable);
+ }
+
+ zoomRunnable =
+ () -> {
+ float currentZoom = zoomRatio.getZoomRatio();
+ ZoomTab closestTab = null;
+ final float threshold = 0.05f; // Threshold for considering the next zoom level
+
+ for (int i = 0; i < zoomTabs.size(); i++) {
+ ZoomTab currentTab = zoomTabs.get(i);
+ // Check if this is the last tab or if the current zoom is less than the next tab's level minus the threshold
+ if (i == zoomTabs.size() - 1 || currentZoom < zoomTabs.get(i + 1).zoomLevel - threshold) {
+ closestTab = currentTab;
+ break;
+ }
+ }
+
+ // If we found a closest tab, update its display and select the tab.
+ if (closestTab != null) {
+ closestTab.setTransientZoomLevel(currentZoom); // Update the tab's display to show the current zoom level
+
+ if (isLandscape && verticalZoomContainer != null) {
+ // For landscape mode with vertical container, manually handle selection
+ isSnappingZoom.set(true);
+ // Unselect all tabs
+ for (ZoomTab tab : zoomTabs) {
+ tab.setSelected(false);
+ }
+ // Select the closest tab
+ closestTab.setSelected(true);
+ isSnappingZoom.set(false);
+ } else if (zoomTabLayout != null) {
+ // For portrait mode with TabLayout
+ TabLayout.Tab tab = zoomTabLayout.getTabAt(closestTab.getTabIndex());
+ if (tab != null) {
+ isSnappingZoom.set(true);
+ zoomTabLayout.selectTab(tab); // This will not trigger the camera zoom change due to the isSnappingZoom flag
+ isSnappingZoom.set(false);
+ }
+ }
+ }
+ };
+ zoomHandler.post(zoomRunnable);
+ }
+ );
+
+ cameraController
+ .getTapToFocusState()
+ .observe(
+ requireActivity(),
+ tapToFocusState -> {
+ if (focusIndicator == null) return;
+ // Show and animate the focus indicator when focusing starts
+ if (tapToFocusState == LifecycleCameraController.TAP_TO_FOCUS_STARTED) {
+ focusIndicator.setVisibility(View.VISIBLE);
+ focusIndicator.setAlpha(0f); // Start fully transparent
+ focusIndicator.animate().alpha(1f).setDuration(200).setInterpolator(new AccelerateDecelerateInterpolator()).start();
+ } else {
+ // Fade out and hide the focus indicator when focusing ends, regardless of the result
+ focusIndicator
+ .animate()
+ .alpha(0f)
+ .setDuration(500)
+ .setInterpolator(new AccelerateDecelerateInterpolator())
+ .withEndAction(() -> focusIndicator.setVisibility(View.INVISIBLE))
+ .start();
+ }
+ }
+ );
+
+ CameraSelector cameraSelector = new CameraSelector.Builder().requireLensFacing(lensFacing).build();
+ cameraController.setCameraSelector(cameraSelector);
+ cameraController.setPinchToZoomEnabled(true);
+ cameraController.setTapToFocusEnabled(true);
+ cameraController.setImageCaptureFlashMode(flashMode);
+ }
+
+ /**
+ * Shows an error toast message to the user
+ *
+ * @param message The error message to display
+ */
+ private void showErrorToast(String message) {
+ if (getActivity() != null && !getActivity().isFinishing()) {
+ requireActivity().runOnUiThread(() -> {
+ try {
+ android.widget.Toast.makeText(
+ requireContext(),
+ message,
+ android.widget.Toast.LENGTH_SHORT
+ ).show();
+ } catch (Exception e) {
+ // Fail silently if we can't show a toast
+ Logger.error(TAG, "Failed to show error toast: " + message, e);
+ }
+ });
+ }
+ }
+
+ private boolean hasFrontFacingCamera() {
+ if (cameraController != null) {
+ CameraSelector frontFacing = new CameraSelector.Builder().requireLensFacing(CameraSelector.LENS_FACING_FRONT).build();
+
+ return cameraController.hasCamera(frontFacing);
+ }
+ return false;
+ }
+
+ /**
+ * Gets a memory-efficient thumbnail for an image URI using downsampling techniques
+ *
+ * @param imageUri The URI of the image to create a thumbnail for
+ * @return A downsampled bitmap thumbnail or null if creation failed
+ */
+ @SuppressWarnings("deprecation")
+ private Bitmap getThumbnail(Uri imageUri) {
+ ContentResolver contentResolver = requireContext().getContentResolver();
+ InputStream inputStream = null;
+
+ try {
+ // Target thumbnail size (smaller than the original implementation to save memory)
+ int targetWidth = (int) (displayMetrics.widthPixels * 0.2);
+ int targetHeight = (int) (displayMetrics.heightPixels * 0.2);
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ try {
+ // For Android 10+ use the built-in thumbnail loader with our target size
+ Size size = new Size(targetWidth, targetHeight);
+ return contentResolver.loadThumbnail(imageUri, size, null);
+ } catch (IOException e) {
+ // Fall back to manual downsampling if the built-in method fails
+ Logger.warn(TAG, "Failed to load thumbnail with system API, falling back to manual downsampling");
+ // Continue to manual downsampling below
+ }
+ }
+
+ // Manual downsampling approach for pre-Q devices or as fallback
+
+ // FIRST PASS: Decode bounds only to determine dimensions
+ BitmapFactory.Options boundsOptions = new BitmapFactory.Options();
+ boundsOptions.inJustDecodeBounds = true; // Only decode bounds, not the actual bitmap
+
+ inputStream = contentResolver.openInputStream(imageUri);
+ BitmapFactory.decodeStream(inputStream, null, boundsOptions);
+ if (inputStream != null) {
+ inputStream.close();
+ }
+
+ // Calculate optimal inSampleSize for downsampling
+ int inSampleSize = calculateInSampleSize(boundsOptions, targetWidth, targetHeight);
+
+ // SECOND PASS: Decode with calculated inSampleSize
+ BitmapFactory.Options decodeOptions = new BitmapFactory.Options();
+ decodeOptions.inSampleSize = inSampleSize;
+ decodeOptions.inPreferredConfig = Bitmap.Config.RGB_565; // Use RGB_565 instead of ARGB_8888 to reduce memory usage by half
+
+ inputStream = contentResolver.openInputStream(imageUri);
+ Bitmap thumbnail = BitmapFactory.decodeStream(inputStream, null, decodeOptions);
+
+ return thumbnail;
+
+ } catch (Exception e) {
+ Logger.error(TAG, "Error creating thumbnail", e);
+
+ // Last resort fallback for older devices
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
+ try {
+ String[] projection = { MediaStore.Images.Media._ID };
+ Cursor cursor = contentResolver.query(imageUri, projection, null, null, null);
+
+ if (cursor != null && cursor.moveToFirst()) {
+ int idColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID);
+ long imageId = cursor.getLong(idColumn);
+ cursor.close();
+
+ return MediaStore.Images.Thumbnails.getThumbnail(
+ contentResolver,
+ imageId,
+ MediaStore.Images.Thumbnails.MINI_KIND,
+ null
+ );
+ }
+ if (cursor != null) {
+ cursor.close();
+ }
+ } catch (Exception ex) {
+ Logger.error(TAG, "Error in thumbnail fallback", ex);
+ }
+ }
+
+ return null;
+ } finally {
+ // Ensure streams are always closed
+ if (inputStream != null) {
+ try {
+ inputStream.close();
+ } catch (IOException e) {
+ Logger.error(TAG, "Error closing input stream", e);
+ }
+ }
+ }
+ }
+
+ /**
+ * Calculates the optimal inSampleSize value for downsampling
+ *
+ * @param options BitmapFactory.Options with outWidth and outHeight set
+ * @param reqWidth Requested width of the resulting bitmap
+ * @param reqHeight Requested height of the resulting bitmap
+ * @return The optimal inSampleSize value (power of 2)
+ */
+ private int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) {
+ // Raw height and width of image
+ final int height = options.outHeight;
+ final int width = options.outWidth;
+ int inSampleSize = 1;
+
+ if (height > reqHeight || width > reqWidth) {
+ final int halfHeight = height / 2;
+ final int halfWidth = width / 2;
+
+ // Calculate the largest inSampleSize value that is a power of 2 and keeps both
+ // height and width larger than the requested height and width.
+ while ((halfHeight / inSampleSize) >= reqHeight && (halfWidth / inSampleSize) >= reqWidth) {
+ inSampleSize *= 2;
+ }
+ }
+
+ return inSampleSize;
+ }
+
+ public abstract static class OnImagesCapturedCallback {
+
+ public void onCaptureSuccess(HashMap images) {}
+
+ public void onCaptureCanceled() {}
+ }
+
+ public class ZoomTab {
+
+ private final float zoomLevel;
+ private final int tabIndex;
+ private final int circleSize;
+ private final TextView textView;
+ private final GradientDrawable background;
+ private Float transientZoomLevel;
+
+ public ZoomTab(Context context, float zoomLevel, int circleSize, int tabIndex) {
+ this.zoomLevel = zoomLevel;
+ this.transientZoomLevel = null;
+ this.textView = new TextView(context);
+ this.background = new GradientDrawable();
+ this.circleSize = circleSize;
+
+ setupTextView();
+ this.tabIndex = tabIndex;
+ }
+
+ private void setupTextView() {
+ String formattedZoom = getFormattedZoom();
+ textView.setGravity(Gravity.CENTER);
+ textView.setText(formattedZoom);
+ textView.setTextSize(12);
+ textView.setBackgroundColor(Color.TRANSPARENT);
+
+ int padding = dpToPx(requireContext(), 8);
+ textView.setPadding(padding, padding, padding, padding);
+
+ int circlePx = dpToPx(requireContext(), circleSize);
+ background.setShape(GradientDrawable.OVAL);
+ background.setSize(circlePx, circlePx); // Make it circular
+
+ textView.setBackground(background);
+
+ ViewGroup.LayoutParams layoutParams = new ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT
+ );
+ textView.setLayoutParams(layoutParams);
+
+ setSelected(false);
+ }
+
+ @NonNull
+ private String getFormattedZoom() {
+ String formattedZoom;
+
+ float whichZoom = transientZoomLevel == null ? zoomLevel : transientZoomLevel;
+
+ if (whichZoom < 0) {
+ formattedZoom = "0x"; // Handle zoom levels less than 0
+ } else if (whichZoom < 10) {
+ DecimalFormat formatLessThanTen = new DecimalFormat("#.#x");
+ formattedZoom = formatLessThanTen.format(whichZoom); // At most 1 digit after the decimal point if less than 10
+ } else {
+ DecimalFormat formatTenOrMore = new DecimalFormat("#x");
+ formattedZoom = formatTenOrMore.format(whichZoom); // No decimal point if 10 or greater
+ }
+ return formattedZoom;
+ }
+
+ public View getView() {
+ return textView;
+ }
+
+ public void setSelected(boolean isSelected) {
+ textView.setTextColor(isSelected ? Color.BLACK : Color.WHITE);
+ background.setColor(isSelected ? ZOOM_BUTTON_COLOR_SELECTED : ZOOM_BUTTON_COLOR_UNSELECTED);
+ }
+
+ public float getZoomLevel() {
+ return zoomLevel;
+ }
+
+ public int getTabIndex() {
+ return tabIndex;
+ }
+
+ public void setTransientZoomLevel(Float zoomLevel) {
+ transientZoomLevel = zoomLevel;
+ updateText();
+ }
+
+ private void updateText() {
+ String formattedZoom = getFormattedZoom();
+ textView.setText(formattedZoom);
+ }
+ }
+}
+
diff --git a/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraPlugin.java b/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraPlugin.java
index bcd2c91b5..5f646ec0b 100644
--- a/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraPlugin.java
+++ b/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraPlugin.java
@@ -27,6 +27,8 @@
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
import androidx.core.content.FileProvider;
+import androidx.fragment.app.FragmentTransaction;
+
import com.getcapacitor.FileUtils;
import com.getcapacitor.JSArray;
import com.getcapacitor.JSObject;
@@ -48,6 +50,7 @@
import java.io.InputStream;
import java.io.OutputStream;
import java.util.ArrayList;
+import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
@@ -104,6 +107,7 @@ public class CameraPlugin extends Plugin {
private static final String IMAGE_EDIT_ERROR = "Unable to edit image";
private static final String IMAGE_GALLERY_SAVE_ERROR = "Unable to save the image in the gallery";
private static final String USER_CANCELLED = "User cancelled photos app";
+ private static final String CAMERA_CAPTURE_CANCELED_ERROR = "User canceled the camera capture session";
private String imageFileSavePath;
private String imageEditedFileSavePath;
@@ -152,6 +156,9 @@ private void doShow(PluginCall call) {
case CAMERA:
showCamera(call);
break;
+ case CAMERA_MULTI:
+ showMultiCamera(call);
+ break;
case PHOTOS:
showPhotos(call);
break;
@@ -166,6 +173,7 @@ private void showPrompt(final PluginCall call) {
List options = new ArrayList<>();
options.add(call.getString("promptLabelPhoto", "From Photos"));
options.add(call.getString("promptLabelPicture", "Take Picture"));
+ options.add(call.getString("promptLabelPicture", "Take Multiple Pictures"));
final CameraBottomSheetDialogFragment fragment = new CameraBottomSheetDialogFragment();
fragment.setTitle(call.getString("promptLabelHeader", "Photo"));
@@ -178,6 +186,9 @@ private void showPrompt(final PluginCall call) {
} else if (index == 1) {
settings.setSource(CameraSource.CAMERA);
openCamera(call);
+ } else if (index == 2) {
+ settings.setSource(CameraSource.CAMERA_MULTI);
+ openMultiCamera(call);
}
},
() -> call.reject(USER_CANCELLED)
@@ -193,6 +204,14 @@ private void showCamera(final PluginCall call) {
openCamera(call);
}
+ private void showMultiCamera(final PluginCall call) {
+ if (!getContext().getPackageManager().hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)) {
+ call.reject(NO_CAMERA_ERROR);
+ return;
+ }
+ openMultiCamera(call);
+ }
+
private void showPhotos(final PluginCall call) {
openPhotos(call);
}
@@ -245,7 +264,7 @@ private void cameraPermissionsCallback(PluginCall call) {
if (call.getMethodName().equals("pickImages")) {
openPhotos(call, true);
} else {
- if (settings.getSource() == CameraSource.CAMERA && getPermissionState(CAMERA) != PermissionState.GRANTED) {
+ if ((settings.getSource() == CameraSource.CAMERA || settings.getSource() == CameraSource.CAMERA_MULTI) && getPermissionState(CAMERA) != PermissionState.GRANTED) {
Logger.debug(getLogTag(), "User denied camera permission: " + getPermissionState(CAMERA).toString());
call.reject(PERMISSION_DENIED_ERROR_CAMERA);
return;
@@ -320,6 +339,31 @@ public void openCamera(final PluginCall call) {
}
}
+ public void openMultiCamera(final PluginCall call) {
+ if (checkCameraPermissions(call)) {
+ final CameraFragment fragment = new CameraFragment();
+ // Pass camera settings to fragment, but disable orientation correction by default for multi-camera
+ CameraSettings multiCameraSettings = settings;
+ multiCameraSettings.setShouldCorrectOrientation(false); // Disable to prevent upside-down images
+ fragment.setCameraSettings(multiCameraSettings);
+ fragment.setImagesCapturedCallback(new CameraFragment.OnImagesCapturedCallback() {
+ @Override
+ public void onCaptureSuccess(HashMap images) {
+ returnMultiCameraResult(call, images);
+ }
+
+ @Override
+ public void onCaptureCanceled() {
+ call.reject(CAMERA_CAPTURE_CANCELED_ERROR);
+ }
+ });
+
+ FragmentTransaction transaction = getActivity().getSupportFragmentManager().beginTransaction();
+ transaction.add(android.R.id.content, fragment);
+ transaction.commit();
+ }
+ }
+
public void openPhotos(final PluginCall call) {
openPhotos(call, false);
}
@@ -593,20 +637,16 @@ private File getTempFile(Uri uri) {
return new File(cacheDir, filename);
}
- /**
- * After processing the image, return the final result back to the caller.
- * @param call
- * @param bitmap
- * @param u
- */
+
+
@SuppressWarnings("deprecation")
- private void returnResult(PluginCall call, Bitmap bitmap, Uri u) {
+ private JSObject createReturnFrom(PluginCall call, Bitmap bitmap, Uri u) {
ExifWrapper exif = ImageUtils.getExifData(getContext(), bitmap, u);
try {
bitmap = prepareBitmap(bitmap, u, exif);
} catch (IOException e) {
call.reject(UNABLE_TO_PROCESS_IMAGE);
- return;
+ return null;
}
// Compress the final image and prepare for output to client
ByteArrayOutputStream bitmapOutputStream = new ByteArrayOutputStream();
@@ -614,7 +654,7 @@ private void returnResult(PluginCall call, Bitmap bitmap, Uri u) {
if (settings.isAllowEditing() && !isEdited) {
editImage(call, u, bitmapOutputStream);
- return;
+ return null;
}
boolean saveToGallery = call.getBoolean("saveToGallery", CameraSettings.DEFAULT_SAVE_IMAGE_TO_GALLERY);
@@ -650,10 +690,10 @@ private void returnResult(PluginCall call, Bitmap bitmap, Uri u) {
}
} else {
String inserted = MediaStore.Images.Media.insertImage(
- getContext().getContentResolver(),
- fileToSavePath,
- fileToSave.getName(),
- ""
+ getContext().getContentResolver(),
+ fileToSavePath,
+ fileToSave.getName(),
+ ""
);
if (inserted == null) {
@@ -669,15 +709,35 @@ private void returnResult(PluginCall call, Bitmap bitmap, Uri u) {
}
}
+ JSObject ret = null;
if (settings.getResultType() == CameraResultType.BASE64) {
- returnBase64(call, exif, bitmapOutputStream);
+ ret = returnBase64(call, exif, bitmapOutputStream);
} else if (settings.getResultType() == CameraResultType.URI) {
- returnFileURI(call, exif, bitmap, u, bitmapOutputStream);
+ ret = returnFileURI(call, exif, bitmap, u, bitmapOutputStream);
+ if (ret == null) {
+ call.reject(UNABLE_TO_PROCESS_IMAGE);
+ }
} else if (settings.getResultType() == CameraResultType.DATAURL) {
- returnDataUrl(call, exif, bitmapOutputStream);
+ ret = returnDataUrl(call, exif, bitmapOutputStream);
} else {
call.reject(INVALID_RESULT_TYPE_ERROR);
}
+
+ return ret;
+ }
+
+ /**
+ * After processing the image, return the final result back to the caller.
+ * @param call
+ * @param bitmap
+ * @param u
+ */
+ private void returnResult(PluginCall call, Bitmap bitmap, Uri u) {
+ JSObject ret = createReturnFrom(call, bitmap, u);
+ if (ret != null) {
+ call.resolve(ret);
+ };
+
// Result returned, clear stored paths and images
if (settings.getResultType() != CameraResultType.URI) {
deleteImageFile();
@@ -688,6 +748,22 @@ private void returnResult(PluginCall call, Bitmap bitmap, Uri u) {
imageEditedFileSavePath = null;
}
+ private void returnMultiCameraResult(PluginCall call, HashMap images) {
+ settings.setAllowEditing(false); // Editing multiple photos would be cumbersome
+
+ JSObject ret = new JSObject();
+ JSArray photos = new JSArray();
+ for (Map.Entry image : images.entrySet()) {
+ JSObject single = createReturnFrom(call, image.getValue(), image.getKey());
+ if (single != null){
+ photos.put(single);
+ };
+ }
+ ret.put("photos", photos);
+
+ call.resolve(ret);
+ }
+
private void deleteImageFile() {
if (imageFileSavePath != null && !settings.isSaveToGallery()) {
File photoFile = new File(imageFileSavePath);
@@ -697,7 +773,7 @@ private void deleteImageFile() {
}
}
- private void returnFileURI(PluginCall call, ExifWrapper exif, Bitmap bitmap, Uri u, ByteArrayOutputStream bitmapOutputStream) {
+ private JSObject returnFileURI(PluginCall call, ExifWrapper exif, Bitmap bitmap, Uri u, ByteArrayOutputStream bitmapOutputStream) {
Uri newUri = getTempImage(u, bitmapOutputStream);
exif.copyExif(newUri.getPath());
if (newUri != null) {
@@ -707,9 +783,9 @@ private void returnFileURI(PluginCall call, ExifWrapper exif, Bitmap bitmap, Uri
ret.put("path", newUri.toString());
ret.put("webPath", FileUtils.getPortablePath(getContext(), bridge.getLocalUrl(), newUri));
ret.put("saved", isSaved);
- call.resolve(ret);
+ return ret;
} else {
- call.reject(UNABLE_TO_PROCESS_IMAGE);
+ return null;
}
}
@@ -761,7 +837,7 @@ private Bitmap replaceBitmap(Bitmap bitmap, final Bitmap newBitmap) {
return bitmap;
}
- private void returnDataUrl(PluginCall call, ExifWrapper exif, ByteArrayOutputStream bitmapOutputStream) {
+ private JSObject returnDataUrl(PluginCall call, ExifWrapper exif, ByteArrayOutputStream bitmapOutputStream) {
byte[] byteArray = bitmapOutputStream.toByteArray();
String encoded = Base64.encodeToString(byteArray, Base64.NO_WRAP);
@@ -769,10 +845,10 @@ private void returnDataUrl(PluginCall call, ExifWrapper exif, ByteArrayOutputStr
data.put("format", "jpeg");
data.put("dataUrl", "data:image/jpeg;base64," + encoded);
data.put("exif", exif.toJson());
- call.resolve(data);
+ return data;
}
- private void returnBase64(PluginCall call, ExifWrapper exif, ByteArrayOutputStream bitmapOutputStream) {
+ private JSObject returnBase64(PluginCall call, ExifWrapper exif, ByteArrayOutputStream bitmapOutputStream) {
byte[] byteArray = bitmapOutputStream.toByteArray();
String encoded = Base64.encodeToString(byteArray, Base64.NO_WRAP);
@@ -780,7 +856,7 @@ private void returnBase64(PluginCall call, ExifWrapper exif, ByteArrayOutputStre
data.put("format", "jpeg");
data.put("base64String", encoded);
data.put("exif", exif.toJson());
- call.resolve(data);
+ return data;
}
@Override
diff --git a/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraSource.java b/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraSource.java
index 2624c6b39..b632d43b3 100644
--- a/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraSource.java
+++ b/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraSource.java
@@ -3,6 +3,7 @@
public enum CameraSource {
PROMPT("PROMPT"),
CAMERA("CAMERA"),
+ CAMERA_MULTI("CAMERA_MULTI"),
PHOTOS("PHOTOS");
private String source;
diff --git a/camera/android/src/main/java/com/capacitorjs/plugins/camera/DeviceUtils.java b/camera/android/src/main/java/com/capacitorjs/plugins/camera/DeviceUtils.java
new file mode 100644
index 000000000..b50030285
--- /dev/null
+++ b/camera/android/src/main/java/com/capacitorjs/plugins/camera/DeviceUtils.java
@@ -0,0 +1,11 @@
+package com.capacitorjs.plugins.camera;
+
+import android.content.Context;
+import android.util.DisplayMetrics;
+
+public class DeviceUtils {
+ public static int dpToPx(Context context, int dp) {
+ DisplayMetrics displayMetrics = context.getResources().getDisplayMetrics();
+ return (int) (dp * displayMetrics.density + 0.5f);
+ }
+}
diff --git a/camera/android/src/main/java/com/capacitorjs/plugins/camera/ImagePreviewFragment.java b/camera/android/src/main/java/com/capacitorjs/plugins/camera/ImagePreviewFragment.java
new file mode 100644
index 000000000..a861d3ed7
--- /dev/null
+++ b/camera/android/src/main/java/com/capacitorjs/plugins/camera/ImagePreviewFragment.java
@@ -0,0 +1,362 @@
+package com.capacitorjs.plugins.camera;
+
+import android.content.res.Configuration;
+import android.content.res.ColorStateList;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.Color;
+import android.graphics.drawable.GradientDrawable;
+import android.net.Uri;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.FrameLayout;
+import android.widget.ImageView;
+import android.widget.ProgressBar;
+import android.widget.TextView;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.DialogFragment;
+import androidx.fragment.app.Fragment;
+import androidx.viewpager2.adapter.FragmentStateAdapter;
+import androidx.viewpager2.widget.ViewPager2;
+import com.google.android.material.floatingactionbutton.FloatingActionButton;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * A DialogFragment that displays a full-screen preview of an image.
+ * This is shown when a user taps on a thumbnail in the camera interface.
+ */
+public class ImagePreviewFragment extends DialogFragment {
+
+ private static final String ARG_IMAGE_URIS = "arg_image_uris";
+ private static final String ARG_CURRENT_POSITION = "arg_current_position";
+
+ private List imageUris;
+ private int currentPosition;
+ private ViewPager2 viewPager;
+ private TextView positionIndicator;
+
+ /**
+ * Create a new instance of ImagePreviewFragment with the provided image URI
+ *
+ * @param uri The URI of the image to display in the preview
+ * @return A new instance of ImagePreviewFragment
+ */
+ public static ImagePreviewFragment newInstance(Uri uri) {
+ List singleImageList = new ArrayList<>();
+ singleImageList.add(uri);
+ return newInstance(singleImageList, 0);
+ }
+
+ /**
+ * Create a new instance of ImagePreviewFragment with a list of images and starting position
+ *
+ * @param imageUris List of image URIs to display
+ * @param position The starting position in the list
+ * @return A new instance of ImagePreviewFragment
+ */
+ public static ImagePreviewFragment newInstance(List imageUris, int position) {
+ ImagePreviewFragment fragment = new ImagePreviewFragment();
+ Bundle args = new Bundle();
+ args.putParcelableArrayList(ARG_IMAGE_URIS, new ArrayList<>(imageUris));
+ args.putInt(ARG_CURRENT_POSITION, position);
+ fragment.setArguments(args);
+ return fragment;
+ }
+
+ @Override
+ public void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setStyle(DialogFragment.STYLE_NORMAL, android.R.style.Theme_Black_NoTitleBar_Fullscreen);
+
+ // Retrieve arguments from Bundle
+ Bundle args = getArguments();
+ if (args != null) {
+ imageUris = args.getParcelableArrayList(ARG_IMAGE_URIS);
+ currentPosition = args.getInt(ARG_CURRENT_POSITION, 0);
+ } else {
+ // Fallback in case no arguments are provided
+ imageUris = new ArrayList<>();
+ currentPosition = 0;
+ }
+ }
+
+ @Nullable
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
+ // Create the main layout
+ FrameLayout rootLayout = new FrameLayout(requireContext());
+ rootLayout.setLayoutParams(new ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT));
+ rootLayout.setBackgroundColor(Color.BLACK);
+
+ // Create ViewPager2 for swipe navigation
+ viewPager = new ViewPager2(requireContext());
+ viewPager.setLayoutParams(new FrameLayout.LayoutParams(
+ FrameLayout.LayoutParams.MATCH_PARENT,
+ FrameLayout.LayoutParams.MATCH_PARENT));
+
+ // Set up the adapter
+ ImagePagerAdapter adapter = new ImagePagerAdapter(this, imageUris);
+ viewPager.setAdapter(adapter);
+ viewPager.setCurrentItem(currentPosition, false);
+
+ rootLayout.addView(viewPager);
+
+ // Create position indicator (only show if more than one image)
+ if (imageUris.size() > 1) {
+ positionIndicator = new TextView(requireContext());
+ positionIndicator.setTextColor(Color.WHITE);
+ positionIndicator.setTextSize(16);
+
+ // Create pill-shaped background
+ GradientDrawable pillBackground = new GradientDrawable();
+ pillBackground.setShape(GradientDrawable.RECTANGLE);
+ pillBackground.setColor(0x80000000); // Semi-transparent black
+ pillBackground.setCornerRadius(dpToPx(requireContext(), 20)); // Large corner radius for pill shape
+ positionIndicator.setBackground(pillBackground);
+
+ positionIndicator.setPadding(dpToPx(requireContext(), 16), dpToPx(requireContext(), 8),
+ dpToPx(requireContext(), 16), dpToPx(requireContext(), 8));
+
+ FrameLayout.LayoutParams indicatorParams = new FrameLayout.LayoutParams(
+ FrameLayout.LayoutParams.WRAP_CONTENT,
+ FrameLayout.LayoutParams.WRAP_CONTENT);
+ indicatorParams.gravity = android.view.Gravity.TOP | android.view.Gravity.CENTER_HORIZONTAL;
+ indicatorParams.setMargins(0, dpToPx(requireContext(), 60), 0, 0);
+ positionIndicator.setLayoutParams(indicatorParams);
+
+ updatePositionIndicator(currentPosition);
+ rootLayout.addView(positionIndicator);
+
+ // Listen for page changes
+ viewPager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback() {
+ @Override
+ public void onPageSelected(int position) {
+ updatePositionIndicator(position);
+ }
+ });
+ }
+
+ // Create the close button with matching CameraFragment styling
+ FloatingActionButton closeButton = new FloatingActionButton(requireContext());
+ closeButton.setImageResource(R.drawable.close_24px);
+ closeButton.setBackgroundTintList(createButtonColorList());
+ closeButton.setColorFilter(Color.WHITE);
+ FrameLayout.LayoutParams buttonParams = new FrameLayout.LayoutParams(
+ FrameLayout.LayoutParams.WRAP_CONTENT,
+ FrameLayout.LayoutParams.WRAP_CONTENT);
+ buttonParams.setMargins(dpToPx(requireContext(), 20), dpToPx(requireContext(), 40), 0, 0);
+ closeButton.setLayoutParams(buttonParams);
+ closeButton.setOnClickListener(v -> dismiss());
+ rootLayout.addView(closeButton);
+
+ return rootLayout;
+ }
+
+ private void updatePositionIndicator(int position) {
+ if (positionIndicator != null) {
+ positionIndicator.setText((position + 1) + " / " + imageUris.size());
+ }
+ }
+
+ /**
+ * Loads the full-resolution image from the given URI
+ *
+ * @param uri The URI of the image to load
+ * @param imageView The ImageView to display the image in
+ * @param progressBar The ProgressBar to show while loading
+ */
+ private void loadFullResolutionImage(Uri uri, ImageView imageView, ProgressBar progressBar) {
+ new Thread(() -> {
+ try {
+ // Show progress bar while loading
+ requireActivity().runOnUiThread(() -> progressBar.setVisibility(View.VISIBLE));
+
+ // Load the full-resolution image
+ InputStream inputStream = requireContext().getContentResolver().openInputStream(uri);
+ Bitmap fullResolutionBitmap = BitmapFactory.decodeStream(inputStream);
+ if (inputStream != null) {
+ inputStream.close();
+ }
+
+ // Get EXIF data and correct orientation
+ ExifWrapper exifWrapper = ImageUtils.getExifData(requireContext(), fullResolutionBitmap, uri);
+ try {
+ fullResolutionBitmap = ImageUtils.correctOrientation(requireContext(), fullResolutionBitmap, uri, exifWrapper);
+
+ // Additional rotation for landscape mode if needed
+ int orientation = requireContext().getResources().getConfiguration().orientation;
+ if (orientation == Configuration.ORIENTATION_LANDSCAPE) {
+ // In landscape mode, ensure the image is properly oriented
+ // The image is already rotated based on EXIF data, so we don't need additional rotation
+ // But we ensure it's displayed with the correct aspect ratio
+ }
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+
+ // Use final reference for the bitmap to use in the UI thread
+ final Bitmap correctedBitmap = fullResolutionBitmap;
+
+ // Update UI on main thread
+ requireActivity().runOnUiThread(() -> {
+ imageView.setImageBitmap(correctedBitmap);
+ progressBar.setVisibility(View.GONE);
+ });
+ } catch (Exception e) {
+ e.printStackTrace();
+ requireActivity().runOnUiThread(() -> {
+ progressBar.setVisibility(View.GONE);
+ });
+ }
+ }).start();
+ }
+
+ private int dpToPx(android.content.Context context, int dp) {
+ return (int) (dp * context.getResources().getDisplayMetrics().density);
+ }
+
+ @NonNull
+ private static ColorStateList createButtonColorList() {
+ int[][] states = new int[][] {
+ new int[] { android.R.attr.state_enabled }, // enabled
+ new int[] { -android.R.attr.state_enabled }, // disabled
+ new int[] { -android.R.attr.state_checked }, // unchecked
+ new int[] { android.R.attr.state_pressed } // pressed
+ };
+
+ int[] colors = new int[] { Color.DKGRAY, Color.TRANSPARENT, Color.TRANSPARENT, Color.LTGRAY };
+ return new ColorStateList(states, colors);
+ }
+
+ /**
+ * Adapter for ViewPager2 to handle image swiping
+ */
+ private static class ImagePagerAdapter extends FragmentStateAdapter {
+ private final List imageUris;
+
+ public ImagePagerAdapter(@NonNull Fragment fragment, List imageUris) {
+ super(fragment);
+ this.imageUris = imageUris;
+ }
+
+ @NonNull
+ @Override
+ public Fragment createFragment(int position) {
+ return ImagePageFragment.newInstance(imageUris.get(position));
+ }
+
+ @Override
+ public int getItemCount() {
+ return imageUris.size();
+ }
+ }
+
+ /**
+ * Fragment for individual image pages in the ViewPager2
+ */
+ public static class ImagePageFragment extends Fragment {
+ private static final String ARG_IMAGE_URI = "image_uri";
+ private Uri imageUri;
+
+ public static ImagePageFragment newInstance(Uri uri) {
+ ImagePageFragment fragment = new ImagePageFragment();
+ Bundle args = new Bundle();
+ args.putParcelable(ARG_IMAGE_URI, uri);
+ fragment.setArguments(args);
+ return fragment;
+ }
+
+ @Nullable
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
+ // Retrieve the image URI from arguments
+ Bundle args = getArguments();
+ if (args != null) {
+ imageUri = args.getParcelable(ARG_IMAGE_URI);
+ }
+
+ // Create the layout for a single image
+ FrameLayout frameLayout = new FrameLayout(requireContext());
+ frameLayout.setLayoutParams(new ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT));
+
+ // Create the image view
+ ImageView imageView = new ImageView(requireContext());
+ imageView.setLayoutParams(new FrameLayout.LayoutParams(
+ FrameLayout.LayoutParams.MATCH_PARENT,
+ FrameLayout.LayoutParams.MATCH_PARENT));
+ imageView.setScaleType(ImageView.ScaleType.FIT_CENTER);
+
+ // Create a progress bar
+ ProgressBar progressBar = new ProgressBar(requireContext());
+ FrameLayout.LayoutParams progressParams = new FrameLayout.LayoutParams(
+ FrameLayout.LayoutParams.WRAP_CONTENT,
+ FrameLayout.LayoutParams.WRAP_CONTENT);
+ progressParams.gravity = android.view.Gravity.CENTER;
+ progressBar.setLayoutParams(progressParams);
+
+ frameLayout.addView(imageView);
+ frameLayout.addView(progressBar);
+
+ // Load the image
+ loadFullResolutionImage(imageUri, imageView, progressBar);
+
+ return frameLayout;
+ }
+
+ private void loadFullResolutionImage(Uri uri, ImageView imageView, ProgressBar progressBar) {
+ new Thread(() -> {
+ try {
+ // Show progress bar while loading
+ requireActivity().runOnUiThread(() -> progressBar.setVisibility(View.VISIBLE));
+
+ // Load the full-resolution image
+ InputStream inputStream = requireContext().getContentResolver().openInputStream(uri);
+ Bitmap fullResolutionBitmap = BitmapFactory.decodeStream(inputStream);
+ if (inputStream != null) {
+ inputStream.close();
+ }
+
+ // Get EXIF data and correct orientation
+ ExifWrapper exifWrapper = ImageUtils.getExifData(requireContext(), fullResolutionBitmap, uri);
+ try {
+ fullResolutionBitmap = ImageUtils.correctOrientation(requireContext(), fullResolutionBitmap, uri, exifWrapper);
+
+ // Additional rotation for landscape mode if needed
+ int orientation = requireContext().getResources().getConfiguration().orientation;
+ if (orientation == Configuration.ORIENTATION_LANDSCAPE) {
+ // In landscape mode, ensure the image is properly oriented
+ // The image is already rotated based on EXIF data, so we don't need additional rotation
+ // But we ensure it's displayed with the correct aspect ratio
+ }
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+
+ // Use final reference for the bitmap to use in the UI thread
+ final Bitmap correctedBitmap = fullResolutionBitmap;
+
+ // Update UI on main thread
+ requireActivity().runOnUiThread(() -> {
+ imageView.setImageBitmap(correctedBitmap);
+ progressBar.setVisibility(View.GONE);
+ });
+ } catch (Exception e) {
+ e.printStackTrace();
+ requireActivity().runOnUiThread(() -> {
+ progressBar.setVisibility(View.GONE);
+ });
+ }
+ }).start();
+ }
+ }
+}
\ No newline at end of file
diff --git a/camera/android/src/main/java/com/capacitorjs/plugins/camera/ImageUtils.java b/camera/android/src/main/java/com/capacitorjs/plugins/camera/ImageUtils.java
index 82d82aad0..adc02a26d 100644
--- a/camera/android/src/main/java/com/capacitorjs/plugins/camera/ImageUtils.java
+++ b/camera/android/src/main/java/com/capacitorjs/plugins/camera/ImageUtils.java
@@ -1,6 +1,7 @@
package com.capacitorjs.plugins.camera;
import android.content.Context;
+import android.content.res.Configuration;
import android.database.Cursor;
import android.graphics.Bitmap;
import android.graphics.Matrix;
@@ -73,12 +74,29 @@ private static Bitmap transform(final Bitmap bitmap, final Matrix matrix) {
*/
public static Bitmap correctOrientation(final Context c, final Bitmap bitmap, final Uri imageUri, ExifWrapper exif) throws IOException {
final int orientation = getOrientation(c, imageUri);
+
if (orientation != 0) {
Matrix matrix = new Matrix();
matrix.postRotate(orientation);
+
+ // If in landscape mode, we need to ensure the image is properly oriented
+ // The EXIF rotation should handle this correctly, but we can add additional
+ // transformations if needed based on testing
+
exif.resetOrientation();
return transform(bitmap, matrix);
} else {
+ // If there's no EXIF orientation but we're in landscape mode,
+ // check the aspect ratio and rotate if needed
+ int width = bitmap.getWidth();
+ int height = bitmap.getHeight();
+ if (width > height) {
+ // Landscape image, rotate 90 degrees to portrait
+ Matrix matrix = new Matrix();
+ matrix.postRotate(90);
+ // Optionally, you may want to flip or further adjust based on your use case
+ return transform(bitmap, matrix);
+ }
return bitmap;
}
}
diff --git a/camera/android/src/main/java/com/capacitorjs/plugins/camera/ThumbnailAdapter.java b/camera/android/src/main/java/com/capacitorjs/plugins/camera/ThumbnailAdapter.java
new file mode 100644
index 000000000..eef9c7bee
--- /dev/null
+++ b/camera/android/src/main/java/com/capacitorjs/plugins/camera/ThumbnailAdapter.java
@@ -0,0 +1,228 @@
+package com.capacitorjs.plugins.camera;
+
+import static com.capacitorjs.plugins.camera.DeviceUtils.dpToPx;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.Color;
+import android.net.Uri;
+import android.view.Gravity;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.FrameLayout;
+import android.widget.ImageView;
+import android.widget.ProgressBar;
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+import java.util.ArrayList;
+
+public class ThumbnailAdapter extends RecyclerView.Adapter {
+
+ private final ArrayList thumbnails;
+ private OnThumbnailsChangedCallback thumbnailsChangedCallback = null;
+ private OnThumbnailClickListener thumbnailClickListener = null;
+
+ ThumbnailAdapter() {
+ this.thumbnails = new ArrayList<>();
+ }
+
+ void addThumbnail(Uri uri, Bitmap thumbnail) {
+ if (thumbnail == null) return;
+ ThumbnailItem item = new ThumbnailItem(uri, thumbnail, false);
+ thumbnails.add(item);
+ notifyItemInserted(thumbnails.size() - 1);
+ }
+
+ void addLoadingThumbnail() {
+ ThumbnailItem item = new ThumbnailItem(null, null, true);
+ thumbnails.add(item);
+ notifyItemInserted(thumbnails.size() - 1);
+ }
+
+ void replaceLoadingThumbnail(Uri uri, Bitmap thumbnail) {
+ // Find the loading thumbnail (should be the last one)
+ for (int i = thumbnails.size() - 1; i >= 0; i--) {
+ ThumbnailItem item = thumbnails.get(i);
+ if (item.isLoading()) {
+ thumbnails.set(i, new ThumbnailItem(uri, thumbnail, false));
+ notifyItemChanged(i);
+ break;
+ }
+ }
+ }
+
+ void removeLoadingThumbnails() {
+ // Remove all loading thumbnails from the end backwards to avoid index issues
+ for (int i = thumbnails.size() - 1; i >= 0; i--) {
+ ThumbnailItem item = thumbnails.get(i);
+ if (item.isLoading()) {
+ thumbnails.remove(i);
+ notifyItemRemoved(i);
+ }
+ }
+ }
+
+ boolean hasLoadingThumbnails() {
+ for (ThumbnailItem item : thumbnails) {
+ if (item.isLoading()) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ @NonNull
+ @Override
+ public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ Context context = parent.getContext();
+ int thumbnailSize = dpToPx(context, 80); // Thumbnail size
+ int margin = dpToPx(context, 4); // Margin for each side
+
+ FrameLayout frameLayout = new FrameLayout(context);
+ FrameLayout.LayoutParams layoutParams = new FrameLayout.LayoutParams(thumbnailSize, thumbnailSize);
+ layoutParams.setMargins(margin, margin, margin, margin);
+ frameLayout.setLayoutParams(layoutParams);
+
+ ImageView imageView = new ImageView(context);
+ imageView.setScaleType(ImageView.ScaleType.CENTER_CROP);
+ frameLayout.addView(imageView);
+
+ ImageView removeButton = new ImageView(context);
+ int buttonSize = dpToPx(context, 24);
+ FrameLayout.LayoutParams buttonParams = new FrameLayout.LayoutParams(buttonSize, buttonSize);
+ buttonParams.gravity = Gravity.TOP | Gravity.END;
+ removeButton.setLayoutParams(buttonParams);
+ removeButton.setImageResource(R.drawable.ic_cancel_white_24dp);
+ frameLayout.addView(removeButton);
+
+ // Add progress bar for loading state
+ ProgressBar progressBar = new ProgressBar(context);
+ int progressSize = dpToPx(context, 32);
+ FrameLayout.LayoutParams progressParams = new FrameLayout.LayoutParams(progressSize, progressSize);
+ progressParams.gravity = Gravity.CENTER;
+ progressBar.setLayoutParams(progressParams);
+ progressBar.setVisibility(View.GONE); // Initially hidden
+ frameLayout.addView(progressBar);
+
+ return new ViewHolder(frameLayout, imageView, removeButton, progressBar);
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
+ ThumbnailItem item = thumbnails.get(position);
+
+ if (item.isLoading()) {
+ // Show loading state
+ holder.imageView.setImageBitmap(null);
+ holder.imageView.setBackgroundColor(Color.GRAY);
+ holder.progressBar.setVisibility(View.VISIBLE);
+ holder.removeButton.setVisibility(View.GONE);
+ holder.mainView.setOnClickListener(null); // Disable clicks for loading items
+ } else {
+ // Show actual thumbnail
+ holder.imageView.setImageBitmap(item.bitmap);
+ holder.imageView.setBackgroundColor(Color.TRANSPARENT);
+ holder.progressBar.setVisibility(View.GONE);
+ holder.removeButton.setVisibility(View.VISIBLE);
+
+ // Set click listener for the thumbnail
+ holder.mainView.setOnClickListener(v -> {
+ if (thumbnailClickListener != null) {
+ thumbnailClickListener.onThumbnailClick(item.getUri(), item.getBitmap());
+ }
+ });
+
+ holder.removeButton.setOnClickListener(
+ v -> {
+ int currentPosition = holder.getAdapterPosition();
+ if (currentPosition != RecyclerView.NO_POSITION) {
+ ThumbnailItem removed = thumbnails.remove(currentPosition);
+
+ notifyItemRemoved(currentPosition);
+
+ if (thumbnailsChangedCallback != null) {
+ thumbnailsChangedCallback.onThumbnailRemoved(removed.getUri(), removed.getBitmap());
+ }
+ }
+ }
+ );
+ }
+ }
+
+ @Override
+ public int getItemCount() {
+ return thumbnails.size();
+ }
+
+ /**
+ * Get the ThumbnailItem at the specified position
+ *
+ * @param position Position of the item to retrieve
+ * @return The ThumbnailItem at the specified position, or null if position is invalid
+ */
+ public ThumbnailItem getThumbnailItem(int position) {
+ if (position >= 0 && position < thumbnails.size()) {
+ return thumbnails.get(position);
+ }
+ return null;
+ }
+
+ public void setOnThumbnailsChangedCallback(OnThumbnailsChangedCallback callback) {
+ this.thumbnailsChangedCallback = callback;
+ }
+
+ public void setOnThumbnailClickListener(OnThumbnailClickListener listener) {
+ this.thumbnailClickListener = listener;
+ }
+
+ static class ViewHolder extends RecyclerView.ViewHolder {
+
+ ImageView imageView;
+ ImageView removeButton;
+ FrameLayout mainView;
+ ProgressBar progressBar;
+
+ ViewHolder(@NonNull FrameLayout view, @NonNull ImageView imageView, @NonNull ImageView removeButton, @NonNull ProgressBar progressBar) {
+ super(view);
+ this.imageView = imageView;
+ this.mainView = view;
+ this.removeButton = removeButton;
+ this.progressBar = progressBar;
+ }
+ }
+
+ public abstract static class OnThumbnailsChangedCallback {
+
+ public void onThumbnailRemoved(Uri uri, Bitmap bmp) {}
+ }
+
+ public interface OnThumbnailClickListener {
+ void onThumbnailClick(Uri uri, Bitmap bitmap);
+ }
+
+ public static class ThumbnailItem {
+
+ private final Uri uri;
+ private final Bitmap bitmap;
+ private final boolean loading;
+
+ public ThumbnailItem(Uri u, Bitmap bmp, boolean isLoading) {
+ this.uri = u;
+ this.bitmap = bmp;
+ this.loading = isLoading;
+ }
+
+ public Uri getUri() {
+ return uri;
+ }
+
+ public Bitmap getBitmap() {
+ return bitmap;
+ }
+
+ public boolean isLoading() {
+ return loading;
+ }
+ }
+}
+
diff --git a/camera/android/src/main/res/animator/button_press_animation.xml b/camera/android/src/main/res/animator/button_press_animation.xml
new file mode 100644
index 000000000..f0731f781
--- /dev/null
+++ b/camera/android/src/main/res/animator/button_press_animation.xml
@@ -0,0 +1,31 @@
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+
+
+
diff --git a/camera/android/src/main/res/animator/button_release_animation.xml b/camera/android/src/main/res/animator/button_release_animation.xml
new file mode 100644
index 000000000..ca5003839
--- /dev/null
+++ b/camera/android/src/main/res/animator/button_release_animation.xml
@@ -0,0 +1,19 @@
+
+
+ -
+
+
+ -
+
+
+
diff --git a/camera/android/src/main/res/drawable/center_focus_24px.xml b/camera/android/src/main/res/drawable/center_focus_24px.xml
new file mode 100644
index 000000000..49d8293b0
--- /dev/null
+++ b/camera/android/src/main/res/drawable/center_focus_24px.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/camera/android/src/main/res/drawable/close_24px.xml b/camera/android/src/main/res/drawable/close_24px.xml
new file mode 100644
index 000000000..893cccdcf
--- /dev/null
+++ b/camera/android/src/main/res/drawable/close_24px.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/camera/android/src/main/res/drawable/done_24px.xml b/camera/android/src/main/res/drawable/done_24px.xml
new file mode 100644
index 000000000..f97e17d55
--- /dev/null
+++ b/camera/android/src/main/res/drawable/done_24px.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/camera/android/src/main/res/drawable/flash_auto_24px.xml b/camera/android/src/main/res/drawable/flash_auto_24px.xml
new file mode 100644
index 000000000..7c69e1604
--- /dev/null
+++ b/camera/android/src/main/res/drawable/flash_auto_24px.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/camera/android/src/main/res/drawable/flash_off_24px.xml b/camera/android/src/main/res/drawable/flash_off_24px.xml
new file mode 100644
index 000000000..800fa0694
--- /dev/null
+++ b/camera/android/src/main/res/drawable/flash_off_24px.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/camera/android/src/main/res/drawable/flash_on_24px.xml b/camera/android/src/main/res/drawable/flash_on_24px.xml
new file mode 100644
index 000000000..1df46eb1d
--- /dev/null
+++ b/camera/android/src/main/res/drawable/flash_on_24px.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/camera/android/src/main/res/drawable/flip_camera_android_24px.xml b/camera/android/src/main/res/drawable/flip_camera_android_24px.xml
new file mode 100644
index 000000000..8c6c96594
--- /dev/null
+++ b/camera/android/src/main/res/drawable/flip_camera_android_24px.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/camera/android/src/main/res/drawable/ic_cancel_white_24dp.xml b/camera/android/src/main/res/drawable/ic_cancel_white_24dp.xml
new file mode 100644
index 000000000..fce2f3bd6
--- /dev/null
+++ b/camera/android/src/main/res/drawable/ic_cancel_white_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/camera/android/src/main/res/drawable/ic_shutter_circle.xml b/camera/android/src/main/res/drawable/ic_shutter_circle.xml
new file mode 100644
index 000000000..6b8af80eb
--- /dev/null
+++ b/camera/android/src/main/res/drawable/ic_shutter_circle.xml
@@ -0,0 +1,4 @@
+
+
+
diff --git a/camera/android/src/main/res/drawable/photo_camera_24px.xml b/camera/android/src/main/res/drawable/photo_camera_24px.xml
new file mode 100644
index 000000000..bab79577e
--- /dev/null
+++ b/camera/android/src/main/res/drawable/photo_camera_24px.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/camera/ios/Sources/CameraPlugin/CameraPlugin.swift b/camera/ios/Sources/CameraPlugin/CameraPlugin.swift
index 67b0fbd41..0a02f765e 100644
--- a/camera/ios/Sources/CameraPlugin/CameraPlugin.swift
+++ b/camera/ios/Sources/CameraPlugin/CameraPlugin.swift
@@ -169,6 +169,8 @@ public class CameraPlugin: CAPPlugin, CAPBridgedPlugin {
self.showPrompt()
case .camera:
self.showCamera()
+ case .cameraMulti:
+ self.showMultiCamera()
case .photos:
self.showPhotos()
}
@@ -414,6 +416,10 @@ private extension CameraPlugin {
self?.showCamera()
}))
+ alert.addAction(UIAlertAction(title: "Take Multiple Pictures", style: .default, handler: { [weak self] (_: UIAlertAction) in
+ self?.showMultiCamera()
+ }))
+
alert.addAction(UIAlertAction(title: settings.userPromptText.cancelAction, style: .cancel, handler: { [weak self] (_: UIAlertAction) in
self?.call?.reject("User cancelled photos app")
}))
@@ -497,6 +503,51 @@ private extension CameraPlugin {
}
}
+ func showMultiCamera() {
+ // Check if we have a camera
+ if (bridge?.isSimEnvironment ?? false) || !UIImagePickerController.isSourceTypeAvailable(UIImagePickerController.SourceType.camera) {
+ CAPLog.print("⚡️ ", self.pluginId, "-", "Camera not available in simulator")
+ call?.reject("Camera not available while running in Simulator")
+ return
+ }
+
+ // Check for permission
+ let authStatus = AVCaptureDevice.authorizationStatus(for: .video)
+ if authStatus == .restricted || authStatus == .denied {
+ call?.reject("User denied access to camera")
+ return
+ }
+
+ // We either already have permission or can prompt
+ AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
+ if granted {
+ DispatchQueue.main.async {
+ self?.presentMultiCameraPicker()
+ }
+ } else {
+ self?.call?.reject("User denied access to camera")
+ }
+ }
+ }
+
+ func presentMultiCameraPicker() {
+ // Set multiple flag to true for this operation
+ self.multiple = true
+
+ let multiCameraViewController = MultiCameraViewController()
+ multiCameraViewController.delegate = self
+ multiCameraViewController.maxImages = self.call?.getInt("limit") ?? 0
+ multiCameraViewController.cameraDirection = settings.direction
+
+ // Present the custom camera UI
+ multiCameraViewController.modalPresentationStyle = settings.presentationStyle
+ if settings.presentationStyle == .popover {
+ multiCameraViewController.popoverPresentationController?.delegate = self
+ setCenteredPopover(multiCameraViewController)
+ }
+ bridge?.viewController?.present(multiCameraViewController, animated: true, completion: nil)
+ }
+
func presentImagePicker() {
let picker = UIImagePickerController()
picker.delegate = self
@@ -582,3 +633,45 @@ private extension CameraPlugin {
return result
}
}
+
+// MARK: - MultiCameraViewControllerDelegate
+extension CameraPlugin: MultiCameraViewControllerDelegate {
+ func multiCameraViewController(_ viewController: MultiCameraViewController, didFinishWith images: [UIImage], metadata: [[String: Any]]) {
+ viewController.dismiss(animated: true) {
+ var processedImages: [ProcessedImage] = []
+
+ // Process each image
+ for (index, image) in images.enumerated() {
+ let meta = index < metadata.count ? metadata[index] : [:]
+ let processedImage = self.processedImage(from: image, with: meta)
+ processedImages.append(processedImage)
+ }
+
+ // Save images to gallery if requested, similar to single photo flow
+ if self.settings.saveToGallery {
+ let dispatchGroup = DispatchGroup()
+ var savedResults: [Bool] = Array(repeating: false, count: processedImages.count)
+
+ for (index, processedImage) in processedImages.enumerated() {
+ dispatchGroup.enter()
+ _ = ImageSaver(image: processedImage.image) { error in
+ savedResults[index] = (error == nil)
+ dispatchGroup.leave()
+ }
+ }
+
+ dispatchGroup.notify(queue: .main) {
+ self.returnImages(processedImages)
+ }
+ } else {
+ self.returnImages(processedImages)
+ }
+ }
+ }
+
+ func multiCameraViewControllerDidCancel(_ viewController: MultiCameraViewController) {
+ viewController.dismiss(animated: true) {
+ self.call?.reject("User cancelled camera")
+ }
+ }
+}
diff --git a/camera/ios/Sources/CameraPlugin/CameraTypes.swift b/camera/ios/Sources/CameraPlugin/CameraTypes.swift
index 382d216e5..ee1a41721 100644
--- a/camera/ios/Sources/CameraPlugin/CameraTypes.swift
+++ b/camera/ios/Sources/CameraPlugin/CameraTypes.swift
@@ -5,6 +5,7 @@ import UIKit
public enum CameraSource: String {
case prompt = "PROMPT"
case camera = "CAMERA"
+ case cameraMulti = "CAMERA_MULTI"
case photos = "PHOTOS"
}
diff --git a/camera/ios/Sources/CameraPlugin/MultiCameraViewController.swift b/camera/ios/Sources/CameraPlugin/MultiCameraViewController.swift
new file mode 100644
index 000000000..55b4a3ef8
--- /dev/null
+++ b/camera/ios/Sources/CameraPlugin/MultiCameraViewController.swift
@@ -0,0 +1,1382 @@
+import UIKit
+import AVFoundation
+import Photos
+import Capacitor
+
+// MARK: - ThumbnailCell
+class ThumbnailCell: UICollectionViewCell {
+ var deleteHandler: (() -> Void)?
+
+ private let imageView: UIImageView = {
+ let imageView = UIImageView()
+ imageView.contentMode = .scaleAspectFill
+ imageView.clipsToBounds = true
+ imageView.layer.cornerRadius = 5
+ imageView.translatesAutoresizingMaskIntoConstraints = false
+ return imageView
+ }()
+
+ private let loadingIndicator: UIActivityIndicatorView = {
+ let indicator = UIActivityIndicatorView(style: .medium)
+ indicator.color = .white
+ indicator.translatesAutoresizingMaskIntoConstraints = false
+ indicator.hidesWhenStopped = true
+ return indicator
+ }()
+
+ private let deleteButton: UIButton = {
+ let button = UIButton(type: .system)
+ button.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal)
+ button.tintColor = .white
+ button.backgroundColor = UIColor.black.withAlphaComponent(0.5)
+ button.layer.cornerRadius = 10
+ button.translatesAutoresizingMaskIntoConstraints = false
+ return button
+ }()
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+ setupUI()
+ }
+
+ required init?(coder: NSCoder) {
+ super.init(coder: coder)
+ setupUI()
+ }
+
+ private func setupUI() {
+ contentView.addSubview(imageView)
+ contentView.addSubview(loadingIndicator)
+ contentView.addSubview(deleteButton)
+
+ NSLayoutConstraint.activate([
+ imageView.topAnchor.constraint(equalTo: contentView.topAnchor),
+ imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
+ imageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
+ imageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
+
+ loadingIndicator.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
+ loadingIndicator.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
+
+ deleteButton.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 2),
+ deleteButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -2),
+ deleteButton.widthAnchor.constraint(equalToConstant: 20),
+ deleteButton.heightAnchor.constraint(equalToConstant: 20)
+ ])
+
+ deleteButton.addTarget(self, action: #selector(deleteButtonTapped), for: .touchUpInside)
+ }
+
+ func configure(with image: UIImage) {
+ imageView.image = image
+ loadingIndicator.stopAnimating()
+ deleteButton.isHidden = false
+ }
+
+ func configureAsLoading() {
+ imageView.image = nil
+ imageView.backgroundColor = UIColor.darkGray.withAlphaComponent(0.8)
+ loadingIndicator.startAnimating()
+ deleteButton.isHidden = true
+ }
+
+ @objc private func deleteButtonTapped() {
+ deleteHandler?()
+ }
+}
+
+// MARK: - ImagePreviewViewController
+class ImagePreviewViewController: UIViewController {
+ private let images: [UIImage]
+ private var currentIndex: Int
+
+ private lazy var collectionView: UICollectionView = {
+ let layout = UICollectionViewFlowLayout()
+ layout.scrollDirection = .horizontal
+ layout.minimumLineSpacing = 0
+ layout.minimumInteritemSpacing = 0
+
+ let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
+ collectionView.backgroundColor = .black
+ collectionView.isPagingEnabled = true
+ collectionView.showsHorizontalScrollIndicator = false
+ collectionView.register(ImagePreviewCell.self, forCellWithReuseIdentifier: "ImagePreviewCell")
+ collectionView.dataSource = self
+ collectionView.delegate = self
+ collectionView.translatesAutoresizingMaskIntoConstraints = false
+ return collectionView
+ }()
+
+ private lazy var positionIndicator: UILabel = {
+ let label = UILabel()
+ label.textColor = .white
+ label.textAlignment = .center
+ label.font = UIFont.systemFont(ofSize: 14, weight: .medium)
+ label.backgroundColor = UIColor.black.withAlphaComponent(0.6)
+ label.layer.cornerRadius = 12
+ label.layer.masksToBounds = true
+ label.translatesAutoresizingMaskIntoConstraints = false
+ return label
+ }()
+
+ init(images: [UIImage], startingIndex: Int = 0) {
+ self.images = images
+ self.currentIndex = max(0, min(startingIndex, images.count - 1))
+ super.init(nibName: nil, bundle: nil)
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+
+ view.backgroundColor = .black
+ setupUI()
+ updatePositionIndicator()
+ }
+
+ override func viewDidLayoutSubviews() {
+ super.viewDidLayoutSubviews()
+
+ // Scroll to the starting index after layout is complete
+ if let layout = collectionView.collectionViewLayout as? UICollectionViewFlowLayout {
+ layout.itemSize = collectionView.bounds.size
+ collectionView.scrollToItem(at: IndexPath(item: currentIndex, section: 0), at: .left, animated: false)
+ }
+ }
+
+ private func setupUI() {
+ view.addSubview(collectionView)
+ view.addSubview(positionIndicator)
+
+ NSLayoutConstraint.activate([
+ collectionView.topAnchor.constraint(equalTo: view.topAnchor),
+ collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+ collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+ collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor)
+ ])
+
+ // Setup close button
+ let closeButton = UIButton(type: .system)
+ closeButton.setImage(UIImage(systemName: "xmark"), for: .normal)
+ closeButton.tintColor = .white
+ closeButton.translatesAutoresizingMaskIntoConstraints = false
+ closeButton.addTarget(self, action: #selector(closeButtonTapped), for: .touchUpInside)
+
+ view.addSubview(closeButton)
+ NSLayoutConstraint.activate([
+ closeButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
+ closeButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
+ closeButton.widthAnchor.constraint(equalToConstant: 44),
+ closeButton.heightAnchor.constraint(equalToConstant: 44)
+ ])
+
+ // Position indicator constraints - only show if more than 1 image
+ if images.count > 1 {
+ NSLayoutConstraint.activate([
+ positionIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
+ positionIndicator.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
+ positionIndicator.widthAnchor.constraint(greaterThanOrEqualToConstant: 40),
+ positionIndicator.heightAnchor.constraint(equalToConstant: 24)
+ ])
+ } else {
+ positionIndicator.isHidden = true
+ }
+ }
+
+ private func updatePositionIndicator() {
+ if images.count > 1 {
+ positionIndicator.text = "\(currentIndex + 1)/\(images.count)"
+ }
+ }
+
+ @objc private func closeButtonTapped() {
+ dismiss(animated: true)
+ }
+}
+
+// MARK: - ImagePreviewViewController Extensions
+extension ImagePreviewViewController: UICollectionViewDataSource {
+ func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
+ return images.count
+ }
+
+ func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
+ guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ImagePreviewCell", for: indexPath) as? ImagePreviewCell else {
+ return UICollectionViewCell()
+ }
+
+ cell.configure(with: images[indexPath.item])
+ return cell
+ }
+}
+
+extension ImagePreviewViewController: UICollectionViewDelegate {
+ func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
+ let pageWidth = scrollView.frame.width
+ let currentPage = Int(scrollView.contentOffset.x / pageWidth)
+
+ if currentPage != currentIndex {
+ currentIndex = currentPage
+ updatePositionIndicator()
+ }
+ }
+}
+
+// MARK: - ImagePreviewCell
+class ImagePreviewCell: UICollectionViewCell {
+ private let imageView: UIImageView = {
+ let imageView = UIImageView()
+ imageView.contentMode = .scaleAspectFit
+ imageView.translatesAutoresizingMaskIntoConstraints = false
+ return imageView
+ }()
+
+ override init(frame: CGRect) {
+ super.init(frame: frame)
+ setupUI()
+ }
+
+ required init?(coder: NSCoder) {
+ super.init(coder: coder)
+ setupUI()
+ }
+
+ private func setupUI() {
+ contentView.addSubview(imageView)
+ NSLayoutConstraint.activate([
+ imageView.topAnchor.constraint(equalTo: contentView.topAnchor),
+ imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
+ imageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
+ imageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor)
+ ])
+ }
+
+ func configure(with image: UIImage) {
+ imageView.image = image
+ }
+}
+
+protocol MultiCameraViewControllerDelegate: AnyObject {
+ func multiCameraViewController(_ viewController: MultiCameraViewController, didFinishWith images: [UIImage], metadata: [[String: Any]])
+ func multiCameraViewControllerDidCancel(_ viewController: MultiCameraViewController)
+}
+
+class MultiCameraViewController: UIViewController {
+ // MARK: - Properties
+ weak var delegate: MultiCameraViewControllerDelegate?
+ var maxImages: Int = 0 // 0 means unlimited
+ var cameraDirection: CameraDirection = .rear
+
+ // Image processing settings
+ var jpegQuality: CGFloat = 1.0
+ var width: CGFloat = 0
+ var height: CGFloat = 0
+ var shouldResize: Bool {
+ return width > 0 || height > 0
+ }
+ var shouldCorrectOrientation = true
+
+ private var captureSession: AVCaptureSession?
+ private var previewLayer: AVCaptureVideoPreviewLayer?
+ private var photoOutput: AVCapturePhotoOutput?
+ private var currentCameraPosition: AVCaptureDevice.Position = .back
+ private var flashMode: AVCaptureDevice.FlashMode = .auto
+
+ // Zoom control properties
+ private var currentZoomFactor: CGFloat = 1.0
+ private var minZoomFactor: CGFloat = 1.0
+ private var maxZoomFactor: CGFloat = 10.0
+ private var lastZoomFactor: CGFloat = 1.0
+
+ private var capturedImages: [UIImage] = []
+ private var capturedMetadata: [[String: Any]] = []
+ private var loadingStates: [Bool] = [] // Track which thumbnails are still loading
+
+ // Track device orientation
+ private var isLandscape: Bool = false
+ private var portraitConstraints: [NSLayoutConstraint] = []
+ private var landscapeConstraints: [NSLayoutConstraint] = []
+
+ // MARK: - UI Elements
+ private lazy var previewView: UIView = {
+ let view = UIView()
+ view.backgroundColor = .black
+ view.contentMode = .scaleAspectFill
+ view.clipsToBounds = true
+ return view
+ }()
+
+ private lazy var bottomBarView: UIView = {
+ let view = UIView()
+ view.backgroundColor = .black
+ return view
+ }()
+
+ private lazy var takePictureButton: UIButton = {
+ let button = UIButton(type: .custom)
+ // Try to load the image from the bundle
+ if let image = UIImage(named: "camera_capture") {
+ button.setImage(image, for: .normal)
+ } else {
+ // Fallback if image is not found
+ button.backgroundColor = .white
+ button.layer.cornerRadius = 35
+ button.layer.borderWidth = 3
+ button.layer.borderColor = UIColor.lightGray.cgColor
+ }
+ button.addTarget(self, action: #selector(takePicture), for: .touchUpInside)
+ return button
+ }()
+
+ private lazy var flipCameraButton: UIButton = {
+ let button = UIButton(type: .system)
+ button.setImage(UIImage(systemName: "camera.rotate"), for: .normal)
+ button.tintColor = .white
+ button.addTarget(self, action: #selector(flipCamera), for: .touchUpInside)
+ return button
+ }()
+
+ private lazy var flashButton: UIButton = {
+ let button = UIButton(type: .system)
+ button.setImage(UIImage(systemName: "bolt.badge.a"), for: .normal)
+ button.tintColor = .white
+ button.addTarget(self, action: #selector(toggleFlash), for: .touchUpInside)
+ return button
+ }()
+
+ private lazy var zoomInButton: UIButton = {
+ let button = UIButton(type: .system)
+ button.setImage(UIImage(systemName: "plus.magnifyingglass"), for: .normal)
+ button.tintColor = .white
+ button.addTarget(self, action: #selector(zoomIn), for: .touchUpInside)
+ return button
+ }()
+
+ private lazy var zoomOutButton: UIButton = {
+ let button = UIButton(type: .system)
+ button.setImage(UIImage(systemName: "minus.magnifyingglass"), for: .normal)
+ button.tintColor = .white
+ button.addTarget(self, action: #selector(zoomOut), for: .touchUpInside)
+ return button
+ }()
+
+ private lazy var zoomFactorLabel: UILabel = {
+ let label = UILabel()
+ label.textColor = .white
+ label.textAlignment = .center
+ label.font = UIFont.systemFont(ofSize: 12)
+ label.text = "1.0x"
+ label.backgroundColor = UIColor.black.withAlphaComponent(0.5)
+ label.layer.cornerRadius = 8
+ label.layer.masksToBounds = true
+ return label
+ }()
+
+ private lazy var closeButton: UIButton = {
+ let button = UIButton(type: .system)
+ button.setImage(UIImage(systemName: "xmark"), for: .normal)
+ button.tintColor = .white
+ button.addTarget(self, action: #selector(cancel), for: .touchUpInside)
+ return button
+ }()
+
+ private lazy var doneButton: UIButton = {
+ let button = UIButton(type: .system)
+ button.setImage(UIImage(systemName: "checkmark"), for: .normal)
+ button.tintColor = .white
+ button.addTarget(self, action: #selector(done), for: .touchUpInside)
+ return button
+ }()
+
+ private lazy var processingSpinner: UIActivityIndicatorView = {
+ let spinner = UIActivityIndicatorView(style: .large)
+ spinner.color = .white
+ spinner.backgroundColor = UIColor.black.withAlphaComponent(0.7)
+ spinner.layer.cornerRadius = 10
+ spinner.translatesAutoresizingMaskIntoConstraints = false
+ spinner.hidesWhenStopped = true
+ return spinner
+ }()
+
+ private lazy var processingLabel: UILabel = {
+ let label = UILabel()
+ label.text = "Processing images..."
+ label.textColor = .white
+ label.font = UIFont.systemFont(ofSize: 16, weight: .medium)
+ label.textAlignment = .center
+ label.translatesAutoresizingMaskIntoConstraints = false
+ return label
+ }()
+
+ private lazy var processingOverlay: UIView = {
+ let view = UIView()
+ view.backgroundColor = UIColor.black.withAlphaComponent(0.7)
+ view.translatesAutoresizingMaskIntoConstraints = false
+ view.isHidden = true
+ return view
+ }()
+
+ private lazy var thumbnailCollectionView: UICollectionView = {
+ let layout = UICollectionViewFlowLayout()
+ layout.scrollDirection = .horizontal
+ layout.itemSize = CGSize(width: 70, height: 70)
+ layout.minimumLineSpacing = 5
+
+ let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
+ collectionView.backgroundColor = .clear
+ collectionView.showsHorizontalScrollIndicator = false
+ collectionView.register(ThumbnailCell.self, forCellWithReuseIdentifier: "ThumbnailCell")
+ collectionView.dataSource = self
+ collectionView.delegate = self
+ return collectionView
+ }()
+
+ // MARK: - Lifecycle
+ override func viewDidLoad() {
+ super.viewDidLoad()
+ setupUI()
+ checkPermissions()
+
+ // Set background color to ensure we can see if there's an issue with the camera
+ view.backgroundColor = .black
+
+ // Add pinch gesture for zoom
+ let pinchGesture = UIPinchGestureRecognizer(target: self, action: #selector(handlePinchGesture(_:)))
+ previewView.addGestureRecognizer(pinchGesture)
+ }
+
+ override func viewDidAppear(_ animated: Bool) {
+ super.viewDidAppear(animated)
+ startCaptureSession()
+ updatePreviewLayerFrame()
+ }
+
+ override func viewDidLayoutSubviews() {
+ super.viewDidLayoutSubviews()
+ updatePreviewLayerFrame()
+ }
+
+ private func updatePreviewLayerFrame() {
+ guard let previewLayer = previewLayer else { return }
+ previewLayer.frame = previewView.bounds
+
+ // Update video orientation based on device orientation
+ guard let connection = previewLayer.connection else { return }
+
+ if connection.isVideoOrientationSupported {
+ let orientation = UIDevice.current.orientation
+
+ switch orientation {
+ case .portrait:
+ connection.videoOrientation = .portrait
+ case .landscapeLeft:
+ connection.videoOrientation = .landscapeRight // Note: device orientation is opposite to video orientation
+ case .landscapeRight:
+ connection.videoOrientation = .landscapeLeft // Note: device orientation is opposite to video orientation
+ case .portraitUpsideDown:
+ connection.videoOrientation = .portraitUpsideDown
+ default:
+ connection.videoOrientation = isLandscape ? .landscapeRight : .portrait
+ }
+ }
+ }
+
+ private func updateConstraintsForOrientation() {
+ // Deactivate all constraints first
+ NSLayoutConstraint.deactivate(portraitConstraints)
+ NSLayoutConstraint.deactivate(landscapeConstraints)
+
+ // Activate the appropriate constraints based on orientation
+ if isLandscape {
+ NSLayoutConstraint.activate(landscapeConstraints)
+ } else {
+ NSLayoutConstraint.activate(portraitConstraints)
+ }
+
+ // Force layout update
+ view.layoutIfNeeded()
+ }
+
+ override func viewWillDisappear(_ animated: Bool) {
+ super.viewWillDisappear(animated)
+ stopCaptureSession()
+ }
+
+ override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
+ super.viewWillTransition(to: size, with: coordinator)
+
+ // Determine if we're switching to landscape or portrait
+ isLandscape = size.width > size.height
+
+ // Handle orientation changes
+ coordinator.animate(alongsideTransition: { [weak self] _ in
+ guard let self = self else { return }
+ self.updatePreviewLayerFrame()
+ self.updateConstraintsForOrientation()
+ })
+ }
+
+ // MARK: - Setup
+ private func setupUI() {
+ view.backgroundColor = .black
+
+ // Add subviews
+ view.addSubview(previewView)
+ view.addSubview(bottomBarView)
+ view.addSubview(thumbnailCollectionView)
+ view.addSubview(closeButton)
+ view.addSubview(flashButton)
+ view.addSubview(zoomInButton)
+ view.addSubview(zoomOutButton)
+ view.addSubview(zoomFactorLabel)
+ view.addSubview(processingOverlay)
+
+ // Add processing overlay subviews
+ processingOverlay.addSubview(processingSpinner)
+ processingOverlay.addSubview(processingLabel)
+
+ bottomBarView.addSubview(takePictureButton)
+ bottomBarView.addSubview(flipCameraButton)
+ bottomBarView.addSubview(doneButton)
+
+ // Setup constraints
+ previewView.translatesAutoresizingMaskIntoConstraints = false
+ bottomBarView.translatesAutoresizingMaskIntoConstraints = false
+ thumbnailCollectionView.translatesAutoresizingMaskIntoConstraints = false
+ takePictureButton.translatesAutoresizingMaskIntoConstraints = false
+ flipCameraButton.translatesAutoresizingMaskIntoConstraints = false
+ doneButton.translatesAutoresizingMaskIntoConstraints = false
+ closeButton.translatesAutoresizingMaskIntoConstraints = false
+ flashButton.translatesAutoresizingMaskIntoConstraints = false
+ zoomInButton.translatesAutoresizingMaskIntoConstraints = false
+ zoomOutButton.translatesAutoresizingMaskIntoConstraints = false
+ zoomFactorLabel.translatesAutoresizingMaskIntoConstraints = false
+
+ // Setup processing overlay constraints
+ NSLayoutConstraint.activate([
+ processingOverlay.topAnchor.constraint(equalTo: view.topAnchor),
+ processingOverlay.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+ processingOverlay.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+ processingOverlay.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+
+ processingSpinner.centerXAnchor.constraint(equalTo: processingOverlay.centerXAnchor),
+ processingSpinner.centerYAnchor.constraint(equalTo: processingOverlay.centerYAnchor, constant: -20),
+
+ processingLabel.topAnchor.constraint(equalTo: processingSpinner.bottomAnchor, constant: 20),
+ processingLabel.centerXAnchor.constraint(equalTo: processingOverlay.centerXAnchor)
+ ])
+
+ // Determine initial orientation
+ isLandscape = UIDevice.current.orientation.isLandscape
+
+ // Create portrait constraints
+ setupPortraitConstraints()
+
+ // Create landscape constraints
+ setupLandscapeConstraints()
+
+ // Activate the appropriate constraints based on current orientation
+ updateConstraintsForOrientation()
+
+ // Initially hide the done button until we have at least one image
+ doneButton.isHidden = true
+ }
+
+ private func setupPortraitConstraints() {
+ // Clear any existing constraints
+ portraitConstraints.removeAll()
+
+ // Add common constraints that don't change with orientation
+ let commonConstraints = [
+ // Preview view top, leading, trailing
+ previewView.topAnchor.constraint(equalTo: view.topAnchor),
+ previewView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+ previewView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+
+ // Close button
+ closeButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 10),
+ closeButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
+ closeButton.widthAnchor.constraint(equalToConstant: 44),
+ closeButton.heightAnchor.constraint(equalToConstant: 44),
+
+ // Flash button
+ flashButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 10),
+ flashButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
+ flashButton.widthAnchor.constraint(equalToConstant: 44),
+ flashButton.heightAnchor.constraint(equalToConstant: 44),
+
+ // Button sizes
+ takePictureButton.widthAnchor.constraint(equalToConstant: 70),
+ takePictureButton.heightAnchor.constraint(equalToConstant: 70),
+ flipCameraButton.widthAnchor.constraint(equalToConstant: 50),
+ flipCameraButton.heightAnchor.constraint(equalToConstant: 50),
+ doneButton.widthAnchor.constraint(equalToConstant: 50),
+ doneButton.heightAnchor.constraint(equalToConstant: 50),
+ zoomInButton.widthAnchor.constraint(equalToConstant: 44),
+ zoomInButton.heightAnchor.constraint(equalToConstant: 44),
+ zoomOutButton.widthAnchor.constraint(equalToConstant: 44),
+ zoomOutButton.heightAnchor.constraint(equalToConstant: 44),
+ zoomFactorLabel.widthAnchor.constraint(equalToConstant: 50),
+ zoomFactorLabel.heightAnchor.constraint(equalToConstant: 25)
+ ]
+
+ portraitConstraints.append(contentsOf: commonConstraints)
+
+ // Portrait-specific constraints
+ let portraitSpecificConstraints = [
+ // Bottom bar - horizontal at bottom
+ bottomBarView.heightAnchor.constraint(equalToConstant: 100),
+ bottomBarView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
+ bottomBarView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
+ bottomBarView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
+
+ // Preview view bottom connects to bottom bar top
+ previewView.bottomAnchor.constraint(equalTo: bottomBarView.topAnchor),
+
+ // Thumbnail collection view
+ thumbnailCollectionView.heightAnchor.constraint(equalToConstant: 80),
+ thumbnailCollectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 10),
+ thumbnailCollectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -10),
+ thumbnailCollectionView.bottomAnchor.constraint(equalTo: bottomBarView.topAnchor, constant: -90), // Positioned higher to be above zoom controls
+
+ // Take picture button
+ takePictureButton.centerXAnchor.constraint(equalTo: bottomBarView.centerXAnchor),
+ takePictureButton.centerYAnchor.constraint(equalTo: bottomBarView.centerYAnchor),
+
+ // Flip camera button
+ flipCameraButton.leadingAnchor.constraint(equalTo: bottomBarView.leadingAnchor, constant: 30),
+ flipCameraButton.centerYAnchor.constraint(equalTo: bottomBarView.centerYAnchor),
+
+ // Done button
+ doneButton.trailingAnchor.constraint(equalTo: bottomBarView.trailingAnchor, constant: -30),
+ doneButton.centerYAnchor.constraint(equalTo: bottomBarView.centerYAnchor),
+
+ // Zoom buttons - positioned below the film strip
+ zoomInButton.bottomAnchor.constraint(equalTo: bottomBarView.topAnchor, constant: -20),
+ zoomInButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
+
+ zoomOutButton.bottomAnchor.constraint(equalTo: bottomBarView.topAnchor, constant: -20),
+ zoomOutButton.trailingAnchor.constraint(equalTo: zoomInButton.leadingAnchor, constant: -10),
+
+ // Zoom factor label
+ zoomFactorLabel.centerYAnchor.constraint(equalTo: zoomInButton.centerYAnchor),
+ zoomFactorLabel.trailingAnchor.constraint(equalTo: zoomOutButton.leadingAnchor, constant: -10)
+ ]
+
+ portraitConstraints.append(contentsOf: portraitSpecificConstraints)
+ }
+
+ private func setupLandscapeConstraints() {
+ // Clear any existing constraints
+ landscapeConstraints.removeAll()
+
+ // Add common constraints that don't change with orientation
+ let commonConstraints = [
+ // Preview view top, leading
+ previewView.topAnchor.constraint(equalTo: view.topAnchor),
+ previewView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+
+ // Close button
+ closeButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 10),
+ closeButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20),
+ closeButton.widthAnchor.constraint(equalToConstant: 44),
+ closeButton.heightAnchor.constraint(equalToConstant: 44),
+
+ // Flash button
+ flashButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 10),
+ flashButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20),
+ flashButton.widthAnchor.constraint(equalToConstant: 44),
+ flashButton.heightAnchor.constraint(equalToConstant: 44),
+
+ // Button sizes
+ takePictureButton.widthAnchor.constraint(equalToConstant: 70),
+ takePictureButton.heightAnchor.constraint(equalToConstant: 70),
+ flipCameraButton.widthAnchor.constraint(equalToConstant: 50),
+ flipCameraButton.heightAnchor.constraint(equalToConstant: 50),
+ doneButton.widthAnchor.constraint(equalToConstant: 50),
+ doneButton.heightAnchor.constraint(equalToConstant: 50),
+ zoomInButton.widthAnchor.constraint(equalToConstant: 44),
+ zoomInButton.heightAnchor.constraint(equalToConstant: 44),
+ zoomOutButton.widthAnchor.constraint(equalToConstant: 44),
+ zoomOutButton.heightAnchor.constraint(equalToConstant: 44),
+ zoomFactorLabel.widthAnchor.constraint(equalToConstant: 50),
+ zoomFactorLabel.heightAnchor.constraint(equalToConstant: 25)
+ ]
+
+ landscapeConstraints.append(contentsOf: commonConstraints)
+
+ // Landscape-specific constraints
+ let landscapeSpecificConstraints = [
+ // Bottom bar - vertical on right side
+ bottomBarView.widthAnchor.constraint(equalToConstant: 120),
+ bottomBarView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
+ bottomBarView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
+ bottomBarView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
+
+ // Preview view connects to bottom bar on right
+ previewView.trailingAnchor.constraint(equalTo: bottomBarView.leadingAnchor),
+ previewView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+
+ // Thumbnail collection view - horizontal at bottom of preview
+ thumbnailCollectionView.heightAnchor.constraint(equalToConstant: 80),
+ thumbnailCollectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 100), // Give space from left edge
+ thumbnailCollectionView.trailingAnchor.constraint(equalTo: bottomBarView.leadingAnchor, constant: -10),
+ thumbnailCollectionView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -10),
+
+ // Take picture button - centered in the vertical bar
+ takePictureButton.centerXAnchor.constraint(equalTo: bottomBarView.centerXAnchor),
+ takePictureButton.centerYAnchor.constraint(equalTo: bottomBarView.centerYAnchor),
+
+ // Flip camera button - above the take picture button
+ flipCameraButton.centerXAnchor.constraint(equalTo: bottomBarView.centerXAnchor),
+ flipCameraButton.bottomAnchor.constraint(equalTo: takePictureButton.topAnchor, constant: -30),
+
+ // Done button - below the take picture button
+ doneButton.centerXAnchor.constraint(equalTo: bottomBarView.centerXAnchor),
+ doneButton.topAnchor.constraint(equalTo: takePictureButton.bottomAnchor, constant: 30),
+
+ // Zoom buttons - on the right side of the preview
+ zoomInButton.trailingAnchor.constraint(equalTo: bottomBarView.leadingAnchor, constant: -20),
+ zoomInButton.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: -20),
+
+ zoomOutButton.trailingAnchor.constraint(equalTo: zoomInButton.leadingAnchor, constant: -10),
+ zoomOutButton.centerYAnchor.constraint(equalTo: zoomInButton.centerYAnchor),
+
+ // Zoom factor label
+ zoomFactorLabel.trailingAnchor.constraint(equalTo: zoomOutButton.leadingAnchor, constant: -10),
+ zoomFactorLabel.centerYAnchor.constraint(equalTo: zoomInButton.centerYAnchor)
+ ]
+
+ landscapeConstraints.append(contentsOf: landscapeSpecificConstraints)
+ }
+
+ private func checkPermissions() {
+ switch AVCaptureDevice.authorizationStatus(for: .video) {
+ case .authorized:
+ setupCaptureSession()
+ case .notDetermined:
+ AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
+ if granted {
+ DispatchQueue.main.async {
+ self?.setupCaptureSession()
+ }
+ } else {
+ DispatchQueue.main.async {
+ self?.showPermissionAlert()
+ }
+ }
+ }
+ default:
+ showPermissionAlert()
+ }
+ }
+
+ private func showPermissionAlert() {
+ let alert = UIAlertController(
+ title: "Camera Access Required",
+ message: "Please allow camera access to use this feature",
+ preferredStyle: .alert
+ )
+
+ alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { [weak self] _ in
+ self?.delegate?.multiCameraViewControllerDidCancel(self!)
+ })
+
+ alert.addAction(UIAlertAction(title: "Settings", style: .default) { _ in
+ if let url = URL(string: UIApplication.openSettingsURLString) {
+ UIApplication.shared.open(url)
+ }
+ })
+
+ present(alert, animated: true)
+ }
+
+ // MARK: - Camera Setup
+ private func setupCaptureSession() {
+ captureSession = AVCaptureSession()
+ guard let captureSession = captureSession else { return }
+
+ captureSession.beginConfiguration()
+
+ // Set the quality level
+ if captureSession.canSetSessionPreset(.photo) {
+ captureSession.sessionPreset = .photo
+ }
+
+ // Setup camera input
+ guard let videoDevice = getCamera() else {
+ captureSession.commitConfiguration()
+ return
+ }
+
+ do {
+ let videoInput = try AVCaptureDeviceInput(device: videoDevice)
+ if captureSession.canAddInput(videoInput) {
+ captureSession.addInput(videoInput)
+ } else {
+ captureSession.commitConfiguration()
+ return
+ }
+ } catch {
+ captureSession.commitConfiguration()
+ return
+ }
+
+ // Setup photo output
+ photoOutput = AVCapturePhotoOutput()
+ guard let photoOutput = photoOutput else {
+ captureSession.commitConfiguration()
+ return
+ }
+
+ if captureSession.canAddOutput(photoOutput) {
+ photoOutput.isHighResolutionCaptureEnabled = true
+ captureSession.addOutput(photoOutput)
+ } else {
+ captureSession.commitConfiguration()
+ return
+ }
+
+ captureSession.commitConfiguration()
+
+ // Setup preview layer on main thread to ensure UI updates are synchronized
+ DispatchQueue.main.async { [weak self] in
+ guard let self = self else { return }
+
+ self.previewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
+ guard let previewLayer = self.previewLayer else { return }
+
+ previewLayer.videoGravity = .resizeAspectFill
+ previewLayer.frame = self.previewView.bounds
+ self.previewView.layer.addSublayer(previewLayer)
+
+ // Make sure the preview layer is properly sized
+ self.updatePreviewLayerFrame()
+ }
+
+ // Add tap gesture for focus
+ let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
+ previewView.addGestureRecognizer(tapGesture)
+
+ // Get device zoom capabilities
+ if let device = getCamera() {
+ minZoomFactor = 1.0
+ maxZoomFactor = min(device.activeFormat.videoMaxZoomFactor, 10.0) // Limit max zoom to 10x
+ }
+ }
+
+ private func getCamera() -> AVCaptureDevice? {
+ currentCameraPosition = cameraDirection == .front ? .front : .back
+
+ if let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: currentCameraPosition) {
+ return device
+ }
+
+ // Fallback to any camera if the requested one is not available
+ return AVCaptureDevice.default(for: .video)
+ }
+
+ private func startCaptureSession() {
+ if captureSession?.isRunning == false {
+ // Start session on background thread
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
+ self?.captureSession?.startRunning()
+
+ // Update UI on main thread
+ DispatchQueue.main.async {
+ self?.updatePreviewLayerFrame()
+ }
+ }
+ }
+ }
+
+ private func stopCaptureSession() {
+ if captureSession?.isRunning == true {
+ captureSession?.stopRunning()
+ }
+ }
+
+ // MARK: - Zoom Control
+ @objc private func handlePinchGesture(_ gesture: UIPinchGestureRecognizer) {
+ guard (captureSession?.inputs.first as? AVCaptureDeviceInput)?.device != nil else { return }
+
+ // Get the pinch scale
+ let scale = gesture.scale
+
+ switch gesture.state {
+ case .began:
+ // Store the current zoom factor when the pinch begins
+ lastZoomFactor = currentZoomFactor
+ case .changed:
+ // Calculate new zoom factor
+ let newZoomFactor = max(minZoomFactor, min(lastZoomFactor * scale, maxZoomFactor))
+ setZoomFactor(newZoomFactor)
+ default:
+ break
+ }
+ }
+
+ @objc private func zoomIn() {
+ let newZoomFactor = min(currentZoomFactor * 1.25, maxZoomFactor)
+ setZoomFactor(newZoomFactor)
+ }
+
+ @objc private func zoomOut() {
+ let newZoomFactor = max(currentZoomFactor / 1.25, minZoomFactor)
+ setZoomFactor(newZoomFactor)
+ }
+
+ private func setZoomFactor(_ zoomFactor: CGFloat) {
+ guard let device = (captureSession?.inputs.first as? AVCaptureDeviceInput)?.device else { return }
+
+ do {
+ try device.lockForConfiguration()
+
+ // Set the zoom factor
+ device.videoZoomFactor = zoomFactor
+ currentZoomFactor = zoomFactor
+
+ // Update the zoom factor label
+ DispatchQueue.main.async { [weak self] in
+ self?.zoomFactorLabel.text = String(format: "%.1fx", zoomFactor)
+ }
+
+ device.unlockForConfiguration()
+ } catch {
+ CAPLog.print("Could not set zoom factor: \(error.localizedDescription)")
+ }
+ }
+
+ // MARK: - Actions
+ @objc private func takePicture() {
+ guard let photoOutput = photoOutput else { return }
+
+ // Add loading thumbnail immediately for responsive UI
+ addLoadingThumbnail()
+
+ // Configure photo settings
+ let photoSettings = AVCapturePhotoSettings()
+ photoSettings.flashMode = flashMode
+
+ if let photoPreviewType = photoSettings.availablePreviewPhotoPixelFormatTypes.first {
+ photoSettings.previewPhotoFormat = [kCVPixelBufferPixelFormatTypeKey as String: photoPreviewType]
+ }
+
+ // Capture the photo
+ photoOutput.capturePhoto(with: photoSettings, delegate: self)
+
+ // Provide haptic feedback
+ let generator = UIImpactFeedbackGenerator(style: .medium)
+ generator.prepare()
+ generator.impactOccurred()
+
+ // Play shutter sound
+ AudioServicesPlaySystemSound(1108) // Camera shutter sound
+ }
+
+ @objc private func flipCamera() {
+ guard let captureSession = captureSession else { return }
+
+ // Remove existing input
+ captureSession.beginConfiguration()
+ if let currentInput = captureSession.inputs.first as? AVCaptureDeviceInput {
+ captureSession.removeInput(currentInput)
+ }
+
+ // Toggle camera position
+ currentCameraPosition = (currentCameraPosition == .back) ? .front : .back
+
+ // Add new input
+ guard let videoDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: currentCameraPosition) else {
+ captureSession.commitConfiguration()
+ return
+ }
+
+ do {
+ let videoInput = try AVCaptureDeviceInput(device: videoDevice)
+ if captureSession.canAddInput(videoInput) {
+ captureSession.addInput(videoInput)
+ }
+ } catch {
+ captureSession.commitConfiguration()
+ return
+ }
+
+ captureSession.commitConfiguration()
+
+ // Keep flash button visible for both cameras (front camera uses screen flash)
+ flashButton.isHidden = false
+
+ // Reset zoom when switching cameras
+ currentZoomFactor = 1.0
+ zoomFactorLabel.text = "1.0x"
+
+ // Update zoom limits for the new camera
+ maxZoomFactor = min(videoDevice.activeFormat.videoMaxZoomFactor, 10.0)
+ }
+
+ @objc private func toggleFlash() {
+ switch flashMode {
+ case .auto:
+ flashMode = .on
+ flashButton.setImage(UIImage(systemName: "bolt.fill"), for: .normal)
+ case .on:
+ flashMode = .off
+ flashButton.setImage(UIImage(systemName: "bolt.slash"), for: .normal)
+ case .off:
+ flashMode = .auto
+ flashButton.setImage(UIImage(systemName: "bolt.badge.a"), for: .normal)
+ @unknown default:
+ flashMode = .auto
+ flashButton.setImage(UIImage(systemName: "bolt.badge.a"), for: .normal)
+ }
+ }
+
+ @objc private func handleTap(_ gesture: UITapGestureRecognizer) {
+ let touchPoint = gesture.location(in: previewView)
+ focusAtPoint(touchPoint)
+ }
+
+ private func focusAtPoint(_ point: CGPoint) {
+ guard let device = (captureSession?.inputs.first as? AVCaptureDeviceInput)?.device,
+ device.isFocusPointOfInterestSupported,
+ device.isFocusModeSupported(.autoFocus) else { return }
+
+ // Convert the touch point to device coordinates
+ let focusPoint = previewLayer?.captureDevicePointConverted(fromLayerPoint: point) ?? CGPoint(x: 0.5, y: 0.5)
+
+ do {
+ try device.lockForConfiguration()
+ device.focusPointOfInterest = focusPoint
+ device.focusMode = .autoFocus
+
+ if device.isExposurePointOfInterestSupported && device.isExposureModeSupported(.autoExpose) {
+ device.exposurePointOfInterest = focusPoint
+ device.exposureMode = .autoExpose
+ }
+
+ device.unlockForConfiguration()
+
+ // Show focus indicator
+ showFocusIndicator(at: point)
+ } catch {
+ CAPLog.print("Could not focus at point: \(error.localizedDescription)")
+ }
+ }
+
+ private func showFocusIndicator(at point: CGPoint) {
+ let focusView = UIView(frame: CGRect(x: 0, y: 0, width: 80, height: 80))
+ focusView.layer.borderColor = UIColor.white.cgColor
+ focusView.layer.borderWidth = 2
+ focusView.center = point
+ focusView.alpha = 0
+
+ previewView.addSubview(focusView)
+
+ UIView.animate(withDuration: 0.2, animations: {
+ focusView.alpha = 1
+ }, completion: { _ in
+ UIView.animate(withDuration: 0.5, delay: 0.5, options: [], animations: {
+ focusView.alpha = 0
+ }, completion: { _ in
+ focusView.removeFromSuperview()
+ })
+ })
+ }
+
+ @objc private func cancel() {
+ // Don't allow canceling while images are processing
+ if hasProcessingImages() {
+ return
+ }
+
+ // If we have images, show confirmation alert
+ if !capturedImages.isEmpty {
+ let alert = UIAlertController(
+ title: "Discard Photos?",
+ message: "Are you sure you want to discard all photos?",
+ preferredStyle: .alert
+ )
+
+ alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
+ alert.addAction(UIAlertAction(title: "Discard", style: .destructive) { [weak self] _ in
+ guard let self = self else { return }
+ self.delegate?.multiCameraViewControllerDidCancel(self)
+ })
+
+ present(alert, animated: true)
+ } else {
+ delegate?.multiCameraViewControllerDidCancel(self)
+ }
+ }
+
+ @objc private func done() {
+ // Check if any images are still processing
+ if hasProcessingImages() {
+ showProcessingOverlay()
+ waitForProcessingCompletion()
+ } else {
+ finishWithImages()
+ }
+ }
+
+ private func hasProcessingImages() -> Bool {
+ return loadingStates.contains(true)
+ }
+
+ private func showProcessingOverlay() {
+ processingOverlay.isHidden = false
+ processingSpinner.startAnimating()
+
+ // Disable user interaction on the main view
+ view.isUserInteractionEnabled = false
+ processingOverlay.isUserInteractionEnabled = true
+ }
+
+ private func hideProcessingOverlay() {
+ processingOverlay.isHidden = true
+ processingSpinner.stopAnimating()
+
+ // Re-enable user interaction
+ view.isUserInteractionEnabled = true
+ }
+
+ private func waitForProcessingCompletion() {
+ // Check every 0.1 seconds if processing is complete
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
+ guard let self = self else { return }
+
+ if self.hasProcessingImages() {
+ // Still processing, check again
+ self.waitForProcessingCompletion()
+ } else {
+ // All images processed, hide overlay and finish
+ self.hideProcessingOverlay()
+ self.finishWithImages()
+ }
+ }
+ }
+
+ private func waitForProcessingCompletionThenFinish() {
+ // Check every 0.1 seconds if processing is complete
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
+ guard let self = self else { return }
+
+ if self.hasProcessingImages() {
+ // Still processing, check again
+ self.waitForProcessingCompletionThenFinish()
+ } else {
+ // All images processed, hide overlay and finish
+ self.hideProcessingOverlay()
+ self.finishWithImages()
+ }
+ }
+ }
+
+ private func finishWithImages() {
+ delegate?.multiCameraViewController(self, didFinishWith: capturedImages, metadata: capturedMetadata)
+ }
+
+ // MARK: - Image Processing
+ private func processImage(from image: UIImage, with metadata: [String: Any]?) -> UIImage {
+ var processedImage = image
+
+ // Apply resizing if needed
+ if shouldResize, width > 0 || height > 0 {
+ processedImage = processedImage.reformat(to: CGSize(width: width, height: height))
+ } else if shouldCorrectOrientation {
+ // resizing implicitly reformats the image so this is only needed if we aren't resizing
+ processedImage = processedImage.reformat()
+ }
+
+ return processedImage
+ }
+
+ // MARK: - Image Management
+ private func addLoadingThumbnail() {
+ // Add placeholder image and loading state
+ capturedImages.append(UIImage()) // Placeholder
+ capturedMetadata.append([:])
+ loadingStates.append(true)
+
+ // Show the done button once we have at least one image (even loading)
+ if doneButton.isHidden {
+ doneButton.isHidden = false
+ }
+
+ // Update the collection view
+ thumbnailCollectionView.reloadData()
+
+ // Scroll to the new image
+ let indexPath = IndexPath(item: capturedImages.count - 1, section: 0)
+ thumbnailCollectionView.scrollToItem(at: indexPath, at: .right, animated: true)
+ }
+
+ private func addCapturedImage(_ image: UIImage, metadata: [String: Any]) {
+ // Find the most recent loading thumbnail and replace it
+ for i in (0.. 0 && capturedImages.count >= maxImages {
+ // Wait for processing if needed, then automatically finish
+ if hasProcessingImages() {
+ showProcessingOverlay()
+ waitForProcessingCompletionThenFinish()
+ } else {
+ finishWithImages()
+ }
+ return
+ }
+ }
+
+ private func removeImage(at index: Int) {
+ guard index < capturedImages.count else { return }
+
+ capturedImages.remove(at: index)
+ capturedMetadata.remove(at: index)
+ loadingStates.remove(at: index)
+
+ // Hide the done button if we have no images
+ if capturedImages.isEmpty {
+ doneButton.isHidden = true
+ }
+
+ // Update the collection view
+ thumbnailCollectionView.reloadData()
+ }
+}
+
+// MARK: - AVCapturePhotoCaptureDelegate
+extension MultiCameraViewController: AVCapturePhotoCaptureDelegate {
+ func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
+ if let error = error {
+ CAPLog.print("Error capturing photo: \(error.localizedDescription)")
+ return
+ }
+
+ guard let imageData = photo.fileDataRepresentation(),
+ let image = UIImage(data: imageData) else {
+ return
+ }
+
+ // Extract metadata
+ let metadata = photo.metadata
+
+ // Apply image processing (resizing, quality, etc.) - this also handles orientation correction
+ let processedImage = processImage(from: image, with: metadata)
+
+ // Apply device orientation correction if we didn't do resizing (since resizing includes orientation correction)
+ let finalImage = shouldResize ? processedImage : fixImageOrientation(processedImage)
+
+ // Add the captured image
+ DispatchQueue.main.async { [weak self] in
+ self?.addCapturedImage(finalImage, metadata: metadata)
+ }
+ }
+
+ private func fixImageOrientation(_ image: UIImage) -> UIImage {
+ // First, normalize the image orientation using reformat()
+ let normalizedImage = image.reformat()
+
+ // Then apply rotation based on the current device orientation
+ let orientation = UIDevice.current.orientation
+ let isUsingFrontCamera = currentCameraPosition == .front
+
+ // Create a new CGImage from the normalized image
+ guard let cgImage = normalizedImage.cgImage else { return normalizedImage }
+
+ // Determine the correct orientation based on device orientation
+ var uiOrientation: UIImage.Orientation = .up // Default is no additional rotation
+
+ switch orientation {
+ case .portrait:
+ // Portrait is already correct after normalization
+ return normalizedImage
+ case .portraitUpsideDown:
+ // Need 180-degree rotation
+ uiOrientation = .down
+ case .landscapeLeft:
+ // Need counter-clockwise 90-degree rotation for back camera
+ uiOrientation = isUsingFrontCamera ? .downMirrored : .left
+ case .landscapeRight:
+ // Need clockwise 90-degree rotation for back camera
+ uiOrientation = isUsingFrontCamera ? .upMirrored : .right
+ default:
+ // For unknown orientations, use the normalized image
+ return normalizedImage
+ }
+
+ // Create a new UIImage with the correct orientation
+ return UIImage(cgImage: cgImage, scale: normalizedImage.scale, orientation: uiOrientation)
+ }
+}
+
+// MARK: - UICollectionViewDataSource
+extension MultiCameraViewController: UICollectionViewDataSource {
+ func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
+ return capturedImages.count
+ }
+
+ func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
+ guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "ThumbnailCell", for: indexPath) as? ThumbnailCell else {
+ return UICollectionViewCell()
+ }
+
+ // Check if this thumbnail is still loading
+ if indexPath.item < loadingStates.count && loadingStates[indexPath.item] {
+ cell.configureAsLoading()
+ cell.deleteHandler = nil // Don't allow deletion of loading thumbnails
+ } else {
+ cell.configure(with: capturedImages[indexPath.item])
+ cell.deleteHandler = { [weak self] in
+ self?.removeImage(at: indexPath.item)
+ }
+ }
+
+ return cell
+ }
+}
+
+// MARK: - UICollectionViewDelegate
+extension MultiCameraViewController: UICollectionViewDelegate {
+ func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
+ // Don't allow interaction with loading thumbnails
+ guard indexPath.item < loadingStates.count && !loadingStates[indexPath.item] else {
+ return
+ }
+
+ // Filter out any loading images for the preview
+ let nonLoadingImages = capturedImages.enumerated().compactMap { index, image in
+ loadingStates.indices.contains(index) && !loadingStates[index] ? image : nil
+ }
+
+ // Calculate the correct starting index in the filtered array
+ let nonLoadingIndices = loadingStates.enumerated().compactMap { index, isLoading in
+ !isLoading ? index : nil
+ }
+
+ guard let startingIndex = nonLoadingIndices.firstIndex(of: indexPath.item) else {
+ return
+ }
+
+ // Create a custom image preview controller with all non-loading images
+ let previewController = ImagePreviewViewController(images: nonLoadingImages, startingIndex: startingIndex)
+
+ // Present it modally
+ present(previewController, animated: true)
+ }
+}
\ No newline at end of file
diff --git a/camera/ios/Sources/CameraPlugin/Resources/Assets.xcassets/Contents.json b/camera/ios/Sources/CameraPlugin/Resources/Assets.xcassets/Contents.json
new file mode 100644
index 000000000..4aa7c5350
--- /dev/null
+++ b/camera/ios/Sources/CameraPlugin/Resources/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
\ No newline at end of file
diff --git a/camera/ios/Sources/CameraPlugin/Resources/Assets.xcassets/camera_capture.imageset/Contents.json b/camera/ios/Sources/CameraPlugin/Resources/Assets.xcassets/camera_capture.imageset/Contents.json
new file mode 100644
index 000000000..5d64d07a6
--- /dev/null
+++ b/camera/ios/Sources/CameraPlugin/Resources/Assets.xcassets/camera_capture.imageset/Contents.json
@@ -0,0 +1,20 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
\ No newline at end of file
diff --git a/camera/src/definitions.ts b/camera/src/definitions.ts
index 83e39aa9a..534922cb7 100644
--- a/camera/src/definitions.ts
+++ b/camera/src/definitions.ts
@@ -335,6 +335,11 @@ export enum CameraSource {
* Take a new photo using the camera.
*/
Camera = 'CAMERA',
+ /**
+ * Take multiple photos in a row using the camera.
+ * Available on Android and iOS.
+ */
+ CameraMulti = 'CAMERA_MULTI',
/**
* Pick an existing photo from the gallery or photo album.
*/