Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement the new official Android Photo Picker #2093

Open
wants to merge 2 commits into
base: master
Choose a base branch
from

Conversation

Pauligrinder
Copy link

Here's a quick implementation of the new Photo Picker. By using this new picker the READ_MEDIA_IMAGES permission which Google won't allow soon isn't needed anymore.

The implementation may be a tad rough, so maybe someone more seasoned could have a look and improve it if necessary. Mainly I raised some version numbers - they work just fine in the app I'm using it for, but maybe they should be lowered to support more use cases.

@Pauligrinder
Copy link
Author

Pauligrinder commented Sep 26, 2024

Looks like this change also fixed .heic image support! (or maybe it just worked now and not before because I may have been using an older version of the library)

Update: oh, looks like cropping doesn't work on .heic images. Cropping seems fine, but then after save it fails. I'll have to look into that...

@AndreAparecidoDaSilva
Copy link

Parece que essa mudança também corrigiu o suporte à imagem .heic! (ou talvez tenha funcionado agora e não antes, porque eu poderia estar usando uma versão mais antiga da biblioteca)

Atualização: oh, parece que o corte não funciona em imagens .heic. O corte parece bom, mas depois de salvar ele falha. Vou ter que dar uma olhada nisso...

Does not work on android 8.1, returns "Cannot resolve image url" when selecting an image.

@Pauligrinder
Copy link
Author

Pauligrinder commented Sep 26, 2024

Does not work on android 8.1, returns "Cannot resolve image url" when selecting an image.

Ok. So yeah, apparently some testing and fixing is still required.

Update: I checked, and looks like Android doesn't support .heic images until SDK 29. I don't know how but I guess we should prevent users from selecting such images on older versions (though tbh they're pretty unlikely to have such images anyway).

Anyway, the picker itself seems to work fine - I tested on Android 5.0 and managed to pick and crop a jpg just fine.

@AndreAparecidoDaSilva
Copy link

Aqui está uma implementação rápida do novo Photo Picker. Ao usar esse novo seletor, a permissão READ_MEDIA_IMAGES, que o Google não permitirá em breve, não é mais necessária.

A implementação pode ser um pouco grosseira, então talvez alguém mais experiente possa dar uma olhada e melhorá-la se necessário. Principalmente, aumentei alguns números de versão - eles funcionam muito bem no aplicativo em que estou usando, mas talvez eles devam ser reduzidos para suportar mais casos de uso.

The problem I mentioned has nothing to do with the image format. In fact, I noticed that in Android 8.1, even when configured to select a single image, when selecting, it is returning as if it were a multiple selection and this prevents the file uri from being resolved. I was able to adapt it to my use case by checking the imagePickerResult function.

@AndreAparecidoDaSilva
Copy link

I think I mentioned you in the wrong comment lol. Anyway, this is how the function I changed for my use case turned out. Since I only use it for images, it worked for me.

private void imagePickerResult(Activity activity, final int requestCode, final int resultCode, final Intent data) {
        if (resultCode == Activity.RESULT_CANCELED) {
            resultCollector.notifyProblem(E_PICKER_CANCELLED_KEY, E_PICKER_CANCELLED_MSG);
        } else if (resultCode == Activity.RESULT_OK) {
            if (multiple) {
                ClipData clipData = data.getClipData();

                try {
                    // only one image selected
                    if (clipData == null) {
                        resultCollector.setWaitCount(1);
                        getAsyncSelection(activity, data.getData(), false);
                    } else {
                        resultCollector.setWaitCount(clipData.getItemCount());
                        for (int i = 0; i < clipData.getItemCount(); i++) {
                            getAsyncSelection(activity, clipData.getItemAt(i).getUri(), false);
                        }
                    }
                } catch (Exception ex) {
                    resultCollector.notifyProblem(E_NO_IMAGE_DATA_FOUND, ex.getMessage());
                }

            } else {

                Uri uri = null;

                // Checks if the selection was of a single image
                if (data.getData() != null) {
                    uri = data.getData();
                } else {
                    // If you have multiple images, take the first one
                    ClipData clipData = data.getClipData();
                    if (clipData != null && clipData.getItemCount() > 0) {
                        ClipData.Item item = clipData.getItemAt(0);
                        uri = item.getUri();
                    }
                }

                if (uri == null) {
                    resultCollector.notifyProblem(E_NO_IMAGE_DATA_FOUND, "Cannot resolve image url");
                    return;
                }

                if (cropping) {
                    startCropping(activity, uri);
                } else {
                    try {
                        getAsyncSelection(activity, uri, false);
                    } catch (Exception ex) {
                        resultCollector.notifyProblem(E_NO_IMAGE_DATA_FOUND, ex.getMessage());
                    }
                }
            }
        }
    }

@Pauligrinder
Copy link
Author

Pauligrinder commented Oct 1, 2024

I think I mentioned you in the wrong comment lol. Anyway, this is how the function I changed for my use case turned out. Since I only use it for images, it worked for me.

Interesting... I tested this on an Android 8.1 emulator, and it worked just fine without your change 🤔 Does it work correctly for you in the master branch?

Also, I'm curious, does it show the picker as shown in the Google documentation (https://developer.android.com/static/images/training/data-storage/kotlin-picker.gif) for you on Android 8.1? On the emulator it doesn't, and I'm wondering is it because the emulator only has Google Api's but not Google Play... Maybe that's the reason I'm not seeing that error?

@AndreAparecidoDaSilva
Copy link

AndreAparecidoDaSilva commented Oct 1, 2024

I tested it on a real device that I have here, a Motorola Moto G5 Plus. It is working with new Android photo picker in it. I also created an emulator to test and I think the same thing happened to you, it worked normally, but in the old version of the gallery, the new Android photo Picker did not install. In short, in the master branch, it was already working normally. The problem only occurred when implementing Android Photo Picker in this version of Android.

@Pauligrinder
Copy link
Author

Ok. Yeah, I guess Google have implemented some fallback, so when the new photo picker is installed it will use that even when trying to call the old one and then some parameters don't work on it... maybe.

I've also noticed some other apps having issues with the new picker - for example in the Home Assistant app, if I pick a photo from the picker the photo fails to load, but if I choose another app in it (Google Photos for example) and choose the exact same photo, it works. I guess it might be the same bug.

@Pauligrinder
Copy link
Author

Pauligrinder commented Oct 2, 2024

I added a similar fix to my fork, thanks for noticing @AndreAparecidoDaSilva :)

