mirror of
https://github.com/commons-app/apps-android-commons.git
synced 2025-10-26 12:23:58 +01:00
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:
parent
3777f18bf9
commit
f8d519e8eb
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