-
-
Notifications
You must be signed in to change notification settings - Fork 1.6k
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
base: master
Are you sure you want to change the base?
Conversation
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... |
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. |
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. |
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? |
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. |
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. |
I added a similar fix to my fork, thanks for noticing @AndreAparecidoDaSilva :) |
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. |
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. |
@Pauligrinder hi, thank you for this. Can you consider ditching The only downside is that it does not use I have managed to make it work with current version of crop-picker using Fragment since react activity cannot play well with
Click mepackage 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);
}
}
Click mepackage 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 |
@efstathiosntonas I'm not against it, but it's out of the scope of this PR. |
@Pauligrinder totally understood, will wait for this PR to be merged and maybe open another PR about it. Thanks |
@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 |
Looks like that is the case @Pauligrinder
So it looks like the 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. |
Hey @ivpusic when will you take this PR? |
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.