@williamgurzoni
Copy link

Hello folks, thanks for implementing this! Any idea when this is going to be merged/released? Thank you.

@Pauligrinder
Copy link
Author

Hello folks, thanks for implementing this! Any idea when this is going to be merged/released? Thank you.

No idea. But in the meantime you can just use my fork directly.

@eisodev
Copy link

eisodev commented Oct 8, 2024

Hello folks, thanks for implementing this! Any idea when this is going to be merged/released? Thank you.

No idea. But in the meantime you can just use my fork directly.

Hello. I'm trying to solve this new policy thing too. How do I install your fork directly and not the main package?

Currently on "react-native-image-picker": "^7.1.2", but thinking if I should make the change over to this package instead.

@Pauligrinder
Copy link
Author

Pauligrinder commented Oct 9, 2024

Hello folks, thanks for implementing this! Any idea when this is going to be merged/released? Thank you.

No idea. But in the meantime you can just use my fork directly.

Hello. I'm trying to solve this new policy thing too. How do I install your fork directly and not the main package?

Currently on "react-native-image-picker": "^7.1.2", but thinking if I should make the change over to this package instead.

npm install --save github:Pauligrinder/react-native-image-crop-picker or I guess if you're using yarn it would be yarn add .... instead.

I personally recommend it, not because one library would be better than another but because cropping is just a very important feature. I hate apps that don't allow you to crop your profile picture for example.

@efstathiosntonas
Copy link

efstathiosntonas commented Oct 15, 2024

@Pauligrinder hi, thank you for this. Can you consider ditching yalantis.ucrop in favor of it's fork: https://github.com/jens-muenker/uCrop-n-Edit which is better maintained?

The only downside is that it does not use onAcrivityResult but ActivityResultLauncher.

I have managed to make it work with current version of crop-picker using Fragment since react activity cannot play well with ActivityResultLauncher.

PickwerModuleFragment.java

Click me
package com.reactnative.ivpusic.imagepicker;

import android.content.Intent;
import android.os.Bundle;

import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.fragment.app.Fragment;

public class PickerModuleFragment extends Fragment {

    public interface PickerModuleCallbacks {
        void onCameraResult(Intent data);
        void onPickerResult(Intent data);
        void onCropResult(Intent data);
        void onCropError(Intent data);
    }

    private PickerModuleCallbacks callbacks;
    private ActivityResultLauncher<Intent> activityResultLauncherCamera;
    private ActivityResultLauncher<Intent> activityResultLauncherPicker;
    private ActivityResultLauncher<Intent> activityResultLauncherCrop;

    public void setCallbacks(PickerModuleCallbacks callbacks) {
        this.callbacks = callbacks;
    }

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setRetainInstance(true);

        // Register the ActivityResultLaunchers here
        activityResultLauncherCamera = registerForActivityResult(
                new ActivityResultContracts.StartActivityForResult(),
                result -> {
                    if (callbacks != null) {
                        callbacks.onCameraResult(result.getData());
                    }
                }
        );

        activityResultLauncherPicker = registerForActivityResult(
                new ActivityResultContracts.StartActivityForResult(),
                result -> {
                    if (callbacks != null) {
                        callbacks.onPickerResult(result.getData());
                    }
                }
        );

        activityResultLauncherCrop = registerForActivityResult(
                new ActivityResultContracts.StartActivityForResult(),
                result -> {
                    if (callbacks != null) {
                        if (result.getResultCode() == getActivity().RESULT_OK) {
                            callbacks.onCropResult(result.getData());
                        } else {
                            callbacks.onCropError(result.getData());
                        }
                    }
                }
        );
    }

    public void launchCamera(Intent intent) {
        activityResultLauncherCamera.launch(intent);
    }

    public void launchPicker(Intent intent) {
        activityResultLauncherPicker.launch(intent);
    }

    public void launchCrop(Intent intent) {
        activityResultLauncherCrop.launch(intent);
    }
}

PickerModule.java

Click me
package com.reactnative.ivpusic.imagepicker;

import android.Manifest;
import android.app.Activity;
import android.content.ClipData;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Color;
import android.media.MediaMetadataRetriever;
import android.net.Uri;
import android.os.Build;
import android.os.Environment;
import android.provider.MediaStore;
import android.util.Base64;
import android.util.Log;
import android.webkit.MimeTypeMap;

import androidx.annotation.NonNull;
import androidx.core.app.ActivityCompat;
import androidx.core.content.FileProvider;
import androidx.fragment.app.FragmentActivity;

import com.facebook.react.bridge.Callback;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.PromiseImpl;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
// Import necessary classes
import com.facebook.react.bridge.*;
import com.facebook.react.modules.core.PermissionAwareActivity;
import com.facebook.react.modules.core.PermissionListener;
import com.yalantis.ucrop.UCrop;
import com.yalantis.ucrop.UCropActivity;

import java.io.*;
import java.util.*;
import java.util.concurrent.Callable;

public class PickerModule extends ReactContextBaseJavaModule implements PickerModuleFragment.PickerModuleCallbacks {

  private static final String E_ACTIVITY_DOES_NOT_EXIST = "E_ACTIVITY_DOES_NOT_EXIST";

  private static final String E_PICKER_CANCELLED_KEY = "E_PICKER_CANCELLED";
  private static final String E_PICKER_CANCELLED_MSG = "User cancelled image selection";

  private static final String E_CALLBACK_ERROR = "E_CALLBACK_ERROR";
  private static final String E_FAILED_TO_SHOW_PICKER = "E_FAILED_TO_SHOW_PICKER";
  private static final String E_FAILED_TO_OPEN_CAMERA = "E_FAILED_TO_OPEN_CAMERA";
  private static final String E_NO_IMAGE_DATA_FOUND = "E_NO_IMAGE_DATA_FOUND";
  private static final String E_CAMERA_IS_NOT_AVAILABLE = "E_CAMERA_IS_NOT_AVAILABLE";
  private static final String E_CANNOT_LAUNCH_CAMERA = "E_CANNOT_LAUNCH_CAMERA";
  private static final String E_ERROR_WHILE_CLEANING_FILES = "E_ERROR_WHILE_CLEANING_FILES";

