From bde22c239d97500175773061d57e071bc472f49b Mon Sep 17 00:00:00 2001 From: Ian Becker Date: Mon, 29 Jan 2024 18:21:06 +0000 Subject: [PATCH 01/19] feat(camera): adds multi-camera feature for Android (#1616) --- camera/android/build.gradle | 4 + camera/android/src/main/AndroidManifest.xml | 1 + .../plugins/camera/CameraFragment.java | 855 ++++++++++++++++++ .../plugins/camera/CameraPlugin.java | 120 ++- .../plugins/camera/CameraSource.java | 1 + .../plugins/camera/DeviceUtils.java | 11 + .../plugins/camera/ThumbnailAdapter.java | 207 +++++ .../main/res/drawable/center_focus_24px.xml | 9 + .../src/main/res/drawable/close_24px.xml | 9 + .../src/main/res/drawable/done_24px.xml | 9 + .../src/main/res/drawable/flash_auto_24px.xml | 9 + .../src/main/res/drawable/flash_off_24px.xml | 9 + .../src/main/res/drawable/flash_on_24px.xml | 9 + .../res/drawable/flip_camera_android_24px.xml | 9 + .../main/res/drawable/photo_camera_24px.xml | 9 + camera/src/definitions.ts | 5 + 16 files changed, 1252 insertions(+), 24 deletions(-) create mode 100644 camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraFragment.java create mode 100644 camera/android/src/main/java/com/capacitorjs/plugins/camera/DeviceUtils.java create mode 100644 camera/android/src/main/java/com/capacitorjs/plugins/camera/ThumbnailAdapter.java create mode 100644 camera/android/src/main/res/drawable/center_focus_24px.xml create mode 100644 camera/android/src/main/res/drawable/close_24px.xml create mode 100644 camera/android/src/main/res/drawable/done_24px.xml create mode 100644 camera/android/src/main/res/drawable/flash_auto_24px.xml create mode 100644 camera/android/src/main/res/drawable/flash_off_24px.xml create mode 100644 camera/android/src/main/res/drawable/flash_on_24px.xml create mode 100644 camera/android/src/main/res/drawable/flip_camera_android_24px.xml create mode 100644 camera/android/src/main/res/drawable/photo_camera_24px.xml 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..4f96a895e --- /dev/null +++ b/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraFragment.java @@ -0,0 +1,855 @@ +package com.capacitorjs.plugins.camera; + +import static com.capacitorjs.plugins.camera.DeviceUtils.dpToPx; + +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.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Color; +import android.graphics.drawable.GradientDrawable; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.provider.MediaStore; +import android.util.DisplayMetrics; +import android.util.Log; +import android.util.Size; +import android.view.GestureDetector; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.AccelerateDecelerateInterpolator; +import android.widget.ImageView; +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.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.Locale; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; + +@SuppressWarnings("FieldCanBeLocal") +public class CameraFragment extends Fragment { + + // Constants + @SuppressWarnings("unused") + 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_MESSAGE = "Are you sure?"; + private final String CONFIRM_CANCEL_POSITIVE = "Yes"; + private final String CONFIRM_CANCEL_NEGATIVE = "No"; + + @ColorInt + private final int ZOOM_TAB_LAYOUT_BACKGROUND_COLOR = 0xA0000000; + @ColorInt + private final int ZOOM_BUTTON_COLOR_SELECTED = 0xD0ADD8E6; + @ColorInt + private final int ZOOM_BUTTON_COLOR_UNSELECTED = 0xC0CCCCCC; + 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 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; + // 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 images; + private ArrayList zoomTabs; + + + private Handler zoomHandler = null; + private Runnable zoomRunnable = null; + + // Callbacks + private OnImagesCapturedCallback imagesCapturedCallback; + + @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); + images = new HashMap<>(); + zoomTabs = new ArrayList<>(); + zoomHandler = new Handler(requireActivity().getMainLooper()); + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + FragmentActivity fragmentActivity = requireActivity(); + displayMetrics = fragmentActivity.getResources().getDisplayMetrics(); + int margin = (int) (16 * displayMetrics.density); + int barHeight = (int) (100 * displayMetrics.density); + + relativeLayout = new RelativeLayout(fragmentActivity); + + ColorStateList buttonColors = createButtonColorList(); + + // Create the bottom bar and the buttons that sit inside it + createBottomBar(fragmentActivity, barHeight, margin, buttonColors); + + // Camera preview is above the bottom bar. The zoom buttons and filmstrip overlap it + createPreviewView(fragmentActivity); + + createFocusIndicator(fragmentActivity); + + // 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); + + return relativeLayout; + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + cameraController = new LifecycleCameraController(requireActivity()); + cameraController.bindToLifecycle(requireActivity()); + previewView.setController(cameraController); + cameraExecutor = Executors.newSingleThreadExecutor(); + relativeLayout.post(this::setupCamera); + + + } + + private void cancel() { + // When the user cancels the camera session, it should clean up all the photos that were + // taken. + for (Map.Entry image : images.entrySet()) { + deleteFile(image.getKey()); + } + if (imagesCapturedCallback != null) { + imagesCapturedCallback.onCaptureCanceled(); + } + closeFragment(); + } + + private void done() { + if (imagesCapturedCallback != null) { + imagesCapturedCallback.onCaptureSuccess(images); + } + closeFragment(); + } + + private void closeFragment() { + requireActivity().getSupportFragmentManager() + .beginTransaction() + .remove(this) + .commit(); + } + + public void setImagesCapturedCallback(OnImagesCapturedCallback imagesCapturedCallback) { + this.imagesCapturedCallback = imagesCapturedCallback; + } + + 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 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); + flashButtonLayoutParams.setMargins(0, margin, margin, 0); + flashButton.setLayoutParams(flashButtonLayoutParams); + flashButton.setOnClickListener(view -> { + 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); + } + case ImageCapture.FLASH_MODE_ON -> { + flashMode = ImageCapture.FLASH_MODE_AUTO; + flashButton.setImageResource(R.drawable.flash_auto_24px); + flashButton.setColorFilter(Color.WHITE); + } + case ImageCapture.FLASH_MODE_AUTO -> { + flashMode = ImageCapture.FLASH_MODE_OFF; + flashButton.setImageResource(R.drawable.flash_off_24px); + flashButton.setColorFilter(Color.WHITE); + } + 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.photo_camera_24px); + takePictureButton.setBackgroundColor(Color.TRANSPARENT); + takePictureButton.setBackgroundTintList(buttonColors); + takePictureButton.setColorFilter(Color.WHITE); + + takePictureButton.setScaleX(1.5f); + takePictureButton.setScaleY(1.5f); + takePictureLayoutParams = new RelativeLayout.LayoutParams( + RelativeLayout.LayoutParams.WRAP_CONTENT, + RelativeLayout.LayoutParams.WRAP_CONTENT + ); + takePictureLayoutParams.addRule(RelativeLayout.CENTER_HORIZONTAL); + takePictureLayoutParams.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM); + takePictureLayoutParams.setMargins(0, 0, 0, margin); + takePictureButton.setLayoutParams(takePictureLayoutParams); + takePictureButton.setOnClickListener(v -> { + 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) { + try { + InputStream stream = requireContext().getContentResolver() + .openInputStream(savedImageUri); + Bitmap bmp = BitmapFactory.decodeStream(stream); + images.put(savedImageUri, bmp); + requireView().post(() -> thumbnailAdapter.addThumbnail( + savedImageUri, + getThumbnail(savedImageUri) + )); + } catch (FileNotFoundException e) { + e.printStackTrace(); + } + } + } + + @Override + public void onError(@NonNull ImageCaptureException exception) { + + } + }); + }); + 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_LEFT); + flipButtonLayoutParams.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM); + flipButtonLayoutParams.setMargins(margin, 0, 0, margin); + flipCameraButton.setLayoutParams(flipButtonLayoutParams); + flipCameraButton.setOnClickListener(v -> { + lensFacing = lensFacing == CameraSelector.LENS_FACING_FRONT ? + CameraSelector.LENS_FACING_BACK : CameraSelector.LENS_FACING_FRONT; + flashButton.setVisibility( + lensFacing == CameraSelector.LENS_FACING_BACK ? View.VISIBLE : View.GONE + ); + if (!zoomTabs.isEmpty()) { + zoomTabLayout.removeAllTabs(); + zoomTabs.clear(); + } + setupCamera(); + }); + bottomBar.addView(flipCameraButton); + } + + @SuppressLint("ClickableViewAccessibility") + private void createPreviewView(FragmentActivity fragmentActivity) { + previewView = new PreviewView(fragmentActivity); + previewView.setId(View.generateViewId()); + + RelativeLayout.LayoutParams previewLayoutParams = new RelativeLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ); + previewLayoutParams.addRule(RelativeLayout.ABOVE, bottomBar.getId()); + previewView.setLayoutParams(previewLayoutParams); + previewView.setScaleType(PreviewView.ScaleType.FILL_CENTER); + + GestureDetector gestureDetector = new GestureDetector(fragmentActivity, new GestureDetector.SimpleOnGestureListener() { + @Override + public boolean onSingleTapConfirmed(@NonNull MotionEvent event) { + // This is a confirmed single tap, so it's safe to perform a click action here + previewView.performClick(); + + return true; + } + }); + + previewView.setOnTouchListener((v, event) -> { + focusIndicator.setX(event.getX() - (focusIndicator.getWidth() / 2f)); + focusIndicator.setY(event.getY() - (focusIndicator.getHeight() / 2f)); + return gestureDetector.onTouchEvent(event); + }); + + relativeLayout.addView(previewView); + } + + private void createFocusIndicator(Context context) { + focusIndicator = new ImageView(context); + focusIndicator.setImageResource(R.drawable.center_focus_24px); + + focusIndicator.post(() -> { + int desiredSizeDp = 72; + // Get the actual dimensions of the ImageView + int width = focusIndicator.getWidth(); + int height = focusIndicator.getHeight(); + + // Determine the smaller dimension to maintain the aspect ratio (assuming square for simplicity) + int minDimension = Math.min(width, height); + + // Convert the desired size from dp to pixels + int desiredSizePx = dpToPx(context, desiredSizeDp); + + // Calculate the scaling factor + float scaleFactor = (float) desiredSizePx / minDimension; + + // Apply the scale to the ImageView + focusIndicator.setScaleX(scaleFactor); + focusIndicator.setScaleY(scaleFactor); + }); + focusIndicator.setColorFilter(Color.WHITE); + focusIndicator.setVisibility(View.INVISIBLE); // Initially hidden + + RelativeLayout.LayoutParams layoutParams = new RelativeLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + layoutParams.addRule(RelativeLayout.ABOVE, bottomBar.getId()); + focusIndicator.setLayoutParams(layoutParams); + + 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); + } + + private void createZoomTabs(FragmentActivity fragmentActivity, TabLayout tabLayout) { + float[] zoomLevels = {minZoom, 1f, 2f, 5f}; + + for (int i = 0; i < zoomLevels.length; i++) { + float zoomLevel = zoomLevels[i]; + ZoomTab zoomTab = new ZoomTab(fragmentActivity, zoomLevel, 40, i); + zoomTabs.add(zoomTab); + TabLayout.Tab tab = tabLayout.newTab(); + tab.setCustomView(zoomTab.getView()); + tabLayout.addTab(tab); + } + + tabLayout.selectTab(tabLayout.getTabAt(1)); + } + + private void createFilmstripView(FragmentActivity fragmentActivity) { + filmstripView = new RecyclerView(fragmentActivity); + RelativeLayout.LayoutParams filmstripLayoutParams = new RelativeLayout.LayoutParams( + RelativeLayout.LayoutParams.WRAP_CONTENT, + RelativeLayout.LayoutParams.WRAP_CONTENT + ); + filmstripLayoutParams.addRule(RelativeLayout.CENTER_HORIZONTAL); + filmstripLayoutParams.addRule(RelativeLayout.ABOVE, zoomTabCardView.getId()); + filmstripView.setLayoutParams(filmstripLayoutParams); + LinearLayoutManager layoutManager = new LinearLayoutManager(fragmentActivity, + LinearLayoutManager.HORIZONTAL, false); + filmstripView.setLayoutManager(layoutManager); + thumbnailAdapter = new ThumbnailAdapter(); + filmstripView.setAdapter(thumbnailAdapter); + relativeLayout.addView(filmstripView); + + thumbnailAdapter.setOnThumbnailsChangedCallback(new ThumbnailAdapter.OnThumbnailsChangedCallback() { + @Override + public void onThumbnailRemoved(Uri uri, Bitmap bmp) { + images.remove(uri); + deleteFile(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_RIGHT); + doneButtonLayoutParams.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM); + doneButtonLayoutParams.setMargins(0, 0, margin, margin); + doneButton.setLayoutParams(doneButtonLayoutParams); + doneButton.setOnClickListener(view -> 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); + closeButtonLayoutParams.setMargins(margin, margin, 0, 0); + closeButton.setLayoutParams(closeButtonLayoutParams); + closeButton.setOnClickListener(view -> new AlertDialog.Builder(requireContext()) + .setMessage(CONFIRM_CANCEL_MESSAGE) + .setPositiveButton(CONFIRM_CANCEL_POSITIVE, (dialogInterface, i) -> cancel()) + .setNegativeButton(CONFIRM_CANCEL_NEGATIVE, + (dialogInterface, i) -> dialogInterface.dismiss()) + .create() + .show() + ); + relativeLayout.addView(closeButton); + } + + private void deleteFile(Uri fileUri) { + try { + ContentResolver contentResolver = requireContext().getContentResolver(); + int deleted = contentResolver.delete(fileUri, null, null); + + if (deleted == 0) { + // File deletion failed + Log.e("Delete File", "Failed to delete file: " + fileUri); + } else { + // File deletion successful + Log.i("Delete File", "File deleted: " + fileUri); + } + } catch (Exception e) { + // Handle any exceptions + e.printStackTrace(); + } + } + + + 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(); + + if (zoomTabs.isEmpty()) { + createZoomTabs(requireActivity(), zoomTabLayout); + } + + 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) { + TabLayout.Tab tab = zoomTabLayout.getTabAt(closestTab.getTabIndex()); + if (tab != null) { + closestTab.setTransientZoomLevel(currentZoom); // Update the tab's display to show the current zoom level + 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) // Duration for fade-in + .setInterpolator(new AccelerateDecelerateInterpolator()) // Ease-in/ease-out + .start(); + } else { + // Fade out and hide the focus indicator when focusing ends, regardless of the result + focusIndicator.animate() + .alpha(0f) + .setDuration(500) // Duration for fade-out + .setInterpolator(new AccelerateDecelerateInterpolator()) // Ease-in/ease-out + .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); + } + + private boolean hasFrontFacingCamera() { + if (cameraController != null) { + CameraSelector frontFacing = new CameraSelector.Builder() + .requireLensFacing(CameraSelector.LENS_FACING_FRONT) + .build(); + + return cameraController.hasCamera(frontFacing); + } + return false; + } + + @SuppressWarnings("deprecation") + private Bitmap getThumbnail(Uri imageUri) { + ContentResolver contentResolver = requireContext().getContentResolver(); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { // API level 29 and above + try { // Specify the size of the thumbnail + int width = (int) (displayMetrics.widthPixels * 0.25); // Thumbnail width as 25% of screen width + int height = (int) (displayMetrics.heightPixels * 0.25); // Thumbnail height as 25% of screen height + Size size = new Size(width, height); + // Load the thumbnail + return contentResolver.loadThumbnail(imageUri, size, null); + } catch (IOException e) { + // Handle exceptions + e.printStackTrace(); + return null; + } + } else { // Below API level 29 + 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); + } + return null; + } + } + + 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(dpToPx(requireContext(),4)); + 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.WHITE : Color.BLACK); + 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 d2a52ac46..10c5ec115 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; @@ -326,6 +345,27 @@ public void openCamera(final PluginCall call) { } } + public void openMultiCamera(final PluginCall call) { + if (checkCameraPermissions(call)) { + final CameraFragment fragment = new CameraFragment(); + 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); } @@ -599,20 +639,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(); @@ -620,7 +656,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); @@ -656,10 +692,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) { @@ -675,15 +711,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(); @@ -694,6 +750,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); @@ -703,7 +775,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) { @@ -713,9 +785,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; } } @@ -767,7 +839,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); @@ -775,10 +847,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); @@ -786,7 +858,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/ThumbnailAdapter.java b/camera/android/src/main/java/com/capacitorjs/plugins/camera/ThumbnailAdapter.java new file mode 100644 index 000000000..2e3e3199c --- /dev/null +++ b/camera/android/src/main/java/com/capacitorjs/plugins/camera/ThumbnailAdapter.java @@ -0,0 +1,207 @@ +package com.capacitorjs.plugins.camera; + +import static com.capacitorjs.plugins.camera.DeviceUtils.dpToPx; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.Bitmap; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffXfermode; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; + +import androidx.annotation.NonNull; +import androidx.constraintlayout.widget.ConstraintLayout; +import androidx.constraintlayout.widget.ConstraintSet; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.material.floatingactionbutton.FloatingActionButton; + +import java.util.ArrayList; + +public class ThumbnailAdapter extends RecyclerView.Adapter { + private final ArrayList thumbnails; + private OnThumbnailsChangedCallback thumbnailsChangedCallback = null; + + ThumbnailAdapter() { + this.thumbnails = new ArrayList<>(); + } + + void addThumbnail(Uri uri, Bitmap thumbnail) { + if (thumbnail == null) return; + ThumbnailItem item = new ThumbnailItem(uri, thumbnail); + thumbnails.add(item); + notifyItemInserted(thumbnails.size() - 1); + } + + + @NonNull + @Override + public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + Context context = parent.getContext(); + int thumbnailPx = dpToPx(context, 100); // Convert dp to pixels + int buttonSize = dpToPx(context, 24); + + // Create the ConstraintLayout as a container + ConstraintLayout constraintLayout = new ConstraintLayout(context); + int extraSpaceForButton = dpToPx(context, 24); // Example extra space in dp + ConstraintLayout.LayoutParams clLayoutParams = new ConstraintLayout.LayoutParams( + thumbnailPx + extraSpaceForButton, + thumbnailPx + extraSpaceForButton + ); + constraintLayout.setLayoutParams(clLayoutParams); + + ImageView imageView = new ImageView(context); + imageView.setId(View.generateViewId()); + imageView.setScaleType(ImageView.ScaleType.CENTER_CROP); + ConstraintLayout.LayoutParams imageParams = new ConstraintLayout.LayoutParams(thumbnailPx, thumbnailPx); + // Set imageView to be centered in the ConstraintLayout + imageParams.startToStart = ConstraintSet.PARENT_ID; + imageParams.topToTop = ConstraintSet.PARENT_ID; + imageParams.endToEnd = ConstraintSet.PARENT_ID; + imageParams.bottomToBottom = ConstraintSet.PARENT_ID; + imageView.setLayoutParams(imageParams); + imageView.setScaleType(ImageView.ScaleType.CENTER_CROP); + constraintLayout.addView(imageView); + + FloatingActionButton removeButton = new FloatingActionButton(context); + removeButton.setCustomSize(buttonSize); + removeButton.setId(View.generateViewId()); + // Set the icon for the remove button + Bitmap cutoutBitmap = createCutoutBitmap(buttonSize); + Drawable cutoutDrawable = new BitmapDrawable(context.getResources(), cutoutBitmap); + removeButton.setImageDrawable(cutoutDrawable); + // Set the background color of the button to white + ColorStateList whiteBackground = ColorStateList.valueOf(Color.TRANSPARENT); + removeButton.setBackgroundTintList(whiteBackground); + ConstraintLayout.LayoutParams buttonParams = new ConstraintLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + // Align button to the top and end of the ConstraintLayout + buttonParams.topToTop = ConstraintSet.PARENT_ID; + buttonParams.endToEnd = ConstraintSet.PARENT_ID; + removeButton.setLayoutParams(buttonParams); + constraintLayout.addView(removeButton); + + // Apply constraints to position the views correctly using ConstraintSet + ConstraintSet set = new ConstraintSet(); + set.clone(constraintLayout); + set.connect(imageView.getId(), ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START, 0); + set.connect(imageView.getId(), ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP, 0); + set.connect(imageView.getId(), ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END, 0); + set.connect(imageView.getId(), ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM, 0); + + set.connect(removeButton.getId(), ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP, 0); + set.connect(removeButton.getId(), ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END, 0); + + // Offset the button to make it overlap the corner of the ImageView + set.setMargin(removeButton.getId(), ConstraintSet.END, -buttonSize / 2); + set.setMargin(removeButton.getId(), ConstraintSet.TOP, -buttonSize / 2); + + set.applyTo(constraintLayout); + return new ViewHolder(constraintLayout, imageView, removeButton); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + holder.imageView.setImageBitmap(thumbnails.get(position).bitmap); + + 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(); + } + + public void setOnThumbnailsChangedCallback(OnThumbnailsChangedCallback callback) { + this.thumbnailsChangedCallback = callback; + } + + + private Bitmap createCutoutBitmap(int diameter) { + Bitmap bitmap = Bitmap.createBitmap(diameter, diameter, Bitmap.Config.ARGB_8888); + Canvas canvas = new Canvas(bitmap); + + // Draw the white circular background + Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); + paint.setColor(Color.WHITE); + canvas.drawCircle(diameter / 2f, diameter / 2f, diameter / 2f, paint); + + paint.setColor(Color.TRANSPARENT); + paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR)); + paint.setStrokeWidth(4); // Set the stroke width for the 'X' + + // Coordinates for the 'X' + float startX1 = diameter * 0.25f; // Start X for the first line + float endX1 = diameter * 0.75f; // End X for the first line + float startY1 = diameter * 0.25f; // Start Y for the first line + float endY1 = diameter * 0.75f; // End Y for the first line + + float startX2 = diameter * 0.75f; // Start X for the second line + float endX2 = diameter * 0.25f; // End X for the second line + float startY2 = diameter * 0.25f; // Start Y for the second line + float endY2 = diameter * 0.75f; // End Y for the second line + + canvas.drawLine(startX1, startY1, endX1, endY1, paint); // First line of 'X' + canvas.drawLine(startX2, startY2, endX2, endY2, paint); // Second line of 'X' + + return bitmap; + } + + static class ViewHolder extends RecyclerView.ViewHolder { + ImageView imageView; + FloatingActionButton removeButton; + ConstraintLayout mainView; + + ViewHolder(@NonNull ConstraintLayout view, @NonNull ImageView imageView, @NonNull FloatingActionButton removeButton) { + super(view); + this.imageView = imageView; + this.mainView = view; + this.removeButton = removeButton; + } + } + + public static abstract class OnThumbnailsChangedCallback { + public void onThumbnailRemoved(Uri uri, Bitmap bmp) { + } + } + + public static class ThumbnailItem { + private final Uri uri; + private final Bitmap bitmap; + + public ThumbnailItem(Uri u, Bitmap bmp) { + this.uri = u; + this.bitmap = bmp; + } + + public Uri getUri() { + return uri; + } + + public Bitmap getBitmap() { + return bitmap; + } + } +} 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..487353aea --- /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/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/src/definitions.ts b/camera/src/definitions.ts index 83e39aa9a..8e6d8d0c7 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. + * Only available on Android. + */ + CameraMulti = 'CAMERA_MULTI', /** * Pick an existing photo from the gallery or photo album. */ From 89d7895e14ec17533fa4199a7016cecc98e340c3 Mon Sep 17 00:00:00 2001 From: Shiva Prasad Date: Thu, 31 Jul 2025 16:21:47 +0700 Subject: [PATCH 02/19] feat(camera): multi shot camera UI improvements with immersive mode support --- camera/README.md | 11 +- .../plugins/camera/CameraFragment.java | 667 ++++++++++-------- .../plugins/camera/ThumbnailAdapter.java | 154 +--- .../res/animator/button_press_animation.xml | 31 + .../res/animator/button_release_animation.xml | 19 + .../res/drawable/ic_cancel_white_24dp.xml | 9 + .../main/res/drawable/ic_shutter_circle.xml | 4 + 7 files changed, 464 insertions(+), 431 deletions(-) create mode 100644 camera/android/src/main/res/animator/button_press_animation.xml create mode 100644 camera/android/src/main/res/animator/button_release_animation.xml create mode 100644 camera/android/src/main/res/drawable/ic_cancel_white_24dp.xml create mode 100644 camera/android/src/main/res/drawable/ic_shutter_circle.xml diff --git a/camera/README.md b/camera/README.md index 9ad234eee..ed90a4df7 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. Only available on Android. | +| **`Photos`** | 'PHOTOS' | Pick an existing photo from the gallery or photo album. | #### CameraDirection 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 index 4f96a895e..334a3b14c 100644 --- a/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraFragment.java +++ b/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraFragment.java @@ -13,6 +13,7 @@ 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; @@ -21,17 +22,18 @@ import android.util.DisplayMetrics; import android.util.Log; import android.util.Size; -import android.view.GestureDetector; 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.WindowInsetsController; import android.view.animation.AccelerateDecelerateInterpolator; import android.widget.ImageView; import android.widget.RelativeLayout; import android.widget.TextView; - import androidx.annotation.ColorInt; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -47,10 +49,8 @@ import androidx.fragment.app.FragmentActivity; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; - 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; @@ -78,11 +78,14 @@ public class CameraFragment extends Fragment { private final String CONFIRM_CANCEL_NEGATIVE = "No"; @ColorInt - private final int ZOOM_TAB_LAYOUT_BACKGROUND_COLOR = 0xA0000000; + private final int ZOOM_TAB_LAYOUT_BACKGROUND_COLOR = 0x80000000; + @ColorInt - private final int ZOOM_BUTTON_COLOR_SELECTED = 0xD0ADD8E6; + private final int ZOOM_BUTTON_COLOR_SELECTED = 0xFFFFFFFF; + @ColorInt - private final int ZOOM_BUTTON_COLOR_UNSELECTED = 0xC0CCCCCC; + private final int ZOOM_BUTTON_COLOR_UNSELECTED = 0x80FFFFFF; + private final AtomicBoolean isSnappingZoom = new AtomicBoolean(false); // View related variables private RelativeLayout relativeLayout; @@ -110,39 +113,38 @@ public class CameraFragment extends Fragment { // 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 images; private ArrayList zoomTabs; - private Handler zoomHandler = null; private Runnable zoomRunnable = null; + private MediaActionSound mediaActionSound; // Callbacks private OnImagesCapturedCallback imagesCapturedCallback; @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[][] 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 - }; + int[] colors = new int[] { Color.DKGRAY, Color.TRANSPARENT, Color.TRANSPARENT, Color.LTGRAY }; return new ColorStateList(states, colors); } @@ -152,6 +154,36 @@ public void onCreate(@Nullable Bundle savedInstanceState) { images = new HashMap<>(); zoomTabs = new ArrayList<>(); zoomHandler = new Handler(requireActivity().getMainLooper()); + mediaActionSound = new MediaActionSound(); + mediaActionSound.load(MediaActionSound.SHUTTER_CLICK); + } + + @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)); + + if (mediaActionSound != null) { + mediaActionSound.release(); + mediaActionSound = null; + } + if (cameraExecutor != null) { + cameraExecutor.shutdown(); + } } @Nullable @@ -159,7 +191,7 @@ public void onCreate(@Nullable Bundle savedInstanceState) { public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { FragmentActivity fragmentActivity = requireActivity(); displayMetrics = fragmentActivity.getResources().getDisplayMetrics(); - int margin = (int) (16 * displayMetrics.density); + int margin = (int) (20 * displayMetrics.density); int barHeight = (int) (100 * displayMetrics.density); relativeLayout = new RelativeLayout(fragmentActivity); @@ -184,6 +216,27 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c 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); + } + + // Remove edge-to-edge insets handling for true fullscreen + requireActivity().getWindow().setDecorFitsSystemWindows(true); + return relativeLayout; } @@ -191,13 +244,15 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c 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(); relativeLayout.post(this::setupCamera); - - } private void cancel() { @@ -220,10 +275,7 @@ private void done() { } private void closeFragment() { - requireActivity().getSupportFragmentManager() - .beginTransaction() - .remove(this) - .commit(); + requireActivity().getSupportFragmentManager().beginTransaction().remove(this).commit(); } public void setImagesCapturedCallback(OnImagesCapturedCallback imagesCapturedCallback) { @@ -234,15 +286,11 @@ private void createBottomBar(FragmentActivity fragmentActivity, int barHeight, i bottomBar = new RelativeLayout(fragmentActivity); bottomBar.setId(View.generateViewId()); bottomBar.setBackgroundColor(Color.BLACK); - bottomBarLayoutParams = new RelativeLayout.LayoutParams( - RelativeLayout.LayoutParams.MATCH_PARENT, - barHeight - ); + 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); @@ -255,95 +303,108 @@ private void createFlashButton(FragmentActivity fragmentActivity, int margin, Co flashButton.setBackgroundTintList(buttonColors); flashButton.setColorFilter(Color.WHITE); flashButtonLayoutParams = new RelativeLayout.LayoutParams( - RelativeLayout.LayoutParams.WRAP_CONTENT, - RelativeLayout.LayoutParams.WRAP_CONTENT + RelativeLayout.LayoutParams.WRAP_CONTENT, + RelativeLayout.LayoutParams.WRAP_CONTENT ); flashButtonLayoutParams.addRule(RelativeLayout.ALIGN_PARENT_TOP); flashButtonLayoutParams.addRule(RelativeLayout.ALIGN_PARENT_RIGHT); - flashButtonLayoutParams.setMargins(0, margin, margin, 0); + int topMargin = (int) (margin * 2.5); + flashButtonLayoutParams.setMargins(0, topMargin, margin, 0); flashButton.setLayoutParams(flashButtonLayoutParams); - flashButton.setOnClickListener(view -> { - 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); - } - case ImageCapture.FLASH_MODE_ON -> { - flashMode = ImageCapture.FLASH_MODE_AUTO; - flashButton.setImageResource(R.drawable.flash_auto_24px); - flashButton.setColorFilter(Color.WHITE); - } - case ImageCapture.FLASH_MODE_AUTO -> { - flashMode = ImageCapture.FLASH_MODE_OFF; - flashButton.setImageResource(R.drawable.flash_off_24px); - flashButton.setColorFilter(Color.WHITE); + 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); } - default -> throw new IllegalStateException("Unexpected flash mode: " + flashMode); + cameraController.setImageCaptureFlashMode(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.photo_camera_24px); + takePictureButton.setImageResource(R.drawable.ic_shutter_circle); takePictureButton.setBackgroundColor(Color.TRANSPARENT); takePictureButton.setBackgroundTintList(buttonColors); - takePictureButton.setColorFilter(Color.WHITE); - takePictureButton.setScaleX(1.5f); - takePictureButton.setScaleY(1.5f); - takePictureLayoutParams = new RelativeLayout.LayoutParams( - RelativeLayout.LayoutParams.WRAP_CONTENT, - RelativeLayout.LayoutParams.WRAP_CONTENT - ); - takePictureLayoutParams.addRule(RelativeLayout.CENTER_HORIZONTAL); - takePictureLayoutParams.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM); - takePictureLayoutParams.setMargins(0, 0, 0, margin); + 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.setOnClickListener(v -> { - 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( + 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); + 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) + contentValues + ) .build(); - cameraController.takePicture(outputOptions, cameraExecutor, new ImageCapture.OnImageSavedCallback() { - @Override - public void onImageSaved(@NonNull ImageCapture.OutputFileResults outputFileResults) { - Uri savedImageUri = outputFileResults.getSavedUri(); - if (savedImageUri != null) { - try { - InputStream stream = requireContext().getContentResolver() - .openInputStream(savedImageUri); - Bitmap bmp = BitmapFactory.decodeStream(stream); - images.put(savedImageUri, bmp); - requireView().post(() -> thumbnailAdapter.addThumbnail( - savedImageUri, - getThumbnail(savedImageUri) - )); - } catch (FileNotFoundException e) { - e.printStackTrace(); + cameraController.takePicture( + outputOptions, + cameraExecutor, + new ImageCapture.OnImageSavedCallback() { + @Override + public void onImageSaved(@NonNull ImageCapture.OutputFileResults outputFileResults) { + Uri savedImageUri = outputFileResults.getSavedUri(); + if (savedImageUri != null) { + try { + InputStream stream = requireContext().getContentResolver().openInputStream(savedImageUri); + Bitmap bmp = BitmapFactory.decodeStream(stream); + images.put(savedImageUri, bmp); + requireView() + .post( + () -> thumbnailAdapter.addThumbnail(savedImageUri, getThumbnail(savedImageUri)) + ); + } catch (FileNotFoundException e) { + e.printStackTrace(); + } + } } - } - } - @Override - public void onError(@NonNull ImageCaptureException exception) { - - } - }); - }); + @Override + public void onError(@NonNull ImageCaptureException exception) {} + } + ); + } + ); bottomBar.addView(takePictureButton); } @@ -354,25 +415,25 @@ private void createFlipButton(FragmentActivity fragmentActivity, int margin, Col flipCameraButton.setColorFilter(Color.WHITE); flipCameraButton.setBackgroundTintList(buttonColors); flipButtonLayoutParams = new RelativeLayout.LayoutParams( - RelativeLayout.LayoutParams.WRAP_CONTENT, - RelativeLayout.LayoutParams.WRAP_CONTENT + RelativeLayout.LayoutParams.WRAP_CONTENT, + RelativeLayout.LayoutParams.WRAP_CONTENT ); - flipButtonLayoutParams.addRule(RelativeLayout.ALIGN_PARENT_LEFT); - flipButtonLayoutParams.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM); - flipButtonLayoutParams.setMargins(margin, 0, 0, margin); + flipButtonLayoutParams.addRule(RelativeLayout.ALIGN_PARENT_START); + flipButtonLayoutParams.addRule(RelativeLayout.CENTER_VERTICAL); + flipButtonLayoutParams.setMargins(margin, 0, 0, 0); flipCameraButton.setLayoutParams(flipButtonLayoutParams); - flipCameraButton.setOnClickListener(v -> { - lensFacing = lensFacing == CameraSelector.LENS_FACING_FRONT ? - CameraSelector.LENS_FACING_BACK : CameraSelector.LENS_FACING_FRONT; - flashButton.setVisibility( - lensFacing == CameraSelector.LENS_FACING_BACK ? View.VISIBLE : View.GONE - ); - if (!zoomTabs.isEmpty()) { - zoomTabLayout.removeAllTabs(); - zoomTabs.clear(); + flipCameraButton.setOnClickListener( + v -> { + v.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY); + lensFacing = lensFacing == CameraSelector.LENS_FACING_FRONT ? CameraSelector.LENS_FACING_BACK : CameraSelector.LENS_FACING_FRONT; + flashButton.setVisibility(lensFacing == CameraSelector.LENS_FACING_BACK ? View.VISIBLE : View.GONE); + if (!zoomTabs.isEmpty()) { + zoomTabLayout.removeAllTabs(); + zoomTabs.clear(); + } + setupCamera(); } - setupCamera(); - }); + ); bottomBar.addView(flipCameraButton); } @@ -382,28 +443,24 @@ private void createPreviewView(FragmentActivity fragmentActivity) { previewView.setId(View.generateViewId()); RelativeLayout.LayoutParams previewLayoutParams = new RelativeLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT ); previewLayoutParams.addRule(RelativeLayout.ABOVE, bottomBar.getId()); previewView.setLayoutParams(previewLayoutParams); previewView.setScaleType(PreviewView.ScaleType.FILL_CENTER); - GestureDetector gestureDetector = new GestureDetector(fragmentActivity, new GestureDetector.SimpleOnGestureListener() { - @Override - public boolean onSingleTapConfirmed(@NonNull MotionEvent event) { - // This is a confirmed single tap, so it's safe to perform a click action here - previewView.performClick(); + previewView.setOnTouchListener( + (v, event) -> { + // Position the focus indicator at the touch point + focusIndicator.setX(event.getX() - (focusIndicator.getWidth() / 2f)); + focusIndicator.setY(event.getY() - (focusIndicator.getHeight() / 2f)); - return true; + // Let the PreviewView handle the rest of the touch event. + // Returning false allows the default tap-to-focus behavior to trigger. + return false; } - }); - - previewView.setOnTouchListener((v, event) -> { - focusIndicator.setX(event.getX() - (focusIndicator.getWidth() / 2f)); - focusIndicator.setY(event.getY() - (focusIndicator.getHeight() / 2f)); - return gestureDetector.onTouchEvent(event); - }); + ); relativeLayout.addView(previewView); } @@ -412,35 +469,13 @@ private void createFocusIndicator(Context context) { focusIndicator = new ImageView(context); focusIndicator.setImageResource(R.drawable.center_focus_24px); - focusIndicator.post(() -> { - int desiredSizeDp = 72; - // Get the actual dimensions of the ImageView - int width = focusIndicator.getWidth(); - int height = focusIndicator.getHeight(); - - // Determine the smaller dimension to maintain the aspect ratio (assuming square for simplicity) - int minDimension = Math.min(width, height); - - // Convert the desired size from dp to pixels - int desiredSizePx = dpToPx(context, desiredSizeDp); - - // Calculate the scaling factor - float scaleFactor = (float) desiredSizePx / minDimension; + int size = dpToPx(context, 72); + RelativeLayout.LayoutParams layoutParams = new RelativeLayout.LayoutParams(size, size); + focusIndicator.setLayoutParams(layoutParams); - // Apply the scale to the ImageView - focusIndicator.setScaleX(scaleFactor); - focusIndicator.setScaleY(scaleFactor); - }); focusIndicator.setColorFilter(Color.WHITE); focusIndicator.setVisibility(View.INVISIBLE); // Initially hidden - RelativeLayout.LayoutParams layoutParams = new RelativeLayout.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ); - layoutParams.addRule(RelativeLayout.ABOVE, bottomBar.getId()); - focusIndicator.setLayoutParams(layoutParams); - relativeLayout.addView(focusIndicator); } @@ -452,14 +487,13 @@ private void createZoomTabLayout(FragmentActivity fragmentActivity, int margin) GradientDrawable backgroundDrawable = new GradientDrawable(); backgroundDrawable.setShape(GradientDrawable.RECTANGLE); backgroundDrawable.setColor(ZOOM_TAB_LAYOUT_BACKGROUND_COLOR); - backgroundDrawable.setCornerRadius(dpToPx(requireContext(),56 / 2)); + 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 + RelativeLayout.LayoutParams.WRAP_CONTENT, + RelativeLayout.LayoutParams.WRAP_CONTENT ); cardViewLayoutParams.addRule(RelativeLayout.ABOVE, bottomBar.getId()); cardViewLayoutParams.addRule(RelativeLayout.CENTER_HORIZONTAL); @@ -470,10 +504,7 @@ private void createZoomTabLayout(FragmentActivity fragmentActivity, int margin) zoomTabLayout = new TabLayout(fragmentActivity); zoomTabLayout.setId(View.generateViewId()); - tabLayoutParams = new RelativeLayout.LayoutParams( - RelativeLayout.LayoutParams.MATCH_PARENT, - RelativeLayout.LayoutParams.WRAP_CONTENT - ); + tabLayoutParams = new RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.WRAP_CONTENT); zoomTabLayout.setLayoutParams(tabLayoutParams); // Set TabLayout parameters @@ -485,44 +516,46 @@ private void createZoomTabLayout(FragmentActivity fragmentActivity, int margin) 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()); + 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()) { + @Override + public void onTabUnselected(TabLayout.Tab tab) { + ZoomTab zoomTab = zoomTabs.get(tab.getPosition()); + zoomTab.setSelected(false); zoomTab.setTransientZoomLevel(null); - if (cameraController != null) { - cameraController.setZoomRatio(zoomTab.getZoomLevel()); + } + + @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); } private void createZoomTabs(FragmentActivity fragmentActivity, TabLayout tabLayout) { - float[] zoomLevels = {minZoom, 1f, 2f, 5f}; + float[] zoomLevels = { minZoom, 1f, 2f, 5f }; for (int i = 0; i < zoomLevels.length; i++) { float zoomLevel = zoomLevels[i]; @@ -539,26 +572,33 @@ private void createZoomTabs(FragmentActivity fragmentActivity, TabLayout tabLayo private void createFilmstripView(FragmentActivity fragmentActivity) { filmstripView = new RecyclerView(fragmentActivity); RelativeLayout.LayoutParams filmstripLayoutParams = new RelativeLayout.LayoutParams( - RelativeLayout.LayoutParams.WRAP_CONTENT, - RelativeLayout.LayoutParams.WRAP_CONTENT + RelativeLayout.LayoutParams.MATCH_PARENT, + RelativeLayout.LayoutParams.WRAP_CONTENT ); filmstripLayoutParams.addRule(RelativeLayout.CENTER_HORIZONTAL); filmstripLayoutParams.addRule(RelativeLayout.ABOVE, zoomTabCardView.getId()); filmstripView.setLayoutParams(filmstripLayoutParams); - LinearLayoutManager layoutManager = new LinearLayoutManager(fragmentActivity, - LinearLayoutManager.HORIZONTAL, false); + + // 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); thumbnailAdapter = new ThumbnailAdapter(); filmstripView.setAdapter(thumbnailAdapter); relativeLayout.addView(filmstripView); - thumbnailAdapter.setOnThumbnailsChangedCallback(new ThumbnailAdapter.OnThumbnailsChangedCallback() { - @Override - public void onThumbnailRemoved(Uri uri, Bitmap bmp) { - images.remove(uri); - deleteFile(uri); + thumbnailAdapter.setOnThumbnailsChangedCallback( + new ThumbnailAdapter.OnThumbnailsChangedCallback() { + @Override + public void onThumbnailRemoved(Uri uri, Bitmap bmp) { + images.remove(uri); + deleteFile(uri); + } } - }); + ); } private void createDoneButton(FragmentActivity fragmentActivity, int margin, ColorStateList buttonColors) { @@ -568,14 +608,19 @@ private void createDoneButton(FragmentActivity fragmentActivity, int margin, Col doneButton.setColorFilter(Color.WHITE); doneButton.setBackgroundTintList(buttonColors); doneButtonLayoutParams = new RelativeLayout.LayoutParams( - RelativeLayout.LayoutParams.WRAP_CONTENT, - RelativeLayout.LayoutParams.WRAP_CONTENT + RelativeLayout.LayoutParams.WRAP_CONTENT, + RelativeLayout.LayoutParams.WRAP_CONTENT ); - doneButtonLayoutParams.addRule(RelativeLayout.ALIGN_PARENT_RIGHT); - doneButtonLayoutParams.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM); - doneButtonLayoutParams.setMargins(0, 0, margin, margin); + doneButtonLayoutParams.addRule(RelativeLayout.ALIGN_PARENT_END); + doneButtonLayoutParams.addRule(RelativeLayout.CENTER_VERTICAL); + doneButtonLayoutParams.setMargins(0, 0, margin, 0); doneButton.setLayoutParams(doneButtonLayoutParams); - doneButton.setOnClickListener(view -> done()); + doneButton.setOnClickListener( + view -> { + view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY); + done(); + } + ); bottomBar.addView(doneButton); } @@ -586,20 +631,29 @@ private void createCloseButton(FragmentActivity fragmentActivity, int margin, Co closeButton.setBackgroundTintList(buttonColors); closeButton.setColorFilter(Color.WHITE); closeButtonLayoutParams = new RelativeLayout.LayoutParams( - RelativeLayout.LayoutParams.WRAP_CONTENT, - RelativeLayout.LayoutParams.WRAP_CONTENT + RelativeLayout.LayoutParams.WRAP_CONTENT, + RelativeLayout.LayoutParams.WRAP_CONTENT ); closeButtonLayoutParams.addRule(RelativeLayout.ALIGN_PARENT_TOP); closeButtonLayoutParams.addRule(RelativeLayout.ALIGN_PARENT_LEFT); - closeButtonLayoutParams.setMargins(margin, margin, 0, 0); + // 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 -> new AlertDialog.Builder(requireContext()) - .setMessage(CONFIRM_CANCEL_MESSAGE) - .setPositiveButton(CONFIRM_CANCEL_POSITIVE, (dialogInterface, i) -> cancel()) - .setNegativeButton(CONFIRM_CANCEL_NEGATIVE, - (dialogInterface, i) -> dialogInterface.dismiss()) - .create() - .show() + closeButton.setOnClickListener( + view -> { + view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY); + if (images != null && !images.isEmpty()) { + new AlertDialog.Builder(requireContext()) + .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); } @@ -622,80 +676,87 @@ private void deleteFile(Uri fileUri) { } } - 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(); - - if (zoomTabs.isEmpty()) { - createZoomTabs(requireActivity(), zoomTabLayout); - } - - if (zoomRunnable != null) { - zoomHandler.removeCallbacks(zoomRunnable); - } + 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(); + + if (zoomTabs.isEmpty()) { + createZoomTabs(requireActivity(), zoomTabLayout); + } - 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 (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) { + TabLayout.Tab tab = zoomTabLayout.getTabAt(closestTab.getTabIndex()); + if (tab != null) { + closestTab.setTransientZoomLevel(currentZoom); // Update the tab's display to show the current zoom level + 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); } + ); - // If we found a closest tab, update its display and select the tab. - if (closestTab != null) { - TabLayout.Tab tab = zoomTabLayout.getTabAt(closestTab.getTabIndex()); - if (tab != null) { - closestTab.setTransientZoomLevel(currentZoom); // Update the tab's display to show the current zoom level - isSnappingZoom.set(true); - zoomTabLayout.selectTab(tab); // This will not trigger the camera zoom change due to the isSnappingZoom flag - isSnappingZoom.set(false); + 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(); } } - }; - 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) // Duration for fade-in - .setInterpolator(new AccelerateDecelerateInterpolator()) // Ease-in/ease-out - .start(); - } else { - // Fade out and hide the focus indicator when focusing ends, regardless of the result - focusIndicator.animate() - .alpha(0f) - .setDuration(500) // Duration for fade-out - .setInterpolator(new AccelerateDecelerateInterpolator()) // Ease-in/ease-out - .withEndAction(() -> focusIndicator.setVisibility(View.INVISIBLE)) - .start(); - } - }); + ); - CameraSelector cameraSelector = new CameraSelector.Builder() - .requireLensFacing(lensFacing) - .build(); + CameraSelector cameraSelector = new CameraSelector.Builder().requireLensFacing(lensFacing).build(); cameraController.setCameraSelector(cameraSelector); cameraController.setPinchToZoomEnabled(true); cameraController.setTapToFocusEnabled(true); @@ -704,9 +765,7 @@ private void setupCamera() throws IllegalStateException { private boolean hasFrontFacingCamera() { if (cameraController != null) { - CameraSelector frontFacing = new CameraSelector.Builder() - .requireLensFacing(CameraSelector.LENS_FACING_FRONT) - .build(); + CameraSelector frontFacing = new CameraSelector.Builder().requireLensFacing(CameraSelector.LENS_FACING_FRONT).build(); return cameraController.hasCamera(frontFacing); } @@ -718,7 +777,7 @@ private Bitmap getThumbnail(Uri imageUri) { ContentResolver contentResolver = requireContext().getContentResolver(); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { // API level 29 and above - try { // Specify the size of the thumbnail + try { // Specify the size of the thumbnail int width = (int) (displayMetrics.widthPixels * 0.25); // Thumbnail width as 25% of screen width int height = (int) (displayMetrics.heightPixels * 0.25); // Thumbnail height as 25% of screen height Size size = new Size(width, height); @@ -730,37 +789,29 @@ private Bitmap getThumbnail(Uri imageUri) { return null; } } else { // Below API level 29 - String[] projection = {MediaStore.Images.Media._ID}; - Cursor cursor = contentResolver.query(imageUri, - projection, - null, - null, - null); + 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); + return MediaStore.Images.Thumbnails.getThumbnail(contentResolver, imageId, MediaStore.Images.Thumbnails.MINI_KIND, null); } return null; } } public abstract static class OnImagesCapturedCallback { - public void onCaptureSuccess(HashMap images) { - } - public void onCaptureCanceled() { - } + public void onCaptureSuccess(HashMap images) {} + + public void onCaptureCanceled() {} } public class ZoomTab { + private final float zoomLevel; private final int tabIndex; private final int circleSize; @@ -783,21 +834,21 @@ private void setupTextView() { String formattedZoom = getFormattedZoom(); textView.setGravity(Gravity.CENTER); textView.setText(formattedZoom); - textView.setTextSize(dpToPx(requireContext(),4)); + textView.setTextSize(12); textView.setBackgroundColor(Color.TRANSPARENT); - int padding = dpToPx(requireContext(),8); + int padding = dpToPx(requireContext(), 8); textView.setPadding(padding, padding, padding, padding); - int circlePx = dpToPx(requireContext(),circleSize); + 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 + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT ); textView.setLayoutParams(layoutParams); @@ -827,11 +878,8 @@ public View getView() { } public void setSelected(boolean isSelected) { - textView.setTextColor(isSelected ? Color.WHITE : Color.BLACK); - background.setColor(isSelected - ? ZOOM_BUTTON_COLOR_SELECTED - : ZOOM_BUTTON_COLOR_UNSELECTED - ); + textView.setTextColor(isSelected ? Color.BLACK : Color.WHITE); + background.setColor(isSelected ? ZOOM_BUTTON_COLOR_SELECTED : ZOOM_BUTTON_COLOR_UNSELECTED); } public float getZoomLevel() { @@ -853,3 +901,4 @@ private void updateText() { } } } + 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 index 2e3e3199c..4d278b336 100644 --- a/camera/android/src/main/java/com/capacitorjs/plugins/camera/ThumbnailAdapter.java +++ b/camera/android/src/main/java/com/capacitorjs/plugins/camera/ThumbnailAdapter.java @@ -3,30 +3,19 @@ import static com.capacitorjs.plugins.camera.DeviceUtils.dpToPx; import android.content.Context; -import android.content.res.ColorStateList; import android.graphics.Bitmap; -import android.graphics.Canvas; -import android.graphics.Color; -import android.graphics.Paint; -import android.graphics.PorterDuff; -import android.graphics.PorterDuffXfermode; -import android.graphics.drawable.BitmapDrawable; -import android.graphics.drawable.Drawable; 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 androidx.annotation.NonNull; -import androidx.constraintlayout.widget.ConstraintLayout; -import androidx.constraintlayout.widget.ConstraintSet; import androidx.recyclerview.widget.RecyclerView; - -import com.google.android.material.floatingactionbutton.FloatingActionButton; - import java.util.ArrayList; public class ThumbnailAdapter extends RecyclerView.Adapter { + private final ArrayList thumbnails; private OnThumbnailsChangedCallback thumbnailsChangedCallback = null; @@ -41,92 +30,51 @@ void addThumbnail(Uri uri, Bitmap thumbnail) { notifyItemInserted(thumbnails.size() - 1); } - @NonNull @Override public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { Context context = parent.getContext(); - int thumbnailPx = dpToPx(context, 100); // Convert dp to pixels - int buttonSize = dpToPx(context, 24); + int thumbnailSize = dpToPx(context, 80); // Thumbnail size + int margin = dpToPx(context, 4); // Margin for each side - // Create the ConstraintLayout as a container - ConstraintLayout constraintLayout = new ConstraintLayout(context); - int extraSpaceForButton = dpToPx(context, 24); // Example extra space in dp - ConstraintLayout.LayoutParams clLayoutParams = new ConstraintLayout.LayoutParams( - thumbnailPx + extraSpaceForButton, - thumbnailPx + extraSpaceForButton - ); - constraintLayout.setLayoutParams(clLayoutParams); + 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.setId(View.generateViewId()); - imageView.setScaleType(ImageView.ScaleType.CENTER_CROP); - ConstraintLayout.LayoutParams imageParams = new ConstraintLayout.LayoutParams(thumbnailPx, thumbnailPx); - // Set imageView to be centered in the ConstraintLayout - imageParams.startToStart = ConstraintSet.PARENT_ID; - imageParams.topToTop = ConstraintSet.PARENT_ID; - imageParams.endToEnd = ConstraintSet.PARENT_ID; - imageParams.bottomToBottom = ConstraintSet.PARENT_ID; - imageView.setLayoutParams(imageParams); imageView.setScaleType(ImageView.ScaleType.CENTER_CROP); - constraintLayout.addView(imageView); - - FloatingActionButton removeButton = new FloatingActionButton(context); - removeButton.setCustomSize(buttonSize); - removeButton.setId(View.generateViewId()); - // Set the icon for the remove button - Bitmap cutoutBitmap = createCutoutBitmap(buttonSize); - Drawable cutoutDrawable = new BitmapDrawable(context.getResources(), cutoutBitmap); - removeButton.setImageDrawable(cutoutDrawable); - // Set the background color of the button to white - ColorStateList whiteBackground = ColorStateList.valueOf(Color.TRANSPARENT); - removeButton.setBackgroundTintList(whiteBackground); - ConstraintLayout.LayoutParams buttonParams = new ConstraintLayout.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.WRAP_CONTENT - ); - // Align button to the top and end of the ConstraintLayout - buttonParams.topToTop = ConstraintSet.PARENT_ID; - buttonParams.endToEnd = ConstraintSet.PARENT_ID; - removeButton.setLayoutParams(buttonParams); - constraintLayout.addView(removeButton); - - // Apply constraints to position the views correctly using ConstraintSet - ConstraintSet set = new ConstraintSet(); - set.clone(constraintLayout); - set.connect(imageView.getId(), ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START, 0); - set.connect(imageView.getId(), ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP, 0); - set.connect(imageView.getId(), ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END, 0); - set.connect(imageView.getId(), ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM, 0); - - set.connect(removeButton.getId(), ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP, 0); - set.connect(removeButton.getId(), ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END, 0); + frameLayout.addView(imageView); - // Offset the button to make it overlap the corner of the ImageView - set.setMargin(removeButton.getId(), ConstraintSet.END, -buttonSize / 2); - set.setMargin(removeButton.getId(), ConstraintSet.TOP, -buttonSize / 2); + 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); - set.applyTo(constraintLayout); - return new ViewHolder(constraintLayout, imageView, removeButton); + return new ViewHolder(frameLayout, imageView, removeButton); } @Override public void onBindViewHolder(@NonNull ViewHolder holder, int position) { holder.imageView.setImageBitmap(thumbnails.get(position).bitmap); - holder.removeButton.setOnClickListener(v -> { - int currentPosition = holder.getAdapterPosition(); - if (currentPosition != RecyclerView.NO_POSITION) { - ThumbnailItem removed = thumbnails.remove(currentPosition); + holder.removeButton.setOnClickListener( + v -> { + int currentPosition = holder.getAdapterPosition(); + if (currentPosition != RecyclerView.NO_POSITION) { + ThumbnailItem removed = thumbnails.remove(currentPosition); - notifyItemRemoved(currentPosition); + notifyItemRemoved(currentPosition); - if (thumbnailsChangedCallback != null) { - thumbnailsChangedCallback.onThumbnailRemoved(removed.getUri(), removed.getBitmap()); + if (thumbnailsChangedCallback != null) { + thumbnailsChangedCallback.onThumbnailRemoved(removed.getUri(), removed.getBitmap()); + } } } - - }); + ); } @Override @@ -138,43 +86,13 @@ public void setOnThumbnailsChangedCallback(OnThumbnailsChangedCallback callback) this.thumbnailsChangedCallback = callback; } - - private Bitmap createCutoutBitmap(int diameter) { - Bitmap bitmap = Bitmap.createBitmap(diameter, diameter, Bitmap.Config.ARGB_8888); - Canvas canvas = new Canvas(bitmap); - - // Draw the white circular background - Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); - paint.setColor(Color.WHITE); - canvas.drawCircle(diameter / 2f, diameter / 2f, diameter / 2f, paint); - - paint.setColor(Color.TRANSPARENT); - paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR)); - paint.setStrokeWidth(4); // Set the stroke width for the 'X' - - // Coordinates for the 'X' - float startX1 = diameter * 0.25f; // Start X for the first line - float endX1 = diameter * 0.75f; // End X for the first line - float startY1 = diameter * 0.25f; // Start Y for the first line - float endY1 = diameter * 0.75f; // End Y for the first line - - float startX2 = diameter * 0.75f; // Start X for the second line - float endX2 = diameter * 0.25f; // End X for the second line - float startY2 = diameter * 0.25f; // Start Y for the second line - float endY2 = diameter * 0.75f; // End Y for the second line - - canvas.drawLine(startX1, startY1, endX1, endY1, paint); // First line of 'X' - canvas.drawLine(startX2, startY2, endX2, endY2, paint); // Second line of 'X' - - return bitmap; - } - static class ViewHolder extends RecyclerView.ViewHolder { + ImageView imageView; - FloatingActionButton removeButton; - ConstraintLayout mainView; + ImageView removeButton; + FrameLayout mainView; - ViewHolder(@NonNull ConstraintLayout view, @NonNull ImageView imageView, @NonNull FloatingActionButton removeButton) { + ViewHolder(@NonNull FrameLayout view, @NonNull ImageView imageView, @NonNull ImageView removeButton) { super(view); this.imageView = imageView; this.mainView = view; @@ -182,12 +100,13 @@ static class ViewHolder extends RecyclerView.ViewHolder { } } - public static abstract class OnThumbnailsChangedCallback { - public void onThumbnailRemoved(Uri uri, Bitmap bmp) { - } + public abstract static class OnThumbnailsChangedCallback { + + public void onThumbnailRemoved(Uri uri, Bitmap bmp) {} } public static class ThumbnailItem { + private final Uri uri; private final Bitmap bitmap; @@ -205,3 +124,4 @@ public Bitmap getBitmap() { } } } + 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/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 @@ + + + From cd97eb7f01d10060b6e38c15fd84952d4ed3d1ac Mon Sep 17 00:00:00 2001 From: Shiva Prasad Date: Thu, 31 Jul 2025 17:54:48 +0700 Subject: [PATCH 03/19] feat(camera): iOS multi camera base implementation --- camera/Package.swift | 5 +- camera/README.md | 43 +- .../Sources/CameraPlugin/CameraPlugin.swift | 75 ++ .../Sources/CameraPlugin/CameraTypes.swift | 1 + .../MultiCameraViewController.swift | 855 ++++++++++++++++++ .../Resources/Assets.xcassets/Contents.json | 6 + .../camera_capture.imageset/Contents.json | 20 + camera/src/definitions.ts | 2 +- 8 files changed, 973 insertions(+), 34 deletions(-) create mode 100644 camera/ios/Sources/CameraPlugin/MultiCameraViewController.swift create mode 100644 camera/ios/Sources/CameraPlugin/Resources/Assets.xcassets/Contents.json create mode 100644 camera/ios/Sources/CameraPlugin/Resources/Assets.xcassets/camera_capture.imageset/Contents.json 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 ed90a4df7..0aa1b4730 100644 --- a/camera/README.md +++ b/camera/README.md @@ -102,15 +102,15 @@ const takePicture = async () => { -* [`getPhoto(...)`](#getphoto) -* [`pickImages(...)`](#pickimages) -* [`pickLimitedLibraryPhotos()`](#picklimitedlibraryphotos) -* [`getLimitedLibraryPhotos()`](#getlimitedlibraryphotos) -* [`checkPermissions()`](#checkpermissions) -* [`requestPermissions(...)`](#requestpermissions) -* [Interfaces](#interfaces) -* [Type Aliases](#type-aliases) -* [Enums](#enums) +- [`getPhoto(...)`](#getphoto) +- [`pickImages(...)`](#pickimages) +- [`pickLimitedLibraryPhotos()`](#picklimitedlibraryphotos) +- [`getLimitedLibraryPhotos()`](#getlimitedlibraryphotos) +- [`checkPermissions()`](#checkpermissions) +- [`requestPermissions(...)`](#requestpermissions) +- [Interfaces](#interfaces) +- [Type Aliases](#type-aliases) +- [Enums](#enums) @@ -136,7 +136,6 @@ with the camera. -------------------- - ### pickImages(...) ```typescript @@ -156,7 +155,6 @@ On iOS 13 and older it only allows to pick one picture. -------------------- - ### pickLimitedLibraryPhotos() ```typescript @@ -173,7 +171,6 @@ On iOS 14 or if the user gave full access to the photos it returns an empty arra -------------------- - ### getLimitedLibraryPhotos() ```typescript @@ -188,7 +185,6 @@ iOS 14+ Only: Return an array of photos selected from the limited photo library. -------------------- - ### checkPermissions() ```typescript @@ -203,7 +199,6 @@ Check camera and photo album permissions -------------------- - ### requestPermissions(...) ```typescript @@ -222,10 +217,8 @@ Request camera and photo album permissions -------------------- - ### Interfaces - #### Photo | Prop | Type | Description | Since | @@ -238,7 +231,6 @@ Request camera and photo album permissions | **`format`** | string | The format of the image, ex: jpeg, png, gif. iOS and Android only support jpeg. Web supports jpeg, png and gif, but the exact availability may vary depending on the browser. gif is only supported if `webUseInput` is set to `true` or if `source` is set to `Photos`. | 1.0.0 | | **`saved`** | boolean | Whether if the image was saved to the gallery or not. On Android and iOS, saving to the gallery can fail if the user didn't grant the required permissions. On Web there is no gallery, so always returns false. | 1.1.0 | - #### ImageOptions | Prop | Type | Description | Default | Since | @@ -253,20 +245,18 @@ Request camera and photo album permissions | **`source`** | CameraSource | The source to get the photo from. By default this prompts the user to select either the photo album or take a photo. | : CameraSource.Prompt | 1.0.0 | | **`direction`** | CameraDirection | iOS and Web only: The camera direction. | : CameraDirection.Rear | 1.0.0 | | **`presentationStyle`** | 'fullscreen' \| 'popover' | iOS only: The presentation style of the Camera. | : 'fullscreen' | 1.0.0 | -| **`webUseInput`** | boolean | Web only: Whether to use the PWA Element experience or file input. The default is to use PWA Elements if installed and fall back to file input. To always use file input, set this to `true`. Learn more about PWA Elements: https://capacitorjs.com/docs/web/pwa-elements | | 1.0.0 | +| **`webUseInput`** | boolean | Web only: Whether to use the PWA Element experience or file input. The default is to use PWA Elements if installed and fall back to file input. To always use file input, set this to `true`. Learn more about PWA Elements: | | 1.0.0 | | **`promptLabelHeader`** | string | Text value to use when displaying the prompt. | : 'Photo' | 1.0.0 | | **`promptLabelCancel`** | string | Text value to use when displaying the prompt. iOS only: The label of the 'cancel' button. | : 'Cancel' | 1.0.0 | | **`promptLabelPhoto`** | string | Text value to use when displaying the prompt. The label of the button to select a saved image. | : 'From Photos' | 1.0.0 | | **`promptLabelPicture`** | string | Text value to use when displaying the prompt. The label of the button to open the camera. | : 'Take Picture' | 1.0.0 | - #### GalleryPhotos | Prop | Type | Description | Since | | ------------ | --------------------------- | ------------------------------- | ----- | | **`photos`** | GalleryPhoto[] | Array of all the picked photos. | 1.2.0 | - #### GalleryPhoto | Prop | Type | Description | Since | @@ -276,7 +266,6 @@ Request camera and photo album permissions | **`exif`** | any | Exif data, if any, retrieved from the image | 1.2.0 | | **`format`** | string | The format of the image, ex: jpeg, png, gif. iOS and Android only support jpeg. Web supports jpeg, png and gif. | 1.2.0 | - #### GalleryImageOptions | Prop | Type | Description | Default | Since | @@ -288,7 +277,6 @@ Request camera and photo album permissions | **`presentationStyle`** | 'fullscreen' \| 'popover' | iOS only: The presentation style of the Camera. | : 'fullscreen' | 1.2.0 | | **`limit`** | number | Maximum number of pictures the user will be able to choose. Note: This option is only supported on Android 13+ and iOS. | 0 (unlimited) | 1.2.0 | - #### PermissionStatus | Prop | Type | @@ -296,35 +284,28 @@ Request camera and photo album permissions | **`camera`** | CameraPermissionState | | **`photos`** | CameraPermissionState | - #### CameraPluginPermissions | Prop | Type | | ----------------- | ----------------------------------- | | **`permissions`** | CameraPermissionType[] | - ### Type Aliases - #### CameraPermissionState PermissionState | 'limited' - #### PermissionState 'prompt' | 'prompt-with-rationale' | 'granted' | 'denied' - #### CameraPermissionType 'camera' | 'photos' - ### Enums - #### CameraResultType | Members | Value | @@ -333,17 +314,15 @@ Request camera and photo album permissions | **`Base64`** | 'base64' | | **`DataUrl`** | 'dataUrl' | - #### 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. | -| **`CameraMulti`** | 'CAMERA_MULTI' | Take multiple photos in a row using the camera. Only available on Android. | +| **`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 | Members | Value | diff --git a/camera/ios/Sources/CameraPlugin/CameraPlugin.swift b/camera/ios/Sources/CameraPlugin/CameraPlugin.swift index 67b0fbd41..224b28296 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,27 @@ 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) + } + + 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..105b3d9b2 --- /dev/null +++ b/camera/ios/Sources/CameraPlugin/MultiCameraViewController.swift @@ -0,0 +1,855 @@ +import UIKit +import AVFoundation +import Photos + +// 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 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(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), + + 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 + } + + @objc private func deleteButtonTapped() { + deleteHandler?() + } +} + +// MARK: - ImagePreviewViewController +class ImagePreviewViewController: UIViewController { + private let previewImage: UIImage + + init(image: UIImage) { + self.previewImage = image + 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 + + // Setup image view + let imageView = UIImageView(image: previewImage) + imageView.contentMode = .scaleAspectFit + imageView.translatesAutoresizingMaskIntoConstraints = false + + view.addSubview(imageView) + NSLayoutConstraint.activate([ + imageView.topAnchor.constraint(equalTo: view.topAnchor), + imageView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + imageView.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) + ]) + } + + @objc private func closeButtonTapped() { + dismiss(animated: true) + } +} + +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 + + 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]] = [] + + // 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 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 + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + stopCaptureSession() + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + + // Handle orientation changes + coordinator.animate(alongsideTransition: { [weak self] _ in + self?.updatePreviewLayerFrame() + }) + } + + // 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) + + 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 + + NSLayoutConstraint.activate([ + // Preview view + previewView.topAnchor.constraint(equalTo: view.topAnchor), + previewView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + previewView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + + // Bottom bar + bottomBarView.heightAnchor.constraint(equalToConstant: 100), + bottomBarView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + bottomBarView.trailingAnchor.constraint(equalTo: view.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: -80), + + // Take picture button + takePictureButton.centerXAnchor.constraint(equalTo: bottomBarView.centerXAnchor), + takePictureButton.centerYAnchor.constraint(equalTo: bottomBarView.centerYAnchor), + takePictureButton.widthAnchor.constraint(equalToConstant: 70), + takePictureButton.heightAnchor.constraint(equalToConstant: 70), + + // Flip camera button + flipCameraButton.leadingAnchor.constraint(equalTo: bottomBarView.leadingAnchor, constant: 30), + flipCameraButton.centerYAnchor.constraint(equalTo: bottomBarView.centerYAnchor), + flipCameraButton.widthAnchor.constraint(equalToConstant: 50), + flipCameraButton.heightAnchor.constraint(equalToConstant: 50), + + // Done button + doneButton.trailingAnchor.constraint(equalTo: bottomBarView.trailingAnchor, constant: -30), + doneButton.centerYAnchor.constraint(equalTo: bottomBarView.centerYAnchor), + doneButton.widthAnchor.constraint(equalToConstant: 50), + doneButton.heightAnchor.constraint(equalToConstant: 50), + + // 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), + + // Zoom buttons + zoomInButton.bottomAnchor.constraint(equalTo: bottomBarView.topAnchor, constant: -20), + zoomInButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + zoomInButton.widthAnchor.constraint(equalToConstant: 44), + zoomInButton.heightAnchor.constraint(equalToConstant: 44), + + zoomOutButton.bottomAnchor.constraint(equalTo: bottomBarView.topAnchor, constant: -20), + zoomOutButton.trailingAnchor.constraint(equalTo: zoomInButton.leadingAnchor, constant: -10), + zoomOutButton.widthAnchor.constraint(equalToConstant: 44), + zoomOutButton.heightAnchor.constraint(equalToConstant: 44), + + // Zoom factor label + zoomFactorLabel.centerYAnchor.constraint(equalTo: zoomInButton.centerYAnchor), + zoomFactorLabel.trailingAnchor.constraint(equalTo: zoomOutButton.leadingAnchor, constant: -10), + zoomFactorLabel.widthAnchor.constraint(equalToConstant: 50), + zoomFactorLabel.heightAnchor.constraint(equalToConstant: 25) + ]) + + // Initially hide the done button until we have at least one image + doneButton.isHidden = true + } + + 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 { + print("Could not set zoom factor: \(error.localizedDescription)") + } + } + + // MARK: - Actions + @objc private func takePicture() { + guard let photoOutput = photoOutput else { return } + + // 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() + + // Update flash button visibility (front camera usually doesn't have flash) + flashButton.isHidden = (currentCameraPosition == .front) + + // Reset zoom when switching cameras + currentZoomFactor = 1.0 + zoomFactorLabel.text = "1.0x" + + // Update zoom limits for the new camera + if videoDevice != nil { + 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 { + 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() { + // 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() { + delegate?.multiCameraViewController(self, didFinishWith: capturedImages, metadata: capturedMetadata) + } + + // MARK: - Image Management + private func addCapturedImage(_ image: UIImage, metadata: [String: Any]) { + capturedImages.append(image) + capturedMetadata.append(metadata) + + // Check if we've reached the maximum number of images + if maxImages > 0 && capturedImages.count >= maxImages { + // Automatically finish if we've reached the limit + delegate?.multiCameraViewController(self, didFinishWith: capturedImages, metadata: capturedMetadata) + return + } + + // Show the done button once we have at least one image + if doneButton.isHidden { + doneButton.isHidden = false + } + + // Update the collection view + thumbnailCollectionView.reloadData() + + // Scroll to the new image + if !capturedImages.isEmpty { + let indexPath = IndexPath(item: capturedImages.count - 1, section: 0) + thumbnailCollectionView.scrollToItem(at: indexPath, at: .right, animated: true) + } + } + + private func removeImage(at index: Int) { + guard index < capturedImages.count else { return } + + capturedImages.remove(at: index) + capturedMetadata.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 { + print("Error capturing photo: \(error.localizedDescription)") + return + } + + guard let imageData = photo.fileDataRepresentation(), + let image = UIImage(data: imageData) else { + return + } + + // Extract metadata + var metadata: [String: Any] = [:] + metadata = photo.metadata + + // Add the captured image + DispatchQueue.main.async { [weak self] in + self?.addCapturedImage(image, metadata: metadata) + } + } +} + +// 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() + } + + 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) { + // Preview the selected image + let image = capturedImages[indexPath.item] + + // Create a custom image preview controller + let previewController = ImagePreviewViewController(image: image) + + // 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 8e6d8d0c7..534922cb7 100644 --- a/camera/src/definitions.ts +++ b/camera/src/definitions.ts @@ -337,7 +337,7 @@ export enum CameraSource { Camera = 'CAMERA', /** * Take multiple photos in a row using the camera. - * Only available on Android. + * Available on Android and iOS. */ CameraMulti = 'CAMERA_MULTI', /** From 7ecd892910b739a4f13c2ecc93de2f7a3740a083 Mon Sep 17 00:00:00 2001 From: Shiva Prasad Date: Thu, 31 Jul 2025 17:58:14 +0700 Subject: [PATCH 04/19] feat(camera): multi camera image preview support for android --- .../plugins/camera/CameraFragment.java | 18 +++ .../plugins/camera/ImagePreviewFragment.java | 141 ++++++++++++++++++ .../plugins/camera/ThumbnailAdapter.java | 19 ++- 3 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 camera/android/src/main/java/com/capacitorjs/plugins/camera/ImagePreviewFragment.java 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 index 334a3b14c..00c72ccf6 100644 --- a/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraFragment.java +++ b/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraFragment.java @@ -599,6 +599,14 @@ public void onThumbnailRemoved(Uri uri, Bitmap bmp) { } } ); + + // 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) { @@ -676,6 +684,16 @@ private void deleteFile(Uri fileUri) { } } + /** + * 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) { + ImagePreviewFragment previewFragment = ImagePreviewFragment.newInstance(uri); + previewFragment.show(requireActivity().getSupportFragmentManager(), "image_preview"); + } + private void setupCamera() throws IllegalStateException { cameraController .getInitializationFuture() 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..1f7d85723 --- /dev/null +++ b/camera/android/src/main/java/com/capacitorjs/plugins/camera/ImagePreviewFragment.java @@ -0,0 +1,141 @@ +package com.capacitorjs.plugins.camera; + +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Color; +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 androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.DialogFragment; +import com.google.android.material.floatingactionbutton.FloatingActionButton; +import java.io.IOException; +import java.io.InputStream; + +/** + * 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 Uri imageUri; + + /** + * 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) { + ImagePreviewFragment fragment = new ImagePreviewFragment(); + fragment.imageUri = uri; + return fragment; + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setStyle(DialogFragment.STYLE_NORMAL, android.R.style.Theme_Black_NoTitleBar_Fullscreen); + } + + @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 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 to show while loading + 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); + + rootLayout.addView(imageView); + rootLayout.addView(progressBar); + + // Load the full-resolution image + loadFullResolutionImage(imageUri, imageView, progressBar); + + // Create the close button + FloatingActionButton closeButton = new FloatingActionButton(requireContext()); + closeButton.setImageResource(R.drawable.close_24px); + 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; + } + + /** + * 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); + } 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); + } +} \ No newline at end of file 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 index 4d278b336..0b53636f3 100644 --- a/camera/android/src/main/java/com/capacitorjs/plugins/camera/ThumbnailAdapter.java +++ b/camera/android/src/main/java/com/capacitorjs/plugins/camera/ThumbnailAdapter.java @@ -18,6 +18,7 @@ public class ThumbnailAdapter extends RecyclerView.Adapter thumbnails; private OnThumbnailsChangedCallback thumbnailsChangedCallback = null; + private OnThumbnailClickListener thumbnailClickListener = null; ThumbnailAdapter() { this.thumbnails = new ArrayList<>(); @@ -59,7 +60,15 @@ public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { @Override public void onBindViewHolder(@NonNull ViewHolder holder, int position) { - holder.imageView.setImageBitmap(thumbnails.get(position).bitmap); + ThumbnailItem item = thumbnails.get(position); + holder.imageView.setImageBitmap(item.bitmap); + + // Set click listener for the thumbnail + holder.mainView.setOnClickListener(v -> { + if (thumbnailClickListener != null) { + thumbnailClickListener.onThumbnailClick(item.getUri(), item.getBitmap()); + } + }); holder.removeButton.setOnClickListener( v -> { @@ -86,6 +95,10 @@ public void setOnThumbnailsChangedCallback(OnThumbnailsChangedCallback callback) this.thumbnailsChangedCallback = callback; } + public void setOnThumbnailClickListener(OnThumbnailClickListener listener) { + this.thumbnailClickListener = listener; + } + static class ViewHolder extends RecyclerView.ViewHolder { ImageView imageView; @@ -105,6 +118,10 @@ 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; From 888d6324ed3349071d7a17aae4192cd8356df64f Mon Sep 17 00:00:00 2001 From: Shiva Prasad Date: Thu, 31 Jul 2025 18:35:59 +0700 Subject: [PATCH 05/19] feat(camera): added support for landscape mode multi shot camera on android --- .../plugins/camera/CameraFragment.java | 534 +++++++++++++++++- .../plugins/camera/ImagePreviewFragment.java | 12 + .../plugins/camera/ImageUtils.java | 13 + 3 files changed, 546 insertions(+), 13 deletions(-) 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 index 00c72ccf6..a0881fe6f 100644 --- a/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraFragment.java +++ b/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraFragment.java @@ -8,6 +8,7 @@ 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; @@ -110,6 +111,8 @@ public class CameraFragment extends Fragment { 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 // Camera variables private int lensFacing = CameraSelector.LENS_FACING_BACK; private int flashMode = ImageCapture.FLASH_MODE_AUTO; @@ -156,6 +159,9 @@ public void onCreate(@Nullable Bundle savedInstanceState) { zoomHandler = new Handler(requireActivity().getMainLooper()); mediaActionSound = new MediaActionSound(); mediaActionSound.load(MediaActionSound.SHUTTER_CLICK); + + // Register for configuration changes (like orientation changes) + setRetainInstance(true); } @Override @@ -187,6 +193,86 @@ public void onDestroy() { } @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; + } + + @Override + public void onConfigurationChanged(@NonNull Configuration newConfig) { + super.onConfigurationChanged(newConfig); + + // Store the current camera controller + LifecycleCameraController currentController = cameraController; + + // Check if device is in landscape mode with the new configuration + isLandscape = newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE; + + // Recreate the layout when orientation changes + if (relativeLayout != null) { + // Save the current camera state + int currentLensFacing = lensFacing; + int currentFlashMode = flashMode; + + // Remove all views but don't destroy the camera controller + 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 the preview view first + createPreviewView(fragmentActivity); + createFocusIndicator(fragmentActivity); + + if (isLandscape) { + // In landscape mode, create a container for controls on the right side + createControlsContainerForLandscape(fragmentActivity, buttonColors, margin); + } 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); + } + + // Restore the camera controller to the new preview view + if (currentController != null) { + previewView.setController(currentController); + cameraController = currentController; + + // 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); + } + } + } + @Override public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { FragmentActivity fragmentActivity = requireActivity(); @@ -194,27 +280,40 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c int margin = (int) (20 * displayMetrics.density); int barHeight = (int) (100 * displayMetrics.density); + // Check if device is in landscape mode + isLandscape = isLandscapeMode(fragmentActivity); + relativeLayout = new RelativeLayout(fragmentActivity); ColorStateList buttonColors = createButtonColorList(); - // Create the bottom bar and the buttons that sit inside it - createBottomBar(fragmentActivity, barHeight, margin, buttonColors); - - // Camera preview is above the bottom bar. The zoom buttons and filmstrip overlap it + // Create the preview view first createPreviewView(fragmentActivity); createFocusIndicator(fragmentActivity); - // Zoom bar is above the bottom bar/buttons - createZoomTabLayout(fragmentActivity, margin); + if (isLandscape) { + // In landscape mode, create a container for controls on the right side + createControlsContainerForLandscape(fragmentActivity, buttonColors, margin); + } else { + // In portrait mode, create the bottom bar and buttons + createBottomBar(fragmentActivity, barHeight, margin, buttonColors); - // Thumbnail images in the filmstrip are above the zoom buttons - createFilmstripView(fragmentActivity); + // 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); - // Close button and flash are top left/right corners - createCloseButton(fragmentActivity, margin, buttonColors); - createFlashButton(fragmentActivity, margin, buttonColors); + // 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(); @@ -296,6 +395,214 @@ private void createBottomBar(FragmentActivity fragmentActivity, int barHeight, i 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 + takePictureLayoutParams.addRule(RelativeLayout.CENTER_VERTICAL); + takePictureLayoutParams.addRule(RelativeLayout.CENTER_HORIZONTAL); + 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); + 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) { + try { + InputStream stream = requireContext().getContentResolver().openInputStream(savedImageUri); + Bitmap bmp = BitmapFactory.decodeStream(stream); + images.put(savedImageUri, bmp); + requireView() + .post( + () -> thumbnailAdapter.addThumbnail(savedImageUri, getThumbnail(savedImageUri)) + ); + } catch (FileNotFoundException e) { + e.printStackTrace(); + } + } + } + + @Override + public void onError(@NonNull ImageCaptureException exception) {} + } + ); + } + ); + 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 right + flipButtonLayoutParams.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM); + flipButtonLayoutParams.addRule(RelativeLayout.CENTER_HORIZONTAL); + flipButtonLayoutParams.setMargins(0, 0, 0, margin * 2); + flipCameraButton.setLayoutParams(flipButtonLayoutParams); + flipCameraButton.setOnClickListener( + v -> { + v.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY); + lensFacing = lensFacing == CameraSelector.LENS_FACING_FRONT ? CameraSelector.LENS_FACING_BACK : CameraSelector.LENS_FACING_FRONT; + flashButton.setVisibility(lensFacing == CameraSelector.LENS_FACING_BACK ? View.VISIBLE : View.GONE); + if (!zoomTabs.isEmpty()) { + zoomTabLayout.removeAllTabs(); + zoomTabs.clear(); + } + 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 right + doneButtonLayoutParams.addRule(RelativeLayout.ALIGN_PARENT_TOP); + doneButtonLayoutParams.addRule(RelativeLayout.CENTER_HORIZONTAL); + doneButtonLayoutParams.setMargins(0, margin * 2, 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 + closeButtonLayoutParams.addRule(RelativeLayout.ALIGN_PARENT_TOP); + closeButtonLayoutParams.addRule(RelativeLayout.ALIGN_PARENT_LEFT); + closeButtonLayoutParams.setMargins(margin * 2, margin * 2, 0, 0); + closeButton.setLayoutParams(closeButtonLayoutParams); + closeButton.setOnClickListener( + view -> { + view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY); + if (images != null && !images.isEmpty()) { + new AlertDialog.Builder(requireContext()) + .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 + flashButtonLayoutParams.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM); + flashButtonLayoutParams.addRule(RelativeLayout.ALIGN_PARENT_LEFT); + flashButtonLayoutParams.setMargins(margin * 2, 0, 0, margin * 2); + 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()); @@ -437,6 +744,67 @@ private void createFlipButton(FragmentActivity fragmentActivity, int margin, Col 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); + + // Set the container to take up the right side of the screen (about 20% of width) + int containerWidth = (int) (displayMetrics.widthPixels * 0.2); + RelativeLayout.LayoutParams containerParams = new RelativeLayout.LayoutParams( + containerWidth, + RelativeLayout.LayoutParams.MATCH_PARENT + ); + containerParams.addRule(RelativeLayout.ALIGN_PARENT_RIGHT); + controlsContainer.setLayoutParams(containerParams); + + // Add the container to the main layout + relativeLayout.addView(controlsContainer); + + // 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 + + // Adjust the preview view to take the full width minus the controls container + RelativeLayout.LayoutParams previewParams = (RelativeLayout.LayoutParams) previewView.getLayoutParams(); + previewParams.width = displayMetrics.widthPixels - containerWidth; + previewParams.height = RelativeLayout.LayoutParams.MATCH_PARENT; + previewParams.addRule(RelativeLayout.ALIGN_PARENT_LEFT); + previewParams.addRule(RelativeLayout.ALIGN_PARENT_TOP); + previewParams.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM); + previewParams.setMargins(0, 0, 0, 0); // Remove any margins + previewView.setLayoutParams(previewParams); + + // 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, margin, buttonColors); + createFlipButtonForLandscape(fragmentActivity, margin, buttonColors); + createDoneButtonForLandscape(fragmentActivity, margin, buttonColors); + + // Create buttons that go directly on the main layout (left side) + createCloseButtonForLandscape(fragmentActivity, margin, buttonColors); + createFlashButtonForLandscape(fragmentActivity, margin, buttonColors); + + // Create zoom tabs for landscape mode + createZoomTabLayoutForLandscape(fragmentActivity, margin); + + // Create filmstrip for landscape mode + createFilmstripViewForLandscape(fragmentActivity); + } + @SuppressLint("ClickableViewAccessibility") private void createPreviewView(FragmentActivity fragmentActivity) { previewView = new PreviewView(fragmentActivity); @@ -446,9 +814,18 @@ private void createPreviewView(FragmentActivity fragmentActivity) { ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT ); - previewLayoutParams.addRule(RelativeLayout.ABOVE, bottomBar.getId()); + + // We'll set the ABOVE rule after bottomBar is created in portrait mode + // In landscape mode, it takes the full height but not the full width + previewView.setLayoutParams(previewLayoutParams); - previewView.setScaleType(PreviewView.ScaleType.FILL_CENTER); + + // Set background color to black to prevent transparency + previewView.setBackgroundColor(Color.BLACK); + + // Use FILL_START for landscape mode to ensure the preview fills the available width + // while maintaining the aspect ratio and aligning to the left + previewView.setScaleType(isLandscape ? PreviewView.ScaleType.FILL_START : PreviewView.ScaleType.FIT_CENTER); previewView.setOnTouchListener( (v, event) -> { @@ -554,6 +931,83 @@ public void onTabReselected(TabLayout.Tab tab) { zoomTabCardView.addView(zoomTabLayout); } + 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 below the flip button + cardViewLayoutParams.addRule(RelativeLayout.BELOW, flipCameraButton.getId()); + cardViewLayoutParams.addRule(RelativeLayout.ABOVE, takePictureButton.getId()); + cardViewLayoutParams.addRule(RelativeLayout.CENTER_HORIZONTAL); + cardViewLayoutParams.setMargins(margin, margin, margin, margin); + zoomTabCardView.setLayoutParams(cardViewLayoutParams); + + controlsContainer.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); + zoomTabLayout.setSelectedTabIndicator(null); + zoomTabLayout.setBackgroundColor(Color.TRANSPARENT); + zoomTabLayout.setBackground(null); + + // Set the listener for tab selection + 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); + } + private void createZoomTabs(FragmentActivity fragmentActivity, TabLayout tabLayout) { float[] zoomLevels = { minZoom, 1f, 2f, 5f }; @@ -609,6 +1063,60 @@ public void onThumbnailClick(Uri uri, Bitmap bitmap) { }); } + 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); + filmstripLayoutParams.width = displayMetrics.widthPixels - + (int)(displayMetrics.widthPixels * 0.2) - // Controls container + flashButtonWidth - // Flash button width + margin * 3; // Extra margin + + // 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 + 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); + thumbnailAdapter = new ThumbnailAdapter(); + filmstripView.setAdapter(thumbnailAdapter); + relativeLayout.addView(filmstripView); + + thumbnailAdapter.setOnThumbnailsChangedCallback( + new ThumbnailAdapter.OnThumbnailsChangedCallback() { + @Override + public void onThumbnailRemoved(Uri uri, Bitmap bmp) { + images.remove(uri); + deleteFile(uri); + } + } + ); + + // 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()); 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 index 1f7d85723..068feae87 100644 --- a/camera/android/src/main/java/com/capacitorjs/plugins/camera/ImagePreviewFragment.java +++ b/camera/android/src/main/java/com/capacitorjs/plugins/camera/ImagePreviewFragment.java @@ -1,5 +1,6 @@ package com.capacitorjs.plugins.camera; +import android.content.res.Configuration; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.Color; @@ -59,6 +60,9 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c imageView.setLayoutParams(new FrameLayout.LayoutParams( FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)); + + // Use FIT_CENTER for better handling of different aspect ratios + // This helps prevent stretching in both portrait and landscape modes imageView.setScaleType(ImageView.ScaleType.FIT_CENTER); // Create a progress bar to show while loading @@ -114,6 +118,14 @@ private void loadFullResolutionImage(Uri uri, ImageView imageView, ProgressBar p 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(); } 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..6b05ab653 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,24 @@ 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); + + // Check if device is in landscape mode + boolean isLandscape = c.getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE; + 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, + // we might need to adjust based on the aspect ratio of the image + // This would be determined through testing return bitmap; } } From 9b1724432c28d5f12676d0bd04be1eeb6f7dd2ce Mon Sep 17 00:00:00 2001 From: Shiva Prasad Date: Thu, 31 Jul 2025 20:51:52 +0700 Subject: [PATCH 06/19] feat(camera): landscape mode ux improvements for android --- .../plugins/camera/CameraFragment.java | 256 ++++++++++++++---- .../plugins/camera/ThumbnailAdapter.java | 13 + 2 files changed, 222 insertions(+), 47 deletions(-) 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 index a0881fe6f..afca8321f 100644 --- a/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraFragment.java +++ b/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraFragment.java @@ -32,6 +32,7 @@ import android.view.Window; import android.view.WindowInsetsController; import android.view.animation.AccelerateDecelerateInterpolator; +import android.widget.FrameLayout; import android.widget.ImageView; import android.widget.RelativeLayout; import android.widget.TextView; @@ -207,10 +208,8 @@ private boolean isLandscapeMode(Context context) { public void onConfigurationChanged(@NonNull Configuration newConfig) { super.onConfigurationChanged(newConfig); - // Store the current camera controller - LifecycleCameraController currentController = cameraController; - // Check if device is in landscape mode with the new configuration + boolean wasLandscape = isLandscape; isLandscape = newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE; // Recreate the layout when orientation changes @@ -219,7 +218,13 @@ public void onConfigurationChanged(@NonNull Configuration newConfig) { int currentLensFacing = lensFacing; int currentFlashMode = flashMode; - // Remove all views but don't destroy the camera controller + // Completely recreate the camera controller when switching orientations + if (cameraController != null) { + cameraController.unbind(); + cameraController = null; + } + + // Remove all views relativeLayout.removeAllViews(); // Recreate the UI with the new orientation @@ -229,8 +234,20 @@ public void onConfigurationChanged(@NonNull Configuration newConfig) { 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) { @@ -256,20 +273,77 @@ public void onConfigurationChanged(@NonNull Configuration newConfig) { createFlashButton(fragmentActivity, margin, buttonColors); } - // Restore the camera controller to the new preview view - if (currentController != null) { - previewView.setController(currentController); - cameraController = currentController; - - // Restore camera settings - lensFacing = currentLensFacing; - flashMode = currentFlashMode; + // 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); + + // Force layout update + relativeLayout.requestLayout(); + relativeLayout.invalidate(); + previewView.requestLayout(); + + // Post a delayed layout update to ensure everything is properly laid out + new Handler(requireActivity().getMainLooper()).postDelayed(() -> { + 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()); + } - // Make sure the camera selector is set correctly - CameraSelector cameraSelector = new CameraSelector.Builder().requireLensFacing(lensFacing).build(); - cameraController.setCameraSelector(cameraSelector); - cameraController.setImageCaptureFlashMode(flashMode); - } + previewParams.setMargins(0, 0, 0, 0); + previewView.setLayoutParams(previewParams); + + // Force update the scale type + previewView.setScaleType(PreviewView.ScaleType.FILL_CENTER); + + // Force a layout update + previewView.requestLayout(); + previewView.invalidate(); + relativeLayout.requestLayout(); + relativeLayout.invalidate(); + + // Add a second delayed update to ensure the camera preview is properly sized + new Handler(requireActivity().getMainLooper()).postDelayed(() -> { + if (previewView != null && isLandscape) { + previewView.setScaleType(PreviewView.ScaleType.FILL_CENTER); + previewView.requestLayout(); + previewView.invalidate(); + relativeLayout.requestLayout(); + relativeLayout.invalidate(); + } + }, 300); + } + }, 100); } } @@ -285,6 +359,17 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c 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 @@ -753,7 +838,7 @@ private void createControlsContainerForLandscape(FragmentActivity fragmentActivi controlsContainer.setId(View.generateViewId()); controlsContainer.setBackgroundColor(Color.BLACK); - // Set the container to take up the right side of the screen (about 20% of width) + // Set the container to take up the right 20% of the screen int containerWidth = (int) (displayMetrics.widthPixels * 0.2); RelativeLayout.LayoutParams containerParams = new RelativeLayout.LayoutParams( containerWidth, @@ -765,27 +850,33 @@ private void createControlsContainerForLandscape(FragmentActivity fragmentActivi // Add the container to the main layout relativeLayout.addView(controlsContainer); - // 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 - - // Adjust the preview view to take the full width minus the controls container + // 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.setMargins(0, 0, 0, 0); // Remove any margins + previewParams.addRule(RelativeLayout.LEFT_OF, controlsContainer.getId()); + + // Remove any margins + previewParams.setMargins(0, 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); @@ -810,28 +901,44 @@ private void createPreviewView(FragmentActivity fragmentActivity) { previewView = new PreviewView(fragmentActivity); previewView.setId(View.generateViewId()); - RelativeLayout.LayoutParams previewLayoutParams = new RelativeLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - ); + RelativeLayout.LayoutParams previewLayoutParams; - // We'll set the ABOVE rule after bottomBar is created in portrait mode - // In landscape mode, it takes the full height but not the full width + 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_START for landscape mode to ensure the preview fills the available width - // while maintaining the aspect ratio and aligning to the left - previewView.setScaleType(isLandscape ? PreviewView.ScaleType.FILL_START : PreviewView.ScaleType.FIT_CENTER); + // Use FILL_CENTER for both orientations to ensure the preview fills the available space + previewView.setScaleType(PreviewView.ScaleType.FILL_CENTER); previewView.setOnTouchListener( (v, event) -> { - // Position the focus indicator at the touch point - focusIndicator.setX(event.getX() - (focusIndicator.getWidth() / 2f)); - focusIndicator.setY(event.getY() - (focusIndicator.getHeight() / 2f)); + 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. @@ -1040,7 +1147,20 @@ private void createFilmstripView(FragmentActivity fragmentActivity) { 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) { + thumbnailAdapter.addThumbnail(item.getUri(), item.getBitmap()); + } + } + } filmstripView.setAdapter(thumbnailAdapter); relativeLayout.addView(filmstripView); @@ -1077,24 +1197,66 @@ private void createFilmstripViewForLandscape(FragmentActivity fragmentActivity) // 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 * 3; // Extra margin + 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 - int padding = dpToPx(fragmentActivity, 12); + // 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); - thumbnailAdapter = new ThumbnailAdapter(); + + // 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); + + return new ViewHolder(frameLayout, imageView, removeButton); + } + }; + + // 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) { + thumbnailAdapter.addThumbnail(item.getUri(), item.getBitmap()); + } + } + } filmstripView.setAdapter(thumbnailAdapter); relativeLayout.addView(filmstripView); 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 index 0b53636f3..92cfefa12 100644 --- a/camera/android/src/main/java/com/capacitorjs/plugins/camera/ThumbnailAdapter.java +++ b/camera/android/src/main/java/com/capacitorjs/plugins/camera/ThumbnailAdapter.java @@ -91,6 +91,19 @@ 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; } From a5338fdc759ec604935478f9485943ef28b8d4cb Mon Sep 17 00:00:00 2001 From: Shiva Prasad Date: Thu, 31 Jul 2025 20:52:02 +0700 Subject: [PATCH 07/19] feat(camera): landscape mode support for iOS --- .../MultiCameraViewController.swift | 261 +++++++++++++++--- 1 file changed, 226 insertions(+), 35 deletions(-) diff --git a/camera/ios/Sources/CameraPlugin/MultiCameraViewController.swift b/camera/ios/Sources/CameraPlugin/MultiCameraViewController.swift index 105b3d9b2..3facbbf9f 100644 --- a/camera/ios/Sources/CameraPlugin/MultiCameraViewController.swift +++ b/camera/ios/Sources/CameraPlugin/MultiCameraViewController.swift @@ -141,6 +141,11 @@ class MultiCameraViewController: UIViewController { private var capturedImages: [UIImage] = [] private var capturedMetadata: [[String: Any]] = [] + // 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() @@ -275,6 +280,42 @@ class MultiCameraViewController: UIViewController { 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) { @@ -285,9 +326,14 @@ class MultiCameraViewController: UIViewController { 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 - self?.updatePreviewLayerFrame() + guard let self = self else { return } + self.updatePreviewLayerFrame() + self.updateConstraintsForOrientation() }) } @@ -322,16 +368,68 @@ class MultiCameraViewController: UIViewController { zoomOutButton.translatesAutoresizingMaskIntoConstraints = false zoomFactorLabel.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - // Preview view + // 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), - // Bottom bar + // 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.leadingAnchor), - bottomBarView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + 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 @@ -341,25 +439,44 @@ class MultiCameraViewController: UIViewController { 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: -80), + 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), - takePictureButton.widthAnchor.constraint(equalToConstant: 70), - takePictureButton.heightAnchor.constraint(equalToConstant: 70), // Flip camera button flipCameraButton.leadingAnchor.constraint(equalTo: bottomBarView.leadingAnchor, constant: 30), flipCameraButton.centerYAnchor.constraint(equalTo: bottomBarView.centerYAnchor), - flipCameraButton.widthAnchor.constraint(equalToConstant: 50), - flipCameraButton.heightAnchor.constraint(equalToConstant: 50), // Done button doneButton.trailingAnchor.constraint(equalTo: bottomBarView.trailingAnchor, constant: -30), doneButton.centerYAnchor.constraint(equalTo: bottomBarView.centerYAnchor), - doneButton.widthAnchor.constraint(equalToConstant: 50), - doneButton.heightAnchor.constraint(equalToConstant: 50), + + // 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), @@ -373,26 +490,66 @@ class MultiCameraViewController: UIViewController { flashButton.widthAnchor.constraint(equalToConstant: 44), flashButton.heightAnchor.constraint(equalToConstant: 44), - // Zoom buttons - zoomInButton.bottomAnchor.constraint(equalTo: bottomBarView.topAnchor, constant: -20), - zoomInButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + // 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.bottomAnchor.constraint(equalTo: bottomBarView.topAnchor, constant: -20), - zoomOutButton.trailingAnchor.constraint(equalTo: zoomInButton.leadingAnchor, constant: -10), 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.centerYAnchor.constraint(equalTo: zoomInButton.centerYAnchor), zoomFactorLabel.trailingAnchor.constraint(equalTo: zoomOutButton.leadingAnchor, constant: -10), - zoomFactorLabel.widthAnchor.constraint(equalToConstant: 50), - zoomFactorLabel.heightAnchor.constraint(equalToConstant: 25) - ]) + zoomFactorLabel.centerYAnchor.constraint(equalTo: zoomInButton.centerYAnchor) + ] - // Initially hide the done button until we have at least one image - doneButton.isHidden = true + landscapeConstraints.append(contentsOf: landscapeSpecificConstraints) } private func checkPermissions() { @@ -654,9 +811,7 @@ class MultiCameraViewController: UIViewController { zoomFactorLabel.text = "1.0x" // Update zoom limits for the new camera - if videoDevice != nil { - maxZoomFactor = min(videoDevice.activeFormat.videoMaxZoomFactor, 10.0) - } + maxZoomFactor = min(videoDevice.activeFormat.videoMaxZoomFactor, 10.0) } @objc private func toggleFlash() { @@ -774,10 +929,8 @@ class MultiCameraViewController: UIViewController { thumbnailCollectionView.reloadData() // Scroll to the new image - if !capturedImages.isEmpty { - let indexPath = IndexPath(item: capturedImages.count - 1, section: 0) - thumbnailCollectionView.scrollToItem(at: indexPath, at: .right, animated: true) - } + let indexPath = IndexPath(item: capturedImages.count - 1, section: 0) + thumbnailCollectionView.scrollToItem(at: indexPath, at: .right, animated: true) } private func removeImage(at index: Int) { @@ -810,14 +963,52 @@ extension MultiCameraViewController: AVCapturePhotoCaptureDelegate { } // Extract metadata - var metadata: [String: Any] = [:] - metadata = photo.metadata + let metadata = photo.metadata + + // Fix image orientation based on device orientation + let fixedImage = fixImageOrientation(image) // Add the captured image DispatchQueue.main.async { [weak self] in - self?.addCapturedImage(image, metadata: metadata) + self?.addCapturedImage(fixedImage, 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 From 7eec59637f21598ac7cef471f821004ed4ab40ff Mon Sep 17 00:00:00 2001 From: Shiva Prasad Date: Thu, 31 Jul 2025 20:57:23 +0700 Subject: [PATCH 08/19] chore(camera): updated readme --- camera/README.md | 51 ++++++++++++++++++++++++++++++++++-------------- 1 file changed, 36 insertions(+), 15 deletions(-) diff --git a/camera/README.md b/camera/README.md index 0aa1b4730..f0709a22b 100644 --- a/camera/README.md +++ b/camera/README.md @@ -102,15 +102,15 @@ const takePicture = async () => { -- [`getPhoto(...)`](#getphoto) -- [`pickImages(...)`](#pickimages) -- [`pickLimitedLibraryPhotos()`](#picklimitedlibraryphotos) -- [`getLimitedLibraryPhotos()`](#getlimitedlibraryphotos) -- [`checkPermissions()`](#checkpermissions) -- [`requestPermissions(...)`](#requestpermissions) -- [Interfaces](#interfaces) -- [Type Aliases](#type-aliases) -- [Enums](#enums) +* [`getPhoto(...)`](#getphoto) +* [`pickImages(...)`](#pickimages) +* [`pickLimitedLibraryPhotos()`](#picklimitedlibraryphotos) +* [`getLimitedLibraryPhotos()`](#getlimitedlibraryphotos) +* [`checkPermissions()`](#checkpermissions) +* [`requestPermissions(...)`](#requestpermissions) +* [Interfaces](#interfaces) +* [Type Aliases](#type-aliases) +* [Enums](#enums) @@ -136,6 +136,7 @@ with the camera. -------------------- + ### pickImages(...) ```typescript @@ -155,6 +156,7 @@ On iOS 13 and older it only allows to pick one picture. -------------------- + ### pickLimitedLibraryPhotos() ```typescript @@ -171,6 +173,7 @@ On iOS 14 or if the user gave full access to the photos it returns an empty arra -------------------- + ### getLimitedLibraryPhotos() ```typescript @@ -185,6 +188,7 @@ iOS 14+ Only: Return an array of photos selected from the limited photo library. -------------------- + ### checkPermissions() ```typescript @@ -199,6 +203,7 @@ Check camera and photo album permissions -------------------- + ### requestPermissions(...) ```typescript @@ -217,8 +222,10 @@ Request camera and photo album permissions -------------------- + ### Interfaces + #### Photo | Prop | Type | Description | Since | @@ -231,6 +238,7 @@ Request camera and photo album permissions | **`format`** | string | The format of the image, ex: jpeg, png, gif. iOS and Android only support jpeg. Web supports jpeg, png and gif, but the exact availability may vary depending on the browser. gif is only supported if `webUseInput` is set to `true` or if `source` is set to `Photos`. | 1.0.0 | | **`saved`** | boolean | Whether if the image was saved to the gallery or not. On Android and iOS, saving to the gallery can fail if the user didn't grant the required permissions. On Web there is no gallery, so always returns false. | 1.1.0 | + #### ImageOptions | Prop | Type | Description | Default | Since | @@ -245,18 +253,20 @@ Request camera and photo album permissions | **`source`** | CameraSource | The source to get the photo from. By default this prompts the user to select either the photo album or take a photo. | : CameraSource.Prompt | 1.0.0 | | **`direction`** | CameraDirection | iOS and Web only: The camera direction. | : CameraDirection.Rear | 1.0.0 | | **`presentationStyle`** | 'fullscreen' \| 'popover' | iOS only: The presentation style of the Camera. | : 'fullscreen' | 1.0.0 | -| **`webUseInput`** | boolean | Web only: Whether to use the PWA Element experience or file input. The default is to use PWA Elements if installed and fall back to file input. To always use file input, set this to `true`. Learn more about PWA Elements: | | 1.0.0 | +| **`webUseInput`** | boolean | Web only: Whether to use the PWA Element experience or file input. The default is to use PWA Elements if installed and fall back to file input. To always use file input, set this to `true`. Learn more about PWA Elements: https://capacitorjs.com/docs/web/pwa-elements | | 1.0.0 | | **`promptLabelHeader`** | string | Text value to use when displaying the prompt. | : 'Photo' | 1.0.0 | | **`promptLabelCancel`** | string | Text value to use when displaying the prompt. iOS only: The label of the 'cancel' button. | : 'Cancel' | 1.0.0 | | **`promptLabelPhoto`** | string | Text value to use when displaying the prompt. The label of the button to select a saved image. | : 'From Photos' | 1.0.0 | | **`promptLabelPicture`** | string | Text value to use when displaying the prompt. The label of the button to open the camera. | : 'Take Picture' | 1.0.0 | + #### GalleryPhotos | Prop | Type | Description | Since | | ------------ | --------------------------- | ------------------------------- | ----- | | **`photos`** | GalleryPhoto[] | Array of all the picked photos. | 1.2.0 | + #### GalleryPhoto | Prop | Type | Description | Since | @@ -266,6 +276,7 @@ Request camera and photo album permissions | **`exif`** | any | Exif data, if any, retrieved from the image | 1.2.0 | | **`format`** | string | The format of the image, ex: jpeg, png, gif. iOS and Android only support jpeg. Web supports jpeg, png and gif. | 1.2.0 | + #### GalleryImageOptions | Prop | Type | Description | Default | Since | @@ -277,6 +288,7 @@ Request camera and photo album permissions | **`presentationStyle`** | 'fullscreen' \| 'popover' | iOS only: The presentation style of the Camera. | : 'fullscreen' | 1.2.0 | | **`limit`** | number | Maximum number of pictures the user will be able to choose. Note: This option is only supported on Android 13+ and iOS. | 0 (unlimited) | 1.2.0 | + #### PermissionStatus | Prop | Type | @@ -284,28 +296,35 @@ Request camera and photo album permissions | **`camera`** | CameraPermissionState | | **`photos`** | CameraPermissionState | + #### CameraPluginPermissions | Prop | Type | | ----------------- | ----------------------------------- | | **`permissions`** | CameraPermissionType[] | + ### Type Aliases + #### CameraPermissionState PermissionState | 'limited' + #### PermissionState 'prompt' | 'prompt-with-rationale' | 'granted' | 'denied' + #### CameraPermissionType 'camera' | 'photos' + ### Enums + #### CameraResultType | Members | Value | @@ -314,14 +333,16 @@ Request camera and photo album permissions | **`Base64`** | 'base64' | | **`DataUrl`** | 'dataUrl' | + #### 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. | +| 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. | +| **`Photos`** | 'PHOTOS' | Pick an existing photo from the gallery or photo album. | + #### CameraDirection From bec2abb202886da5acd12c665c198e97efbe7eb3 Mon Sep 17 00:00:00 2001 From: Shiva Prasad Date: Fri, 1 Aug 2025 06:51:54 +0700 Subject: [PATCH 09/19] feat(camera): ui improvements and image rotation handler when there is no exif data on android --- .../capacitorjs/plugins/camera/ImageUtils.java | 15 ++++++++++----- .../src/main/res/drawable/flash_off_24px.xml | 2 +- 2 files changed, 11 insertions(+), 6 deletions(-) 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 6b05ab653..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 @@ -75,9 +75,6 @@ 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); - // Check if device is in landscape mode - boolean isLandscape = c.getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE; - if (orientation != 0) { Matrix matrix = new Matrix(); matrix.postRotate(orientation); @@ -90,8 +87,16 @@ public static Bitmap correctOrientation(final Context c, final Bitmap bitmap, fi return transform(bitmap, matrix); } else { // If there's no EXIF orientation but we're in landscape mode, - // we might need to adjust based on the aspect ratio of the image - // This would be determined through testing + // 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/res/drawable/flash_off_24px.xml b/camera/android/src/main/res/drawable/flash_off_24px.xml index 487353aea..800fa0694 100644 --- a/camera/android/src/main/res/drawable/flash_off_24px.xml +++ b/camera/android/src/main/res/drawable/flash_off_24px.xml @@ -5,5 +5,5 @@ android:viewportHeight="960"> + android:pathData="M280,80h400l-80,280h160L643,529l-57,-57 22,-32h-54l-47,-47 67,-233L360,160v86l-80,-80v-86ZM400,880v-320L280,560v-166L55,169l57,-57 736,736 -57,57 -241,-241L400,880Z"/> From b4e20cf12828593ab6dc73fe7e89f4fdea533cd8 Mon Sep 17 00:00:00 2001 From: Shiva Prasad Date: Fri, 1 Aug 2025 19:12:33 +0700 Subject: [PATCH 10/19] feat(camera): multi camera image thumbnail processing improvements for android --- .../plugins/camera/CameraFragment.java | 837 +++++++++++++++--- .../plugins/camera/ImagePreviewFragment.java | 230 ++++- .../plugins/camera/ThumbnailAdapter.java | 115 ++- 3 files changed, 1028 insertions(+), 154 deletions(-) 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 index afca8321f..ff0885347 100644 --- a/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraFragment.java +++ b/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraFragment.java @@ -2,6 +2,8 @@ import static com.capacitorjs.plugins.camera.DeviceUtils.dpToPx; +import android.view.ViewTreeObserver; + import android.annotation.SuppressLint; import android.app.AlertDialog; import android.content.ContentResolver; @@ -34,6 +36,7 @@ import android.view.animation.AccelerateDecelerateInterpolator; import android.widget.FrameLayout; import android.widget.ImageView; +import android.widget.ProgressBar; import android.widget.RelativeLayout; import android.widget.TextView; import androidx.annotation.ColorInt; @@ -60,11 +63,14 @@ 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; +import androidx.collection.LruCache; @SuppressWarnings("FieldCanBeLocal") public class CameraFragment extends Fragment { @@ -129,7 +135,7 @@ public class CameraFragment extends Fragment { private ExecutorService cameraExecutor; private LifecycleCameraController cameraController; // Utility variables - private HashMap images; + private LruCache imageCache; private ArrayList zoomTabs; private Handler zoomHandler = null; @@ -155,7 +161,28 @@ private static ColorStateList createButtonColorList() { @Override public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); - images = new HashMap<>(); + + // Calculate cache size as 1/8th of available memory + final int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024); + final int cacheSize = maxMemory / 8; + + // Initialize LruCache for image memory management + imageCache = new LruCache(cacheSize) { + @Override + protected int sizeOf(Uri key, Bitmap bitmap) { + // Size in kilobytes + return bitmap.getByteCount() / 1024; + } + + @Override + protected void entryRemoved(boolean evicted, Uri key, Bitmap oldValue, Bitmap newValue) { + if (evicted && oldValue != null && !oldValue.isRecycled()) { + // Recycle bitmap to free memory immediately when evicted from cache + oldValue.recycle(); + } + } + }; + zoomTabs = new ArrayList<>(); zoomHandler = new Handler(requireActivity().getMainLooper()); mediaActionSound = new MediaActionSound(); @@ -184,12 +211,63 @@ public void onDestroy() { 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) { + imageCache.evictAll(); + imageCache = null; + } + if (mediaActionSound != null) { mediaActionSound.release(); mediaActionSound = 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()) { + // Use a no-op listener to avoid crashes when removing unknown listeners + ViewTreeObserver.OnGlobalLayoutListener noOpListener = () -> {}; + relativeLayout.getViewTreeObserver().removeOnGlobalLayoutListener(noOpListener); + } + + if (previewView != null && previewView.getViewTreeObserver().isAlive()) { + ViewTreeObserver.OnGlobalLayoutListener noOpListener = () -> {}; + previewView.getViewTreeObserver().removeOnGlobalLayoutListener(noOpListener); + } + + if (zoomTabCardView != null && zoomTabCardView.getViewTreeObserver().isAlive()) { + ViewTreeObserver.OnGlobalLayoutListener noOpListener = () -> {}; + zoomTabCardView.getViewTreeObserver().removeOnGlobalLayoutListener(noOpListener); + } + + if (filmstripView != null && filmstripView.getViewTreeObserver().isAlive()) { + ViewTreeObserver.OnGlobalLayoutListener noOpListener = () -> {}; + filmstripView.getViewTreeObserver().removeOnGlobalLayoutListener(noOpListener); + } + } catch (Exception e) { + // Log but don't crash if there's an issue cleaning up listeners + Log.e(TAG, "Error cleaning up ViewTreeObserver listeners", e); } } @@ -292,58 +370,60 @@ public void onConfigurationChanged(@NonNull Configuration newConfig) { relativeLayout.invalidate(); previewView.requestLayout(); - // Post a delayed layout update to ensure everything is properly laid out - new Handler(requireActivity().getMainLooper()).postDelayed(() -> { - 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); - - // Force a layout update - previewView.requestLayout(); - previewView.invalidate(); - relativeLayout.requestLayout(); - relativeLayout.invalidate(); - - // Add a second delayed update to ensure the camera preview is properly sized - new Handler(requireActivity().getMainLooper()).postDelayed(() -> { - if (previewView != null && isLandscape) { - previewView.setScaleType(PreviewView.ScaleType.FILL_CENTER); - previewView.requestLayout(); - previewView.invalidate(); - relativeLayout.requestLayout(); - relativeLayout.invalidate(); + // Use ViewTreeObserver to efficiently handle layout updates + relativeLayout.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + // Remove the listener to prevent multiple callbacks + relativeLayout.getViewTreeObserver().removeOnGlobalLayoutListener(this); + + 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()); } - }, 300); + + 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 + previewView.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + // Remove this listener after execution + previewView.getViewTreeObserver().removeOnGlobalLayoutListener(this); + + if (previewView != null && isLandscape) { + previewView.setScaleType(PreviewView.ScaleType.FILL_CENTER); + } + } + }); + } } - }, 100); + }); } } @@ -436,15 +516,72 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat cameraController.bindToLifecycle(requireActivity()); previewView.setController(cameraController); cameraExecutor = Executors.newSingleThreadExecutor(); - relativeLayout.post(this::setupCamera); + + // Use ViewTreeObserver to ensure layout is complete before setting up camera + previewView.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + // Remove the listener to prevent multiple callbacks + previewView.getViewTreeObserver().removeOnGlobalLayoutListener(this); + setupCamera(); + } + }); + } + + /** + * 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) { + imageCache.put(uri, bitmap); + } + } + + /** + * 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) { + // We need to manually iterate through the snapshot to get all entries + for (Map.Entry entry : imageCache.snapshot().entrySet()) { + result.put(entry.getKey(), entry.getValue()); + } + } + return result; } private void cancel() { // When the user cancels the camera session, it should clean up all the photos that were // taken. - for (Map.Entry image : images.entrySet()) { - deleteFile(image.getKey()); + int failedDeletions = 0; + for (Map.Entry image : getAllCachedImages().entrySet()) { + if (!deleteFile(image.getKey())) { + failedDeletions++; + Log.w(TAG, "Failed to delete image during cancel: " + image.getKey()); + } } + + if (failedDeletions > 0) { + Log.w(TAG, "Failed to delete " + failedDeletions + " images during cancel"); + // We still proceed with cancellation even if some deletions failed + } + if (imagesCapturedCallback != null) { imagesCapturedCallback.onCaptureCanceled(); } @@ -453,13 +590,27 @@ private void cancel() { private void done() { if (imagesCapturedCallback != null) { - imagesCapturedCallback.onCaptureSuccess(images); + imagesCapturedCallback.onCaptureSuccess(getAllCachedImages()); } closeFragment(); } + /** + * Safely closes the fragment, handling any potential exceptions + */ private void closeFragment() { - requireActivity().getSupportFragmentManager().beginTransaction().remove(this).commit(); + try { + if (getActivity() != null && !getActivity().isFinishing() && isAdded()) { + requireActivity().getSupportFragmentManager().beginTransaction().remove(this).commit(); + } else { + Log.w(TAG, "Cannot close fragment: activity is null, finishing, or fragment not added"); + } + } catch (IllegalStateException e) { + // This can happen if the activity is being destroyed + Log.e(TAG, "Error closing fragment", e); + } catch (Exception e) { + Log.e(TAG, "Unexpected error closing fragment", e); + } } public void setImagesCapturedCallback(OnImagesCapturedCallback imagesCapturedCallback) { @@ -505,6 +656,16 @@ private void createTakePictureButtonForLandscape(FragmentActivity fragmentActivi 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); @@ -524,22 +685,102 @@ private void createTakePictureButtonForLandscape(FragmentActivity fragmentActivi public void onImageSaved(@NonNull ImageCapture.OutputFileResults outputFileResults) { Uri savedImageUri = outputFileResults.getSavedUri(); if (savedImageUri != null) { + InputStream stream = null; try { - InputStream stream = requireContext().getContentResolver().openInputStream(savedImageUri); - Bitmap bmp = BitmapFactory.decodeStream(stream); - images.put(savedImageUri, bmp); - requireView() - .post( - () -> thumbnailAdapter.addThumbnail(savedImageUri, getThumbnail(savedImageUri)) - ); + stream = requireContext().getContentResolver().openInputStream(savedImageUri); + if (stream == null) { + Log.e(TAG, "Failed to open input stream for saved image: " + savedImageUri); + 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) { + Log.e(TAG, "Failed to decode bitmap from saved image: " + savedImageUri); + showErrorToast("Failed to process captured image"); + return; + } + + addImageToCache(savedImageUri, bmp); + + // 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) { - e.printStackTrace(); + Log.e(TAG, "File not found for saved image: " + savedImageUri, e); + showErrorToast("Image file not found"); + } catch (OutOfMemoryError e) { + Log.e(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.evictAll(); + } + System.gc(); // Request garbage collection + } catch (Exception e) { + Log.e(TAG, "Error processing saved image: " + savedImageUri, e); + showErrorToast("Error processing image"); + } finally { + if (stream != null) { + try { + stream.close(); + } catch (IOException e) { + Log.e(TAG, "Error closing input stream", e); + } + } } + } else { + Log.e(TAG, "Saved image URI is null"); + showErrorToast("Failed to save image"); } } @Override - public void onError(@NonNull ImageCaptureException exception) {} + 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; + } + + Log.e(TAG, "Image capture error: " + errorMessage, exception); + + // Remove any loading thumbnails since capture failed + requireActivity().runOnUiThread(() -> { + if (thumbnailAdapter != null && thumbnailAdapter.hasLoadingThumbnails()) { + thumbnailAdapter.removeLoadingThumbnails(); + } + }); + + showErrorToast(errorMessage); + } } ); } @@ -565,6 +806,13 @@ private void createFlipButtonForLandscape(FragmentActivity fragmentActivity, int 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"); + } + lensFacing = lensFacing == CameraSelector.LENS_FACING_FRONT ? CameraSelector.LENS_FACING_BACK : CameraSelector.LENS_FACING_FRONT; flashButton.setVisibility(lensFacing == CameraSelector.LENS_FACING_BACK ? View.VISIBLE : View.GONE); if (!zoomTabs.isEmpty()) { @@ -619,7 +867,7 @@ private void createCloseButtonForLandscape(FragmentActivity fragmentActivity, in closeButton.setOnClickListener( view -> { view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY); - if (images != null && !images.isEmpty()) { + if (imageCache != null && imageCache.size() > 0) { new AlertDialog.Builder(requireContext()) .setMessage(CONFIRM_CANCEL_MESSAGE) .setPositiveButton(CONFIRM_CANCEL_POSITIVE, (dialogInterface, i) -> cancel()) @@ -758,6 +1006,16 @@ private void createTakePictureButton(FragmentActivity fragmentActivity, int marg 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); @@ -777,22 +1035,102 @@ private void createTakePictureButton(FragmentActivity fragmentActivity, int marg public void onImageSaved(@NonNull ImageCapture.OutputFileResults outputFileResults) { Uri savedImageUri = outputFileResults.getSavedUri(); if (savedImageUri != null) { + InputStream stream = null; try { - InputStream stream = requireContext().getContentResolver().openInputStream(savedImageUri); - Bitmap bmp = BitmapFactory.decodeStream(stream); - images.put(savedImageUri, bmp); - requireView() - .post( - () -> thumbnailAdapter.addThumbnail(savedImageUri, getThumbnail(savedImageUri)) - ); + stream = requireContext().getContentResolver().openInputStream(savedImageUri); + if (stream == null) { + Log.e(TAG, "Failed to open input stream for saved image: " + savedImageUri); + 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) { + Log.e(TAG, "Failed to decode bitmap from saved image: " + savedImageUri); + showErrorToast("Failed to process captured image"); + return; + } + + addImageToCache(savedImageUri, bmp); + + // 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) { - e.printStackTrace(); + Log.e(TAG, "File not found for saved image: " + savedImageUri, e); + showErrorToast("Image file not found"); + } catch (OutOfMemoryError e) { + Log.e(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.evictAll(); + } + System.gc(); // Request garbage collection + } catch (Exception e) { + Log.e(TAG, "Error processing saved image: " + savedImageUri, e); + showErrorToast("Error processing image"); + } finally { + if (stream != null) { + try { + stream.close(); + } catch (IOException e) { + Log.e(TAG, "Error closing input stream", e); + } + } } + } else { + Log.e(TAG, "Saved image URI is null"); + showErrorToast("Failed to save image"); } } @Override - public void onError(@NonNull ImageCaptureException exception) {} + 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; + } + + Log.e(TAG, "Image capture error: " + errorMessage, exception); + + // Remove any loading thumbnails since capture failed + requireActivity().runOnUiThread(() -> { + if (thumbnailAdapter != null && thumbnailAdapter.hasLoadingThumbnails()) { + thumbnailAdapter.removeLoadingThumbnails(); + } + }); + + showErrorToast(errorMessage); + } } ); } @@ -817,6 +1155,13 @@ private void createFlipButton(FragmentActivity fragmentActivity, int margin, Col 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"); + } + lensFacing = lensFacing == CameraSelector.LENS_FACING_FRONT ? CameraSelector.LENS_FACING_BACK : CameraSelector.LENS_FACING_FRONT; flashButton.setVisibility(lensFacing == CameraSelector.LENS_FACING_BACK ? View.VISIBLE : View.GONE); if (!zoomTabs.isEmpty()) { @@ -1036,6 +1381,32 @@ public void onTabReselected(TabLayout.Tab tab) { ); zoomTabCardView.addView(zoomTabLayout); + + // Use ViewTreeObserver to ensure zoom tab layout is properly laid out + zoomTabCardView.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + // Remove the listener to prevent multiple callbacks + zoomTabCardView.getViewTreeObserver().removeOnGlobalLayoutListener(this); + + // 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); + } + } + } + } + } + }); } private void createZoomTabLayoutForLandscape(FragmentActivity fragmentActivity, int margin) { @@ -1113,6 +1484,32 @@ public void onTabReselected(TabLayout.Tab tab) { ); zoomTabCardView.addView(zoomTabLayout); + + // Use ViewTreeObserver to ensure zoom tab layout is properly laid out in landscape mode + zoomTabCardView.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + // Remove the listener to prevent multiple callbacks + zoomTabCardView.getViewTreeObserver().removeOnGlobalLayoutListener(this); + + // 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); + } + } + } + } + } + }); } private void createZoomTabs(FragmentActivity fragmentActivity, TabLayout tabLayout) { @@ -1157,19 +1554,43 @@ private void createFilmstripView(FragmentActivity fragmentActivity) { for (int i = 0; i < existingAdapter.getItemCount(); i++) { ThumbnailAdapter.ThumbnailItem item = existingAdapter.getThumbnailItem(i); if (item != null) { - thumbnailAdapter.addThumbnail(item.getUri(), item.getBitmap()); + if (!item.isLoading()) { + thumbnailAdapter.addThumbnail(item.getUri(), item.getBitmap()); + } } } } filmstripView.setAdapter(thumbnailAdapter); relativeLayout.addView(filmstripView); + // Use ViewTreeObserver to ensure filmstrip is properly laid out + filmstripView.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + // Remove the listener to prevent multiple callbacks + filmstripView.getViewTreeObserver().removeOnGlobalLayoutListener(this); + + // Scroll to the end of the filmstrip to show the most recent thumbnails + if (thumbnailAdapter.getItemCount() > 0) { + filmstripView.scrollToPosition(thumbnailAdapter.getItemCount() - 1); + } + } + }); + thumbnailAdapter.setOnThumbnailsChangedCallback( new ThumbnailAdapter.OnThumbnailsChangedCallback() { @Override public void onThumbnailRemoved(Uri uri, Bitmap bmp) { - images.remove(uri); - deleteFile(uri); + Bitmap bitmap = getImageFromCache(uri); + if (imageCache != null) { + imageCache.remove(uri); + } + + if (!deleteFile(uri)) { + Log.w(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 + } } } ); @@ -1244,7 +1665,16 @@ public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { removeButton.setImageResource(R.drawable.ic_cancel_white_24dp); frameLayout.addView(removeButton); - return new ViewHolder(frameLayout, imageView, 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); } }; @@ -1253,19 +1683,42 @@ public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { for (int i = 0; i < existingAdapter.getItemCount(); i++) { ThumbnailAdapter.ThumbnailItem item = existingAdapter.getThumbnailItem(i); if (item != null) { - thumbnailAdapter.addThumbnail(item.getUri(), item.getBitmap()); + 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 + filmstripView.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + // Remove the listener to prevent multiple callbacks + filmstripView.getViewTreeObserver().removeOnGlobalLayoutListener(this); + + // Scroll to the end of the filmstrip to show the most recent thumbnails + if (thumbnailAdapter.getItemCount() > 0) { + filmstripView.scrollToPosition(thumbnailAdapter.getItemCount() - 1); + } + } + }); + thumbnailAdapter.setOnThumbnailsChangedCallback( new ThumbnailAdapter.OnThumbnailsChangedCallback() { @Override public void onThumbnailRemoved(Uri uri, Bitmap bmp) { - images.remove(uri); - deleteFile(uri); + if (imageCache != null) { + imageCache.remove(uri); + } + + if (!deleteFile(uri)) { + Log.w(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 + } } } ); @@ -1321,7 +1774,7 @@ private void createCloseButton(FragmentActivity fragmentActivity, int margin, Co closeButton.setOnClickListener( view -> { view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY); - if (images != null && !images.isEmpty()) { + if (imageCache != null && imageCache.size() > 0) { new AlertDialog.Builder(requireContext()) .setMessage(CONFIRM_CANCEL_MESSAGE) .setPositiveButton(CONFIRM_CANCEL_POSITIVE, (dialogInterface, i) -> cancel()) @@ -1336,21 +1789,44 @@ private void createCloseButton(FragmentActivity fragmentActivity, int margin, Co relativeLayout.addView(closeButton); } - private void deleteFile(Uri fileUri) { + /** + * 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) { + Log.e(TAG, "Cannot delete null URI"); + return false; + } + try { ContentResolver contentResolver = requireContext().getContentResolver(); int deleted = contentResolver.delete(fileUri, null, null); if (deleted == 0) { // File deletion failed - Log.e("Delete File", "Failed to delete file: " + fileUri); + Log.e(TAG, "Failed to delete file: " + fileUri); + return false; } else { // File deletion successful - Log.i("Delete File", "File deleted: " + fileUri); + Log.i(TAG, "File deleted: " + fileUri); + return true; } + } catch (SecurityException e) { + // Handle permission issues + Log.e(TAG, "Security exception when deleting file: " + fileUri, e); + showErrorToast("Permission denied to delete image"); + return false; + } catch (IllegalArgumentException e) { + // Handle invalid URI + Log.e(TAG, "Invalid URI when deleting file: " + fileUri, e); + return false; } catch (Exception e) { - // Handle any exceptions - e.printStackTrace(); + // Handle any other exceptions + Log.e(TAG, "Error deleting file: " + fileUri, e); + return false; } } @@ -1360,7 +1836,36 @@ private void deleteFile(Uri fileUri) { * @param uri The URI of the image to display in the preview */ private void showImagePreview(Uri uri) { - ImagePreviewFragment previewFragment = ImagePreviewFragment.newInstance(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"); } @@ -1451,6 +1956,28 @@ private void setupCamera() throws IllegalStateException { 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 + Log.e(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(); @@ -1460,35 +1987,127 @@ private boolean hasFrontFacingCamera() { 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; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { // API level 29 and above - try { // Specify the size of the thumbnail - int width = (int) (displayMetrics.widthPixels * 0.25); // Thumbnail width as 25% of screen width - int height = (int) (displayMetrics.heightPixels * 0.25); // Thumbnail height as 25% of screen height - Size size = new Size(width, height); - // Load the thumbnail - return contentResolver.loadThumbnail(imageUri, size, null); - } catch (IOException e) { - // Handle exceptions - e.printStackTrace(); - return 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 + Log.w(TAG, "Failed to load thumbnail with system API, falling back to manual downsampling", e); + // 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(); } - } else { // Below API level 29 - 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(); + // Calculate optimal inSampleSize for downsampling + int inSampleSize = calculateInSampleSize(boundsOptions, targetWidth, targetHeight); - return MediaStore.Images.Thumbnails.getThumbnail(contentResolver, imageId, MediaStore.Images.Thumbnails.MINI_KIND, null); + // 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) { + Log.e(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) { + Log.e(TAG, "Error in thumbnail fallback", ex); + } } + return null; + } finally { + // Ensure streams are always closed + if (inputStream != null) { + try { + inputStream.close(); + } catch (IOException e) { + Log.e(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 { 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 index 068feae87..ad5c58a34 100644 --- a/camera/android/src/main/java/com/capacitorjs/plugins/camera/ImagePreviewFragment.java +++ b/camera/android/src/main/java/com/capacitorjs/plugins/camera/ImagePreviewFragment.java @@ -1,9 +1,11 @@ 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; @@ -12,12 +14,18 @@ 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. @@ -25,7 +33,10 @@ */ public class ImagePreviewFragment extends DialogFragment { - private Uri imageUri; + private List imageUris; + private int currentPosition; + private ViewPager2 viewPager; + private TextView positionIndicator; /** * Create a new instance of ImagePreviewFragment with the provided image URI @@ -34,8 +45,22 @@ public class ImagePreviewFragment extends DialogFragment { * @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(); - fragment.imageUri = uri; + fragment.imageUris = new ArrayList<>(imageUris); + fragment.currentPosition = position; return fragment; } @@ -55,33 +80,58 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c ViewGroup.LayoutParams.MATCH_PARENT)); rootLayout.setBackgroundColor(Color.BLACK); - // Create the image view - ImageView imageView = new ImageView(requireContext()); - imageView.setLayoutParams(new FrameLayout.LayoutParams( + // 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); - // Use FIT_CENTER for better handling of different aspect ratios - // This helps prevent stretching in both portrait and landscape modes - imageView.setScaleType(ImageView.ScaleType.FIT_CENTER); - - // Create a progress bar to show while loading - 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); - - rootLayout.addView(imageView); - rootLayout.addView(progressBar); - - // Load the full-resolution image - loadFullResolutionImage(imageUri, imageView, progressBar); + // 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 + // 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, @@ -93,6 +143,12 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c 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 @@ -150,4 +206,132 @@ private void loadFullResolutionImage(Uri uri, ImageView imageView, ProgressBar p 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 Uri imageUri; + + public static ImagePageFragment newInstance(Uri uri) { + ImagePageFragment fragment = new ImagePageFragment(); + fragment.imageUri = uri; + return fragment; + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + // 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/ThumbnailAdapter.java b/camera/android/src/main/java/com/capacitorjs/plugins/camera/ThumbnailAdapter.java index 92cfefa12..eef9c7bee 100644 --- a/camera/android/src/main/java/com/capacitorjs/plugins/camera/ThumbnailAdapter.java +++ b/camera/android/src/main/java/com/capacitorjs/plugins/camera/ThumbnailAdapter.java @@ -4,12 +4,14 @@ 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; @@ -26,11 +28,49 @@ public class ThumbnailAdapter extends RecyclerView.Adapter= 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) { @@ -55,35 +95,58 @@ public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { removeButton.setImageResource(R.drawable.ic_cancel_white_24dp); frameLayout.addView(removeButton); - return new ViewHolder(frameLayout, imageView, 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); - holder.imageView.setImageBitmap(item.bitmap); - - // Set click listener for the thumbnail - holder.mainView.setOnClickListener(v -> { - if (thumbnailClickListener != null) { - thumbnailClickListener.onThumbnailClick(item.getUri(), item.getBitmap()); - } - }); + + 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); + holder.removeButton.setOnClickListener( + v -> { + int currentPosition = holder.getAdapterPosition(); + if (currentPosition != RecyclerView.NO_POSITION) { + ThumbnailItem removed = thumbnails.remove(currentPosition); - notifyItemRemoved(currentPosition); + notifyItemRemoved(currentPosition); - if (thumbnailsChangedCallback != null) { - thumbnailsChangedCallback.onThumbnailRemoved(removed.getUri(), removed.getBitmap()); + if (thumbnailsChangedCallback != null) { + thumbnailsChangedCallback.onThumbnailRemoved(removed.getUri(), removed.getBitmap()); + } } } - } - ); + ); + } } @Override @@ -117,12 +180,14 @@ static class ViewHolder extends RecyclerView.ViewHolder { ImageView imageView; ImageView removeButton; FrameLayout mainView; + ProgressBar progressBar; - ViewHolder(@NonNull FrameLayout view, @NonNull ImageView imageView, @NonNull ImageView removeButton) { + 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; } } @@ -139,10 +204,12 @@ public static class ThumbnailItem { private final Uri uri; private final Bitmap bitmap; + private final boolean loading; - public ThumbnailItem(Uri u, Bitmap bmp) { + public ThumbnailItem(Uri u, Bitmap bmp, boolean isLoading) { this.uri = u; this.bitmap = bmp; + this.loading = isLoading; } public Uri getUri() { @@ -152,6 +219,10 @@ public Uri getUri() { public Bitmap getBitmap() { return bitmap; } + + public boolean isLoading() { + return loading; + } } } From fa3b05fd30d8e390740998afad9dffba157f13f4 Mon Sep 17 00:00:00 2001 From: Shiva Prasad Date: Fri, 1 Aug 2025 19:46:00 +0700 Subject: [PATCH 11/19] feat(camera): wait for processing before closing camera - android --- .../plugins/camera/CameraFragment.java | 297 ++++++++++++++++-- .../plugins/camera/CameraPlugin.java | 4 + 2 files changed, 270 insertions(+), 31 deletions(-) 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 index ff0885347..a87910df7 100644 --- a/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraFragment.java +++ b/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraFragment.java @@ -21,6 +21,7 @@ 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; @@ -70,7 +71,6 @@ import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; -import androidx.collection.LruCache; @SuppressWarnings("FieldCanBeLocal") public class CameraFragment extends Fragment { @@ -135,7 +135,7 @@ public class CameraFragment extends Fragment { private ExecutorService cameraExecutor; private LifecycleCameraController cameraController; // Utility variables - private LruCache imageCache; + private HashMap imageCache; private ArrayList zoomTabs; private Handler zoomHandler = null; @@ -144,6 +144,16 @@ public class CameraFragment extends Fragment { // 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; @NonNull private static ColorStateList createButtonColorList() { @@ -162,26 +172,9 @@ private static ColorStateList createButtonColorList() { public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); - // Calculate cache size as 1/8th of available memory - final int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024); - final int cacheSize = maxMemory / 8; - - // Initialize LruCache for image memory management - imageCache = new LruCache(cacheSize) { - @Override - protected int sizeOf(Uri key, Bitmap bitmap) { - // Size in kilobytes - return bitmap.getByteCount() / 1024; - } - - @Override - protected void entryRemoved(boolean evicted, Uri key, Bitmap oldValue, Bitmap newValue) { - if (evicted && oldValue != null && !oldValue.isRecycled()) { - // Recycle bitmap to free memory immediately when evicted from cache - oldValue.recycle(); - } - } - }; + // Initialize simple HashMap for image storage + imageCache = new HashMap<>(); + Log.d(TAG, "Initialized HashMap for image cache"); zoomTabs = new ArrayList<>(); zoomHandler = new Handler(requireActivity().getMainLooper()); @@ -216,14 +209,32 @@ public void onDestroy() { // Clear image cache to free memory if (imageCache != null) { - imageCache.evictAll(); - 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) { + Log.e(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(); @@ -501,6 +512,9 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c // Remove edge-to-edge insets handling for true fullscreen requireActivity().getWindow().setDecorFitsSystemWindows(true); + // Create processing overlay + createProcessingOverlay(fragmentActivity); + return relativeLayout; } @@ -536,7 +550,16 @@ public void onGlobalLayout() { */ private void addImageToCache(Uri uri, Bitmap bitmap) { if (uri != null && bitmap != null && !bitmap.isRecycled() && imageCache != null) { - imageCache.put(uri, bitmap); + try { + int bitmapSizeKB = bitmap.getByteCount() / 1024; + Log.d(TAG, "Adding image to cache: " + uri + ", bitmap size: " + bitmap.getWidth() + "x" + bitmap.getHeight() + " (" + bitmapSizeKB + " KB)"); + imageCache.put(uri, bitmap); + Log.d(TAG, "Cache size after adding: " + imageCache.size() + " images"); + } catch (Exception e) { + Log.e(TAG, "Error adding image to cache", e); + } + } else { + Log.w(TAG, "Failed to add image to cache - uri: " + uri + ", bitmap: " + bitmap + ", imageCache: " + imageCache); } } @@ -558,15 +581,29 @@ private Bitmap getImageFromCache(Uri uri) { private HashMap getAllCachedImages() { HashMap result = new HashMap<>(); if (imageCache != null) { - // We need to manually iterate through the snapshot to get all entries - for (Map.Entry entry : imageCache.snapshot().entrySet()) { + Log.d(TAG, "getAllCachedImages: cache size = " + imageCache.size()); + // Copy all entries from the cache + for (Map.Entry entry : imageCache.entrySet()) { + Log.d(TAG, "Adding cached image: " + entry.getKey()); result.put(entry.getKey(), entry.getValue()); } } + Log.d(TAG, "getAllCachedImages: returning " + result.size() + " images"); 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; @@ -589,6 +626,38 @@ private void cancel() { } private void done() { + // Check if there are still images being processed + if (thumbnailAdapter != null && thumbnailAdapter.hasLoadingThumbnails()) { + Log.d(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 { + Log.d(TAG, "No images processing, proceeding immediately"); + // No processing needed, proceed immediately + finalizeDone(); + } + } + + private void finalizeDone() { if (imagesCapturedCallback != null) { imagesCapturedCallback.onCaptureSuccess(getAllCachedImages()); } @@ -613,9 +682,163 @@ private void closeFragment() { } } + /** + * 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); + + Log.d(TAG, "Processing overlay created and hidden"); + } + + /** + * Shows the processing overlay + */ + private void showProcessingOverlay() { + Log.d(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()) { + Log.w(TAG, "Cannot process null or recycled bitmap"); + return null; + } + + // If no settings are available, return original bitmap + if (cameraSettings == null) { + Log.d(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 orientation correction (only if explicitly enabled) + if (cameraSettings.isShouldCorrectOrientation()) { + Bitmap correctedBitmap = ImageUtils.correctOrientation(getContext(), processedBitmap, imageUri, exif); + if (correctedBitmap != processedBitmap && correctedBitmap != null) { + Log.d(TAG, "Applied orientation correction"); + if (processedBitmap != originalBitmap) { + processedBitmap.recycle(); + } + processedBitmap = correctedBitmap; + wasProcessed = true; + } + } + + // 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) { + Log.d(TAG, "Applied resizing to " + cameraSettings.getWidth() + "x" + cameraSettings.getHeight()); + if (processedBitmap != originalBitmap) { + processedBitmap.recycle(); + } + processedBitmap = resizedBitmap; + wasProcessed = true; + } + } + + if (wasProcessed) { + Log.d(TAG, "Bitmap processed: " + originalBitmap.getWidth() + "x" + originalBitmap.getHeight() + + " -> " + processedBitmap.getWidth() + "x" + processedBitmap.getHeight()); + } + + return processedBitmap; + } catch (Exception e) { + Log.e(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); @@ -704,7 +927,13 @@ public void onImageSaved(@NonNull ImageCapture.OutputFileResults outputFileResul return; } - addImageToCache(savedImageUri, bmp); + // 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()) { @@ -726,7 +955,7 @@ public void onImageSaved(@NonNull ImageCapture.OutputFileResults outputFileResul showErrorToast("Not enough memory to process image"); // Try to recover by clearing the cache if (imageCache != null) { - imageCache.evictAll(); + imageCache.clear(); } System.gc(); // Request garbage collection } catch (Exception e) { @@ -1054,7 +1283,13 @@ public void onImageSaved(@NonNull ImageCapture.OutputFileResults outputFileResul return; } - addImageToCache(savedImageUri, bmp); + // 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()) { @@ -1076,7 +1311,7 @@ public void onImageSaved(@NonNull ImageCapture.OutputFileResults outputFileResul showErrorToast("Not enough memory to process image"); // Try to recover by clearing the cache if (imageCache != null) { - imageCache.evictAll(); + imageCache.clear(); } System.gc(); // Request garbage collection } catch (Exception e) { 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 10c5ec115..0c46a0dd6 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 @@ -348,6 +348,10 @@ 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) { From 95898f713ff6fed6a738fa89568ad450dd050950 Mon Sep 17 00:00:00 2001 From: Shiva Prasad Date: Fri, 1 Aug 2025 20:16:57 +0700 Subject: [PATCH 12/19] chore(camera): lint android --- .../plugins/camera/CameraFragment.java | 61 +++++++++---------- 1 file changed, 30 insertions(+), 31 deletions(-) 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 index a87910df7..ff2820bb5 100644 --- a/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraFragment.java +++ b/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraFragment.java @@ -76,7 +76,6 @@ public class CameraFragment extends Fragment { // Constants - @SuppressWarnings("unused") private final String TAG = "CameraFragment"; private final String FILENAME = "yyyy-MM-dd-HH-mm-ss-SSS"; private final String PHOTO_TYPE = "image/jpeg"; @@ -144,10 +143,10 @@ public class CameraFragment extends Fragment { // Callbacks private OnImagesCapturedCallback imagesCapturedCallback; - + // Camera settings private CameraSettings cameraSettings; - + // Processing spinner overlay private View processingOverlay; private ProgressBar processingSpinner; @@ -228,7 +227,7 @@ public void onDestroy() { mediaActionSound.release(); mediaActionSound = null; } - + // Clean up processing handler and runnable if (processingHandler != null && processingRunnable != null) { processingHandler.removeCallbacks(processingRunnable); @@ -595,7 +594,7 @@ private HashMap getAllCachedImages() { 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 @@ -603,7 +602,7 @@ private void cancel() { thumbnailAdapter.removeLoadingThumbnails(); } } - + // When the user cancels the camera session, it should clean up all the photos that were // taken. int failedDeletions = 0; @@ -631,7 +630,7 @@ private void done() { Log.d(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() { @@ -656,7 +655,7 @@ public void run() { finalizeDone(); } } - + private void finalizeDone() { if (imagesCapturedCallback != null) { imagesCapturedCallback.onCaptureSuccess(getAllCachedImages()); @@ -691,13 +690,13 @@ private void createProcessingOverlay(FragmentActivity 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()); @@ -707,7 +706,7 @@ private void createProcessingOverlay(FragmentActivity fragmentActivity) { ); contentParams.addRule(RelativeLayout.CENTER_IN_PARENT); contentContainer.setLayoutParams(contentParams); - + // Create spinner processingSpinner = new ProgressBar(fragmentActivity); processingSpinner.setId(View.generateViewId()); @@ -715,7 +714,7 @@ private void createProcessingOverlay(FragmentActivity fragmentActivity) { 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()); @@ -731,17 +730,17 @@ private void createProcessingOverlay(FragmentActivity fragmentActivity) { 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); - + Log.d(TAG, "Processing overlay created and hidden"); } @@ -763,7 +762,7 @@ private void hideProcessingOverlay() { if (processingOverlay != null) { processingOverlay.setVisibility(View.GONE); } - + // Clean up processing handler and runnable if (processingHandler != null && processingRunnable != null) { processingHandler.removeCallbacks(processingRunnable); @@ -775,11 +774,11 @@ private void hideProcessingOverlay() { 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) */ @@ -788,19 +787,19 @@ private Bitmap processBitmap(Bitmap originalBitmap, Uri imageUri) { Log.w(TAG, "Cannot process null or recycled bitmap"); return null; } - + // If no settings are available, return original bitmap if (cameraSettings == null) { Log.d(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 orientation correction (only if explicitly enabled) if (cameraSettings.isShouldCorrectOrientation()) { Bitmap correctedBitmap = ImageUtils.correctOrientation(getContext(), processedBitmap, imageUri, exif); @@ -813,7 +812,7 @@ private Bitmap processBitmap(Bitmap originalBitmap, Uri imageUri) { wasProcessed = true; } } - + // Apply resizing if (cameraSettings.isShouldResize() && cameraSettings.getWidth() > 0 && cameraSettings.getHeight() > 0) { Bitmap resizedBitmap = ImageUtils.resize(processedBitmap, cameraSettings.getWidth(), cameraSettings.getHeight()); @@ -826,12 +825,12 @@ private Bitmap processBitmap(Bitmap originalBitmap, Uri imageUri) { wasProcessed = true; } } - + if (wasProcessed) { - Log.d(TAG, "Bitmap processed: " + originalBitmap.getWidth() + "x" + originalBitmap.getHeight() + + Log.d(TAG, "Bitmap processed: " + originalBitmap.getWidth() + "x" + originalBitmap.getHeight() + " -> " + processedBitmap.getWidth() + "x" + processedBitmap.getHeight()); } - + return processedBitmap; } catch (Exception e) { Log.e(TAG, "Error processing bitmap", e); @@ -2077,11 +2076,11 @@ private void showImagePreview(Uri 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()) { @@ -2091,14 +2090,14 @@ private void showImagePreview(Uri uri) { 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"); From 2466d033844b76ccdffdef80893aa98fcfb858de Mon Sep 17 00:00:00 2001 From: Shiva Prasad Date: Fri, 1 Aug 2025 21:07:44 +0700 Subject: [PATCH 13/19] feat(camera): zoom button support for landscape mode in android --- .../plugins/camera/CameraFragment.java | 364 ++++++++++++------ .../plugins/camera/ImagePreviewFragment.java | 77 ++-- 2 files changed, 306 insertions(+), 135 deletions(-) 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 index ff2820bb5..f8614d4c6 100644 --- a/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraFragment.java +++ b/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraFragment.java @@ -37,6 +37,7 @@ 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; @@ -102,6 +103,7 @@ public class CameraFragment extends Fragment { 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; @@ -154,6 +156,15 @@ public class CameraFragment extends Fragment { 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[][] { @@ -255,25 +266,42 @@ public void onDestroy() { private void cleanupViewTreeObservers() { try { // Clean up ViewTreeObserver listeners for all views that might have them - if (relativeLayout != null && relativeLayout.getViewTreeObserver().isAlive()) { - // Use a no-op listener to avoid crashes when removing unknown listeners - ViewTreeObserver.OnGlobalLayoutListener noOpListener = () -> {}; - relativeLayout.getViewTreeObserver().removeOnGlobalLayoutListener(noOpListener); + if (relativeLayout != null && relativeLayout.getViewTreeObserver().isAlive() && relativeLayoutListener != null) { + relativeLayout.getViewTreeObserver().removeOnGlobalLayoutListener(relativeLayoutListener); + relativeLayoutListener = null; } if (previewView != null && previewView.getViewTreeObserver().isAlive()) { - ViewTreeObserver.OnGlobalLayoutListener noOpListener = () -> {}; - previewView.getViewTreeObserver().removeOnGlobalLayoutListener(noOpListener); + if (previewViewListener != null) { + previewView.getViewTreeObserver().removeOnGlobalLayoutListener(previewViewListener); + previewViewListener = null; + } + if (previewViewSecondaryListener != null) { + previewView.getViewTreeObserver().removeOnGlobalLayoutListener(previewViewSecondaryListener); + previewViewSecondaryListener = null; + } } if (zoomTabCardView != null && zoomTabCardView.getViewTreeObserver().isAlive()) { - ViewTreeObserver.OnGlobalLayoutListener noOpListener = () -> {}; - zoomTabCardView.getViewTreeObserver().removeOnGlobalLayoutListener(noOpListener); + if (zoomTabCardViewListener != null) { + zoomTabCardView.getViewTreeObserver().removeOnGlobalLayoutListener(zoomTabCardViewListener); + zoomTabCardViewListener = null; + } + if (zoomTabCardViewSecondaryListener != null) { + zoomTabCardView.getViewTreeObserver().removeOnGlobalLayoutListener(zoomTabCardViewSecondaryListener); + zoomTabCardViewSecondaryListener = null; + } } if (filmstripView != null && filmstripView.getViewTreeObserver().isAlive()) { - ViewTreeObserver.OnGlobalLayoutListener noOpListener = () -> {}; - filmstripView.getViewTreeObserver().removeOnGlobalLayoutListener(noOpListener); + 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 @@ -312,6 +340,17 @@ public void onConfigurationChanged(@NonNull Configuration newConfig) { 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(); @@ -375,17 +414,21 @@ public void onConfigurationChanged(@NonNull Configuration newConfig) { 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 - relativeLayout.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { + 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 @@ -420,20 +463,23 @@ public void onGlobalLayout() { previewView.setScaleType(PreviewView.ScaleType.FILL_CENTER); // Add a second layout listener for fine-tuning after the initial layout - previewView.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { + 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); } } @@ -531,14 +577,16 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat cameraExecutor = Executors.newSingleThreadExecutor(); // Use ViewTreeObserver to ensure layout is complete before setting up camera - previewView.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { + 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); } /** @@ -1041,12 +1089,26 @@ private void createFlipButtonForLandscape(FragmentActivity fragmentActivity, int showErrorToast("Capture cancelled due to camera switch"); } + Log.d(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; + Log.d(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()) { - zoomTabLayout.removeAllTabs(); + Log.d(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(); } ); @@ -1396,12 +1458,26 @@ private void createFlipButton(FragmentActivity fragmentActivity, int margin, Col showErrorToast("Capture cancelled due to camera switch"); } + Log.d(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; + Log.d(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()) { - zoomTabLayout.removeAllTabs(); + Log.d(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(); } ); @@ -1617,11 +1693,12 @@ public void onTabReselected(TabLayout.Tab tab) { zoomTabCardView.addView(zoomTabLayout); // Use ViewTreeObserver to ensure zoom tab layout is properly laid out - zoomTabCardView.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { + 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) { @@ -1640,10 +1717,12 @@ public void onGlobalLayout() { } } } - }); + }; + zoomTabCardView.getViewTreeObserver().addOnGlobalLayoutListener(zoomTabCardViewListener); } private void createZoomTabLayoutForLandscape(FragmentActivity fragmentActivity, int margin) { + Log.d(TAG, "Creating zoom tab layout for landscape mode with vertical stacking"); zoomTabCardView = new CardView(fragmentActivity); zoomTabCardView.setId(View.generateViewId()); @@ -1659,106 +1738,146 @@ private void createZoomTabLayoutForLandscape(FragmentActivity fragmentActivity, RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.WRAP_CONTENT ); - // Position it below the flip button - cardViewLayoutParams.addRule(RelativeLayout.BELOW, flipCameraButton.getId()); - cardViewLayoutParams.addRule(RelativeLayout.ABOVE, takePictureButton.getId()); - cardViewLayoutParams.addRule(RelativeLayout.CENTER_HORIZONTAL); + // 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); - controlsContainer.addView(zoomTabCardView); + // Add to the main layout (preview area) instead of the controls container + relativeLayout.addView(zoomTabCardView); + Log.d(TAG, "Added zoom tab card view to main layout (preview area)"); + + // 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); - zoomTabLayout = new TabLayout(fragmentActivity); - zoomTabLayout.setId(View.generateViewId()); - tabLayoutParams = new RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.WRAP_CONTENT); - zoomTabLayout.setLayoutParams(tabLayoutParams); + // Add padding between buttons + int buttonSpacing = dpToPx(fragmentActivity, 8); + verticalZoomContainer.setPadding(buttonSpacing, buttonSpacing, buttonSpacing, buttonSpacing); - // Set TabLayout parameters - zoomTabLayout.setTabGravity(TabLayout.GRAVITY_FILL); - zoomTabLayout.setTabMode(TabLayout.MODE_FIXED); - zoomTabLayout.setSelectedTabIndicatorColor(Color.TRANSPARENT); - zoomTabLayout.setSelectedTabIndicator(null); - zoomTabLayout.setBackgroundColor(Color.TRANSPARENT); - zoomTabLayout.setBackground(null); + zoomTabCardView.addView(verticalZoomContainer); - // Set the listener for tab selection - 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()); - } - } - } + // Store the vertical container for use in createZoomTabsForLandscape + zoomTabLayout = null; // We're not using TabLayout in landscape mode + this.verticalZoomContainer = verticalZoomContainer; - @Override - public void onTabUnselected(TabLayout.Tab tab) { - ZoomTab zoomTab = zoomTabs.get(tab.getPosition()); - zoomTab.setSelected(false); - zoomTab.setTransientZoomLevel(null); - } + Log.d(TAG, "Created vertical zoom container for landscape mode"); + } - @Override - public void onTabReselected(TabLayout.Tab tab) { - ZoomTab zoomTab = zoomTabs.get(tab.getPosition()); + 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; + + Log.d(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 }; + } + } + + Log.d(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) { + Log.d(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); + Log.d(TAG, "Added zoom tab to TabLayout: " + zoomLevel + "x"); + } 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()); } } - } - } - ); - - zoomTabCardView.addView(zoomTabLayout); + }); - // Use ViewTreeObserver to ensure zoom tab layout is properly laid out in landscape mode - zoomTabCardView.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { - @Override - public void onGlobalLayout() { - // Remove the listener to prevent multiple callbacks - zoomTabCardView.getViewTreeObserver().removeOnGlobalLayoutListener(this); - - // 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); - } - } - } - } + verticalContainer.addView(zoomView); + Log.d(TAG, "Added zoom tab to vertical container: " + zoomLevel + "x"); } - }); - } - - private void createZoomTabs(FragmentActivity fragmentActivity, TabLayout tabLayout) { - float[] zoomLevels = { minZoom, 1f, 2f, 5f }; - for (int i = 0; i < zoomLevels.length; i++) { - float zoomLevel = zoomLevels[i]; - ZoomTab zoomTab = new ZoomTab(fragmentActivity, zoomLevel, 40, i); - zoomTabs.add(zoomTab); - TabLayout.Tab tab = tabLayout.newTab(); - tab.setCustomView(zoomTab.getView()); - tabLayout.addTab(tab); + // Track which should be the default selected tab (1x zoom) + if (Math.abs(zoomLevel - 1f) < 0.01f) { + selectedTabIndex = zoomTabs.size() - 1; + } } - tabLayout.selectTab(tabLayout.getTabAt(1)); + Log.d(TAG, "Total zoom tabs created: " + zoomTabs.size()); + + // Select the 1x zoom tab + if (selectedTabIndex >= 0) { + if (tabLayout != null && selectedTabIndex < tabLayout.getTabCount()) { + tabLayout.selectTab(tabLayout.getTabAt(selectedTabIndex)); + Log.d(TAG, "Selected default zoom tab at index: " + selectedTabIndex); + } else if (verticalContainer != null && selectedTabIndex < zoomTabs.size()) { + // For landscape mode, manually select the 1x zoom tab + zoomTabs.get(selectedTabIndex).setSelected(true); + Log.d(TAG, "Selected default zoom tab for landscape at index: " + selectedTabIndex); + } + } } private void createFilmstripView(FragmentActivity fragmentActivity) { @@ -1798,18 +1917,20 @@ private void createFilmstripView(FragmentActivity fragmentActivity) { relativeLayout.addView(filmstripView); // Use ViewTreeObserver to ensure filmstrip is properly laid out - filmstripView.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { + 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() { @@ -1927,18 +2048,20 @@ public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { relativeLayout.addView(filmstripView); // Use ViewTreeObserver to ensure filmstrip is properly laid out in landscape mode - filmstripView.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { + 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() { @@ -2121,8 +2244,17 @@ private void setupCamera() throws IllegalStateException { minZoom = zoomState.getMinZoomRatio(); maxZoom = zoomState.getMaxZoomRatio(); + Log.d(TAG, "Zoom state changed - minZoom: " + minZoom + ", maxZoom: " + maxZoom + ", current zoom tabs: " + zoomTabs.size()); + if (zoomTabs.isEmpty()) { - createZoomTabs(requireActivity(), zoomTabLayout); + Log.d(TAG, "Creating zoom tabs because zoomTabs is empty"); + if (isLandscape && verticalZoomContainer != null) { + createZoomTabsForLandscape(requireActivity(), verticalZoomContainer); + } else if (zoomTabLayout != null) { + createZoomTabs(requireActivity(), zoomTabLayout); + } + } else { + Log.d(TAG, "Not creating zoom tabs because zoomTabs is not empty (" + zoomTabs.size() + " tabs exist)"); } if (zoomRunnable != null) { @@ -2146,12 +2278,26 @@ private void setupCamera() throws IllegalStateException { // If we found a closest tab, update its display and select the tab. if (closestTab != null) { - TabLayout.Tab tab = zoomTabLayout.getTabAt(closestTab.getTabIndex()); - if (tab != null) { - closestTab.setTransientZoomLevel(currentZoom); // Update the tab's display to show the current zoom level + 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); - zoomTabLayout.selectTab(tab); // This will not trigger the camera zoom change due to the isSnappingZoom flag + // 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); + } } } }; 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 index ad5c58a34..a861d3ed7 100644 --- a/camera/android/src/main/java/com/capacitorjs/plugins/camera/ImagePreviewFragment.java +++ b/camera/android/src/main/java/com/capacitorjs/plugins/camera/ImagePreviewFragment.java @@ -33,6 +33,9 @@ */ 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; @@ -59,8 +62,10 @@ public static ImagePreviewFragment newInstance(Uri uri) { */ public static ImagePreviewFragment newInstance(List imageUris, int position) { ImagePreviewFragment fragment = new ImagePreviewFragment(); - fragment.imageUris = new ArrayList<>(imageUris); - fragment.currentPosition = position; + Bundle args = new Bundle(); + args.putParcelableArrayList(ARG_IMAGE_URIS, new ArrayList<>(imageUris)); + args.putInt(ARG_CURRENT_POSITION, position); + fragment.setArguments(args); return fragment; } @@ -68,6 +73,17 @@ public static ImagePreviewFragment newInstance(List imageUris, int position 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 @@ -85,12 +101,12 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c 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) @@ -98,27 +114,27 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c 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), + + 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 @@ -143,7 +159,7 @@ public void onPageSelected(int position) { return rootLayout; } - + private void updatePositionIndicator(int position) { if (positionIndicator != null) { positionIndicator.setText((position + 1) + " / " + imageUris.size()); @@ -206,7 +222,7 @@ private void loadFullResolutionImage(Uri uri, ImageView imageView, ProgressBar p 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[][] { @@ -219,58 +235,67 @@ private static ColorStateList createButtonColorList() { 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(); - fragment.imageUri = uri; + 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( @@ -278,16 +303,16 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c 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 { From 1f6f1011096ccfc2d650220d4e3b11b9e72a9600 Mon Sep 17 00:00:00 2001 From: Shiva Prasad Date: Fri, 1 Aug 2025 21:17:11 +0700 Subject: [PATCH 14/19] feat(camera): safe area awareness for android multi camera --- .../plugins/camera/CameraFragment.java | 225 +++++++++++++++--- 1 file changed, 192 insertions(+), 33 deletions(-) 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 index f8614d4c6..577acb52a 100644 --- a/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraFragment.java +++ b/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraFragment.java @@ -33,7 +33,9 @@ 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; @@ -74,6 +76,21 @@ 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 @@ -121,6 +138,12 @@ public class CameraFragment extends Fragment { 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; @@ -320,6 +343,91 @@ 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()); + + Log.d(TAG, "Display cutout detected - Top: " + safeInsetTop + + ", Bottom: " + safeInsetBottom + + ", Left: " + safeInsetLeft + + ", Right: " + safeInsetRight); + } else { + Log.d(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); + + Log.d(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"; + Log.d(TAG, "Safe Area Status - Orientation: " + orientation + + ", Safe Insets - Top: " + safeInsetTop + + ", Bottom: " + safeInsetBottom + + ", Left: " + safeInsetLeft + + ", Right: " + safeInsetRight); + + if (isLandscape && safeInsetRight > dpToPx(requireContext(), 16)) { + Log.i(TAG, "Landscape mode with significant right inset detected - likely camera cutout area"); + } + } + @Override public void onConfigurationChanged(@NonNull Configuration newConfig) { super.onConfigurationChanged(newConfig); @@ -327,6 +435,9 @@ public void onConfigurationChanged(@NonNull Configuration 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) { @@ -379,7 +490,9 @@ public void onConfigurationChanged(@NonNull Configuration newConfig) { if (isLandscape) { // In landscape mode, create a container for controls on the right side - createControlsContainerForLandscape(fragmentActivity, buttonColors, margin); + // 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); @@ -493,6 +606,9 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c // 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 @@ -515,7 +631,9 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c if (isLandscape) { // In landscape mode, create a container for controls on the right side - createControlsContainerForLandscape(fragmentActivity, buttonColors, margin); + // 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); @@ -554,6 +672,15 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c 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); @@ -848,28 +975,28 @@ private Bitmap processBitmap(Bitmap originalBitmap, Uri imageUri) { ExifWrapper exif = ImageUtils.getExifData(getContext(), processedBitmap, imageUri); boolean wasProcessed = false; - // Apply orientation correction (only if explicitly enabled) - if (cameraSettings.isShouldCorrectOrientation()) { - Bitmap correctedBitmap = ImageUtils.correctOrientation(getContext(), processedBitmap, imageUri, exif); - if (correctedBitmap != processedBitmap && correctedBitmap != null) { - Log.d(TAG, "Applied orientation correction"); + // 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) { + Log.d(TAG, "Applied resizing to " + cameraSettings.getWidth() + "x" + cameraSettings.getHeight()); if (processedBitmap != originalBitmap) { processedBitmap.recycle(); } - processedBitmap = correctedBitmap; + processedBitmap = resizedBitmap; wasProcessed = true; } } - // 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) { - Log.d(TAG, "Applied resizing to " + cameraSettings.getWidth() + "x" + cameraSettings.getHeight()); + // Apply orientation correction (only if explicitly enabled) + if (cameraSettings.isShouldCorrectOrientation()) { + Bitmap correctedBitmap = ImageUtils.correctOrientation(getContext(), processedBitmap, imageUri, exif); + if (correctedBitmap != processedBitmap && correctedBitmap != null) { + Log.d(TAG, "Applied orientation correction"); if (processedBitmap != originalBitmap) { processedBitmap.recycle(); } - processedBitmap = resizedBitmap; + processedBitmap = correctedBitmap; wasProcessed = true; } } @@ -917,9 +1044,17 @@ private void createTakePictureButtonForLandscape(FragmentActivity fragmentActivi RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.WRAP_CONTENT ); - // Position in the center of the right side + + // 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( @@ -1074,10 +1209,13 @@ private void createFlipButtonForLandscape(FragmentActivity fragmentActivity, int RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.WRAP_CONTENT ); - // Position at the bottom right + // Position at the bottom center of controls container with safe area margins flipButtonLayoutParams.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM); flipButtonLayoutParams.addRule(RelativeLayout.CENTER_HORIZONTAL); - flipButtonLayoutParams.setMargins(0, 0, 0, margin * 2); + + // 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 -> { @@ -1125,10 +1263,13 @@ private void createDoneButtonForLandscape(FragmentActivity fragmentActivity, int RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.WRAP_CONTENT ); - // Position at the top right + // Position at the top center of controls container with safe area margins doneButtonLayoutParams.addRule(RelativeLayout.ALIGN_PARENT_TOP); doneButtonLayoutParams.addRule(RelativeLayout.CENTER_HORIZONTAL); - doneButtonLayoutParams.setMargins(0, margin * 2, 0, 0); + + // 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 -> { @@ -1149,10 +1290,14 @@ private void createCloseButtonForLandscape(FragmentActivity fragmentActivity, in RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.WRAP_CONTENT ); - // Position at the top left of the preview area + // 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); - closeButtonLayoutParams.setMargins(margin * 2, margin * 2, 0, 0); + + // 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 -> { @@ -1184,10 +1329,14 @@ private void createFlashButtonForLandscape(FragmentActivity fragmentActivity, in RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.WRAP_CONTENT ); - // Position at the bottom left of the preview area + // 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); - flashButtonLayoutParams.setMargins(margin * 2, 0, 0, margin * 2); + + // 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 -> { @@ -1493,13 +1642,23 @@ private void createControlsContainerForLandscape(FragmentActivity fragmentActivi controlsContainer.setId(View.generateViewId()); controlsContainer.setBackgroundColor(Color.BLACK); - // Set the container to take up the right 20% of the screen - int containerWidth = (int) (displayMetrics.widthPixels * 0.2); + // 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 @@ -1525,8 +1684,8 @@ private void createControlsContainerForLandscape(FragmentActivity fragmentActivi previewParams.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM); previewParams.addRule(RelativeLayout.LEFT_OF, controlsContainer.getId()); - // Remove any margins - previewParams.setMargins(0, 0, 0, 0); + // 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 @@ -1536,16 +1695,16 @@ private void createControlsContainerForLandscape(FragmentActivity fragmentActivi previewView.setBackgroundColor(Color.BLACK); // Create camera control buttons for landscape mode that go in the right container - createTakePictureButtonForLandscape(fragmentActivity, margin, buttonColors); - createFlipButtonForLandscape(fragmentActivity, margin, buttonColors); - createDoneButtonForLandscape(fragmentActivity, margin, buttonColors); + 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, margin, buttonColors); - createFlashButtonForLandscape(fragmentActivity, margin, buttonColors); + createCloseButtonForLandscape(fragmentActivity, safeMargin, buttonColors); + createFlashButtonForLandscape(fragmentActivity, safeMargin, buttonColors); // Create zoom tabs for landscape mode - createZoomTabLayoutForLandscape(fragmentActivity, margin); + createZoomTabLayoutForLandscape(fragmentActivity, safeMargin); // Create filmstrip for landscape mode createFilmstripViewForLandscape(fragmentActivity); From c4b5d37dccda4adbf0a63441c559efc7cd04bcbf Mon Sep 17 00:00:00 2001 From: Shiva Prasad Date: Fri, 1 Aug 2025 21:44:29 +0700 Subject: [PATCH 15/19] chore(camera): cleanup camera fragment logs and switch to capacitor logger --- .../plugins/camera/CameraFragment.java | 164 ++++++++---------- 1 file changed, 73 insertions(+), 91 deletions(-) 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 index 577acb52a..6e266b1ab 100644 --- a/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraFragment.java +++ b/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraFragment.java @@ -58,6 +58,7 @@ 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; @@ -78,14 +79,14 @@ @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 @@ -207,7 +208,6 @@ public void onCreate(@Nullable Bundle savedInstanceState) { // Initialize simple HashMap for image storage imageCache = new HashMap<>(); - Log.d(TAG, "Initialized HashMap for image cache"); zoomTabs = new ArrayList<>(); zoomHandler = new Handler(requireActivity().getMainLooper()); @@ -252,7 +252,7 @@ public void onDestroy() { imageCache.clear(); imageCache = null; } catch (Exception e) { - Log.e(TAG, "Error clearing image cache", e); + Logger.error(TAG, "Error clearing image cache", e); imageCache = null; } } @@ -328,7 +328,7 @@ private void cleanupViewTreeObservers() { } } catch (Exception e) { // Log but don't crash if there's an issue cleaning up listeners - Log.e(TAG, "Error cleaning up ViewTreeObserver listeners", e); + Logger.error(TAG, "Error cleaning up ViewTreeObserver listeners", e); } } @@ -363,12 +363,12 @@ private void calculateSafeAreaInsets() { safeInsetLeft = Math.max(safeInsetLeft, displayCutout.getSafeInsetLeft()); safeInsetRight = Math.max(safeInsetRight, displayCutout.getSafeInsetRight()); - Log.d(TAG, "Display cutout detected - Top: " + safeInsetTop + + Logger.debug(TAG, "Display cutout detected - Top: " + safeInsetTop + ", Bottom: " + safeInsetBottom + ", Left: " + safeInsetLeft + ", Right: " + safeInsetRight); } else { - Log.d(TAG, "No display cutout detected"); + Logger.debug(TAG, "No display cutout detected"); } // Also check for system window insets as fallback @@ -389,11 +389,11 @@ private void calculateSafeAreaInsets() { safeInsetLeft = Math.max(safeInsetLeft, minSafeMargin); safeInsetRight = Math.max(safeInsetRight, minSafeMargin); - Log.d(TAG, "Final safe area insets - Top: " + safeInsetTop + + Logger.debug(TAG, "Final safe area insets - Top: " + safeInsetTop + ", Bottom: " + safeInsetBottom + ", Left: " + safeInsetLeft + ", Right: " + safeInsetRight); - + // Log safe area status for debugging logSafeAreaStatus(); } @@ -417,14 +417,14 @@ private int getSafeControlMargin(int baseMargin) { */ private void logSafeAreaStatus() { String orientation = isLandscape ? "LANDSCAPE" : "PORTRAIT"; - Log.d(TAG, "Safe Area Status - Orientation: " + orientation + - ", Safe Insets - Top: " + safeInsetTop + - ", Bottom: " + safeInsetBottom + - ", Left: " + safeInsetLeft + + Logger.debug(TAG, "Safe Area Status - Orientation: " + orientation + + ", Safe Insets - Top: " + safeInsetTop + + ", Bottom: " + safeInsetBottom + + ", Left: " + safeInsetLeft + ", Right: " + safeInsetRight); - + if (isLandscape && safeInsetRight > dpToPx(requireContext(), 16)) { - Log.i(TAG, "Landscape mode with significant right inset detected - likely camera cutout area"); + Logger.info(TAG, "Landscape mode with significant right inset detected - likely camera cutout area"); } } @@ -435,7 +435,7 @@ public void onConfigurationChanged(@NonNull Configuration 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(); @@ -725,15 +725,12 @@ public void onGlobalLayout() { private void addImageToCache(Uri uri, Bitmap bitmap) { if (uri != null && bitmap != null && !bitmap.isRecycled() && imageCache != null) { try { - int bitmapSizeKB = bitmap.getByteCount() / 1024; - Log.d(TAG, "Adding image to cache: " + uri + ", bitmap size: " + bitmap.getWidth() + "x" + bitmap.getHeight() + " (" + bitmapSizeKB + " KB)"); imageCache.put(uri, bitmap); - Log.d(TAG, "Cache size after adding: " + imageCache.size() + " images"); } catch (Exception e) { - Log.e(TAG, "Error adding image to cache", e); + Logger.error(TAG, "Error adding image to cache", e); } } else { - Log.w(TAG, "Failed to add image to cache - uri: " + uri + ", bitmap: " + bitmap + ", imageCache: " + imageCache); + Logger.warn(TAG, "Failed to add image to cache - uri: " + uri + ", bitmap: " + bitmap + ", imageCache: " + imageCache); } } @@ -755,14 +752,11 @@ private Bitmap getImageFromCache(Uri uri) { private HashMap getAllCachedImages() { HashMap result = new HashMap<>(); if (imageCache != null) { - Log.d(TAG, "getAllCachedImages: cache size = " + imageCache.size()); // Copy all entries from the cache for (Map.Entry entry : imageCache.entrySet()) { - Log.d(TAG, "Adding cached image: " + entry.getKey()); result.put(entry.getKey(), entry.getValue()); } } - Log.d(TAG, "getAllCachedImages: returning " + result.size() + " images"); return result; } @@ -784,12 +778,12 @@ private void cancel() { for (Map.Entry image : getAllCachedImages().entrySet()) { if (!deleteFile(image.getKey())) { failedDeletions++; - Log.w(TAG, "Failed to delete image during cancel: " + image.getKey()); + Logger.warn(TAG, "Failed to delete image during cancel: " + image.getKey()); } } if (failedDeletions > 0) { - Log.w(TAG, "Failed to delete " + failedDeletions + " images during cancel"); + Logger.warn(TAG, "Failed to delete " + failedDeletions + " images during cancel"); // We still proceed with cancellation even if some deletions failed } @@ -802,7 +796,7 @@ private void cancel() { private void done() { // Check if there are still images being processed if (thumbnailAdapter != null && thumbnailAdapter.hasLoadingThumbnails()) { - Log.d(TAG, "Images still processing, showing spinner overlay"); + Logger.debug(TAG, "Images still processing, showing spinner overlay"); // Show non-dismissable spinner while processing showProcessingOverlay(); @@ -825,7 +819,7 @@ public void run() { }; processingHandler.post(processingRunnable); } else { - Log.d(TAG, "No images processing, proceeding immediately"); + Logger.debug(TAG, "No images processing, proceeding immediately"); // No processing needed, proceed immediately finalizeDone(); } @@ -846,13 +840,13 @@ private void closeFragment() { if (getActivity() != null && !getActivity().isFinishing() && isAdded()) { requireActivity().getSupportFragmentManager().beginTransaction().remove(this).commit(); } else { - Log.w(TAG, "Cannot close fragment: activity is null, finishing, or fragment not added"); + 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 - Log.e(TAG, "Error closing fragment", e); + Logger.error(TAG, "Error closing fragment", e); } catch (Exception e) { - Log.e(TAG, "Unexpected error closing fragment", e); + Logger.error(TAG, "Unexpected error closing fragment", e); } } @@ -915,15 +909,13 @@ private void createProcessingOverlay(FragmentActivity fragmentActivity) { // Add the overlay to main layout relativeLayout.addView(processingOverlay); - - Log.d(TAG, "Processing overlay created and hidden"); } /** * Shows the processing overlay */ private void showProcessingOverlay() { - Log.d(TAG, "Showing processing overlay"); + Logger.debug(TAG, "Showing processing overlay"); if (processingOverlay != null) { processingOverlay.setVisibility(View.VISIBLE); processingOverlay.bringToFront(); @@ -959,13 +951,13 @@ public void setCameraSettings(CameraSettings settings) { */ private Bitmap processBitmap(Bitmap originalBitmap, Uri imageUri) { if (originalBitmap == null || originalBitmap.isRecycled()) { - Log.w(TAG, "Cannot process null or recycled bitmap"); + Logger.warn(TAG, "Cannot process null or recycled bitmap"); return null; } // If no settings are available, return original bitmap if (cameraSettings == null) { - Log.d(TAG, "No camera settings available, returning original bitmap"); + Logger.debug(TAG, "No camera settings available, returning original bitmap"); return originalBitmap; } @@ -979,7 +971,7 @@ private Bitmap processBitmap(Bitmap originalBitmap, Uri imageUri) { if (cameraSettings.isShouldResize() && cameraSettings.getWidth() > 0 && cameraSettings.getHeight() > 0) { Bitmap resizedBitmap = ImageUtils.resize(processedBitmap, cameraSettings.getWidth(), cameraSettings.getHeight()); if (resizedBitmap != processedBitmap && resizedBitmap != null) { - Log.d(TAG, "Applied resizing to " + cameraSettings.getWidth() + "x" + cameraSettings.getHeight()); + Logger.debug(TAG, "Applied resizing to " + cameraSettings.getWidth() + "x" + cameraSettings.getHeight()); if (processedBitmap != originalBitmap) { processedBitmap.recycle(); } @@ -992,7 +984,7 @@ private Bitmap processBitmap(Bitmap originalBitmap, Uri imageUri) { if (cameraSettings.isShouldCorrectOrientation()) { Bitmap correctedBitmap = ImageUtils.correctOrientation(getContext(), processedBitmap, imageUri, exif); if (correctedBitmap != processedBitmap && correctedBitmap != null) { - Log.d(TAG, "Applied orientation correction"); + Logger.debug(TAG, "Applied orientation correction"); if (processedBitmap != originalBitmap) { processedBitmap.recycle(); } @@ -1002,13 +994,13 @@ private Bitmap processBitmap(Bitmap originalBitmap, Uri imageUri) { } if (wasProcessed) { - Log.d(TAG, "Bitmap processed: " + originalBitmap.getWidth() + "x" + originalBitmap.getHeight() + + Logger.debug(TAG, "Bitmap processed: " + originalBitmap.getWidth() + "x" + originalBitmap.getHeight() + " -> " + processedBitmap.getWidth() + "x" + processedBitmap.getHeight()); } return processedBitmap; } catch (Exception e) { - Log.e(TAG, "Error processing bitmap", e); + Logger.error(TAG, "Error processing bitmap", e); // If processing fails, return original bitmap and don't crash return originalBitmap; } @@ -1094,7 +1086,7 @@ public void onImageSaved(@NonNull ImageCapture.OutputFileResults outputFileResul try { stream = requireContext().getContentResolver().openInputStream(savedImageUri); if (stream == null) { - Log.e(TAG, "Failed to open input stream for saved image: " + savedImageUri); + Logger.error(TAG, "Failed to open input stream for saved image: " + savedImageUri, null); showErrorToast("Failed to process captured image"); return; } @@ -1104,7 +1096,7 @@ public void onImageSaved(@NonNull ImageCapture.OutputFileResults outputFileResul Bitmap bmp = BitmapFactory.decodeStream(stream, null, options); if (bmp == null) { - Log.e(TAG, "Failed to decode bitmap from saved image: " + savedImageUri); + Logger.error(TAG, "Failed to decode bitmap from saved image: " + savedImageUri, null); showErrorToast("Failed to process captured image"); return; } @@ -1130,10 +1122,10 @@ public void onImageSaved(@NonNull ImageCapture.OutputFileResults outputFileResul }); } } catch (FileNotFoundException e) { - Log.e(TAG, "File not found for saved image: " + savedImageUri, e); + Logger.error(TAG, "File not found for saved image: " + savedImageUri, e); showErrorToast("Image file not found"); } catch (OutOfMemoryError e) { - Log.e(TAG, "Out of memory when processing image: " + savedImageUri, 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) { @@ -1141,19 +1133,19 @@ public void onImageSaved(@NonNull ImageCapture.OutputFileResults outputFileResul } System.gc(); // Request garbage collection } catch (Exception e) { - Log.e(TAG, "Error processing saved image: " + savedImageUri, e); + Logger.error(TAG, "Error processing saved image: " + savedImageUri, e); showErrorToast("Error processing image"); } finally { if (stream != null) { try { stream.close(); } catch (IOException e) { - Log.e(TAG, "Error closing input stream", e); + Logger.error(TAG, "Error closing input stream", e); } } } } else { - Log.e(TAG, "Saved image URI is null"); + Logger.error(TAG, "Saved image URI is null", null); showErrorToast("Failed to save image"); } } @@ -1181,7 +1173,7 @@ public void onError(@NonNull ImageCaptureException exception) { break; } - Log.e(TAG, "Image capture error: " + errorMessage, exception); + Logger.error(TAG, "Image capture error: " + errorMessage, exception); // Remove any loading thumbnails since capture failed requireActivity().runOnUiThread(() -> { @@ -1227,13 +1219,13 @@ private void createFlipButtonForLandscape(FragmentActivity fragmentActivity, int showErrorToast("Capture cancelled due to camera switch"); } - Log.d(TAG, "Switching camera from " + (lensFacing == CameraSelector.LENS_FACING_FRONT ? "FRONT" : "BACK")); + 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; - Log.d(TAG, "Switched camera to " + (lensFacing == CameraSelector.LENS_FACING_FRONT ? "FRONT" : "BACK")); + 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()) { - Log.d(TAG, "Clearing " + zoomTabs.size() + " zoom tabs"); + Logger.debug(TAG, "Clearing " + zoomTabs.size() + " zoom tabs"); if (zoomTabLayout != null) { zoomTabLayout.removeAllTabs(); } @@ -1478,7 +1470,7 @@ public void onImageSaved(@NonNull ImageCapture.OutputFileResults outputFileResul try { stream = requireContext().getContentResolver().openInputStream(savedImageUri); if (stream == null) { - Log.e(TAG, "Failed to open input stream for saved image: " + savedImageUri); + Logger.error(TAG, "Failed to open input stream for saved image: " + savedImageUri, null); showErrorToast("Failed to process captured image"); return; } @@ -1488,7 +1480,7 @@ public void onImageSaved(@NonNull ImageCapture.OutputFileResults outputFileResul Bitmap bmp = BitmapFactory.decodeStream(stream, null, options); if (bmp == null) { - Log.e(TAG, "Failed to decode bitmap from saved image: " + savedImageUri); + Logger.error(TAG, "Failed to decode bitmap from saved image: " + savedImageUri, null); showErrorToast("Failed to process captured image"); return; } @@ -1514,10 +1506,10 @@ public void onImageSaved(@NonNull ImageCapture.OutputFileResults outputFileResul }); } } catch (FileNotFoundException e) { - Log.e(TAG, "File not found for saved image: " + savedImageUri, e); + Logger.error(TAG, "File not found for saved image: " + savedImageUri, e); showErrorToast("Image file not found"); } catch (OutOfMemoryError e) { - Log.e(TAG, "Out of memory when processing image: " + savedImageUri, 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) { @@ -1525,19 +1517,19 @@ public void onImageSaved(@NonNull ImageCapture.OutputFileResults outputFileResul } System.gc(); // Request garbage collection } catch (Exception e) { - Log.e(TAG, "Error processing saved image: " + savedImageUri, e); + Logger.error(TAG, "Error processing saved image: " + savedImageUri, e); showErrorToast("Error processing image"); } finally { if (stream != null) { try { stream.close(); } catch (IOException e) { - Log.e(TAG, "Error closing input stream", e); + Logger.error(TAG, "Error closing input stream", e); } } } } else { - Log.e(TAG, "Saved image URI is null"); + Logger.error(TAG, "Saved image URI is null", null); showErrorToast("Failed to save image"); } } @@ -1565,7 +1557,7 @@ public void onError(@NonNull ImageCaptureException exception) { break; } - Log.e(TAG, "Image capture error: " + errorMessage, exception); + Logger.error(TAG, "Image capture error: " + errorMessage, exception); // Remove any loading thumbnails since capture failed requireActivity().runOnUiThread(() -> { @@ -1607,13 +1599,13 @@ private void createFlipButton(FragmentActivity fragmentActivity, int margin, Col showErrorToast("Capture cancelled due to camera switch"); } - Log.d(TAG, "Switching camera from " + (lensFacing == CameraSelector.LENS_FACING_FRONT ? "FRONT" : "BACK")); + 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; - Log.d(TAG, "Switched camera to " + (lensFacing == CameraSelector.LENS_FACING_FRONT ? "FRONT" : "BACK")); + 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()) { - Log.d(TAG, "Clearing " + zoomTabs.size() + " zoom tabs"); + Logger.debug(TAG, "Clearing " + zoomTabs.size() + " zoom tabs"); if (zoomTabLayout != null) { zoomTabLayout.removeAllTabs(); } @@ -1881,7 +1873,6 @@ public void onGlobalLayout() { } private void createZoomTabLayoutForLandscape(FragmentActivity fragmentActivity, int margin) { - Log.d(TAG, "Creating zoom tab layout for landscape mode with vertical stacking"); zoomTabCardView = new CardView(fragmentActivity); zoomTabCardView.setId(View.generateViewId()); @@ -1905,7 +1896,6 @@ private void createZoomTabLayoutForLandscape(FragmentActivity fragmentActivity, // Add to the main layout (preview area) instead of the controls container relativeLayout.addView(zoomTabCardView); - Log.d(TAG, "Added zoom tab card view to main layout (preview area)"); // Create a LinearLayout with vertical orientation instead of TabLayout for landscape mode LinearLayout verticalZoomContainer = new LinearLayout(fragmentActivity); @@ -1929,8 +1919,6 @@ private void createZoomTabLayoutForLandscape(FragmentActivity fragmentActivity, // Store the vertical container for use in createZoomTabsForLandscape zoomTabLayout = null; // We're not using TabLayout in landscape mode this.verticalZoomContainer = verticalZoomContainer; - - Log.d(TAG, "Created vertical zoom container for landscape mode"); } private void createZoomTabs(FragmentActivity fragmentActivity, TabLayout tabLayout) { @@ -1946,7 +1934,7 @@ private void createZoomTabsForLandscape(FragmentActivity fragmentActivity, Linea private void createZoomTabsInternal(FragmentActivity fragmentActivity, TabLayout tabLayout, LinearLayout verticalContainer) { float[] zoomLevels; - Log.d(TAG, "Creating zoom tabs for camera facing: " + (lensFacing == CameraSelector.LENS_FACING_FRONT ? "FRONT" : "BACK") + + 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 @@ -1961,14 +1949,14 @@ private void createZoomTabsInternal(FragmentActivity fragmentActivity, TabLayout } } - Log.d(TAG, "Zoom levels to create: " + java.util.Arrays.toString(zoomLevels)); + 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) { - Log.d(TAG, "Skipping zoom level " + zoomLevel + " because it exceeds maxZoom " + maxZoom); + Logger.debug(TAG, "Skipping zoom level " + zoomLevel + " because it exceeds maxZoom " + maxZoom); continue; } @@ -1982,7 +1970,6 @@ private void createZoomTabsInternal(FragmentActivity fragmentActivity, TabLayout TabLayout.Tab tab = tabLayout.newTab(); tab.setCustomView(zoomTab.getView()); tabLayout.addTab(tab); - Log.d(TAG, "Added zoom tab to TabLayout: " + zoomLevel + "x"); } else if (verticalContainer != null) { // Landscape mode - add to vertical LinearLayout View zoomView = zoomTab.getView(); @@ -2015,7 +2002,6 @@ private void createZoomTabsInternal(FragmentActivity fragmentActivity, TabLayout }); verticalContainer.addView(zoomView); - Log.d(TAG, "Added zoom tab to vertical container: " + zoomLevel + "x"); } // Track which should be the default selected tab (1x zoom) @@ -2024,17 +2010,13 @@ private void createZoomTabsInternal(FragmentActivity fragmentActivity, TabLayout } } - Log.d(TAG, "Total zoom tabs created: " + zoomTabs.size()); - // Select the 1x zoom tab if (selectedTabIndex >= 0) { if (tabLayout != null && selectedTabIndex < tabLayout.getTabCount()) { tabLayout.selectTab(tabLayout.getTabAt(selectedTabIndex)); - Log.d(TAG, "Selected default zoom tab at index: " + selectedTabIndex); } else if (verticalContainer != null && selectedTabIndex < zoomTabs.size()) { // For landscape mode, manually select the 1x zoom tab zoomTabs.get(selectedTabIndex).setSelected(true); - Log.d(TAG, "Selected default zoom tab for landscape at index: " + selectedTabIndex); } } } @@ -2101,7 +2083,7 @@ public void onThumbnailRemoved(Uri uri, Bitmap bmp) { } if (!deleteFile(uri)) { - Log.w(TAG, "Failed to delete file after thumbnail removal: " + 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 } @@ -2231,7 +2213,7 @@ public void onThumbnailRemoved(Uri uri, Bitmap bmp) { } if (!deleteFile(uri)) { - Log.w(TAG, "Failed to delete file after thumbnail removal in landscape mode: " + 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 } @@ -2313,7 +2295,7 @@ private void createCloseButton(FragmentActivity fragmentActivity, int margin, Co */ private boolean deleteFile(Uri fileUri) { if (fileUri == null) { - Log.e(TAG, "Cannot delete null URI"); + Logger.error(TAG, "Cannot delete null URI", null); return false; } @@ -2323,25 +2305,25 @@ private boolean deleteFile(Uri fileUri) { if (deleted == 0) { // File deletion failed - Log.e(TAG, "Failed to delete file: " + fileUri); + Logger.error(TAG, "Failed to delete file: " + fileUri, null); return false; } else { // File deletion successful - Log.i(TAG, "File deleted: " + fileUri); + Logger.info(TAG, "File deleted: " + fileUri); return true; } } catch (SecurityException e) { // Handle permission issues - Log.e(TAG, "Security exception when deleting file: " + fileUri, e); + Logger.error(TAG, "Security exception when deleting file: " + fileUri, e); showErrorToast("Permission denied to delete image"); return false; } catch (IllegalArgumentException e) { // Handle invalid URI - Log.e(TAG, "Invalid URI when deleting file: " + fileUri, e); + Logger.error(TAG, "Invalid URI when deleting file: " + fileUri, e); return false; } catch (Exception e) { // Handle any other exceptions - Log.e(TAG, "Error deleting file: " + fileUri, e); + Logger.error(TAG, "Error deleting file: " + fileUri, e); return false; } } @@ -2403,17 +2385,17 @@ private void setupCamera() throws IllegalStateException { minZoom = zoomState.getMinZoomRatio(); maxZoom = zoomState.getMaxZoomRatio(); - Log.d(TAG, "Zoom state changed - minZoom: " + minZoom + ", maxZoom: " + maxZoom + ", current zoom tabs: " + zoomTabs.size()); + Logger.debug(TAG, "Zoom state changed - minZoom: " + minZoom + ", maxZoom: " + maxZoom + ", current zoom tabs: " + zoomTabs.size()); if (zoomTabs.isEmpty()) { - Log.d(TAG, "Creating zoom tabs because zoomTabs is empty"); + 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 { - Log.d(TAG, "Not creating zoom tabs because zoomTabs is not empty (" + zoomTabs.size() + " tabs exist)"); + Logger.debug(TAG, "Not creating zoom tabs because zoomTabs is not empty (" + zoomTabs.size() + " tabs exist)"); } if (zoomRunnable != null) { @@ -2511,7 +2493,7 @@ private void showErrorToast(String message) { ).show(); } catch (Exception e) { // Fail silently if we can't show a toast - Log.e(TAG, "Failed to show error toast: " + message, e); + Logger.error(TAG, "Failed to show error toast: " + message, e); } }); } @@ -2549,7 +2531,7 @@ private Bitmap getThumbnail(Uri imageUri) { return contentResolver.loadThumbnail(imageUri, size, null); } catch (IOException e) { // Fall back to manual downsampling if the built-in method fails - Log.w(TAG, "Failed to load thumbnail with system API, falling back to manual downsampling", e); + Logger.warn(TAG, "Failed to load thumbnail with system API, falling back to manual downsampling"); // Continue to manual downsampling below } } @@ -2580,7 +2562,7 @@ private Bitmap getThumbnail(Uri imageUri) { return thumbnail; } catch (Exception e) { - Log.e(TAG, "Error creating thumbnail", e); + Logger.error(TAG, "Error creating thumbnail", e); // Last resort fallback for older devices if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { @@ -2604,7 +2586,7 @@ private Bitmap getThumbnail(Uri imageUri) { cursor.close(); } } catch (Exception ex) { - Log.e(TAG, "Error in thumbnail fallback", ex); + Logger.error(TAG, "Error in thumbnail fallback", ex); } } @@ -2615,7 +2597,7 @@ private Bitmap getThumbnail(Uri imageUri) { try { inputStream.close(); } catch (IOException e) { - Log.e(TAG, "Error closing input stream", e); + Logger.error(TAG, "Error closing input stream", e); } } } From c62fd7eb6d3b85688706df8f9160745e21e6f1fb Mon Sep 17 00:00:00 2001 From: Shiva Prasad Date: Fri, 1 Aug 2025 22:12:28 +0700 Subject: [PATCH 16/19] feat(camera): improved thumbnail loading ux, fixed saveToGallery option --- .../Sources/CameraPlugin/CameraPlugin.swift | 20 +- .../MultiCameraViewController.swift | 259 +++++++++++++++--- 2 files changed, 243 insertions(+), 36 deletions(-) diff --git a/camera/ios/Sources/CameraPlugin/CameraPlugin.swift b/camera/ios/Sources/CameraPlugin/CameraPlugin.swift index 224b28296..0a02f765e 100644 --- a/camera/ios/Sources/CameraPlugin/CameraPlugin.swift +++ b/camera/ios/Sources/CameraPlugin/CameraPlugin.swift @@ -647,7 +647,25 @@ extension CameraPlugin: MultiCameraViewControllerDelegate { processedImages.append(processedImage) } - self.returnImages(processedImages) + // 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) + } } } diff --git a/camera/ios/Sources/CameraPlugin/MultiCameraViewController.swift b/camera/ios/Sources/CameraPlugin/MultiCameraViewController.swift index 3facbbf9f..55f8e2e27 100644 --- a/camera/ios/Sources/CameraPlugin/MultiCameraViewController.swift +++ b/camera/ios/Sources/CameraPlugin/MultiCameraViewController.swift @@ -1,6 +1,7 @@ import UIKit import AVFoundation import Photos +import Capacitor // MARK: - ThumbnailCell class ThumbnailCell: UICollectionViewCell { @@ -15,6 +16,14 @@ class ThumbnailCell: UICollectionViewCell { 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) @@ -37,6 +46,7 @@ class ThumbnailCell: UICollectionViewCell { private func setupUI() { contentView.addSubview(imageView) + contentView.addSubview(loadingIndicator) contentView.addSubview(deleteButton) NSLayoutConstraint.activate([ @@ -45,6 +55,9 @@ class ThumbnailCell: UICollectionViewCell { 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), @@ -56,6 +69,15 @@ class ThumbnailCell: UICollectionViewCell { 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() { @@ -65,10 +87,41 @@ class ThumbnailCell: UICollectionViewCell { // MARK: - ImagePreviewViewController class ImagePreviewViewController: UIViewController { - private let previewImage: UIImage + 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 + }() - init(image: UIImage) { - self.previewImage = image + 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) } @@ -80,18 +133,29 @@ class ImagePreviewViewController: UIViewController { super.viewDidLoad() view.backgroundColor = .black + setupUI() + updatePositionIndicator() + } - // Setup image view - let imageView = UIImageView(image: previewImage) - imageView.contentMode = .scaleAspectFit - imageView.translatesAutoresizingMaskIntoConstraints = false + 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) - view.addSubview(imageView) NSLayoutConstraint.activate([ - imageView.topAnchor.constraint(equalTo: view.topAnchor), - imageView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - imageView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - imageView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + 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 @@ -108,6 +172,24 @@ class ImagePreviewViewController: UIViewController { 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() { @@ -115,6 +197,68 @@ class ImagePreviewViewController: UIViewController { } } +// 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) @@ -140,6 +284,7 @@ class MultiCameraViewController: UIViewController { 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 @@ -745,7 +890,7 @@ class MultiCameraViewController: UIViewController { device.unlockForConfiguration() } catch { - print("Could not set zoom factor: \(error.localizedDescription)") + CAPLog.print("Could not set zoom factor: \(error.localizedDescription)") } } @@ -753,6 +898,9 @@ class MultiCameraViewController: UIViewController { @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 @@ -803,8 +951,8 @@ class MultiCameraViewController: UIViewController { captureSession.commitConfiguration() - // Update flash button visibility (front camera usually doesn't have flash) - flashButton.isHidden = (currentCameraPosition == .front) + // Keep flash button visible for both cameras (front camera uses screen flash) + flashButton.isHidden = false // Reset zoom when switching cameras currentZoomFactor = 1.0 @@ -859,7 +1007,7 @@ class MultiCameraViewController: UIViewController { // Show focus indicator showFocusIndicator(at: point) } catch { - print("Could not focus at point: \(error.localizedDescription)") + CAPLog.print("Could not focus at point: \(error.localizedDescription)") } } @@ -909,18 +1057,13 @@ class MultiCameraViewController: UIViewController { } // MARK: - Image Management - private func addCapturedImage(_ image: UIImage, metadata: [String: Any]) { - capturedImages.append(image) - capturedMetadata.append(metadata) + private func addLoadingThumbnail() { + // Add placeholder image and loading state + capturedImages.append(UIImage()) // Placeholder + capturedMetadata.append([:]) + loadingStates.append(true) - // Check if we've reached the maximum number of images - if maxImages > 0 && capturedImages.count >= maxImages { - // Automatically finish if we've reached the limit - delegate?.multiCameraViewController(self, didFinishWith: capturedImages, metadata: capturedMetadata) - return - } - - // Show the done button once we have at least one image + // Show the done button once we have at least one image (even loading) if doneButton.isHidden { doneButton.isHidden = false } @@ -933,11 +1076,35 @@ class MultiCameraViewController: UIViewController { 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 { + // Automatically finish if we've reached the limit + delegate?.multiCameraViewController(self, didFinishWith: capturedImages, metadata: capturedMetadata) + 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 { @@ -953,7 +1120,7 @@ class MultiCameraViewController: UIViewController { extension MultiCameraViewController: AVCapturePhotoCaptureDelegate { func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) { if let error = error { - print("Error capturing photo: \(error.localizedDescription)") + CAPLog.print("Error capturing photo: \(error.localizedDescription)") return } @@ -1022,9 +1189,15 @@ extension MultiCameraViewController: UICollectionViewDataSource { return UICollectionViewCell() } - cell.configure(with: capturedImages[indexPath.item]) - cell.deleteHandler = { [weak self] in - self?.removeImage(at: indexPath.item) + // 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 @@ -1034,11 +1207,27 @@ extension MultiCameraViewController: UICollectionViewDataSource { // MARK: - UICollectionViewDelegate extension MultiCameraViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - // Preview the selected image - let image = capturedImages[indexPath.item] + // 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 - let previewController = ImagePreviewViewController(image: image) + // 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) From 77a64a8f7986f64716491bdfc939eadc5ce8e202 Mon Sep 17 00:00:00 2001 From: Shiva Prasad Date: Fri, 1 Aug 2025 22:15:02 +0700 Subject: [PATCH 17/19] fix(camera): wait for processing to complete before confirming selection --- .../MultiCameraViewController.swift | 124 +++++++++++++++++- 1 file changed, 122 insertions(+), 2 deletions(-) diff --git a/camera/ios/Sources/CameraPlugin/MultiCameraViewController.swift b/camera/ios/Sources/CameraPlugin/MultiCameraViewController.swift index 55f8e2e27..d9d21a670 100644 --- a/camera/ios/Sources/CameraPlugin/MultiCameraViewController.swift +++ b/camera/ios/Sources/CameraPlugin/MultiCameraViewController.swift @@ -382,6 +382,34 @@ class MultiCameraViewController: UIViewController { 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 @@ -495,6 +523,11 @@ class MultiCameraViewController: UIViewController { 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) @@ -512,6 +545,20 @@ class MultiCameraViewController: UIViewController { 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 @@ -1032,6 +1079,11 @@ class MultiCameraViewController: UIViewController { } @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( @@ -1053,6 +1105,69 @@ class MultiCameraViewController: UIViewController { } @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) } @@ -1093,8 +1208,13 @@ class MultiCameraViewController: UIViewController { // Check if we've reached the maximum number of images if maxImages > 0 && capturedImages.count >= maxImages { - // Automatically finish if we've reached the limit - delegate?.multiCameraViewController(self, didFinishWith: capturedImages, metadata: capturedMetadata) + // Wait for processing if needed, then automatically finish + if hasProcessingImages() { + showProcessingOverlay() + waitForProcessingCompletionThenFinish() + } else { + finishWithImages() + } return } } From 8dc217d0629b60c20537e535cd638f66f0c6b30a Mon Sep 17 00:00:00 2001 From: Shiva Prasad Date: Fri, 1 Aug 2025 22:19:08 +0700 Subject: [PATCH 18/19] feat(camera): added support for quality, width and heigh to ios multicam --- .../MultiCameraViewController.swift | 63 +++++++++++++------ 1 file changed, 45 insertions(+), 18 deletions(-) diff --git a/camera/ios/Sources/CameraPlugin/MultiCameraViewController.swift b/camera/ios/Sources/CameraPlugin/MultiCameraViewController.swift index d9d21a670..55b4a3ef8 100644 --- a/camera/ios/Sources/CameraPlugin/MultiCameraViewController.swift +++ b/camera/ios/Sources/CameraPlugin/MultiCameraViewController.swift @@ -270,6 +270,15 @@ class MultiCameraViewController: UIViewController { 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? @@ -524,7 +533,7 @@ class MultiCameraViewController: UIViewController { view.addSubview(zoomOutButton) view.addSubview(zoomFactorLabel) view.addSubview(processingOverlay) - + // Add processing overlay subviews processingOverlay.addSubview(processingSpinner) processingOverlay.addSubview(processingLabel) @@ -545,17 +554,17 @@ class MultiCameraViewController: UIViewController { 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) ]) @@ -1083,7 +1092,7 @@ class MultiCameraViewController: UIViewController { if hasProcessingImages() { return } - + // If we have images, show confirmation alert if !capturedImages.isEmpty { let alert = UIAlertController( @@ -1113,33 +1122,33 @@ class MultiCameraViewController: UIViewController { 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() @@ -1150,12 +1159,12 @@ class MultiCameraViewController: UIViewController { } } } - + 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() @@ -1166,11 +1175,26 @@ class MultiCameraViewController: UIViewController { } } } - + 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 @@ -1252,12 +1276,15 @@ extension MultiCameraViewController: AVCapturePhotoCaptureDelegate { // Extract metadata let metadata = photo.metadata - // Fix image orientation based on device orientation - let fixedImage = fixImageOrientation(image) + // 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(fixedImage, metadata: metadata) + self?.addCapturedImage(finalImage, metadata: metadata) } } From b56d88d78f39fa159d9ded9c1fc821c3a95385c7 Mon Sep 17 00:00:00 2001 From: Shiva Prasad Date: Fri, 1 Aug 2025 23:03:23 +0700 Subject: [PATCH 19/19] chore(camera): match the android discard photos alert message with iOS --- .../java/com/capacitorjs/plugins/camera/CameraFragment.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 index 6e266b1ab..b50e4ad4e 100644 --- a/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraFragment.java +++ b/camera/android/src/main/java/com/capacitorjs/plugins/camera/CameraFragment.java @@ -99,7 +99,8 @@ public class CameraFragment extends Fragment { private final String FILENAME = "yyyy-MM-dd-HH-mm-ss-SSS"; private final String PHOTO_TYPE = "image/jpeg"; - private final String CONFIRM_CANCEL_MESSAGE = "Are you sure?"; + 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"; @@ -1296,6 +1297,7 @@ private void createCloseButtonForLandscape(FragmentActivity fragmentActivity, in 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()) @@ -2274,6 +2276,7 @@ private void createCloseButton(FragmentActivity fragmentActivity, int margin, Co 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())