From f8d519e8eb04f3de5897d1b05b08a9cb94c2614e Mon Sep 17 00:00:00 2001 From: Saifuddin Adenwala Date: Fri, 6 Dec 2024 14:01:40 +0530 Subject: [PATCH] Migrated filepicker from Java to Kotlin (#5997) * Rename .java to .kt * Migrated filepicker module from Java to Kotlin * Rename .java to .kt * Migrated filepicker module from Java to Kotlin * fix: test cases --- .../nrw/commons/filepicker/Constants.java | 23 - .../free/nrw/commons/filepicker/Costants.kt | 29 ++ .../commons/filepicker/DefaultCallback.java | 16 - .../nrw/commons/filepicker/DefaultCallback.kt | 12 + .../filepicker/ExtendedFileProvider.java | 7 - .../filepicker/ExtendedFileProvider.kt | 5 + .../nrw/commons/filepicker/FilePicker.java | 355 -------------- .../free/nrw/commons/filepicker/FilePicker.kt | 441 ++++++++++++++++++ .../filepicker/FilePickerConfiguration.java | 44 -- .../filepicker/FilePickerConfiguration.kt | 46 ++ .../filepicker/MimeTypeMapWrapper.java | 26 -- .../commons/filepicker/MimeTypeMapWrapper.kt | 24 + .../nrw/commons/filepicker/PickedFiles.java | 208 --------- .../nrw/commons/filepicker/PickedFiles.kt | 195 ++++++++ .../commons/filepicker/UploadableFile.java | 213 --------- .../nrw/commons/filepicker/UploadableFile.kt | 168 +++++++ .../nrw/commons/settings/SettingsFragment.kt | 15 +- .../nrw/commons/utils/CustomSelectorUtils.kt | 2 +- .../filepicker/ShadowFileProvider.java | 32 -- .../commons/filepicker/ShadowFileProvider.kt | 36 ++ .../nrw/commons/upload/UploadPresenterTest.kt | 2 +- 21 files changed, 970 insertions(+), 929 deletions(-) delete mode 100644 app/src/main/java/fr/free/nrw/commons/filepicker/Constants.java create mode 100644 app/src/main/java/fr/free/nrw/commons/filepicker/Costants.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/filepicker/DefaultCallback.java create mode 100644 app/src/main/java/fr/free/nrw/commons/filepicker/DefaultCallback.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/filepicker/ExtendedFileProvider.java create mode 100644 app/src/main/java/fr/free/nrw/commons/filepicker/ExtendedFileProvider.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.java create mode 100644 app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/filepicker/FilePickerConfiguration.java create mode 100644 app/src/main/java/fr/free/nrw/commons/filepicker/FilePickerConfiguration.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/filepicker/MimeTypeMapWrapper.java create mode 100644 app/src/main/java/fr/free/nrw/commons/filepicker/MimeTypeMapWrapper.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/filepicker/PickedFiles.java create mode 100644 app/src/main/java/fr/free/nrw/commons/filepicker/PickedFiles.kt delete mode 100644 app/src/main/java/fr/free/nrw/commons/filepicker/UploadableFile.java create mode 100644 app/src/main/java/fr/free/nrw/commons/filepicker/UploadableFile.kt delete mode 100644 app/src/test/kotlin/fr/free/nrw/commons/filepicker/ShadowFileProvider.java create mode 100644 app/src/test/kotlin/fr/free/nrw/commons/filepicker/ShadowFileProvider.kt diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/Constants.java b/app/src/main/java/fr/free/nrw/commons/filepicker/Constants.java deleted file mode 100644 index 97a16acc3..000000000 --- a/app/src/main/java/fr/free/nrw/commons/filepicker/Constants.java +++ /dev/null @@ -1,23 +0,0 @@ -package fr.free.nrw.commons.filepicker; - -public interface Constants { - String DEFAULT_FOLDER_NAME = "CommonsContributions"; - - /** - * Provides the request codes for permission handling - */ - interface RequestCodes { - int LOCATION = 1; - int STORAGE = 2; - } - - /** - * Provides locations as string for corresponding operations - */ - interface BundleKeys { - String FOLDER_NAME = "fr.free.nrw.commons.folder_name"; - String ALLOW_MULTIPLE = "fr.free.nrw.commons.allow_multiple"; - String COPY_TAKEN_PHOTOS = "fr.free.nrw.commons.copy_taken_photos"; - String COPY_PICKED_IMAGES = "fr.free.nrw.commons.copy_picked_images"; - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/Costants.kt b/app/src/main/java/fr/free/nrw/commons/filepicker/Costants.kt new file mode 100644 index 000000000..e405a6d52 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/filepicker/Costants.kt @@ -0,0 +1,29 @@ +package fr.free.nrw.commons.filepicker + +interface Constants { + companion object { + const val DEFAULT_FOLDER_NAME = "CommonsContributions" + } + + /** + * Provides the request codes for permission handling + */ + interface RequestCodes { + companion object { + const val LOCATION = 1 + const val STORAGE = 2 + } + } + + /** + * Provides locations as string for corresponding operations + */ + interface BundleKeys { + companion object { + const val FOLDER_NAME = "fr.free.nrw.commons.folder_name" + const val ALLOW_MULTIPLE = "fr.free.nrw.commons.allow_multiple" + const val COPY_TAKEN_PHOTOS = "fr.free.nrw.commons.copy_taken_photos" + const val COPY_PICKED_IMAGES = "fr.free.nrw.commons.copy_picked_images" + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/DefaultCallback.java b/app/src/main/java/fr/free/nrw/commons/filepicker/DefaultCallback.java deleted file mode 100644 index e8373dc6f..000000000 --- a/app/src/main/java/fr/free/nrw/commons/filepicker/DefaultCallback.java +++ /dev/null @@ -1,16 +0,0 @@ -package fr.free.nrw.commons.filepicker; - -/** - * Provides abstract methods which are overridden while handling Contribution Results - * inside the ContributionsController - */ -public abstract class DefaultCallback implements FilePicker.Callbacks { - - @Override - public void onImagePickerError(Exception e, FilePicker.ImageSource source, int type) { - } - - @Override - public void onCanceled(FilePicker.ImageSource source, int type) { - } -} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/DefaultCallback.kt b/app/src/main/java/fr/free/nrw/commons/filepicker/DefaultCallback.kt new file mode 100644 index 000000000..baaba67b5 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/filepicker/DefaultCallback.kt @@ -0,0 +1,12 @@ +package fr.free.nrw.commons.filepicker + +/** + * Provides abstract methods which are overridden while handling Contribution Results + * inside the ContributionsController + */ +abstract class DefaultCallback: FilePicker.Callbacks { + + override fun onImagePickerError(e: Exception, source: FilePicker.ImageSource, type: Int) {} + + override fun onCanceled(source: FilePicker.ImageSource, type: Int) {} +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/ExtendedFileProvider.java b/app/src/main/java/fr/free/nrw/commons/filepicker/ExtendedFileProvider.java deleted file mode 100644 index af3dc8622..000000000 --- a/app/src/main/java/fr/free/nrw/commons/filepicker/ExtendedFileProvider.java +++ /dev/null @@ -1,7 +0,0 @@ -package fr.free.nrw.commons.filepicker; - -import androidx.core.content.FileProvider; - -public class ExtendedFileProvider extends FileProvider { - -} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/ExtendedFileProvider.kt b/app/src/main/java/fr/free/nrw/commons/filepicker/ExtendedFileProvider.kt new file mode 100644 index 000000000..746058fc4 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/filepicker/ExtendedFileProvider.kt @@ -0,0 +1,5 @@ +package fr.free.nrw.commons.filepicker + +import androidx.core.content.FileProvider + +class ExtendedFileProvider: FileProvider() {} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.java b/app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.java deleted file mode 100644 index b64db24c5..000000000 --- a/app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.java +++ /dev/null @@ -1,355 +0,0 @@ -package fr.free.nrw.commons.filepicker; - -import static fr.free.nrw.commons.filepicker.PickedFiles.singleFileList; - -import android.app.Activity; -import android.content.ClipData; -import android.content.Context; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.pm.PackageManager; -import android.content.pm.ResolveInfo; -import android.net.Uri; -import android.provider.MediaStore; -import android.text.TextUtils; -import androidx.activity.result.ActivityResult; -import androidx.activity.result.ActivityResultLauncher; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.preference.PreferenceManager; -import fr.free.nrw.commons.customselector.model.Image; -import fr.free.nrw.commons.customselector.ui.selector.CustomSelectorActivity; -import java.io.File; -import java.io.IOException; -import java.net.URISyntaxException; -import java.util.ArrayList; -import java.util.List; - -public class FilePicker implements Constants { - - private static final String KEY_PHOTO_URI = "photo_uri"; - private static final String KEY_VIDEO_URI = "video_uri"; - private static final String KEY_LAST_CAMERA_PHOTO = "last_photo"; - private static final String KEY_LAST_CAMERA_VIDEO = "last_video"; - private static final String KEY_TYPE = "type"; - - /** - * Returns the uri of the clicked image so that it can be put in MediaStore - */ - private static Uri createCameraPictureFile(@NonNull Context context) throws IOException { - File imagePath = PickedFiles.getCameraPicturesLocation(context); - Uri uri = PickedFiles.getUriToFile(context, imagePath); - SharedPreferences.Editor editor = PreferenceManager.getDefaultSharedPreferences(context).edit(); - editor.putString(KEY_PHOTO_URI, uri.toString()); - editor.putString(KEY_LAST_CAMERA_PHOTO, imagePath.toString()); - editor.apply(); - return uri; - } - - private static Intent createGalleryIntent(@NonNull Context context, int type, - boolean openDocumentIntentPreferred) { - // storing picked image type to shared preferences - storeType(context, type); - //Supported types are SVG, PNG and JPEG,GIF, TIFF, WebP, XCF - final String[] mimeTypes = { "image/jpg","image/png","image/jpeg", "image/gif", "image/tiff", "image/webp", "image/xcf", "image/svg+xml", "image/webp"}; - return plainGalleryPickerIntent(openDocumentIntentPreferred) - .putExtra(Intent.EXTRA_ALLOW_MULTIPLE, configuration(context).allowsMultiplePickingInGallery()) - .putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes); - } - - /** - * CreateCustomSectorIntent, creates intent for custom selector activity. - * @param context - * @param type - * @return Custom selector intent - */ - private static Intent createCustomSelectorIntent(@NonNull Context context, int type) { - storeType(context, type); - return new Intent(context, CustomSelectorActivity.class); - } - - private static Intent createCameraForImageIntent(@NonNull Context context, int type) { - storeType(context, type); - - Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); - try { - Uri capturedImageUri = createCameraPictureFile(context); - //We have to explicitly grant the write permission since Intent.setFlag works only on API Level >=20 - grantWritePermission(context, intent, capturedImageUri); - intent.putExtra(MediaStore.EXTRA_OUTPUT, capturedImageUri); - } catch (Exception e) { - e.printStackTrace(); - } - - return intent; - } - - private static void revokeWritePermission(@NonNull Context context, Uri uri) { - context.revokeUriPermission(uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION); - } - - private static void grantWritePermission(@NonNull Context context, Intent intent, Uri uri) { - List resInfoList = context.getPackageManager().queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY); - for (ResolveInfo resolveInfo : resInfoList) { - String packageName = resolveInfo.activityInfo.packageName; - context.grantUriPermission(packageName, uri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION); - } - } - - private static void storeType(@NonNull Context context, int type) { - PreferenceManager.getDefaultSharedPreferences(context).edit().putInt(KEY_TYPE, type).apply(); - } - - private static int restoreType(@NonNull Context context) { - return PreferenceManager.getDefaultSharedPreferences(context).getInt(KEY_TYPE, 0); - } - - /** - * Opens default galery or a available galleries picker if there is no default - * - * @param type Custom type of your choice, which will be returned with the images - */ - public static void openGallery(Activity activity, ActivityResultLauncher resultLauncher, int type, boolean openDocumentIntentPreferred) { - Intent intent = createGalleryIntent(activity, type, openDocumentIntentPreferred); - resultLauncher.launch(intent); - } - - /** - * Opens Custom Selector - */ - public static void openCustomSelector(Activity activity, ActivityResultLauncher resultLauncher, int type) { - Intent intent = createCustomSelectorIntent(activity, type); - resultLauncher.launch(intent); - } - - /** - * Opens the camera app to pick image clicked by user - */ - public static void openCameraForImage(Activity activity, ActivityResultLauncher resultLauncher, int type) { - Intent intent = createCameraForImageIntent(activity, type); - resultLauncher.launch(intent); - } - - @Nullable - private static UploadableFile takenCameraPicture(Context context) throws URISyntaxException { - String lastCameraPhoto = PreferenceManager.getDefaultSharedPreferences(context).getString(KEY_LAST_CAMERA_PHOTO, null); - if (lastCameraPhoto != null) { - return new UploadableFile(new File(lastCameraPhoto)); - } else { - return null; - } - } - - @Nullable - private static UploadableFile takenCameraVideo(Context context) throws URISyntaxException { - String lastCameraPhoto = PreferenceManager.getDefaultSharedPreferences(context).getString(KEY_LAST_CAMERA_VIDEO, null); - if (lastCameraPhoto != null) { - return new UploadableFile(new File(lastCameraPhoto)); - } else { - return null; - } - } - - public static List handleExternalImagesPicked(Intent data, Activity activity) { - try { - return getFilesFromGalleryPictures(data, activity); - } catch (IOException | SecurityException e) { - e.printStackTrace(); - } - return new ArrayList<>(); - } - - private static boolean isPhoto(Intent data) { - return data == null || (data.getData() == null && data.getClipData() == null); - } - - private static Intent plainGalleryPickerIntent(boolean openDocumentIntentPreferred) { - /* - * Asking for ACCESS_MEDIA_LOCATION at runtime solved the location-loss issue - * in the custom selector in Contributions fragment. - * Detailed discussion: https://github.com/commons-app/apps-android-commons/issues/5015 - * - * This permission check, however, was insufficient to fix location-loss in - * the regular selector in Contributions fragment and Nearby fragment, - * especially on some devices running Android 13 that use the new Photo Picker by default. - * - * New Photo Picker: https://developer.android.com/training/data-storage/shared/photopicker - * - * The new Photo Picker introduced by Android redacts location tags from EXIF metadata. - * Reported on the Google Issue Tracker: https://issuetracker.google.com/issues/243294058 - * Status: Won't fix (Intended behaviour) - * - * Switched intent from ACTION_GET_CONTENT to ACTION_OPEN_DOCUMENT (by default; can - * be changed through the Setting page) as: - * - * ACTION_GET_CONTENT opens the 'best application' for choosing that kind of data - * The best application is the new Photo Picker that redacts the location tags - * - * ACTION_OPEN_DOCUMENT, however, displays the various DocumentsProvider instances - * installed on the device, letting the user interactively navigate through them. - * - * So, this allows us to use the traditional file picker that does not redact location tags - * from EXIF. - * - */ - Intent intent; - if (openDocumentIntentPreferred) { - intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); - } else { - intent = new Intent(Intent.ACTION_GET_CONTENT); - } - intent.setType("image/*"); - return intent; - } - - public static void onPictureReturnedFromDocuments(ActivityResult result, Activity activity, @NonNull FilePicker.Callbacks callbacks) { - if(result.getResultCode() == Activity.RESULT_OK && !isPhoto(result.getData())){ - try { - Uri photoPath = result.getData().getData(); - UploadableFile photoFile = PickedFiles.pickedExistingPicture(activity, photoPath); - callbacks.onImagesPicked(singleFileList(photoFile), FilePicker.ImageSource.DOCUMENTS, restoreType(activity)); - - if (configuration(activity).shouldCopyPickedImagesToPublicGalleryAppFolder()) { - PickedFiles.copyFilesInSeparateThread(activity, singleFileList(photoFile)); - } - } catch (Exception e) { - e.printStackTrace(); - callbacks.onImagePickerError(e, FilePicker.ImageSource.DOCUMENTS, restoreType(activity)); - } - } else { - callbacks.onCanceled(FilePicker.ImageSource.DOCUMENTS, restoreType(activity)); - } - } - - /** - * onPictureReturnedFromCustomSelector. - * Retrieve and forward the images to upload wizard through callback. - */ - public static void onPictureReturnedFromCustomSelector(ActivityResult result, Activity activity, @NonNull FilePicker.Callbacks callbacks) { - if(result.getResultCode() == Activity.RESULT_OK){ - try { - List files = getFilesFromCustomSelector(result.getData(), activity); - callbacks.onImagesPicked(files, ImageSource.CUSTOM_SELECTOR, restoreType(activity)); - } catch (Exception e) { - e.printStackTrace(); - callbacks.onImagePickerError(e, ImageSource.CUSTOM_SELECTOR, restoreType(activity)); - } - } else { - callbacks.onCanceled(ImageSource.CUSTOM_SELECTOR, restoreType(activity)); - } - } - - /** - * Get files from custom selector - * Retrieve and process the selected images from the custom selector. - */ - private static List getFilesFromCustomSelector(Intent data, Activity activity) throws IOException, SecurityException { - List files = new ArrayList<>(); - ArrayList images = data.getParcelableArrayListExtra("Images"); - for(Image image : images) { - Uri uri = image.getUri(); - UploadableFile file = PickedFiles.pickedExistingPicture(activity, uri); - files.add(file); - } - - if (configuration(activity).shouldCopyPickedImagesToPublicGalleryAppFolder()) { - PickedFiles.copyFilesInSeparateThread(activity, files); - } - - return files; - } - - public static void onPictureReturnedFromGallery(ActivityResult result, Activity activity, @NonNull FilePicker.Callbacks callbacks) { - if(result.getResultCode() == Activity.RESULT_OK && !isPhoto(result.getData())){ - try { - List files = getFilesFromGalleryPictures(result.getData(), activity); - callbacks.onImagesPicked(files, FilePicker.ImageSource.GALLERY, restoreType(activity)); - } catch (Exception e) { - e.printStackTrace(); - callbacks.onImagePickerError(e, FilePicker.ImageSource.GALLERY, restoreType(activity)); - } - } else{ - callbacks.onCanceled(FilePicker.ImageSource.GALLERY, restoreType(activity)); - } - } - - private static List getFilesFromGalleryPictures(Intent data, Activity activity) throws IOException, SecurityException { - List files = new ArrayList<>(); - ClipData clipData = data.getClipData(); - if (clipData == null) { - Uri uri = data.getData(); - UploadableFile file = PickedFiles.pickedExistingPicture(activity, uri); - files.add(file); - } else { - for (int i = 0; i < clipData.getItemCount(); i++) { - Uri uri = clipData.getItemAt(i).getUri(); - UploadableFile file = PickedFiles.pickedExistingPicture(activity, uri); - files.add(file); - } - } - - if (configuration(activity).shouldCopyPickedImagesToPublicGalleryAppFolder()) { - PickedFiles.copyFilesInSeparateThread(activity, files); - } - - return files; - } - - public static void onPictureReturnedFromCamera(ActivityResult activityResult, Activity activity, @NonNull FilePicker.Callbacks callbacks) { - if(activityResult.getResultCode() == Activity.RESULT_OK){ - try { - String lastImageUri = PreferenceManager.getDefaultSharedPreferences(activity).getString(KEY_PHOTO_URI, null); - if (!TextUtils.isEmpty(lastImageUri)) { - revokeWritePermission(activity, Uri.parse(lastImageUri)); - } - - UploadableFile photoFile = FilePicker.takenCameraPicture(activity); - List files = new ArrayList<>(); - files.add(photoFile); - - if (photoFile == null) { - Exception e = new IllegalStateException("Unable to get the picture returned from camera"); - callbacks.onImagePickerError(e, FilePicker.ImageSource.CAMERA_IMAGE, restoreType(activity)); - } else { - if (configuration(activity).shouldCopyTakenPhotosToPublicGalleryAppFolder()) { - PickedFiles.copyFilesInSeparateThread(activity, singleFileList(photoFile)); - } - - callbacks.onImagesPicked(files, FilePicker.ImageSource.CAMERA_IMAGE, restoreType(activity)); - } - - PreferenceManager.getDefaultSharedPreferences(activity) - .edit() - .remove(KEY_LAST_CAMERA_PHOTO) - .remove(KEY_PHOTO_URI) - .apply(); - } catch (Exception e) { - e.printStackTrace(); - callbacks.onImagePickerError(e, FilePicker.ImageSource.CAMERA_IMAGE, restoreType(activity)); - } - } else { - callbacks.onCanceled(FilePicker.ImageSource.CAMERA_IMAGE, restoreType(activity)); - } - } - - public static FilePickerConfiguration configuration(@NonNull Context context) { - return new FilePickerConfiguration(context); - } - - - public enum ImageSource { - GALLERY, DOCUMENTS, CAMERA_IMAGE, CAMERA_VIDEO, CUSTOM_SELECTOR - } - - public interface Callbacks { - void onImagePickerError(Exception e, FilePicker.ImageSource source, int type); - - void onImagesPicked(@NonNull List imageFiles, FilePicker.ImageSource source, int type); - - void onCanceled(FilePicker.ImageSource source, int type); - } - - public interface HandleActivityResult{ - void onHandleActivityResult(FilePicker.Callbacks callbacks); - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.kt b/app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.kt new file mode 100644 index 000000000..6bf8a1061 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.kt @@ -0,0 +1,441 @@ +package fr.free.nrw.commons.filepicker + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.provider.MediaStore +import androidx.activity.result.ActivityResult +import androidx.activity.result.ActivityResultLauncher +import androidx.preference.PreferenceManager +import fr.free.nrw.commons.customselector.model.Image +import fr.free.nrw.commons.customselector.ui.selector.CustomSelectorActivity +import fr.free.nrw.commons.filepicker.PickedFiles.singleFileList +import java.io.File +import java.io.IOException +import java.net.URISyntaxException + + +object FilePicker : Constants { + + private const val KEY_PHOTO_URI = "photo_uri" + private const val KEY_VIDEO_URI = "video_uri" + private const val KEY_LAST_CAMERA_PHOTO = "last_photo" + private const val KEY_LAST_CAMERA_VIDEO = "last_video" + private const val KEY_TYPE = "type" + + /** + * Returns the uri of the clicked image so that it can be put in MediaStore + */ + @Throws(IOException::class) + @JvmStatic + private fun createCameraPictureFile(context: Context): Uri { + val imagePath = PickedFiles.getCameraPicturesLocation(context) + val uri = PickedFiles.getUriToFile(context, imagePath) + val editor = PreferenceManager.getDefaultSharedPreferences(context).edit() + editor.putString(KEY_PHOTO_URI, uri.toString()) + editor.putString(KEY_LAST_CAMERA_PHOTO, imagePath.toString()) + editor.apply() + return uri + } + + + @JvmStatic + private fun createGalleryIntent( + context: Context, + type: Int, + openDocumentIntentPreferred: Boolean + ): Intent { + // storing picked image type to shared preferences + storeType(context, type) + // Supported types are SVG, PNG and JPEG, GIF, TIFF, WebP, XCF + val mimeTypes = arrayOf( + "image/jpg", + "image/png", + "image/jpeg", + "image/gif", + "image/tiff", + "image/webp", + "image/xcf", + "image/svg+xml", + "image/webp" + ) + return plainGalleryPickerIntent(openDocumentIntentPreferred) + .putExtra( + Intent.EXTRA_ALLOW_MULTIPLE, + configuration(context).allowsMultiplePickingInGallery() + ) + .putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes) + } + + /** + * CreateCustomSectorIntent, creates intent for custom selector activity. + * @param context + * @param type + * @return Custom selector intent + */ + @JvmStatic + private fun createCustomSelectorIntent(context: Context, type: Int): Intent { + storeType(context, type) + return Intent(context, CustomSelectorActivity::class.java) + } + + @JvmStatic + private fun createCameraForImageIntent(context: Context, type: Int): Intent { + storeType(context, type) + + val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE) + try { + val capturedImageUri = createCameraPictureFile(context) + // We have to explicitly grant the write permission since Intent.setFlag works only on API Level >=20 + grantWritePermission(context, intent, capturedImageUri) + intent.putExtra(MediaStore.EXTRA_OUTPUT, capturedImageUri) + } catch (e: Exception) { + e.printStackTrace() + } + + return intent + } + + @JvmStatic + private fun revokeWritePermission(context: Context, uri: Uri) { + context.revokeUriPermission( + uri, + Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } + + @JvmStatic + private fun grantWritePermission(context: Context, intent: Intent, uri: Uri) { + val resInfoList = + context.packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY) + for (resolveInfo in resInfoList) { + val packageName = resolveInfo.activityInfo.packageName + context.grantUriPermission( + packageName, + uri, + Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } + } + + @JvmStatic + private fun storeType(context: Context, type: Int) { + PreferenceManager.getDefaultSharedPreferences(context).edit().putInt(KEY_TYPE, type).apply() + } + + @JvmStatic + private fun restoreType(context: Context): Int { + return PreferenceManager.getDefaultSharedPreferences(context).getInt(KEY_TYPE, 0) + } + + /** + * Opens default gallery or available galleries picker if there is no default + * + * @param type Custom type of your choice, which will be returned with the images + */ + @JvmStatic + fun openGallery( + activity: Activity, + resultLauncher: ActivityResultLauncher, + type: Int, + openDocumentIntentPreferred: Boolean + ) { + val intent = createGalleryIntent(activity, type, openDocumentIntentPreferred) + resultLauncher.launch(intent) + } + + /** + * Opens Custom Selector + */ + @JvmStatic + fun openCustomSelector( + activity: Activity, + resultLauncher: ActivityResultLauncher, + type: Int + ) { + val intent = createCustomSelectorIntent(activity, type) + resultLauncher.launch(intent) + } + + /** + * Opens the camera app to pick image clicked by user + */ + @JvmStatic + fun openCameraForImage( + activity: Activity, + resultLauncher: ActivityResultLauncher, + type: Int + ) { + val intent = createCameraForImageIntent(activity, type) + resultLauncher.launch(intent) + } + + @Throws(URISyntaxException::class) + @JvmStatic + private fun takenCameraPicture(context: Context): UploadableFile? { + val lastCameraPhoto = PreferenceManager.getDefaultSharedPreferences(context) + .getString(KEY_LAST_CAMERA_PHOTO, null) + return if (lastCameraPhoto != null) { + UploadableFile(File(lastCameraPhoto)) + } else { + null + } + } + + @Throws(URISyntaxException::class) + @JvmStatic + private fun takenCameraVideo(context: Context): UploadableFile? { + val lastCameraVideo = PreferenceManager.getDefaultSharedPreferences(context) + .getString(KEY_LAST_CAMERA_VIDEO, null) + return if (lastCameraVideo != null) { + UploadableFile(File(lastCameraVideo)) + } else { + null + } + } + + @JvmStatic + fun handleExternalImagesPicked(data: Intent?, activity: Activity): List { + return try { + getFilesFromGalleryPictures(data, activity) + } catch (e: IOException) { + e.printStackTrace() + emptyList() + } catch (e: SecurityException) { + e.printStackTrace() + emptyList() + } + } + + @JvmStatic + private fun isPhoto(data: Intent?): Boolean { + return data == null || (data.data == null && data.clipData == null) + } + + @JvmStatic + private fun plainGalleryPickerIntent( + openDocumentIntentPreferred: Boolean + ): Intent { + /* + * Asking for ACCESS_MEDIA_LOCATION at runtime solved the location-loss issue + * in the custom selector in Contributions fragment. + * Detailed discussion: https://github.com/commons-app/apps-android-commons/issues/5015 + * + * This permission check, however, was insufficient to fix location-loss in + * the regular selector in Contributions fragment and Nearby fragment, + * especially on some devices running Android 13 that use the new Photo Picker by default. + * + * New Photo Picker: https://developer.android.com/training/data-storage/shared/photopicker + * + * The new Photo Picker introduced by Android redacts location tags from EXIF metadata. + * Reported on the Google Issue Tracker: https://issuetracker.google.com/issues/243294058 + * Status: Won't fix (Intended behaviour) + * + * Switched intent from ACTION_GET_CONTENT to ACTION_OPEN_DOCUMENT (by default; can + * be changed through the Setting page) as: + * + * ACTION_GET_CONTENT opens the 'best application' for choosing that kind of data + * The best application is the new Photo Picker that redacts the location tags + * + * ACTION_OPEN_DOCUMENT, however, displays the various DocumentsProvider instances + * installed on the device, letting the user interactively navigate through them. + * + * So, this allows us to use the traditional file picker that does not redact location tags + * from EXIF. + * + */ + val intent = if (openDocumentIntentPreferred) { + Intent(Intent.ACTION_OPEN_DOCUMENT) + } else { + Intent(Intent.ACTION_GET_CONTENT) + } + intent.type = "image/*" + return intent + } + + @JvmStatic + fun onPictureReturnedFromDocuments( + result: ActivityResult, + activity: Activity, + callbacks: Callbacks + ) { + if (result.resultCode == Activity.RESULT_OK && !isPhoto(result.data)) { + try { + val photoPath = result.data?.data + val photoFile = PickedFiles.pickedExistingPicture(activity, photoPath!!) + callbacks.onImagesPicked( + singleFileList(photoFile), + ImageSource.DOCUMENTS, + restoreType(activity) + ) + + if (configuration(activity).shouldCopyPickedImagesToPublicGalleryAppFolder()) { + PickedFiles.copyFilesInSeparateThread(activity, singleFileList(photoFile)) + } + } catch (e: Exception) { + e.printStackTrace() + callbacks.onImagePickerError(e, ImageSource.DOCUMENTS, restoreType(activity)) + } + } else { + callbacks.onCanceled(ImageSource.DOCUMENTS, restoreType(activity)) + } + } + + /** + * onPictureReturnedFromCustomSelector. + * Retrieve and forward the images to upload wizard through callback. + */ + @JvmStatic + fun onPictureReturnedFromCustomSelector( + result: ActivityResult, + activity: Activity, + callbacks: Callbacks + ) { + if (result.resultCode == Activity.RESULT_OK) { + try { + val files = getFilesFromCustomSelector(result.data, activity) + callbacks.onImagesPicked(files, ImageSource.CUSTOM_SELECTOR, restoreType(activity)) + } catch (e: Exception) { + e.printStackTrace() + callbacks.onImagePickerError(e, ImageSource.CUSTOM_SELECTOR, restoreType(activity)) + } + } else { + callbacks.onCanceled(ImageSource.CUSTOM_SELECTOR, restoreType(activity)) + } + } + + /** + * Get files from custom selector + * Retrieve and process the selected images from the custom selector. + */ + @Throws(IOException::class, SecurityException::class) + @JvmStatic + private fun getFilesFromCustomSelector( + data: Intent?, + activity: Activity + ): List { + val files = mutableListOf() + val images = data?.getParcelableArrayListExtra("Images") + images?.forEach { image -> + val uri = image.uri + val file = PickedFiles.pickedExistingPicture(activity, uri) + files.add(file) + } + + if (configuration(activity).shouldCopyPickedImagesToPublicGalleryAppFolder()) { + PickedFiles.copyFilesInSeparateThread(activity, files) + } + + return files + } + + @JvmStatic + fun onPictureReturnedFromGallery( + result: ActivityResult, + activity: Activity, + callbacks: Callbacks + ) { + if (result.resultCode == Activity.RESULT_OK && !isPhoto(result.data)) { + try { + val files = getFilesFromGalleryPictures(result.data, activity) + callbacks.onImagesPicked(files, ImageSource.GALLERY, restoreType(activity)) + } catch (e: Exception) { + e.printStackTrace() + callbacks.onImagePickerError(e, ImageSource.GALLERY, restoreType(activity)) + } + } else { + callbacks.onCanceled(ImageSource.GALLERY, restoreType(activity)) + } + } + + @Throws(IOException::class, SecurityException::class) + @JvmStatic + private fun getFilesFromGalleryPictures( + data: Intent?, + activity: Activity + ): List { + val files = mutableListOf() + val clipData = data?.clipData + if (clipData == null) { + val uri = data?.data + val file = PickedFiles.pickedExistingPicture(activity, uri!!) + files.add(file) + } else { + for (i in 0 until clipData.itemCount) { + val uri = clipData.getItemAt(i).uri + val file = PickedFiles.pickedExistingPicture(activity, uri) + files.add(file) + } + } + + if (configuration(activity).shouldCopyPickedImagesToPublicGalleryAppFolder()) { + PickedFiles.copyFilesInSeparateThread(activity, files) + } + + return files + } + + @JvmStatic + fun onPictureReturnedFromCamera( + activityResult: ActivityResult, + activity: Activity, + callbacks: Callbacks + ) { + if (activityResult.resultCode == Activity.RESULT_OK) { + try { + val lastImageUri = PreferenceManager.getDefaultSharedPreferences(activity) + .getString(KEY_PHOTO_URI, null) + if (!lastImageUri.isNullOrEmpty()) { + revokeWritePermission(activity, Uri.parse(lastImageUri)) + } + + val photoFile = takenCameraPicture(activity) + val files = mutableListOf() + photoFile?.let { files.add(it) } + + if (photoFile == null) { + val e = IllegalStateException("Unable to get the picture returned from camera") + callbacks.onImagePickerError(e, ImageSource.CAMERA_IMAGE, restoreType(activity)) + } else { + if (configuration(activity).shouldCopyTakenPhotosToPublicGalleryAppFolder()) { + PickedFiles.copyFilesInSeparateThread(activity, singleFileList(photoFile)) + } + callbacks.onImagesPicked(files, ImageSource.CAMERA_IMAGE, restoreType(activity)) + } + + PreferenceManager.getDefaultSharedPreferences(activity).edit() + .remove(KEY_LAST_CAMERA_PHOTO) + .remove(KEY_PHOTO_URI) + .apply() + } catch (e: Exception) { + e.printStackTrace() + callbacks.onImagePickerError(e, ImageSource.CAMERA_IMAGE, restoreType(activity)) + } + } else { + callbacks.onCanceled(ImageSource.CAMERA_IMAGE, restoreType(activity)) + } + } + + @JvmStatic + fun configuration(context: Context): FilePickerConfiguration { + return FilePickerConfiguration(context) + } + + enum class ImageSource { + GALLERY, DOCUMENTS, CAMERA_IMAGE, CAMERA_VIDEO, CUSTOM_SELECTOR + } + + interface Callbacks { + fun onImagePickerError(e: Exception, source: ImageSource, type: Int) + + fun onImagesPicked(imageFiles: List, source: ImageSource, type: Int) + + fun onCanceled(source: ImageSource, type: Int) + } + + interface HandleActivityResult { + fun onHandleActivityResult(callbacks: Callbacks) + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/FilePickerConfiguration.java b/app/src/main/java/fr/free/nrw/commons/filepicker/FilePickerConfiguration.java deleted file mode 100644 index 08a204e8b..000000000 --- a/app/src/main/java/fr/free/nrw/commons/filepicker/FilePickerConfiguration.java +++ /dev/null @@ -1,44 +0,0 @@ -package fr.free.nrw.commons.filepicker; - -import android.content.Context; -import androidx.preference.PreferenceManager; - -public class FilePickerConfiguration implements Constants { - - private Context context; - - FilePickerConfiguration(Context context) { - this.context = context; - } - - public FilePickerConfiguration setAllowMultiplePickInGallery(boolean allowMultiple) { - PreferenceManager.getDefaultSharedPreferences(context).edit() - .putBoolean(BundleKeys.ALLOW_MULTIPLE, allowMultiple) - .apply(); - return this; - } - - public FilePickerConfiguration setCopyTakenPhotosToPublicGalleryAppFolder(boolean copy) { - PreferenceManager.getDefaultSharedPreferences(context).edit() - .putBoolean(BundleKeys.COPY_TAKEN_PHOTOS, copy) - .apply(); - return this; - } - - public String getFolderName() { - return PreferenceManager.getDefaultSharedPreferences(context).getString(BundleKeys.FOLDER_NAME, DEFAULT_FOLDER_NAME); - } - - public boolean allowsMultiplePickingInGallery() { - return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(BundleKeys.ALLOW_MULTIPLE, false); - } - - public boolean shouldCopyTakenPhotosToPublicGalleryAppFolder() { - return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(BundleKeys.COPY_TAKEN_PHOTOS, false); - } - - public boolean shouldCopyPickedImagesToPublicGalleryAppFolder() { - return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(BundleKeys.COPY_PICKED_IMAGES, false); - } - -} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/FilePickerConfiguration.kt b/app/src/main/java/fr/free/nrw/commons/filepicker/FilePickerConfiguration.kt new file mode 100644 index 000000000..db025a544 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/filepicker/FilePickerConfiguration.kt @@ -0,0 +1,46 @@ +package fr.free.nrw.commons.filepicker + +import android.content.Context +import androidx.preference.PreferenceManager + +class FilePickerConfiguration( + private val context: Context +): Constants { + + fun setAllowMultiplePickInGallery(allowMultiple: Boolean): FilePickerConfiguration { + PreferenceManager.getDefaultSharedPreferences(context).edit() + .putBoolean(Constants.BundleKeys.ALLOW_MULTIPLE, allowMultiple) + .apply() + return this + } + + fun setCopyTakenPhotosToPublicGalleryAppFolder(copy: Boolean): FilePickerConfiguration { + PreferenceManager.getDefaultSharedPreferences(context).edit() + .putBoolean(Constants.BundleKeys.COPY_TAKEN_PHOTOS, copy) + .apply() + return this + } + + fun getFolderName(): String { + return PreferenceManager.getDefaultSharedPreferences(context) + .getString( + Constants.BundleKeys.FOLDER_NAME, + Constants.DEFAULT_FOLDER_NAME + ) ?: Constants.DEFAULT_FOLDER_NAME + } + + fun allowsMultiplePickingInGallery(): Boolean { + return PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean(Constants.BundleKeys.ALLOW_MULTIPLE, false) + } + + fun shouldCopyTakenPhotosToPublicGalleryAppFolder(): Boolean { + return PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean(Constants.BundleKeys.COPY_TAKEN_PHOTOS, false) + } + + fun shouldCopyPickedImagesToPublicGalleryAppFolder(): Boolean { + return PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean(Constants.BundleKeys.COPY_PICKED_IMAGES, false) + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/MimeTypeMapWrapper.java b/app/src/main/java/fr/free/nrw/commons/filepicker/MimeTypeMapWrapper.java deleted file mode 100644 index e6c82f5c1..000000000 --- a/app/src/main/java/fr/free/nrw/commons/filepicker/MimeTypeMapWrapper.java +++ /dev/null @@ -1,26 +0,0 @@ -package fr.free.nrw.commons.filepicker; - -import android.webkit.MimeTypeMap; - -import com.facebook.common.internal.ImmutableMap; - -import java.util.Map; - -public class MimeTypeMapWrapper { - - private static final MimeTypeMap sMimeTypeMap = MimeTypeMap.getSingleton(); - - private static final Map sMimeTypeToExtensionMap = - ImmutableMap.of( - "image/heif", "heif", - "image/heic", "heic"); - - public static String getExtensionFromMimeType(String mimeType) { - String result = sMimeTypeToExtensionMap.get(mimeType); - if (result != null) { - return result; - } - return sMimeTypeMap.getExtensionFromMimeType(mimeType); - } - -} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/MimeTypeMapWrapper.kt b/app/src/main/java/fr/free/nrw/commons/filepicker/MimeTypeMapWrapper.kt new file mode 100644 index 000000000..0cf21cc02 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/filepicker/MimeTypeMapWrapper.kt @@ -0,0 +1,24 @@ +package fr.free.nrw.commons.filepicker + +import android.webkit.MimeTypeMap + +class MimeTypeMapWrapper { + + companion object { + private val sMimeTypeMap = MimeTypeMap.getSingleton() + + private val sMimeTypeToExtensionMap = mapOf( + "image/heif" to "heif", + "image/heic" to "heic" + ) + + @JvmStatic + fun getExtensionFromMimeType(mimeType: String): String? { + val result = sMimeTypeToExtensionMap[mimeType] + if (result != null) { + return result + } + return sMimeTypeMap.getExtensionFromMimeType(mimeType) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/PickedFiles.java b/app/src/main/java/fr/free/nrw/commons/filepicker/PickedFiles.java deleted file mode 100644 index ca1abba62..000000000 --- a/app/src/main/java/fr/free/nrw/commons/filepicker/PickedFiles.java +++ /dev/null @@ -1,208 +0,0 @@ -package fr.free.nrw.commons.filepicker; - -import android.content.ContentResolver; -import android.content.Context; -import android.media.MediaScannerConnection; -import android.net.Uri; -import android.os.Environment; -import android.webkit.MimeTypeMap; - -import androidx.annotation.NonNull; -import androidx.core.content.FileProvider; - -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.text.SimpleDateFormat; -import java.util.ArrayList; -import java.util.Calendar; -import java.util.List; -import java.util.UUID; - -import timber.log.Timber; - -/** - * PickedFiles. - * Process the upload items. - */ -public class PickedFiles implements Constants { - - /** - * Get Folder Name - * @param context - * @return default application folder name. - */ - private static String getFolderName(@NonNull Context context) { - return FilePicker.configuration(context).getFolderName(); - } - - /** - * tempImageDirectory - * @param context - * @return temporary image directory to copy and perform exif changes. - */ - private static File tempImageDirectory(@NonNull Context context) { - File privateTempDir = new File(context.getCacheDir(), DEFAULT_FOLDER_NAME); - if (!privateTempDir.exists()) privateTempDir.mkdirs(); - return privateTempDir; - } - - /** - * writeToFile - * writes inputStream data to the destination file. - * @param in input stream of source file. - * @param file destination file - */ - private static void writeToFile(InputStream in, File file) throws IOException { - try (OutputStream out = new FileOutputStream(file)) { - byte[] buf = new byte[1024]; - int len; - while ((len = in.read(buf)) > 0) { - out.write(buf, 0, len); - } - } - } - - /** - * Copy file function. - * Copies source file to destination file. - * @param src source file - * @param dst destination file - * @throws IOException (File input stream exception) - */ - private static void copyFile(File src, File dst) throws IOException { - try (InputStream in = new FileInputStream(src)) { - writeToFile(in, dst); - } - } - - /** - * Copy files in separate thread. - * Copies all the uploadable files to the temp image folder on background thread. - * @param context - * @param filesToCopy uploadable file list to be copied. - */ - static void copyFilesInSeparateThread(final Context context, final List filesToCopy) { - new Thread(() -> { - List copiedFiles = new ArrayList<>(); - int i = 1; - for (UploadableFile uploadableFile : filesToCopy) { - File fileToCopy = uploadableFile.getFile(); - File dstDir = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), getFolderName(context)); - if (!dstDir.exists()) { - dstDir.mkdirs(); - } - - String[] filenameSplit = fileToCopy.getName().split("\\."); - String extension = "." + filenameSplit[filenameSplit.length - 1]; - String filename = String.format("IMG_%s_%d.%s", new SimpleDateFormat("yyyyMMdd_HHmmss").format(Calendar.getInstance().getTime()), i, extension); - - File dstFile = new File(dstDir, filename); - try { - dstFile.createNewFile(); - copyFile(fileToCopy, dstFile); - copiedFiles.add(dstFile); - } catch (IOException e) { - e.printStackTrace(); - } - i++; - } - scanCopiedImages(context, copiedFiles); - }).run(); - } - - /** - * singleFileList. - * converts a single uploadableFile to list of uploadableFile. - * @param file uploadable file - * @return - */ - static List singleFileList(UploadableFile file) { - List list = new ArrayList<>(); - list.add(file); - return list; - } - - /** - * ScanCopiedImages - * Scan copied images metadata using media scanner. - * @param context - * @param copiedImages copied images list. - */ - static void scanCopiedImages(Context context, List copiedImages) { - String[] paths = new String[copiedImages.size()]; - for (int i = 0; i < copiedImages.size(); i++) { - paths[i] = copiedImages.get(i).toString(); - } - - MediaScannerConnection.scanFile(context, - paths, null, - (path, uri) -> { - Timber.d("Scanned " + path + ":"); - Timber.d("-> uri=%s", uri); - }); - } - - /** - * pickedExistingPicture - * convert the image into uploadable file. - * @param photoUri Uri of the image. - * @return Uploadable file ready for tag redaction. - */ - public static UploadableFile pickedExistingPicture(@NonNull Context context, Uri photoUri) throws IOException, SecurityException {// SecurityException for those file providers who share URI but forget to grant necessary permissions - File directory = tempImageDirectory(context); - File photoFile = new File(directory, UUID.randomUUID().toString() + "." + getMimeType(context, photoUri)); - if (photoFile.createNewFile()) { - try (InputStream pictureInputStream = context.getContentResolver().openInputStream(photoUri)) { - writeToFile(pictureInputStream, photoFile); - } - } else { - throw new IOException("could not create photoFile to write upon"); - } - return new UploadableFile(photoUri, photoFile); - } - - /** - * getCameraPictureLocation - */ - static File getCameraPicturesLocation(@NonNull Context context) throws IOException { - File dir = tempImageDirectory(context); - return File.createTempFile(UUID.randomUUID().toString(), ".jpg", dir); - } - - /** - * To find out the extension of required object in given uri - * Solution by http://stackoverflow.com/a/36514823/1171484 - */ - private static String getMimeType(@NonNull Context context, @NonNull Uri uri) { - String extension; - - //Check uri format to avoid null - if (uri.getScheme().equals(ContentResolver.SCHEME_CONTENT)) { - //If scheme is a content - extension = MimeTypeMapWrapper.getExtensionFromMimeType(context.getContentResolver().getType(uri)); - } else { - //If scheme is a File - //This will replace white spaces with %20 and also other special characters. This will avoid returning null values on file name with spaces and special characters. - extension = MimeTypeMap.getFileExtensionFromUrl(Uri.fromFile(new File(uri.getPath())).toString()); - - } - - return extension; - } - - /** - * GetUriToFile - * @param file get uri of file - * @return uri of requested file. - */ - static Uri getUriToFile(@NonNull Context context, @NonNull File file) { - String packageName = context.getApplicationContext().getPackageName(); - String authority = packageName + ".provider"; - return FileProvider.getUriForFile(context, authority, file); - } - -} \ No newline at end of file diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/PickedFiles.kt b/app/src/main/java/fr/free/nrw/commons/filepicker/PickedFiles.kt new file mode 100644 index 000000000..9694dedb5 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/filepicker/PickedFiles.kt @@ -0,0 +1,195 @@ +package fr.free.nrw.commons.filepicker + +import android.content.ContentResolver +import android.content.Context +import android.media.MediaScannerConnection +import android.net.Uri +import android.os.Environment +import android.webkit.MimeTypeMap +import androidx.core.content.FileProvider +import fr.free.nrw.commons.filepicker.Constants.Companion.DEFAULT_FOLDER_NAME +import timber.log.Timber +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.UUID + + +/** + * PickedFiles. + * Process the upload items. + */ +object PickedFiles : Constants { + + /** + * Get Folder Name + * @return default application folder name. + */ + @JvmStatic + private fun getFolderName(context: Context): String { + return FilePicker.configuration(context).getFolderName() + } + + /** + * tempImageDirectory + * @return temporary image directory to copy and perform exif changes. + */ + @JvmStatic + private fun tempImageDirectory(context: Context): File { + val privateTempDir = File(context.cacheDir, DEFAULT_FOLDER_NAME) + if (!privateTempDir.exists()) privateTempDir.mkdirs() + return privateTempDir + } + + /** + * writeToFile + * Writes inputStream data to the destination file. + */ + @JvmStatic + @Throws(IOException::class) + private fun writeToFile(inputStream: InputStream, file: File) { + inputStream.use { input -> + FileOutputStream(file).use { output -> + val buffer = ByteArray(1024) + var length: Int + while (input.read(buffer).also { length = it } > 0) { + output.write(buffer, 0, length) + } + } + } + } + + /** + * Copy file function. + * Copies source file to destination file. + */ + @Throws(IOException::class) + @JvmStatic + private fun copyFile(src: File, dst: File) { + FileInputStream(src).use { inputStream -> + writeToFile(inputStream, dst) + } + } + + /** + * Copy files in separate thread. + * Copies all the uploadable files to the temp image folder on background thread. + */ + @JvmStatic + fun copyFilesInSeparateThread(context: Context, filesToCopy: List) { + Thread { + val copiedFiles = mutableListOf() + var index = 1 + filesToCopy.forEach { uploadableFile -> + val fileToCopy = uploadableFile.file + val dstDir = File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), + getFolderName(context) + ) + if (!dstDir.exists()) dstDir.mkdirs() + + val filenameSplit = fileToCopy.name.split(".") + val extension = ".${filenameSplit.last()}" + val filename = "IMG_${SimpleDateFormat( + "yyyyMMdd_HHmmss", + Locale.getDefault()).format(Date())}_$index$extension" + val dstFile = File(dstDir, filename) + + try { + dstFile.createNewFile() + copyFile(fileToCopy, dstFile) + copiedFiles.add(dstFile) + } catch (e: IOException) { + e.printStackTrace() + } + index++ + } + scanCopiedImages(context, copiedFiles) + }.start() + } + + /** + * singleFileList + * Converts a single uploadableFile to list of uploadableFile. + */ + @JvmStatic + fun singleFileList(file: UploadableFile): List { + return listOf(file) + } + + /** + * ScanCopiedImages + * Scans copied images metadata using media scanner. + */ + @JvmStatic + fun scanCopiedImages(context: Context, copiedImages: List) { + val paths = copiedImages.map { it.toString() }.toTypedArray() + MediaScannerConnection.scanFile(context, paths, null) { path, uri -> + Timber.d("Scanned $path:") + Timber.d("-> uri=$uri") + } + } + + /** + * pickedExistingPicture + * Convert the image into uploadable file. + */ + @Throws(IOException::class, SecurityException::class) + @JvmStatic + fun pickedExistingPicture(context: Context, photoUri: Uri): UploadableFile { + val directory = tempImageDirectory(context) + val mimeType = getMimeType(context, photoUri) + val photoFile = File(directory, "${UUID.randomUUID()}.$mimeType") + + if (photoFile.createNewFile()) { + context.contentResolver.openInputStream(photoUri)?.use { inputStream -> + writeToFile(inputStream, photoFile) + } + } else { + throw IOException("Could not create photoFile to write upon") + } + return UploadableFile(photoUri, photoFile) + } + + /** + * getCameraPictureLocation + */ + @Throws(IOException::class) + @JvmStatic + fun getCameraPicturesLocation(context: Context): File { + val dir = tempImageDirectory(context) + return File.createTempFile(UUID.randomUUID().toString(), ".jpg", dir) + } + + /** + * To find out the extension of the required object in a given uri + */ + @JvmStatic + private fun getMimeType(context: Context, uri: Uri): String { + return if (uri.scheme == ContentResolver.SCHEME_CONTENT) { + context.contentResolver.getType(uri) + ?.let { MimeTypeMapWrapper.getExtensionFromMimeType(it) } + } else { + MimeTypeMap.getFileExtensionFromUrl( + Uri.fromFile(uri.path?.let { File(it) }).toString() + ) + } ?: "jpg" // Default to jpg if unable to determine type + } + + /** + * GetUriToFile + * @param file get uri of file + * @return uri of requested file. + */ + @JvmStatic + fun getUriToFile(context: Context, file: File): Uri { + val packageName = context.applicationContext.packageName + val authority = "$packageName.provider" + return FileProvider.getUriForFile(context, authority, file) + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/UploadableFile.java b/app/src/main/java/fr/free/nrw/commons/filepicker/UploadableFile.java deleted file mode 100644 index 1fe306a8b..000000000 --- a/app/src/main/java/fr/free/nrw/commons/filepicker/UploadableFile.java +++ /dev/null @@ -1,213 +0,0 @@ -package fr.free.nrw.commons.filepicker; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.database.Cursor; -import android.net.Uri; -import android.os.Parcel; -import android.os.Parcelable; - -import androidx.annotation.Nullable; -import androidx.exifinterface.media.ExifInterface; - -import fr.free.nrw.commons.upload.FileUtils; -import java.io.File; -import java.io.IOException; -import java.util.Date; -import timber.log.Timber; - -public class UploadableFile implements Parcelable { - public static final Creator CREATOR = new Creator() { - @Override - public UploadableFile createFromParcel(Parcel in) { - return new UploadableFile(in); - } - - @Override - public UploadableFile[] newArray(int size) { - return new UploadableFile[size]; - } - }; - - private final Uri contentUri; - private final File file; - - public UploadableFile(Uri contentUri, File file) { - this.contentUri = contentUri; - this.file = file; - } - - public UploadableFile(File file) { - this.file = file; - this.contentUri = Uri.fromFile(new File(file.getPath())); - } - - public UploadableFile(Parcel in) { - this.contentUri = in.readParcelable(Uri.class.getClassLoader()); - file = (File) in.readSerializable(); - } - - public Uri getContentUri() { - return contentUri; - } - - public File getFile() { - return file; - } - - public String getFilePath() { - return file.getPath(); - } - - public Uri getMediaUri() { - return Uri.parse(getFilePath()); - } - - public String getMimeType(Context context) { - return FileUtils.getMimeType(context, getMediaUri()); - } - - @Override - public int describeContents() { - return 0; - } - - /** - * First try to get the file creation date from EXIF else fall back to CP - * @param context - * @return - */ - @Nullable - public DateTimeWithSource getFileCreatedDate(Context context) { - DateTimeWithSource dateTimeFromExif = getDateTimeFromExif(); - if (dateTimeFromExif == null) { - return getFileCreatedDateFromCP(context); - } else { - return dateTimeFromExif; - } - } - - /** - * Get filePath creation date from uri from all possible content providers - * - * @return - */ - private DateTimeWithSource getFileCreatedDateFromCP(Context context) { - try { - Cursor cursor = context.getContentResolver().query(contentUri, null, null, null, null); - if (cursor == null) { - return null;//Could not fetch last_modified - } - //Content provider contracts for opening gallery from the app and that by sharing from gallery from outside are different and we need to handle both the cases - int lastModifiedColumnIndex = cursor.getColumnIndex("last_modified");//If gallery is opened from in app - if (lastModifiedColumnIndex == -1) { - lastModifiedColumnIndex = cursor.getColumnIndex("datetaken"); - } - //If both the content providers do not give the data, lets leave it to Jesus - if (lastModifiedColumnIndex == -1) { - cursor.close(); - return null; - } - cursor.moveToFirst(); - return new DateTimeWithSource(cursor.getLong(lastModifiedColumnIndex), DateTimeWithSource.CP_SOURCE); - } catch (Exception e) { - return null;////Could not fetch last_modified - } - } - - /** - * Indicate whether the EXIF contains the location (both latitude and longitude). - * - * @return whether the location exists for the file's EXIF - */ - public boolean hasLocation() { - try { - ExifInterface exif = new ExifInterface(file.getAbsolutePath()); - final String latitude = exif.getAttribute(ExifInterface.TAG_GPS_LATITUDE); - final String longitude = exif.getAttribute(ExifInterface.TAG_GPS_LATITUDE); - return latitude != null && longitude != null; - } catch (IOException | NumberFormatException | IndexOutOfBoundsException e) { - Timber.tag("UploadableFile"); - Timber.d(e); - } - return false; - } - - /** - * Get filePath creation date from uri from EXIF - * - * @return - */ - private DateTimeWithSource getDateTimeFromExif() { - try { - ExifInterface exif = new ExifInterface(file.getAbsolutePath()); - // TAG_DATETIME returns the last edited date, we need TAG_DATETIME_ORIGINAL for creation date - // See issue https://github.com/commons-app/apps-android-commons/issues/1971 - String dateTimeSubString = exif.getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL); - if (dateTimeSubString!=null) { //getAttribute may return null - String year = dateTimeSubString.substring(0,4); - String month = dateTimeSubString.substring(5,7); - String day = dateTimeSubString.substring(8,10); - // This date is stored as a string (not as a date), the rason is we don't want to include timezones - String dateCreatedString = String.format("%04d-%02d-%02d", Integer.parseInt(year), Integer.parseInt(month), Integer.parseInt(day)); - if (dateCreatedString.length() == 10) { //yyyy-MM-dd format of date is expected - @SuppressLint("RestrictedApi") Long dateTime = exif.getDateTimeOriginal(); - if(dateTime != null){ - Date date = new Date(dateTime); - return new DateTimeWithSource(date, dateCreatedString, DateTimeWithSource.EXIF_SOURCE); - } - } - } - } catch (IOException | NumberFormatException | IndexOutOfBoundsException e) { - Timber.tag("UploadableFile"); - Timber.d(e); - } - return null; - } - - @Override - public void writeToParcel(Parcel parcel, int i) { - parcel.writeParcelable(contentUri, 0); - parcel.writeSerializable(file); - } - - /** - * This class contains the epochDate along with the source from which it was extracted - */ - public class DateTimeWithSource { - public static final String CP_SOURCE = "contentProvider"; - public static final String EXIF_SOURCE = "exif"; - - private final long epochDate; - private String dateString; // this does not includes timezone information - private final String source; - - public DateTimeWithSource(long epochDate, String source) { - this.epochDate = epochDate; - this.source = source; - } - - public DateTimeWithSource(Date date, String source) { - this.epochDate = date.getTime(); - this.source = source; - } - - public DateTimeWithSource(Date date, String dateString, String source) { - this.epochDate = date.getTime(); - this.dateString = dateString; - this.source = source; - } - - public long getEpochDate() { - return epochDate; - } - - public String getDateString() { - return dateString; - } - - public String getSource() { - return source; - } - } -} diff --git a/app/src/main/java/fr/free/nrw/commons/filepicker/UploadableFile.kt b/app/src/main/java/fr/free/nrw/commons/filepicker/UploadableFile.kt new file mode 100644 index 000000000..1398e7785 --- /dev/null +++ b/app/src/main/java/fr/free/nrw/commons/filepicker/UploadableFile.kt @@ -0,0 +1,168 @@ +package fr.free.nrw.commons.filepicker + +import android.annotation.SuppressLint +import android.content.Context +import android.database.Cursor +import android.net.Uri +import android.os.Parcel +import android.os.Parcelable + +import androidx.exifinterface.media.ExifInterface + +import fr.free.nrw.commons.upload.FileUtils +import java.io.File +import java.io.IOException +import java.util.Date +import timber.log.Timber + +class UploadableFile : Parcelable { + + val contentUri: Uri + val file: File + + constructor(contentUri: Uri, file: File) { + this.contentUri = contentUri + this.file = file + } + + constructor(file: File) { + this.file = file + this.contentUri = Uri.fromFile(File(file.path)) + } + + private constructor(parcel: Parcel) { + contentUri = parcel.readParcelable(Uri::class.java.classLoader)!! + file = parcel.readSerializable() as File + } + + fun getFilePath(): String { + return file.path + } + + fun getMediaUri(): Uri { + return Uri.parse(getFilePath()) + } + + fun getMimeType(context: Context): String? { + return FileUtils.getMimeType(context, getMediaUri()) + } + + override fun describeContents(): Int = 0 + + /** + * First try to get the file creation date from EXIF, else fall back to Content Provider (CP) + */ + fun getFileCreatedDate(context: Context): DateTimeWithSource? { + return getDateTimeFromExif() ?: getFileCreatedDateFromCP(context) + } + + /** + * Get filePath creation date from URI using all possible content providers + */ + private fun getFileCreatedDateFromCP(context: Context): DateTimeWithSource? { + return try { + val cursor: Cursor? = context.contentResolver.query(contentUri, null, null, null, null) + cursor?.use { + val lastModifiedColumnIndex = cursor + .getColumnIndex( + "last_modified" + ).takeIf { it != -1 } + ?: cursor.getColumnIndex("datetaken") + if (lastModifiedColumnIndex == -1) return null // No valid column found + cursor.moveToFirst() + DateTimeWithSource( + cursor.getLong( + lastModifiedColumnIndex + ), DateTimeWithSource.CP_SOURCE) + } + } catch (e: Exception) { + Timber.tag("UploadableFile").d(e) + null + } + } + + /** + * Indicates whether the EXIF contains the location (both latitude and longitude). + */ + fun hasLocation(): Boolean { + return try { + val exif = ExifInterface(file.absolutePath) + val latitude = exif.getAttribute(ExifInterface.TAG_GPS_LATITUDE) + val longitude = exif.getAttribute(ExifInterface.TAG_GPS_LONGITUDE) + latitude != null && longitude != null + } catch (e: IOException) { + Timber.tag("UploadableFile").d(e) + false + } + } + + /** + * Get filePath creation date from URI using EXIF data + */ + private fun getDateTimeFromExif(): DateTimeWithSource? { + return try { + val exif = ExifInterface(file.absolutePath) + val dateTimeSubString = exif.getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL) + if (dateTimeSubString != null) { + val year = dateTimeSubString.substring(0, 4).toInt() + val month = dateTimeSubString.substring(5, 7).toInt() + val day = dateTimeSubString.substring(8, 10).toInt() + val dateCreatedString = "%04d-%02d-%02d".format(year, month, day) + if (dateCreatedString.length == 10) { + @SuppressLint("RestrictedApi") + val dateTime = exif.dateTimeOriginal + if (dateTime != null) { + val date = Date(dateTime) + return DateTimeWithSource(date, dateCreatedString, DateTimeWithSource.EXIF_SOURCE) + } + } + } + null + } catch (e: Exception) { + Timber.tag("UploadableFile").d(e) + null + } + } + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeParcelable(contentUri, flags) + parcel.writeSerializable(file) + } + + class DateTimeWithSource { + companion object { + const val CP_SOURCE = "contentProvider" + const val EXIF_SOURCE = "exif" + } + + val epochDate: Long + var dateString: String? = null + val source: String + + constructor(epochDate: Long, source: String) { + this.epochDate = epochDate + this.source = source + } + + constructor(date: Date, source: String) { + epochDate = date.time + this.source = source + } + + constructor(date: Date, dateString: String, source: String) { + epochDate = date.time + this.dateString = dateString + this.source = source + } + } + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel): UploadableFile { + return UploadableFile(parcel) + } + + override fun newArray(size: Int): Array { + return arrayOfNulls(size) + } + } +} diff --git a/app/src/main/java/fr/free/nrw/commons/settings/SettingsFragment.kt b/app/src/main/java/fr/free/nrw/commons/settings/SettingsFragment.kt index 86ee5c4fe..91146059d 100644 --- a/app/src/main/java/fr/free/nrw/commons/settings/SettingsFragment.kt +++ b/app/src/main/java/fr/free/nrw/commons/settings/SettingsFragment.kt @@ -38,6 +38,7 @@ import fr.free.nrw.commons.campaigns.CampaignView import fr.free.nrw.commons.contributions.ContributionController import fr.free.nrw.commons.contributions.MainActivity import fr.free.nrw.commons.di.ApplicationlessInjection +import fr.free.nrw.commons.filepicker.FilePicker import fr.free.nrw.commons.kvstore.JsonKvStore import fr.free.nrw.commons.location.LocationServiceManager import fr.free.nrw.commons.logging.CommonsLogSender @@ -83,9 +84,17 @@ class SettingsFragment : PreferenceFragmentCompat() { private val cameraPickLauncherForResult: ActivityResultLauncher = registerForActivityResult(StartActivityForResult()) { result -> - contributionController.handleActivityResultWithCallback(requireActivity()) { callbacks -> - contributionController.onPictureReturnedFromCamera(result, requireActivity(), callbacks) - } + contributionController.handleActivityResultWithCallback( + requireActivity(), + object: FilePicker.HandleActivityResult { + override fun onHandleActivityResult(callbacks: FilePicker.Callbacks) { + contributionController.onPictureReturnedFromCamera( + result, + requireActivity(), + callbacks + ) + } + }) } /** diff --git a/app/src/main/java/fr/free/nrw/commons/utils/CustomSelectorUtils.kt b/app/src/main/java/fr/free/nrw/commons/utils/CustomSelectorUtils.kt index fc80252fc..62bd3f1a9 100644 --- a/app/src/main/java/fr/free/nrw/commons/utils/CustomSelectorUtils.kt +++ b/app/src/main/java/fr/free/nrw/commons/utils/CustomSelectorUtils.kt @@ -63,7 +63,7 @@ class CustomSelectorUtils { fileProcessor.redactExifTags(exifInterface, fileProcessor.getExifTagsToRedact()) val sha1 = fileUtilsWrapper.getSHA1( - fileUtilsWrapper.getFileInputStream(uploadableFile.filePath), + fileUtilsWrapper.getFileInputStream(uploadableFile.getFilePath()), ) uploadableFile.file.delete() sha1 diff --git a/app/src/test/kotlin/fr/free/nrw/commons/filepicker/ShadowFileProvider.java b/app/src/test/kotlin/fr/free/nrw/commons/filepicker/ShadowFileProvider.java deleted file mode 100644 index 4da9e2690..000000000 --- a/app/src/test/kotlin/fr/free/nrw/commons/filepicker/ShadowFileProvider.java +++ /dev/null @@ -1,32 +0,0 @@ -package fr.free.nrw.commons.filepicker; - -import android.database.Cursor; -import android.database.MatrixCursor; -import android.net.Uri; -import android.provider.OpenableColumns; -import androidx.core.content.FileProvider; -import org.robolectric.annotation.Implementation; -import org.robolectric.annotation.Implements; - -@Implements(FileProvider.class) -public class ShadowFileProvider { - - @Implementation - public Cursor query(final Uri uri, final String[] projection, final String selection, - final String[] selectionArgs, - final String sortOrder) { - - if (uri == null) { - return null; - } - - final String[] columns = {OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE}; - final Object[] values = {"dummy", 500}; - final MatrixCursor cursor = new MatrixCursor(columns, 1); - - if (!uri.equals(Uri.EMPTY)) { - cursor.addRow(values); - } - return cursor; - } -} diff --git a/app/src/test/kotlin/fr/free/nrw/commons/filepicker/ShadowFileProvider.kt b/app/src/test/kotlin/fr/free/nrw/commons/filepicker/ShadowFileProvider.kt new file mode 100644 index 000000000..fc9d20cf6 --- /dev/null +++ b/app/src/test/kotlin/fr/free/nrw/commons/filepicker/ShadowFileProvider.kt @@ -0,0 +1,36 @@ +package fr.free.nrw.commons.filepicker + +import android.database.Cursor +import android.database.MatrixCursor +import android.net.Uri +import android.provider.OpenableColumns +import androidx.core.content.FileProvider +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements + +@Implements(FileProvider::class) +class ShadowFileProvider { + + @Implementation + fun query( + uri: Uri?, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String? + ): Cursor? { + + if (uri == null) { + return null + } + + val columns = arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE) + val values = arrayOf("dummy", 500) + val cursor = MatrixCursor(columns, 1) + + if (uri != Uri.EMPTY) { + cursor.addRow(values) + } + return cursor + } +} diff --git a/app/src/test/kotlin/fr/free/nrw/commons/upload/UploadPresenterTest.kt b/app/src/test/kotlin/fr/free/nrw/commons/upload/UploadPresenterTest.kt index 29a35c1e5..861d1a6a4 100644 --- a/app/src/test/kotlin/fr/free/nrw/commons/upload/UploadPresenterTest.kt +++ b/app/src/test/kotlin/fr/free/nrw/commons/upload/UploadPresenterTest.kt @@ -62,7 +62,7 @@ class UploadPresenterTest { `when`(repository.buildContributions()).thenReturn(Observable.just(contribution)) uploadableFiles.add(uploadableFile) `when`(view.uploadableFiles).thenReturn(uploadableFiles) - `when`(uploadableFile.filePath).thenReturn("data://test") + `when`(uploadableFile.getFilePath()).thenReturn("data://test") } /**