  private static final String E_NO_LIBRARY_PERMISSION_KEY = "E_NO_LIBRARY_PERMISSION";
  private static final String E_NO_LIBRARY_PERMISSION_MSG = "User did not grant library permission.";
  private static final String E_NO_CAMERA_PERMISSION_KEY = "E_NO_CAMERA_PERMISSION";
  private static final String E_NO_CAMERA_PERMISSION_MSG = "User did not grant camera permission.";

  private String mediaType = "any";
  private boolean multiple = false;
  private boolean includeBase64 = false;
  private boolean includeExif = false;
  private boolean cropping = false;
  private boolean cropperCircleOverlay = false;
  private boolean freeStyleCropEnabled = false;
  private boolean showCropGuidelines = true;
  private boolean showCropFrame = true;
  private boolean hideBottomControls = false;
  private boolean enableRotationGesture = false;
  private boolean disableCropperColorSetters = false;
  private boolean useFrontCamera = false;
  private ReadableMap options;

  private String cropperActiveWidgetColor = null;
  private String cropperStatusBarColor = null;
  private String cropperToolbarColor = null;
  private String cropperToolbarTitle = null;
  private String cropperToolbarWidgetColor = null;

  private int width = 0;
  private int height = 0;
  private Uri sourceUrl = null;

  private Uri mCameraCaptureURI;
  private String mCurrentMediaPath;
  private ResultCollector resultCollector = new ResultCollector();
  private Compression compression = new Compression();
  private ReactApplicationContext reactContext;

  private PickerModuleFragment pickerModuleFragment;

  public PickerModule(ReactApplicationContext reactContext) {
      super(reactContext);
      this.reactContext = reactContext;
  }

  private void ensureFragmentSetup() {
      final Activity activity = getCurrentActivity();
      if (activity == null) {
          Log.e("PickerModule", "Activity is null when trying to set up fragment");
          return;
      }

      if (pickerModuleFragment == null) {
          if (activity instanceof FragmentActivity) {
              final FragmentActivity fragmentActivity = (FragmentActivity) activity;

              fragmentActivity.runOnUiThread(new Runnable() {
                  @Override
                  public void run() {
                      pickerModuleFragment = (PickerModuleFragment) fragmentActivity.getSupportFragmentManager().findFragmentByTag("PickerModuleFragment");

                      if (pickerModuleFragment == null) {
                          pickerModuleFragment = new PickerModuleFragment();
                          fragmentActivity.getSupportFragmentManager()
                                  .beginTransaction()
                                  .add(pickerModuleFragment, "PickerModuleFragment")
                                  .commitNowAllowingStateLoss(); // Use commitNowAllowingStateLoss()

                          // The fragment's onCreate() has been called at this point
                      }

                      pickerModuleFragment.setCallbacks(PickerModule.this);
                  }
              });
          } else {
              Log.e("PickerModule", "Activity is not an instance of FragmentActivity");
          }
      } else {
          pickerModuleFragment.setCallbacks(this);
      }
  }

  private String getTmpDir(Activity activity) {
      String tmpDir = activity.getCacheDir() + "/react-native-image-crop-picker";
      new File(tmpDir).mkdir();

      return tmpDir;
  }

  @Override
  public String getName() {
      return "ImageCropPicker";
  }

  private void setConfiguration(final ReadableMap options) {
      mediaType = options.hasKey("mediaType") ? options.getString("mediaType") : "any";
      multiple = options.hasKey("multiple") && options.getBoolean("multiple");
      includeBase64 = options.hasKey("includeBase64") && options.getBoolean("includeBase64");
      includeExif = options.hasKey("includeExif") && options.getBoolean("includeExif");
      width = options.hasKey("width") ? options.getInt("width") : 0;
      height = options.hasKey("height") ? options.getInt("height") : 0;
      cropping = options.hasKey("cropping") && options.getBoolean("cropping");
      cropperActiveWidgetColor = options.hasKey("cropperActiveWidgetColor") ? options.getString("cropperActiveWidgetColor") : null;
      cropperStatusBarColor = options.hasKey("cropperStatusBarColor") ? options.getString("cropperStatusBarColor") : null;
      cropperToolbarColor = options.hasKey("cropperToolbarColor") ? options.getString("cropperToolbarColor") : null;
      cropperToolbarTitle = options.hasKey("cropperToolbarTitle") ? options.getString("cropperToolbarTitle") : null;
      cropperToolbarWidgetColor = options.hasKey("cropperToolbarWidgetColor") ? options.getString("cropperToolbarWidgetColor") : null;
      cropperCircleOverlay = options.hasKey("cropperCircleOverlay") && options.getBoolean("cropperCircleOverlay");
      freeStyleCropEnabled = options.hasKey("freeStyleCropEnabled") && options.getBoolean("freeStyleCropEnabled");
      showCropGuidelines = !options.hasKey("showCropGuidelines") || options.getBoolean("showCropGuidelines");
      showCropFrame = !options.hasKey("showCropFrame") || options.getBoolean("showCropFrame");
      hideBottomControls = options.hasKey("hideBottomControls") && options.getBoolean("hideBottomControls");
      enableRotationGesture = options.hasKey("enableRotationGesture") && options.getBoolean("enableRotationGesture");
      disableCropperColorSetters = options.hasKey("disableCropperColorSetters") && options.getBoolean("disableCropperColorSetters");
      useFrontCamera = options.hasKey("useFrontCamera") && options.getBoolean("useFrontCamera");
      this.options = options;
  }

  private void deleteRecursive(File fileOrDirectory) {
      if (fileOrDirectory == null) return;
      if (fileOrDirectory.isDirectory()) {
          File[] childFiles = fileOrDirectory.listFiles();
          if (childFiles != null) {
              for (File child : childFiles) {
                  deleteRecursive(child);
              }
          }
      }

      fileOrDirectory.delete();
  }

