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
This commit is contained in:
Saifuddin Adenwala 2024-12-06 14:01:40 +05:30 committed by GitHub
parent 3777f18bf9
commit f8d519e8eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 970 additions and 929 deletions

View file

@ -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";
}
}

View 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"
}
}
}

View file

@ -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) {
}
}

View file

@ -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) {}
}

View file

@ -1,7 +0,0 @@
package fr.free.nrw.commons.filepicker;
import androidx.core.content.FileProvider;
public class ExtendedFileProvider extends FileProvider {
}

View file

@ -0,0 +1,5 @@
package fr.free.nrw.commons.filepicker
import androidx.core.content.FileProvider
class ExtendedFileProvider: FileProvider() {}

View file

@ -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);
}
}

View 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)
}
}

View file

@ -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);
}
}

View file

@ -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)
}
}

View file

@ -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);
}
}

View file

@ -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)
}
}
}

View file

@ -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);
}
}

View 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)
}
}

View 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;
}
}
}

View file

@ -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)
}
}
}

View file

@ -38,6 +38,7 @@ import fr.free.nrw.commons.campaigns.CampaignView
import fr.free.nrw.commons.contributions.ContributionController import fr.free.nrw.commons.contributions.ContributionController
import fr.free.nrw.commons.contributions.MainActivity import fr.free.nrw.commons.contributions.MainActivity
import fr.free.nrw.commons.di.ApplicationlessInjection 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.kvstore.JsonKvStore
import fr.free.nrw.commons.location.LocationServiceManager import fr.free.nrw.commons.location.LocationServiceManager
import fr.free.nrw.commons.logging.CommonsLogSender import fr.free.nrw.commons.logging.CommonsLogSender
@ -83,9 +84,17 @@ class SettingsFragment : PreferenceFragmentCompat() {
private val cameraPickLauncherForResult: ActivityResultLauncher<Intent> = private val cameraPickLauncherForResult: ActivityResultLauncher<Intent> =
registerForActivityResult(StartActivityForResult()) { result -> registerForActivityResult(StartActivityForResult()) { result ->
contributionController.handleActivityResultWithCallback(requireActivity()) { callbacks -> contributionController.handleActivityResultWithCallback(
contributionController.onPictureReturnedFromCamera(result, requireActivity(), callbacks) requireActivity(),
object: FilePicker.HandleActivityResult {
override fun onHandleActivityResult(callbacks: FilePicker.Callbacks) {
contributionController.onPictureReturnedFromCamera(
result,
requireActivity(),
callbacks
)
} }
})
} }
/** /**

View file

@ -63,7 +63,7 @@ class CustomSelectorUtils {
fileProcessor.redactExifTags(exifInterface, fileProcessor.getExifTagsToRedact()) fileProcessor.redactExifTags(exifInterface, fileProcessor.getExifTagsToRedact())
val sha1 = val sha1 =
fileUtilsWrapper.getSHA1( fileUtilsWrapper.getSHA1(
fileUtilsWrapper.getFileInputStream(uploadableFile.filePath), fileUtilsWrapper.getFileInputStream(uploadableFile.getFilePath()),
) )
uploadableFile.file.delete() uploadableFile.file.delete()
sha1 sha1

View file

@ -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;
}
}

View file

@ -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
}
}

View file

@ -62,7 +62,7 @@ class UploadPresenterTest {
`when`(repository.buildContributions()).thenReturn(Observable.just(contribution)) `when`(repository.buildContributions()).thenReturn(Observable.just(contribution))
uploadableFiles.add(uploadableFile) uploadableFiles.add(uploadableFile)
`when`(view.uploadableFiles).thenReturn(uploadableFiles) `when`(view.uploadableFiles).thenReturn(uploadableFiles)
`when`(uploadableFile.filePath).thenReturn("data://test") `when`(uploadableFile.getFilePath()).thenReturn("data://test")
} }
/** /**