Skip to content

Commit

Permalink
handle scoped storage on android 10+
Browse files Browse the repository at this point in the history
  • Loading branch information
jkwiecien committed Apr 12, 2021
1 parent 9f5b434 commit 942e5ea
Show file tree
Hide file tree
Showing 5 changed files with 134 additions and 62 deletions.
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.CAMERA" />
```

**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:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
Expand All @@ -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) {
Expand Down
122 changes: 81 additions & 41 deletions library/src/main/java/pl/aprilapps/easyphotopicker/Files.kt
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<File>) {
Thread(Runnable {
val copiedFiles = ArrayList<File>()
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<File>) {
val paths = arrayOfNulls<String>(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<File>) {
Thread {
val copiedFilesPaths: List<String?> = 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<String>) {
MediaScannerConnection.scanFile(context, paths.toTypedArray(), null) { path, uri ->
Log.d(EASYIMAGE_LOG_TAG, "Scanned media with path: $path | uri: $uri")
}
}

@Throws(IOException::class)
Expand Down
5 changes: 5 additions & 0 deletions sample/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,15 @@

<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />

<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />

<application
android:allowBackup="true"
android:icon="@drawable/sample_app_icon"
android:label="@string/app_name"
android:requestLegacyExternalStorage="true"
android:supportsRtl="true"
android:theme="@style/AppTheme"
tools:ignore="GoogleAppIndexingWarning">
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;

Expand All @@ -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);
Expand All @@ -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")
Expand All @@ -74,36 +79,56 @@ 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);
}
}
});


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

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

0 comments on commit 942e5ea

Please sign in to comment.