  @ReactMethod
  public void clean(final Promise promise) {

      final Activity activity = getCurrentActivity();
      final PickerModule module = this;

      if (activity == null) {
          promise.reject(E_ACTIVITY_DOES_NOT_EXIST, "Activity doesn't exist");
          return;
      }

      permissionsCheck(activity, promise, Collections.singletonList(Manifest.permission.WRITE_EXTERNAL_STORAGE), new Callable<Void>() {
          @Override
          public Void call() {
              try {
                  File file = new File(module.getTmpDir(activity));
                  if (!file.exists()) throw new Exception("File does not exist");

                  module.deleteRecursive(file);
                  promise.resolve(null);
              } catch (Exception ex) {
                  ex.printStackTrace();
                  promise.reject(E_ERROR_WHILE_CLEANING_FILES, ex.getMessage());
              }

              return null;
          }
      });
  }

  @ReactMethod
  public void cleanSingle(final String pathToDelete, final Promise promise) {
      if (pathToDelete == null) {
          promise.reject(E_ERROR_WHILE_CLEANING_FILES, "Cannot cleanup empty path");
          return;
      }

      final Activity activity = getCurrentActivity();
      final PickerModule module = this;

      if (activity == null) {
          promise.reject(E_ACTIVITY_DOES_NOT_EXIST, "Activity doesn't exist");
          return;
      }

      permissionsCheck(activity, promise, Collections.singletonList(Manifest.permission.WRITE_EXTERNAL_STORAGE), new Callable<Void>() {
          @Override
          public Void call() throws Exception {
              try {
                  String path = pathToDelete;
                  final String filePrefix = "file://";
                  if (path.startsWith(filePrefix)) {
                      path = path.substring(filePrefix.length());
                  }

                  File file = new File(path);
                  if (!file.exists()) throw new Exception("File does not exist. Path: " + path);

                  module.deleteRecursive(file);
                  promise.resolve(null);
              } catch (Exception ex) {
                  ex.printStackTrace();
                  promise.reject(E_ERROR_WHILE_CLEANING_FILES, ex.getMessage());
              }

              return null;
          }
      });
  }

  private void permissionsCheck(final Activity activity, final Promise promise, final List<String> requiredPermissions, final Callable<Void> callback) {

      List<String> missingPermissions = new ArrayList<>();
      List<String> supportedPermissions = new ArrayList<>(requiredPermissions);

      // Android 11 introduced scoped storage, and WRITE_EXTERNAL_STORAGE no longer works there
      if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) {
          supportedPermissions.remove(Manifest.permission.WRITE_EXTERNAL_STORAGE);
      }

      for (String permission : supportedPermissions) {
          int status = ActivityCompat.checkSelfPermission(activity, permission);
          if (status != PackageManager.PERMISSION_GRANTED) {
              missingPermissions.add(permission);
          }
      }

      if (!missingPermissions.isEmpty()) {

          ((PermissionAwareActivity) activity).requestPermissions(missingPermissions.toArray(new String[0]), 1, new PermissionListener() {

              @Override
              public boolean onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
                  if (requestCode == 1) {

                      for (int permissionIndex = 0; permissionIndex < permissions.length; permissionIndex++) {
                          String permission = permissions[permissionIndex];
                          int grantResult = grantResults[permissionIndex];

                          if (grantResult == PackageManager.PERMISSION_DENIED) {
                              if (permission.equals(Manifest.permission.CAMERA)) {
                                  promise.reject(E_NO_CAMERA_PERMISSION_KEY, E_NO_CAMERA_PERMISSION_MSG);
                              } else if (permission.equals(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
                                  promise.reject(E_NO_LIBRARY_PERMISSION_KEY, E_NO_LIBRARY_PERMISSION_MSG);
                              } else {
                                  promise.reject(E_NO_LIBRARY_PERMISSION_KEY, "Required permission missing");
                              }
                              return true;
                          }
                      }

                      try {
                          callback.call();
                      } catch (Exception e) {
                          promise.reject(E_CALLBACK_ERROR, "Unknown error", e);
                      }
                  }

                  return true;
              }
          });

          return;
      }

