From f5ad7f6399caba6801a17cfe551d78887fad85e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacek=20Kwiecie=C5=84?= Date: Fri, 9 Apr 2021 16:19:05 +0200 Subject: [PATCH 1/6] update target api to 30 --- build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index bf0d44d..7ad143c 100644 --- a/build.gradle +++ b/build.gradle @@ -3,8 +3,8 @@ buildscript { ext { version_name = '3.1.0' kotlin_version = '1.4.32' - compile_sdk_version = 28 - target_sdk_version = 28 + compile_sdk_version = 30 + target_sdk_version = 30 min_sdk_version = 15 version_code = 15 } From e04f7ba927de3673b4edbee308d18db473a4a80a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacek=20Kwiecie=C5=84?= Date: Fri, 9 Apr 2021 16:19:05 +0200 Subject: [PATCH 2/6] update target api to 30 --- build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index bf0d44d..7ad143c 100644 --- a/build.gradle +++ b/build.gradle @@ -3,8 +3,8 @@ buildscript { ext { version_name = '3.1.0' kotlin_version = '1.4.32' - compile_sdk_version = 28 - target_sdk_version = 28 + compile_sdk_version = 30 + target_sdk_version = 30 min_sdk_version = 15 version_code = 15 } From 5fc2324d578bd0d9fa46ddb3e8b1e39e4a380a75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacek=20Kwiecie=C5=84?= Date: Fri, 9 Apr 2021 16:24:45 +0200 Subject: [PATCH 3/6] fix compiler errors after bumping target --- library/src/main/java/pl/aprilapps/easyphotopicker/Files.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/library/src/main/java/pl/aprilapps/easyphotopicker/Files.kt b/library/src/main/java/pl/aprilapps/easyphotopicker/Files.kt index c8de53c..42a0454 100644 --- a/library/src/main/java/pl/aprilapps/easyphotopicker/Files.kt +++ b/library/src/main/java/pl/aprilapps/easyphotopicker/Files.kt @@ -93,6 +93,8 @@ object Files { @Throws(IOException::class) internal fun pickedExistingPicture(context: Context, photoUri: Uri): File { val pictureInputStream = context.contentResolver.openInputStream(photoUri) + ?: throw IOException("Could not open input stream for a file: $photoUri") + val directory = tempImageDirectory(context) val photoFile = File(directory, generateFileName() + "." + getMimeType(context, photoUri)) photoFile.createNewFile() From 6b1329ba2cbd23becda5e280e7165160384ac56f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacek=20Kwiecie=C5=84?= Date: Mon, 12 Apr 2021 09:01:40 +0200 Subject: [PATCH 4/6] bump version name & code --- build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index 7ad143c..42f9789 100644 --- a/build.gradle +++ b/build.gradle @@ -1,12 +1,12 @@ buildscript { ext { - version_name = '3.1.0' + version_name = '3.2.0' kotlin_version = '1.4.32' compile_sdk_version = 30 target_sdk_version = 30 min_sdk_version = 15 - version_code = 15 + version_code = 17 } repositories { From f51c38cbd5e73ecc2a9035ac65e1e296f6d5d772 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacek=20Kwiecie=C5=84?= Date: Mon, 12 Apr 2021 09:36:41 +0200 Subject: [PATCH 5/6] remove WRITE_EXTERNAL_STORAGE_PERMISSION --- sample/src/main/AndroidManifest.xml | 2 +- .../easyphotopicker/sample/MainActivity.java | 24 +++---------------- 2 files changed, 4 insertions(+), 22 deletions(-) diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml index c554450..d43d438 100644 --- a/sample/src/main/AndroidManifest.xml +++ b/sample/src/main/AndroidManifest.xml @@ -7,7 +7,7 @@ android:name="android.hardware.camera" android:required="true" /> - + Date: Mon, 12 Apr 2021 11:57:33 +0200 Subject: [PATCH 6/6] handle scoped storage on android 10+ --- README.md | 11 +- .../pl/aprilapps/easyphotopicker/EasyImage.kt | 7 +- .../pl/aprilapps/easyphotopicker/Files.kt | 122 ++++++++++++------ sample/src/main/AndroidManifest.xml | 5 + .../easyphotopicker/sample/MainActivity.java | 51 +++++--- 5 files changed, 134 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index 76faef4..34706f9 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,18 @@ EasyImage allows you to easily capture images and videos from the gallery, camer # Setup ## Runtime permissions -This library requires specific runtime permissions. Declare it in your `AndroidManifest.xml`: +No additional permisions are required if you DO NOT `use setCopyImagesToPublicGalleryFolder()` setting. But if you do: + +### For devices running Android 10 and newer: +Nothing is required + +### For devices running Android 9 or lower: +Permission need to be specified in Manifest: ```xml - ``` -**Please note**: for devices running API 23 (marshmallow) you have to request this permissions in the runtime, before calling `EasyImage.openCamera()`. It's demonstrated in the sample app. +Also you'll need to ask for this permission in the runtime in the moment of your choice. Sample app does that. **There is also one issue about runtime permissions**. According to the docs: diff --git a/library/src/main/java/pl/aprilapps/easyphotopicker/EasyImage.kt b/library/src/main/java/pl/aprilapps/easyphotopicker/EasyImage.kt index 4c02980..0173919 100644 --- a/library/src/main/java/pl/aprilapps/easyphotopicker/EasyImage.kt +++ b/library/src/main/java/pl/aprilapps/easyphotopicker/EasyImage.kt @@ -234,7 +234,7 @@ class EasyImage private constructor( try { if (cameraFile.uri.toString().isEmpty()) Intents.revokeWritePermission(activity, cameraFile.uri) val files = mutableListOf(cameraFile) - if (copyImagesToPublicGalleryFolder) Files.copyFilesInSeparateThread(activity, folderName, files.map { it.file }) + if (copyImagesToPublicGalleryFolder) Files.copyImagesToPublicGallery(activity, folderName, files.map { it.file }) callbacks.onMediaFilesPicked(files.toTypedArray(), MediaSource.CAMERA_IMAGE) } catch (error: Throwable) { error.printStackTrace() @@ -250,7 +250,8 @@ class EasyImage private constructor( try { if (cameraFile.uri.toString().isEmpty()) Intents.revokeWritePermission(activity, cameraFile.uri) val files = mutableListOf(cameraFile) - if (copyImagesToPublicGalleryFolder) Files.copyFilesInSeparateThread(activity, folderName, files.map { it.file }) +// if (copyImagesToPublicGalleryFolder) Files.copyFilesInSeparateThread(activity, folderName, files.map { it.file }) + //FIXME callbacks.onMediaFilesPicked(files.toTypedArray(), MediaSource.CAMERA_VIDEO) } catch (error: Throwable) { error.printStackTrace() @@ -263,7 +264,7 @@ class EasyImage private constructor( private fun onFileReturnedFromChooser(resultIntent: Intent?, activity: Activity, callbacks: Callbacks) { Log.d(EASYIMAGE_LOG_TAG, "File returned from chooser") if (resultIntent != null && !Intents.isTherePhotoTakenWithCameraInsideIntent(resultIntent) - && (resultIntent.data != null || Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN && resultIntent.clipData != null )) { + && (resultIntent.data != null || Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN && resultIntent.clipData != null)) { onPickedExistingPictures(resultIntent, activity, callbacks) removeCameraFileAndCleanup() } else if (lastCameraFile != null) { diff --git a/library/src/main/java/pl/aprilapps/easyphotopicker/Files.kt b/library/src/main/java/pl/aprilapps/easyphotopicker/Files.kt index 42a0454..3a11f6d 100644 --- a/library/src/main/java/pl/aprilapps/easyphotopicker/Files.kt +++ b/library/src/main/java/pl/aprilapps/easyphotopicker/Files.kt @@ -1,16 +1,22 @@ package pl.aprilapps.easyphotopicker import android.content.ContentResolver +import android.content.ContentValues import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Matrix +import android.media.ExifInterface import android.media.MediaScannerConnection import android.net.Uri +import android.os.Build import android.os.Environment +import android.provider.MediaStore import android.util.Log import android.webkit.MimeTypeMap +import androidx.annotation.RequiresApi import androidx.core.content.FileProvider import java.io.* -import java.text.SimpleDateFormat -import java.util.* object Files { @@ -41,53 +47,87 @@ object Files { } } - @Throws(IOException::class) - private fun copyFile(src: File, dst: File) { - val inputStream = FileInputStream(src) - writeToFile(inputStream, dst) + private fun rotateImage(bitmap: Bitmap, degrees: Float): Bitmap { + val matrix = Matrix() + matrix.postRotate(degrees) + return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) } - internal fun copyFilesInSeparateThread(context: Context, folderName: String, filesToCopy: List) { - Thread(Runnable { - val copiedFiles = ArrayList() - var i = 1 - for (fileToCopy in filesToCopy) { - val dstDir = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), folderName) - if (!dstDir.exists()) dstDir.mkdirs() - - val filenameSplit = fileToCopy.name.split("\\.".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() - val extension = "." + filenameSplit[filenameSplit.size - 1] - val datePart = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Calendar.getInstance().time) - val filename = "IMG_${datePart}_$i.$extension%d.%s" - val dstFile = File(dstDir, filename) - try { - dstFile.createNewFile() - copyFile(fileToCopy, dstFile) - copiedFiles.add(dstFile) - } catch (e: IOException) { - e.printStackTrace() - } + private fun flipImage(bitmap: Bitmap, horizontal: Boolean, vertical: Boolean): Bitmap { + val matrix = Matrix() + matrix.preScale((if (horizontal) -1 else 1).toFloat(), (if (vertical) -1 else 1).toFloat()) + return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) + } - i++ + private fun getFixedRotationBitmap(bitmapFile: File): Bitmap { + val exifInterface = ExifInterface(bitmapFile.path) + val orientation = exifInterface.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL) + + return with(BitmapFactory.decodeFile(bitmapFile.path)) { + when (orientation) { + ExifInterface.ORIENTATION_ROTATE_90 -> rotateImage(this, 90f) + ExifInterface.ORIENTATION_ROTATE_180 -> rotateImage(this, 180f) + ExifInterface.ORIENTATION_ROTATE_270 -> rotateImage(this, 270f) + ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> flipImage(this, horizontal = true, vertical = false) + ExifInterface.ORIENTATION_FLIP_VERTICAL -> flipImage(this, horizontal = false, vertical = true) + else -> this } - scanCopiedImages(context, copiedFiles) - }).run() + } + } + + @RequiresApi(29) + private fun copyImageToPublicGallery(context: Context, fileToCopy: File, folderName: String): String { + val bitmapToCopy = getFixedRotationBitmap(fileToCopy) + val contentResolver = context.contentResolver + val contentValues = ContentValues() + contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, fileToCopy.name) + contentValues.put(MediaStore.MediaColumns.MIME_TYPE, "image/jpg") + contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/$folderName") + val copyUri: Uri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentValues)!! + val outputStream: OutputStream = contentResolver.openOutputStream(copyUri)!! + bitmapToCopy.compress(Bitmap.CompressFormat.JPEG, 100, outputStream) + outputStream.close() + Log.d(EASYIMAGE_LOG_TAG, "Copied image to public gallery: ${copyUri.path}") + return copyUri.path!! + } + + private fun legacyCopyImageToPublicGallery(fileToCopy: File, folderName: String): String { + val bitmapToCopy = getFixedRotationBitmap(fileToCopy) + val legacyExternalStorageDir = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), folderName) + if (!legacyExternalStorageDir.exists()) legacyExternalStorageDir.mkdirs() + val copyFile = File(legacyExternalStorageDir, fileToCopy.name) + copyFile.createNewFile() + val outputStream = FileOutputStream(copyFile) + bitmapToCopy.compress(Bitmap.CompressFormat.JPEG, 100, outputStream) + outputStream.close() + Log.d(EASYIMAGE_LOG_TAG, "Copied image to public gallery: ${copyFile.path}") + return copyFile.path } - private fun scanCopiedImages(context: Context, copiedImages: List) { - val paths = arrayOfNulls(copiedImages.size) - for (i in copiedImages.indices) { - paths[i] = copiedImages[i].toString() - } - MediaScannerConnection.scanFile(context, - paths, null, - object : MediaScannerConnection.OnScanCompletedListener { - override fun onScanCompleted(path: String, uri: Uri) { - Log.d(javaClass.simpleName, "Scanned $path:") - Log.d(javaClass.simpleName, "-> uri=$uri") + internal fun copyImagesToPublicGallery(context: Context, folderName: String, filesToCopy: List) { + Thread { + val copiedFilesPaths: List = filesToCopy.map { fileToCopy -> + try { + if (Build.VERSION.SDK_INT >= 29) { + copyImageToPublicGallery(context, fileToCopy, folderName) + } else { + legacyCopyImageToPublicGallery(fileToCopy, folderName) } - }) + } catch (error: Throwable) { + error.printStackTrace() + Log.e(EASYIMAGE_LOG_TAG, "File couldn't be copied to public gallery: ${fileToCopy.name}") + null + } + } + runMediaScanner(context, copiedFilesPaths.filterNotNull()) + }.run() + } + + private fun runMediaScanner(context: Context, paths: List) { + MediaScannerConnection.scanFile(context, paths.toTypedArray(), null) { path, uri -> + Log.d(EASYIMAGE_LOG_TAG, "Scanned media with path: $path | uri: $uri") + } } @Throws(IOException::class) diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml index d43d438..ebd6a2d 100644 --- a/sample/src/main/AndroidManifest.xml +++ b/sample/src/main/AndroidManifest.xml @@ -9,10 +9,15 @@ + + diff --git a/sample/src/main/java/pl/aprilapps/easyphotopicker/sample/MainActivity.java b/sample/src/main/java/pl/aprilapps/easyphotopicker/sample/MainActivity.java index 21b2a9f..4c42ebf 100644 --- a/sample/src/main/java/pl/aprilapps/easyphotopicker/sample/MainActivity.java +++ b/sample/src/main/java/pl/aprilapps/easyphotopicker/sample/MainActivity.java @@ -1,7 +1,9 @@ package pl.aprilapps.easyphotopicker.sample; +import android.Manifest; import android.content.Intent; import android.content.pm.PackageManager; +import android.os.Build; import android.os.Bundle; import android.util.Log; import android.view.View; @@ -29,6 +31,7 @@ public class MainActivity extends AppCompatActivity implements EasyImage.EasyIma private static final int CHOOSER_PERMISSIONS_REQUEST_CODE = 7459; private static final int GALLERY_REQUEST_CODE = 7502; private static final int DOCUMENTS_REQUEST_CODE = 7503; + private static final int LEGACY_EXTERNAL_STORAGE_PERMISSION_REQUEST_CODE = 456; protected RecyclerView recyclerView; @@ -40,6 +43,8 @@ public class MainActivity extends AppCompatActivity implements EasyImage.EasyIma private EasyImage easyImage; + private static final String[] LEGACY_WRITE_PERMISSIONS = new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}; + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -60,7 +65,7 @@ protected void onCreate(Bundle savedInstanceState) { easyImage = new EasyImage.Builder(this) .setChooserTitle("Pick media") - .setCopyImagesToPublicGalleryFolder(false) + .setCopyImagesToPublicGalleryFolder(true) // THIS requires granting WRITE_EXTERNAL_STORAGE permission for devices running Android 9 or lower // .setChooserType(ChooserType.CAMERA_AND_DOCUMENTS) .setChooserType(ChooserType.CAMERA_AND_GALLERY) .setFolderName("EasyImage sample") @@ -74,7 +79,11 @@ protected void onCreate(Bundle savedInstanceState) { findViewById(R.id.gallery_button).setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { - easyImage.openGallery(MainActivity.this); + if (isLegacyExternalStoragePermissionRequired()) { + requestLegacyWriteExternalStoragePermission(); + } else { + easyImage.openGallery(MainActivity.this); + } } }); @@ -82,28 +91,44 @@ public void onClick(View view) { findViewById(R.id.camera_button).setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { - easyImage.openCameraForImage(MainActivity.this); + if (isLegacyExternalStoragePermissionRequired()) { + requestLegacyWriteExternalStoragePermission(); + } else { + easyImage.openCameraForImage(MainActivity.this); + } } }); findViewById(R.id.camera_video_button).setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { - easyImage.openCameraForVideo(MainActivity.this); + if (isLegacyExternalStoragePermissionRequired()) { + requestLegacyWriteExternalStoragePermission(); + } else { + easyImage.openCameraForVideo(MainActivity.this); + } } }); findViewById(R.id.documents_button).setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { - easyImage.openDocuments(MainActivity.this); + if (isLegacyExternalStoragePermissionRequired()) { + requestLegacyWriteExternalStoragePermission(); + } else { + easyImage.openDocuments(MainActivity.this); + } } }); findViewById(R.id.chooser_button).setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { - easyImage.openChooser(MainActivity.this); + if (isLegacyExternalStoragePermissionRequired()) { + requestLegacyWriteExternalStoragePermission(); + } else { + easyImage.openChooser(MainActivity.this); + } } }); @@ -181,16 +206,12 @@ private void onPhotosReturned(@NonNull MediaFile[] returnedPhotos) { recyclerView.scrollToPosition(photos.size() - 1); } - private boolean arePermissionsGranted(String[] permissions) { - for (String permission : permissions) { - if (ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) - return false; - - } - return true; + private boolean isLegacyExternalStoragePermissionRequired() { + boolean permissionGranted = ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED; + return Build.VERSION.SDK_INT < 29 && !permissionGranted; } - private void requestPermissionsCompat(String[] permissions, int requestCode) { - ActivityCompat.requestPermissions(MainActivity.this, permissions, requestCode); + private void requestLegacyWriteExternalStoragePermission() { + ActivityCompat.requestPermissions(MainActivity.this, LEGACY_WRITE_PERMISSIONS, LEGACY_EXTERNAL_STORAGE_PERMISSION_REQUEST_CODE); } }