mirror of
				https://github.com/commons-app/apps-android-commons.git
				synced 2025-10-31 14:53:59 +01:00 
			
		
		
		
	Merge branch 'main' into fix-issues
This commit is contained in:
		
						commit
						00866c565c
					
				
					 21 changed files with 970 additions and 929 deletions
				
			
		|  | @ -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"; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										29
									
								
								app/src/main/java/fr/free/nrw/commons/filepicker/Costants.kt
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								app/src/main/java/fr/free/nrw/commons/filepicker/Costants.kt
									
										
									
									
									
										Normal file
									
								
							|  | @ -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" | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -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) { | ||||
|     } | ||||
| } | ||||
|  | @ -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) {} | ||||
| } | ||||
|  | @ -1,7 +0,0 @@ | |||
| package fr.free.nrw.commons.filepicker; | ||||
| 
 | ||||
| import androidx.core.content.FileProvider; | ||||
| 
 | ||||
| public class ExtendedFileProvider extends FileProvider { | ||||
| 
 | ||||
| } | ||||
|  | @ -0,0 +1,5 @@ | |||
| package fr.free.nrw.commons.filepicker | ||||
| 
 | ||||
| import androidx.core.content.FileProvider | ||||
| 
 | ||||
| class ExtendedFileProvider: FileProvider() {} | ||||
|  | @ -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<ResolveInfo> 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<Intent> resultLauncher, int type, boolean openDocumentIntentPreferred) { | ||||
|         Intent intent = createGalleryIntent(activity, type, openDocumentIntentPreferred); | ||||
|         resultLauncher.launch(intent); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Opens Custom Selector | ||||
|      */ | ||||
|     public static void openCustomSelector(Activity activity, ActivityResultLauncher<Intent> 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<Intent> 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<UploadableFile> 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<UploadableFile> 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<UploadableFile> getFilesFromCustomSelector(Intent data, Activity activity) throws  IOException, SecurityException { | ||||
|         List<UploadableFile> files = new ArrayList<>(); | ||||
|         ArrayList<Image> 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<UploadableFile> 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<UploadableFile> getFilesFromGalleryPictures(Intent data, Activity activity) throws IOException, SecurityException { | ||||
|         List<UploadableFile> 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<UploadableFile> 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<UploadableFile> imageFiles, FilePicker.ImageSource source, int type); | ||||
| 
 | ||||
|         void onCanceled(FilePicker.ImageSource source, int type); | ||||
|     } | ||||
| 
 | ||||
|     public interface HandleActivityResult{ | ||||
|         void onHandleActivityResult(FilePicker.Callbacks callbacks); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										441
									
								
								app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.kt
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										441
									
								
								app/src/main/java/fr/free/nrw/commons/filepicker/FilePicker.kt
									
										
									
									
									
										Normal file
									
								
							|  | @ -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<Intent>, | ||||
|         type: Int, | ||||
|         openDocumentIntentPreferred: Boolean | ||||
|     ) { | ||||
|         val intent = createGalleryIntent(activity, type, openDocumentIntentPreferred) | ||||
|         resultLauncher.launch(intent) | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Opens Custom Selector | ||||
|      */ | ||||
|     @JvmStatic | ||||
|     fun openCustomSelector( | ||||
|         activity: Activity, | ||||
|         resultLauncher: ActivityResultLauncher<Intent>, | ||||
|         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<Intent>, | ||||
|         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<UploadableFile> { | ||||
|         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<UploadableFile> { | ||||
|         val files = mutableListOf<UploadableFile>() | ||||
|         val images = data?.getParcelableArrayListExtra<Image>("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<UploadableFile> { | ||||
|         val files = mutableListOf<UploadableFile>() | ||||
|         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<UploadableFile>() | ||||
|                 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<UploadableFile>, source: ImageSource, type: Int) | ||||
| 
 | ||||
|         fun onCanceled(source: ImageSource, type: Int) | ||||
|     } | ||||
| 
 | ||||
|     interface HandleActivityResult { | ||||
|         fun onHandleActivityResult(callbacks: Callbacks) | ||||
|     } | ||||
| } | ||||
|  | @ -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); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | @ -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) | ||||
|     } | ||||
| } | ||||
|  | @ -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<String, String> 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); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
|  | @ -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) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -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<UploadableFile> filesToCopy) { | ||||
|         new Thread(() -> { | ||||
|             List<File> 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<UploadableFile> singleFileList(UploadableFile file) { | ||||
|         List<UploadableFile> 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<File> 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); | ||||
|     } | ||||
| 
 | ||||
| } | ||||
							
								
								
									
										195
									
								
								app/src/main/java/fr/free/nrw/commons/filepicker/PickedFiles.kt
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										195
									
								
								app/src/main/java/fr/free/nrw/commons/filepicker/PickedFiles.kt
									
										
									
									
									
										Normal file
									
								
							|  | @ -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<UploadableFile>) { | ||||
|         Thread { | ||||
|             val copiedFiles = mutableListOf<File>() | ||||
|             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<UploadableFile> { | ||||
|         return listOf(file) | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * ScanCopiedImages | ||||
|      * Scans copied images metadata using media scanner. | ||||
|      */ | ||||
|     @JvmStatic | ||||
|     fun scanCopiedImages(context: Context, copiedImages: List<File>) { | ||||
|         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) | ||||
|     } | ||||
| } | ||||
|  | @ -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<UploadableFile> CREATOR = new Creator<UploadableFile>() { | ||||
|         @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; | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -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<UploadableFile> { | ||||
|         override fun createFromParcel(parcel: Parcel): UploadableFile { | ||||
|             return UploadableFile(parcel) | ||||
|         } | ||||
| 
 | ||||
|         override fun newArray(size: Int): Array<UploadableFile?> { | ||||
|             return arrayOfNulls(size) | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | @ -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<Intent> = | ||||
|         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 | ||||
|                     ) | ||||
|                 } | ||||
|         }) | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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; | ||||
|     } | ||||
| } | ||||
|  | @ -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<String>?, | ||||
|         selection: String?, | ||||
|         selectionArgs: Array<String>?, | ||||
|         sortOrder: String? | ||||
|     ): Cursor? { | ||||
| 
 | ||||
|         if (uri == null) { | ||||
|             return null | ||||
|         } | ||||
| 
 | ||||
|         val columns = arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE) | ||||
|         val values = arrayOf<Any>("dummy", 500) | ||||
|         val cursor = MatrixCursor(columns, 1) | ||||
| 
 | ||||
|         if (uri != Uri.EMPTY) { | ||||
|             cursor.addRow(values) | ||||
|         } | ||||
|         return cursor | ||||
|     } | ||||
| } | ||||
|  | @ -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") | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue
	
	 Nicolas Raoul
						Nicolas Raoul