      // All permissions granted
      try {
          callback.call();
      } catch (Exception e) {
          promise.reject(E_CALLBACK_ERROR, "Unknown error", e);
      }
  }

  @ReactMethod
  public void openCamera(final ReadableMap options, final Promise promise) {
      final Activity activity = getCurrentActivity();

      if (activity == null) {
          promise.reject(E_ACTIVITY_DOES_NOT_EXIST, "Activity doesn't exist");
          return;
      }

      ensureFragmentSetup();

      if (!isCameraAvailable(activity)) {
          promise.reject(E_CAMERA_IS_NOT_AVAILABLE, "Camera not available");
          return;
      }

      setConfiguration(options);
      resultCollector.setup(promise, false);

      permissionsCheck(activity, promise, Arrays.asList(Manifest.permission.CAMERA, Manifest.permission.WRITE_EXTERNAL_STORAGE), new Callable<Void>() {
          @Override
          public Void call() {
              initiateCamera(activity);
              return null;
          }
      });
  }

  private void initiateCamera(final Activity activity) {
      activity.runOnUiThread(new Runnable() {
          @Override
          public void run() {
              try {
                  String intentAction;
                  File dataFile;

                  if (mediaType.equals("video")) {
                      intentAction = MediaStore.ACTION_VIDEO_CAPTURE;
                      dataFile = createVideoFile();
                  } else {
                      intentAction = MediaStore.ACTION_IMAGE_CAPTURE;
                      dataFile = createImageFile();
                  }

                  Intent cameraIntent = new Intent(intentAction);

                  if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
                      mCameraCaptureURI = Uri.fromFile(dataFile);
                  } else {
                      mCameraCaptureURI = FileProvider.getUriForFile(activity,
                              activity.getApplicationContext().getPackageName() + ".provider",
                              dataFile);
                  }

                  cameraIntent.putExtra(MediaStore.EXTRA_OUTPUT, mCameraCaptureURI);

                  if (useFrontCamera) {
                      cameraIntent.putExtra("android.intent.extras.CAMERA_FACING", 1);
                      cameraIntent.putExtra("android.intent.extras.LENS_FACING_FRONT", 1);
                      cameraIntent.putExtra("android.intent.extra.USE_FRONT_CAMERA", true);
                  }

                  if (cameraIntent.resolveActivity(activity.getPackageManager()) == null) {
                      resultCollector.notifyProblem(E_CANNOT_LAUNCH_CAMERA, "Cannot launch camera");
                      return;
                  }

                  pickerModuleFragment.launchCamera(cameraIntent);
              } catch (Exception e) {
                  resultCollector.notifyProblem(E_FAILED_TO_OPEN_CAMERA, e.getMessage() != null ? e.getMessage() : "Unknown error");
              }
          }
      });
  }

  @ReactMethod
  public void openPicker(final ReadableMap options, final Promise promise) {
      final Activity activity = getCurrentActivity();

      if (activity == null) {
          promise.reject(E_ACTIVITY_DOES_NOT_EXIST, "Activity doesn't exist");
          return;
      }

      ensureFragmentSetup();

      setConfiguration(options);
      resultCollector.setup(promise, multiple);

      permissionsCheck(activity, promise, Collections.singletonList(Manifest.permission.WRITE_EXTERNAL_STORAGE), new Callable<Void>() {
          @Override
          public Void call() {
              initiatePicker(activity);
              return null;
          }
      });
  }

  private void initiatePicker(final Activity activity) {
      activity.runOnUiThread(new Runnable() {
          @Override
          public void run() {
              try {
                  final Intent galleryIntent = new Intent(Intent.ACTION_GET_CONTENT);

                  if (cropping || mediaType.equals("photo")) {
                      galleryIntent.setType("image/*");
                      if (cropping) {
                          String[] mimetypes = {"image/jpeg", "image/png"};
                          galleryIntent.putExtra(Intent.EXTRA_MIME_TYPES, mimetypes);
                      }
                  } else if (mediaType.equals("video")) {
                      galleryIntent.setType("video/*");
                  } else {
                      galleryIntent.setType("*/*");
                      String[] mimetypes = {"image/*", "video/*"};
                      galleryIntent.putExtra(Intent.EXTRA_MIME_TYPES, mimetypes);
                  }

                  galleryIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
                  galleryIntent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, multiple);
                  galleryIntent.addCategory(Intent.CATEGORY_OPENABLE);

                  final Intent chooserIntent = Intent.createChooser(galleryIntent, "Pick an image");
                  pickerModuleFragment.launchPicker(chooserIntent);
              } catch (Exception e) {
                  resultCollector.notifyProblem(E_FAILED_TO_SHOW_PICKER, e.getMessage() != null ? e.getMessage() : "Unknown error");
              }
          }
      });
  }

  @ReactMethod
  public void openCropper(final ReadableMap options, final Promise promise) {
      final Activity activity = getCurrentActivity();

      if (activity == null) {
          promise.reject(E_ACTIVITY_DOES_NOT_EXIST, "Activity doesn't exist");
          return;
      }

      ensureFragmentSetup();

      setConfiguration(options);
      resultCollector.setup(promise, false);

      final Uri uri = Uri.parse(options.getString("path"));
      sourceUrl = uri;
      permissionsCheck(activity, promise, Collections.singletonList(Manifest.permission.WRITE_EXTERNAL_STORAGE), new Callable<Void>() {
          @Override
          public Void call() {
              startCropping(activity, uri);
              return null;
          }
      });
  }

  private void startCropping(final Activity activity, final Uri uri) {
      activity.runOnUiThread(new Runnable() {
          @Override
          public void run() {
              try {
                  UCrop.Options options = new UCrop.Options();
                  options.setCompressionFormat(Bitmap.CompressFormat.JPEG);
                  options.setCompressionQuality(100);
                  options.setCircleDimmedLayer(cropperCircleOverlay);
                  options.setFreeStyleCropEnabled(freeStyleCropEnabled);
                  options.setShowCropGrid(showCropGuidelines);
                  options.setShowCropFrame(showCropFrame);
                  options.setHideBottomControls(hideBottomControls);

                  if (cropperToolbarTitle != null) {
                      options.setToolbarTitle(cropperToolbarTitle);
                  }

                  if (enableRotationGesture) {
                      options.setAllowedGestures(
                              UCropActivity.ALL,
                              UCropActivity.ALL,
                              UCropActivity.ALL
                      );
                  }

                  if (!disableCropperColorSetters) {
                      configureCropperColors(options);
                  }

                  UCrop uCrop = UCrop
                          .of(uri, Uri.fromFile(new File(getTmpDir(activity), UUID.randomUUID().toString() + ".jpg")))
                          .withOptions(options);

                  if (width > 0 && height > 0) {
                      uCrop.withAspectRatio(width, height);
                  }

                  // Start the UCrop activity
                  Intent uCropIntent = uCrop.getIntent(activity);
                  pickerModuleFragment.launchCrop(uCropIntent);
              } catch (Exception e) {
                  resultCollector.notifyProblem(E_FAILED_TO_SHOW_PICKER, e.getMessage() != null ? e.getMessage() : "Unknown error");
              }
          }
      });
  }

  @Override
  public void onCameraResult(Intent data) {
      final Activity activity = getCurrentActivity();
      if (activity == null) {
          resultCollector.notifyProblem(E_ACTIVITY_DOES_NOT_EXIST, "Activity doesn't exist");
          return;
      }
      cameraPickerResult(activity, data);
  }

  @Override
  public void onPickerResult(Intent data) {
      final Activity activity = getCurrentActivity();
      if (activity == null) {
          resultCollector.notifyProblem(E_ACTIVITY_DOES_NOT_EXIST, "Activity doesn't exist");
          return;
      }
      imagePickerResult(activity, data);
  }

  @Override
  public void onCropResult(Intent data) {
      handleCropResult(data);
  }

  @Override
  public void onCropError(Intent data) {
      handleCropError(data);
  }

  private void cameraPickerResult(Activity activity, final Intent data) {
      Uri uri = mCameraCaptureURI;

      if (uri == null) {
          resultCollector.notifyProblem(E_NO_IMAGE_DATA_FOUND, "Cannot resolve image url");
          return;
      }

      sourceUrl = uri;

      if (cropping) {
          startCropping(activity, uri);
      } else {
          try {
              resultCollector.setWaitCount(1);
              WritableMap result = getSelection(activity, uri, true);

              // If recording a video, getSelection handles resultCollector part itself and returns null
              if (result != null) {
                  resultCollector.notifySuccess(result);
              }
          } catch (Exception ex) {
              String errorMessage = ex.getMessage() != null ? ex.getMessage() : "Unknown error";
              resultCollector.notifyProblem(E_NO_IMAGE_DATA_FOUND, errorMessage);
          }
      }
  }

  private void imagePickerResult(Activity activity, final Intent data) {
      if (data == null) {
          resultCollector.notifyProblem(E_PICKER_CANCELLED_KEY, E_PICKER_CANCELLED_MSG);
          return;
      }

      if (multiple) {
          ClipData clipData = data.getClipData();

          try {
              // Only one image selected
              if (clipData == null) {
                  resultCollector.setWaitCount(1);
                  getAsyncSelection(activity, data.getData(), false);
              } else {
                  resultCollector.setWaitCount(clipData.getItemCount());
                  for (int i = 0; i < clipData.getItemCount(); i++) {
                      getAsyncSelection(activity, clipData.getItemAt(i).getUri(), false);
                  }
              }
          } catch (Exception ex) {
              String errorMessage = ex.getMessage() != null ? ex.getMessage() : "Unknown error";
              resultCollector.notifyProblem(E_NO_IMAGE_DATA_FOUND, errorMessage);
          }

      } else {
          Uri uri = data.getData();

          if (uri == null) {
              resultCollector.notifyProblem(E_NO_IMAGE_DATA_FOUND, "Cannot resolve image url");
              return;
          }

          sourceUrl = uri;

          if (cropping) {
              startCropping(activity, uri);
          } else {
              try {
                  getAsyncSelection(activity, uri, false);
              } catch (Exception ex) {
                  String errorMessage = ex.getMessage() != null ? ex.getMessage() : "Unknown error";
                  resultCollector.notifyProblem(E_NO_IMAGE_DATA_FOUND, errorMessage);
              }
          }
      }
  }

  private void handleCropResult(Intent data) {
      Activity activity = getCurrentActivity();
      if (activity == null) {
          resultCollector.notifyProblem(E_ACTIVITY_DOES_NOT_EXIST, "Activity doesn't exist");
          return;
      }
      if (data != null) {
          Uri resultUri = UCrop.getOutput(data);

          if (resultUri != null) {
              try {
                  if (width > 0 && height > 0) {
                      File resized = compression.resize(this.reactContext, resultUri.getPath(), width, height, width, height, 100);
                      if (resized == null) {
                          throw new Exception("Failed to resize image");
                      }
                      resultUri = Uri.fromFile(resized);
                  }

                  WritableMap result = getSelection(activity, resultUri, false);

                  if (result != null) {
                      result.putMap("cropRect", getCroppedRectMap(data));

                      resultCollector.setWaitCount(1);
                      resultCollector.notifySuccess(result);
                  } else {
                      throw new Exception("Cannot crop video files");
                  }
              } catch (Exception ex) {
                  String errorMessage = ex.getMessage() != null ? ex.getMessage() : "Unknown error";
                  resultCollector.notifyProblem(E_NO_IMAGE_DATA_FOUND, errorMessage);
              }
          } else {
              resultCollector.notifyProblem(E_NO_IMAGE_DATA_FOUND, "Cannot find image data");
          }
      } else {
          resultCollector.notifyProblem(E_PICKER_CANCELLED_KEY, E_PICKER_CANCELLED_MSG);
      }
  }

  private void handleCropError(Intent data) {
      if (data != null) {
          Throwable cropError = UCrop.getError(data);
          String errorMessage = (cropError != null && cropError.getMessage() != null) ? cropError.getMessage() : "Unexpected error during cropping";
          resultCollector.notifyProblem(E_NO_IMAGE_DATA_FOUND, errorMessage);
      } else {
          // Data is null, user likely cancelled the crop operation
          resultCollector.notifyProblem(E_PICKER_CANCELLED_KEY, E_PICKER_CANCELLED_MSG);
      }
  }

  private boolean isCameraAvailable(Activity activity) {
      return activity.getPackageManager().hasSystemFeature(PackageManager.FEATURE_CAMERA)
              || activity.getPackageManager().hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY);
  }

  private File createImageFile() throws IOException {
      String imageFileName = "image-" + UUID.randomUUID().toString();
      File path = this.reactContext.getExternalFilesDir(Environment.DIRECTORY_PICTURES);

      if (path != null && !path.exists() && !path.isDirectory()) {
          path.mkdirs();
      }

      File image = File.createTempFile(imageFileName, ".jpg", path);
      mCurrentMediaPath = image.getAbsolutePath();

      return image;
  }

  private File createVideoFile() throws IOException {
      String videoFileName = "video-" + UUID.randomUUID().toString();
      File path = this.reactContext.getExternalFilesDir(Environment.DIRECTORY_PICTURES);

      if (path != null && !path.exists() && !path.isDirectory()) {
          path.mkdirs();
      }

      File video = File.createTempFile(videoFileName, ".mp4", path);
      mCurrentMediaPath = video.getAbsolutePath();

      return video;
  }

  private String getBase64StringFromFile(String absoluteFilePath) {
      try (InputStream inputStream = new FileInputStream(new File(absoluteFilePath));
           ByteArrayOutputStream output = new ByteArrayOutputStream()) {

          byte[] buffer = new byte[8192];
          int bytesRead;

          while ((bytesRead = inputStream.read(buffer)) != -1) {
              output.write(buffer, 0, bytesRead);
          }

          return Base64.encodeToString(output.toByteArray(), Base64.NO_WRAP);
      } catch (IOException e) {
          e.printStackTrace();
          return null;
      }
  }

  private String getMimeType(String url) {
      String mimeType = null;
      Uri uri = Uri.fromFile(new File(url));
      if (uri.getScheme() != null && uri.getScheme().equals(ContentResolver.SCHEME_CONTENT)) {
          ContentResolver cr = this.reactContext.getContentResolver();
          mimeType = cr.getType(uri);
      } else {
          String fileExtension = MimeTypeMap.getFileExtensionFromUrl(uri.toString());
          if (fileExtension != null) {
              mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(fileExtension.toLowerCase());
          }
      }
      return mimeType;
  }

  private WritableMap getSelection(Activity activity, Uri uri, boolean isCamera) throws Exception {
      if (activity == null) {
          throw new Exception("Activity is null");
      }
      String path = resolveRealPath(activity, uri, isCamera);
      if (path == null || path.isEmpty()) {
          throw new Exception("Cannot resolve asset path.");
      }

      String mime = getMimeType(path);
      if (mime != null && mime.startsWith("video/")) {
          getVideo(activity, path, mime);
          return null;
      }

      return getImage(activity, path);
  }

  private void getAsyncSelection(final Activity activity, Uri uri, boolean isCamera) throws Exception {
      if (activity == null) {
          throw new Exception("Activity is null");
      }
      String path = resolveRealPath(activity, uri, isCamera);
      if (path == null || path.isEmpty()) {
          resultCollector.notifyProblem(E_NO_IMAGE_DATA_FOUND, "Cannot resolve asset path.");
          return;
      }

      String mime = getMimeType(path);
      if (mime != null && mime.startsWith("video/")) {
          getVideo(activity, path, mime);
          return;
      }

      resultCollector.notifySuccess(getImage(activity, path));
  }

  private Bitmap validateVideo(String path) throws Exception {
      MediaMetadataRetriever retriever = new MediaMetadataRetriever();
      retriever.setDataSource(path);
      Bitmap bmp = retriever.getFrameAtTime();

      if (bmp == null) {
          throw new Exception("Cannot retrieve video data");
      }

      return bmp;
  }

  private static Long getVideoDuration(String path) {
      try {
          MediaMetadataRetriever retriever = new MediaMetadataRetriever();
          retriever.setDataSource(path);

          return Long.parseLong(retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION));
      } catch (Exception e) {
          return -1L;
      }
  }

  private void getVideo(final Activity activity, final String path, final String mime) throws Exception {
      validateVideo(path);
      final String compressedVideoPath = getTmpDir(activity) + "/" + UUID.randomUUID().toString() + ".mp4";

      new Thread(new Runnable() {
          @Override
          public void run() {
              compression.compressVideo(activity, options, path, compressedVideoPath, new PromiseImpl(new Callback() {
                  @Override
                  public void invoke(Object... args) {
                      String videoPath = (String) args[0];

                      try {
                          Bitmap bmp = validateVideo(videoPath);
                          long modificationDate = new File(videoPath).lastModified();
                          long duration = getVideoDuration(videoPath);

                          WritableMap video = new WritableNativeMap();
                          video.putInt("width", bmp.getWidth());
                          video.putInt("height", bmp.getHeight());
                          video.putString("mime", mime);
                          video.putInt("size", (int) new File(videoPath).length());
                          video.putInt("duration", (int) duration);
                          video.putString("path", "file://" + videoPath);
                          video.putString("modificationDate", String.valueOf(modificationDate));

                          resultCollector.notifySuccess(video);
                      } catch (Exception e) {
                          String errorMessage = e.getMessage() != null ? e.getMessage() : "Unknown error";
                          resultCollector.notifyProblem(E_NO_IMAGE_DATA_FOUND, errorMessage);
                      }
                  }
              }, new Callback() {
                  @Override
                  public void invoke(Object... args) {
                      WritableNativeMap ex = (WritableNativeMap) args[0];
                      resultCollector.notifyProblem(ex.getString("code"), ex.getString("message"));
                  }
              }));
          }
      }).start();
  }

  private String resolveRealPath(Activity activity, Uri uri, boolean isCamera) throws IOException {
      String path = null;

      if (isCamera) {
          path = mCurrentMediaPath;
          if (path != null && path.startsWith("file:")) {
              path = path.substring(5);
          }
      } else {
          path = RealPathUtil.getRealPathFromURI(activity, uri);
      }

      if (path == null) {
          path = uri.getPath();
      }

      if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
          String externalCacheDirPath = activity.getExternalCacheDir() != null ? activity.getExternalCacheDir().getAbsolutePath() : "";
          String externalFilesDirPath = activity.getExternalFilesDir(null) != null ? activity.getExternalFilesDir(null).getAbsolutePath() : "";
          String cacheDirPath = activity.getCacheDir().getAbsolutePath();
          String filesDirPath = activity.getFilesDir().getAbsolutePath();

          if (!path.startsWith(externalCacheDirPath)
                  && !path.startsWith(externalFilesDirPath)
                  && !path.startsWith(cacheDirPath)
                  && !path.startsWith(filesDirPath)) {
              File copiedFile = this.createExternalStoragePrivateFile(activity, uri);
              path = copiedFile.getAbsolutePath();
          }
      }

      return path;
  }

  private File createExternalStoragePrivateFile(Context context, Uri uri) throws IOException {
      InputStream inputStream = context.getContentResolver().openInputStream(uri);

      String extension = this.getExtension(context, uri);
      if (extension == null) {
          extension = "jpg"; // default extension
      }
      File tempDir = new File(context.getExternalCacheDir(), "temp");
      if (!tempDir.exists()) {
          tempDir.mkdirs();
      }
      File file = new File(tempDir, System.currentTimeMillis() + "." + extension);

      try (FileOutputStream fileOutputStream = new FileOutputStream(file);
           InputStream is = inputStream) {
          int bufferSize = 1024;
          byte[] buffer = new byte[bufferSize];
          int len;
          while ((len = is.read(buffer)) != -1) {
              fileOutputStream.write(buffer, 0, len);
              fileOutputStream.flush();
          }
      } catch (IOException e) {
          Log.w("image-crop-picker", "Error writing " + file, e);
          throw e;
      }

      return file;
  }

  public String getExtension(Context context, Uri uri) {
      String extension = null;

      if (uri.getScheme() != null && uri.getScheme().equals(ContentResolver.SCHEME_CONTENT)) {
          final MimeTypeMap mime = MimeTypeMap.getSingleton();
          extension = mime.getExtensionFromMimeType(context.getContentResolver().getType(uri));
      } else {
          String path = uri.getPath();
          if (path != null) {
              int index = path.lastIndexOf('.');
              if (index != -1) {
                  extension = path.substring(index + 1);
              }
          }
      }

      return extension;
  }

  private BitmapFactory.Options validateImage(String path) throws Exception {
      BitmapFactory.Options options = new BitmapFactory.Options();
      options.inJustDecodeBounds = true;
      options.inPreferredConfig = Bitmap.Config.RGB_565;
      options.inDither = true;

      BitmapFactory.decodeFile(path, options);

      if (options.outMimeType == null || options.outWidth == 0 || options.outHeight == 0) {
          throw new Exception("Invalid image selected");
      }

      return options;
  }

  private WritableMap getImage(final Activity activity, String path) throws Exception {
      WritableMap image = new WritableNativeMap();

      if (path.startsWith("http://") || path.startsWith("https://")) {
          throw new Exception("Cannot select remote files");
      }
      BitmapFactory.Options original = validateImage(path);

      File compressedImage = compression.compressImage(this.reactContext, options, path, original);
      String compressedImagePath = compressedImage.getPath();
      BitmapFactory.Options options = validateImage(compressedImagePath);
      long modificationDate = new File(path).lastModified();

      image.putString("path", "file://" + compressedImagePath);
      image.putInt("width", options.outWidth);
      image.putInt("height", options.outHeight);
      image.putString("mime", options.outMimeType);
      image.putInt("size", (int) new File(compressedImagePath).length());
      image.putString("modificationDate", String.valueOf(modificationDate));

      if (sourceUrl != null && !path.isEmpty()) {
          image.putString("sourceURL", sourceUrl.toString());
      } else {
          image.putString("sourceURL", Uri.fromFile(new File(path)).toString());
      }

      if (includeBase64) {
          String base64 = getBase64StringFromFile(compressedImagePath);
          if (base64 != null) {
              image.putString("data", base64);
          }
      }

      if (includeExif) {
          try {
              WritableMap exif = ExifExtractor.extract(path);
              image.putMap("exif", exif);
          } catch (Exception ex) {
              ex.printStackTrace();
          }
      }

      return image;
  }

  private void configureCropperColors(UCrop.Options options) {
      if (cropperActiveWidgetColor != null) {
          options.setActiveControlsWidgetColor(Color.parseColor(cropperActiveWidgetColor));
      }

      if (cropperToolbarColor != null) {
          options.setToolbarColor(Color.parseColor(cropperToolbarColor));
      }

      if (cropperStatusBarColor != null) {
          options.setStatusBarColor(Color.parseColor(cropperStatusBarColor));
      }

      if (cropperToolbarWidgetColor != null) {
          options.setToolbarWidgetColor(Color.parseColor(cropperToolbarWidgetColor));
      }
  }

  private static WritableMap getCroppedRectMap(Intent data) {
      final int DEFAULT_VALUE = -1;
      final WritableMap map = new WritableNativeMap();

      map.putInt("x", data.getIntExtra(UCrop.EXTRA_OUTPUT_OFFSET_X, DEFAULT_VALUE));
      map.putInt("y", data.getIntExtra(UCrop.EXTRA_OUTPUT_OFFSET_Y, DEFAULT_VALUE));
      map.putInt("width", data.getIntExtra(UCrop.EXTRA_OUTPUT_IMAGE_WIDTH, DEFAULT_VALUE));
      map.putInt("height", data.getIntExtra(UCrop.EXTRA_OUTPUT_IMAGE_HEIGHT, DEFAULT_VALUE));

      return map;
  }
}

