From 41c3e08925f7e9c448e2929bdf0f67ff34a41c34 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Wed, 3 Nov 2021 12:23:11 +0900 Subject: [PATCH] #112 use File API to delete, when possible --- .../aves/model/provider/ImageProvider.kt | 18 ++-- .../model/provider/MediaStoreImageProvider.kt | 88 +++++++++++-------- .../thibault/aves/utils/StorageUtils.kt | 2 + 3 files changed, 62 insertions(+), 46 deletions(-) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt index d129b187c..e70aa3437 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt @@ -9,7 +9,6 @@ import android.net.Uri import android.os.Binder import android.os.Build import android.util.Log -import androidx.annotation.RequiresApi import androidx.exifinterface.media.ExifInterface import com.bumptech.glide.Glide import com.bumptech.glide.load.DecodeFormat @@ -734,7 +733,7 @@ abstract class ImageProvider { targetUri: Uri, targetPath: String ) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isMediaUriPermissionGranted(context, targetUri, mimeType)) { + if (isMediaUriPermissionGranted(context, targetUri, mimeType)) { val targetStream = StorageUtils.openOutputStream(context, targetUri, mimeType) ?: throw Exception("failed to open output stream for uri=$targetUri") DocumentFileCompat.fromFile(sourceFile).copyTo(targetStream) } else { @@ -758,14 +757,17 @@ abstract class ImageProvider { // used when skipping a move/creation op because the target file already exists val skippedFieldMap: HashMap = hashMapOf("skipped" to true) - @RequiresApi(Build.VERSION_CODES.Q) fun isMediaUriPermissionGranted(context: Context, uri: Uri, mimeType: String): Boolean { - val safeUri = StorageUtils.getMediaStoreScopedStorageSafeUri(uri, mimeType) + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val safeUri = StorageUtils.getMediaStoreScopedStorageSafeUri(uri, mimeType) - val pid = Binder.getCallingPid() - val uid = Binder.getCallingUid() - val flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION - return context.checkUriPermission(safeUri, pid, uid, flags) == PackageManager.PERMISSION_GRANTED + val pid = Binder.getCallingPid() + val uid = Binder.getCallingUid() + val flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION + context.checkUriPermission(safeUri, pid, uid, flags) == PackageManager.PERMISSION_GRANTED + } else { + false + } } } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt index 14bdb0260..7950a128f 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt @@ -12,7 +12,6 @@ import android.os.Build import android.os.Environment import android.provider.MediaStore import android.util.Log -import androidx.annotation.RequiresApi import com.commonsware.cwac.document.DocumentFileCompat import deckers.thibault.aves.MainActivity import deckers.thibault.aves.MainActivity.Companion.DELETE_SINGLE_PERMISSION_REQUEST @@ -247,26 +246,38 @@ class MediaStoreImageProvider : ImageProvider() { // `uri` is a media URI, not a document URI override suspend fun delete(activity: Activity, uri: Uri, path: String?, mimeType: String) { - if (!(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q - && isMediaUriPermissionGranted(activity, uri, mimeType)) - ) { - // if the file is on SD card, calling the content resolver `delete()` - // removes the entry from the Media Store but it doesn't delete the file, - // even when the app has the permission, so we manually delete the document file - path ?: throw Exception("failed to delete file because path is null") - if (File(path).exists() && StorageUtils.requireAccessPermission(activity, path)) { + path ?: throw Exception("failed to delete file because path is null") + + val file = File(path) + if (file.exists()) { + if (StorageUtils.canEditByFile(activity, path)) { + Log.d(LOG_TAG, "delete file at uri=$uri path=$path") + if (file.delete()) { + scanObsoletePath(activity, path, mimeType) + return + } + } else if (!isMediaUriPermissionGranted(activity, uri, mimeType) + && StorageUtils.requireAccessPermission(activity, path) + ) { + // if the file is on SD card, calling the content resolver `delete()` + // removes the entry from the Media Store but it doesn't delete the file, + // even when the app has the permission, so we manually delete the document file Log.d(LOG_TAG, "delete document at uri=$uri path=$path") val df = StorageUtils.getDocumentFile(activity, path, uri) @Suppress("BlockingMethodInNonBlockingContext") - if (df != null && df.delete()) return - throw Exception("failed to delete file with df=$df") + if (df != null && df.delete()) { + scanObsoletePath(activity, path, mimeType) + return + } + throw Exception("failed to delete document with df=$df") } } try { - Log.d(LOG_TAG, "delete content at uri=$uri") + Log.d(LOG_TAG, "delete content at uri=$uri path=$path") if (activity.contentResolver.delete(uri, null, null) > 0) return + throw Exception("failed to delete row from content provider") } catch (securityException: SecurityException) { // even if the app has access permission granted on the containing directory, // the delete request may yield a `RecoverableSecurityException` on Android 10+ @@ -291,7 +302,6 @@ class MediaStoreImageProvider : ImageProvider() { throw securityException } } - throw Exception("failed to delete row from content provider") } override suspend fun moveMultiple( @@ -518,26 +528,29 @@ class MediaStoreImageProvider : ImageProvider() { return skippedFieldMap } - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q - && isMediaUriPermissionGranted(activity, oldMediaUri, mimeType) - ) { + return if (isMediaUriPermissionGranted(activity, oldMediaUri, mimeType)) { renameSingleByMediaStore(activity, mimeType, oldMediaUri, newFile) } else { renameSingleByTreeDoc(activity, mimeType, oldMediaUri, oldPath, newFile) } } - @RequiresApi(Build.VERSION_CODES.Q) private suspend fun renameSingleByMediaStore( activity: Activity, mimeType: String, mediaUri: Uri, newFile: File ): FieldMap { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + throw Exception("unsupported Android version") + } + val uri = StorageUtils.getMediaStoreScopedStorageSafeUri(mediaUri, mimeType) // `IS_PENDING` is necessary for `TITLE`, not for `DISPLAY_NAME` - val tempValues = ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 1) } + val tempValues = ContentValues().apply { + put(MediaStore.MediaColumns.IS_PENDING, 1) + } if (activity.contentResolver.update(uri, tempValues, null, null) == 0) { throw Exception("failed to update fields for uri=$uri") } @@ -658,30 +671,29 @@ class MediaStoreImageProvider : ImageProvider() { return null } - if (newUri == null) { - cont.resumeWithException(Exception("failed to get URI of item at path=$path")) - return@scanFile - } - - var contentUri: Uri? = null - // `newURI` is possibly a file media URI (e.g. "content://media/12a9-8b42/file/62872") - // but we need an image/video media URI (e.g. "content://media/external/images/media/62872") - val contentId = newUri.tryParseId() - if (contentId != null) { - if (isImage(mimeType)) { - contentUri = ContentUris.withAppendedId(IMAGE_CONTENT_URI, contentId) - } else if (isVideo(mimeType)) { - contentUri = ContentUris.withAppendedId(VIDEO_CONTENT_URI, contentId) + if (newUri != null) { + var contentUri: Uri? = null + // `newURI` is possibly a file media URI (e.g. "content://media/12a9-8b42/file/62872") + // but we need an image/video media URI (e.g. "content://media/external/images/media/62872") + val contentId = newUri.tryParseId() + if (contentId != null) { + if (isImage(mimeType)) { + contentUri = ContentUris.withAppendedId(IMAGE_CONTENT_URI, contentId) + } else if (isVideo(mimeType)) { + contentUri = ContentUris.withAppendedId(VIDEO_CONTENT_URI, contentId) + } } - } - // prefer image/video content URI, fallback to original URI (possibly a file content URI) - val newFields = scanUri(contentUri) ?: scanUri(newUri) + // prefer image/video content URI, fallback to original URI (possibly a file content URI) + val newFields = scanUri(contentUri) ?: scanUri(newUri) - if (newFields != null) { - cont.resume(newFields) + if (newFields != null) { + cont.resume(newFields) + } else { + cont.resumeWithException(Exception("failed to get item details from provider at contentUri=$contentUri (from newUri=$newUri)")) + } } else { - cont.resumeWithException(Exception("failed to get item details from provider at contentUri=$contentUri (from newUri=$newUri)")) + cont.resumeWithException(Exception("failed to get URI of item at path=$path")) } } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt index 4691a6bee..79118d575 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt @@ -394,6 +394,8 @@ object StorageUtils { * Misc */ + fun canEditByFile(context: Context, path: String) = !requireAccessPermission(context, path) + fun requireAccessPermission(context: Context, anyPath: String): Boolean { // on Android R, we should always require access permission, even on primary volume if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) {