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. */