I believe it's a good oportunity to ditch the deprecated onActivityResult for good.

@Pauligrinder
Copy link
Author

@efstathiosntonas I'm not against it, but it's out of the scope of this PR.

@efstathiosntonas
Copy link

@Pauligrinder totally understood, will wait for this PR to be merged and maybe open another PR about it. Thanks

@BuddyMuhammad
Copy link

@Pauligrinder Thank you for the implementation! We eagerly await merging and releasing this to align with Google's new requirements. This update will help resolve some crucial issues on the latest Android versions

@leonardorib
Copy link

leonardorib commented Oct 21, 2024

Ok. Yeah, I guess Google have implemented some fallback, so when the new photo picker is installed it will use that even when trying to call the old one and then some parameters don't work on it... maybe.

Looks like that is the case @Pauligrinder

https://android-developers.googleblog.com/2023/04/photo-picker-everywhere.html#:~:text=GET_CONTENT%20takeover

Since our last blog post, we started rolling out support for the GET_CONTENT intent in the Android photo picker whenever the specified MIME type filter matches image/* and/or video/*

https://medium.com/androiddevelopers/permissionless-is-the-future-of-storage-on-android-3fbceeb3d70a#:~:text=ACTION_GET_CONTENT%20behaviour%20change

As we just saw, adopting the Android photo picker requires just a few lines of code. While we want all apps to use it, migration might take some time in your app. That’s why we are bringing the benefits of Android photo picker to existing apps using ACTION_GET_CONTENT by switching the system file picker for the photo picker under the hood without any code change required in the upcoming months. If you launch the ACTION_GET_CONTENT intent with an image and/or video mime type filter, the photo picker will be shown instead of the document picker. For applications, the expected intent results will be the same: a list of Uri.

So it looks like the READ_MEDIA_IMAGES permission is not needed after all since we are fallbacking to the new Photo Picker anyways.

But I agree that implementing the new official API is a much better approach long-term so this PR would be valuable to have. Nice work, by the way.

They implemented the fallback as a way to transition existing apps, but using the official API is much more likely to have long term support.

@wood1986
Copy link

wood1986 commented Nov 5, 2024

Hey @ivpusic when will you take this PR?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

8 participants