#112 use File API to delete, when possible

This commit is contained in:
Thibault Deckers 2021-11-03 12:23:11 +09:00
parent 0bd3e509fa
commit 41c3e08925
3 changed files with 62 additions and 46 deletions

View file

@ -9,7 +9,6 @@ import android.net.Uri
import android.os.Binder import android.os.Binder
import android.os.Build import android.os.Build
import android.util.Log import android.util.Log
import androidx.annotation.RequiresApi
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.bumptech.glide.load.DecodeFormat import com.bumptech.glide.load.DecodeFormat
@ -734,7 +733,7 @@ abstract class ImageProvider {
targetUri: Uri, targetUri: Uri,
targetPath: String 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") val targetStream = StorageUtils.openOutputStream(context, targetUri, mimeType) ?: throw Exception("failed to open output stream for uri=$targetUri")
DocumentFileCompat.fromFile(sourceFile).copyTo(targetStream) DocumentFileCompat.fromFile(sourceFile).copyTo(targetStream)
} else { } else {
@ -758,14 +757,17 @@ abstract class ImageProvider {
// used when skipping a move/creation op because the target file already exists // used when skipping a move/creation op because the target file already exists
val skippedFieldMap: HashMap<String, Any?> = hashMapOf("skipped" to true) val skippedFieldMap: HashMap<String, Any?> = hashMapOf("skipped" to true)
@RequiresApi(Build.VERSION_CODES.Q)
fun isMediaUriPermissionGranted(context: Context, uri: Uri, mimeType: String): Boolean { 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 pid = Binder.getCallingPid()
val uid = Binder.getCallingUid() val uid = Binder.getCallingUid()
val flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION val flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION
return context.checkUriPermission(safeUri, pid, uid, flags) == PackageManager.PERMISSION_GRANTED context.checkUriPermission(safeUri, pid, uid, flags) == PackageManager.PERMISSION_GRANTED
} else {
false
}
} }
} }
} }

View file

@ -12,7 +12,6 @@ import android.os.Build
import android.os.Environment import android.os.Environment
import android.provider.MediaStore import android.provider.MediaStore
import android.util.Log import android.util.Log
import androidx.annotation.RequiresApi
import com.commonsware.cwac.document.DocumentFileCompat import com.commonsware.cwac.document.DocumentFileCompat
import deckers.thibault.aves.MainActivity import deckers.thibault.aves.MainActivity
import deckers.thibault.aves.MainActivity.Companion.DELETE_SINGLE_PERMISSION_REQUEST 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 // `uri` is a media URI, not a document URI
override suspend fun delete(activity: Activity, uri: Uri, path: String?, mimeType: String) { override suspend fun delete(activity: Activity, uri: Uri, path: String?, mimeType: String) {
if (!(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q path ?: throw Exception("failed to delete file because path is null")
&& isMediaUriPermissionGranted(activity, uri, mimeType))
) { val file = File(path)
// if the file is on SD card, calling the content resolver `delete()` if (file.exists()) {
// removes the entry from the Media Store but it doesn't delete the file, if (StorageUtils.canEditByFile(activity, path)) {
// even when the app has the permission, so we manually delete the document file Log.d(LOG_TAG, "delete file at uri=$uri path=$path")
path ?: throw Exception("failed to delete file because path is null") if (file.delete()) {
if (File(path).exists() && StorageUtils.requireAccessPermission(activity, path)) { 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") Log.d(LOG_TAG, "delete document at uri=$uri path=$path")
val df = StorageUtils.getDocumentFile(activity, path, uri) val df = StorageUtils.getDocumentFile(activity, path, uri)
@Suppress("BlockingMethodInNonBlockingContext") @Suppress("BlockingMethodInNonBlockingContext")
if (df != null && df.delete()) return if (df != null && df.delete()) {
throw Exception("failed to delete file with df=$df") scanObsoletePath(activity, path, mimeType)
return
}
throw Exception("failed to delete document with df=$df")
} }
} }
try { 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 if (activity.contentResolver.delete(uri, null, null) > 0) return
throw Exception("failed to delete row from content provider")
} catch (securityException: SecurityException) { } catch (securityException: SecurityException) {
// even if the app has access permission granted on the containing directory, // even if the app has access permission granted on the containing directory,
// the delete request may yield a `RecoverableSecurityException` on Android 10+ // the delete request may yield a `RecoverableSecurityException` on Android 10+
@ -291,7 +302,6 @@ class MediaStoreImageProvider : ImageProvider() {
throw securityException throw securityException
} }
} }
throw Exception("failed to delete row from content provider")
} }
override suspend fun moveMultiple( override suspend fun moveMultiple(
@ -518,26 +528,29 @@ class MediaStoreImageProvider : ImageProvider() {
return skippedFieldMap return skippedFieldMap
} }
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q return if (isMediaUriPermissionGranted(activity, oldMediaUri, mimeType)) {
&& isMediaUriPermissionGranted(activity, oldMediaUri, mimeType)
) {
renameSingleByMediaStore(activity, mimeType, oldMediaUri, newFile) renameSingleByMediaStore(activity, mimeType, oldMediaUri, newFile)
} else { } else {
renameSingleByTreeDoc(activity, mimeType, oldMediaUri, oldPath, newFile) renameSingleByTreeDoc(activity, mimeType, oldMediaUri, oldPath, newFile)
} }
} }
@RequiresApi(Build.VERSION_CODES.Q)
private suspend fun renameSingleByMediaStore( private suspend fun renameSingleByMediaStore(
activity: Activity, activity: Activity,
mimeType: String, mimeType: String,
mediaUri: Uri, mediaUri: Uri,
newFile: File newFile: File
): FieldMap { ): FieldMap {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
throw Exception("unsupported Android version")
}
val uri = StorageUtils.getMediaStoreScopedStorageSafeUri(mediaUri, mimeType) val uri = StorageUtils.getMediaStoreScopedStorageSafeUri(mediaUri, mimeType)
// `IS_PENDING` is necessary for `TITLE`, not for `DISPLAY_NAME` // `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) { if (activity.contentResolver.update(uri, tempValues, null, null) == 0) {
throw Exception("failed to update fields for uri=$uri") throw Exception("failed to update fields for uri=$uri")
} }
@ -658,30 +671,29 @@ class MediaStoreImageProvider : ImageProvider() {
return null return null
} }
if (newUri == null) { if (newUri != null) {
cont.resumeWithException(Exception("failed to get URI of item at path=$path")) var contentUri: Uri? = null
return@scanFile // `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()
var contentUri: Uri? = null if (contentId != null) {
// `newURI` is possibly a file media URI (e.g. "content://media/12a9-8b42/file/62872") if (isImage(mimeType)) {
// but we need an image/video media URI (e.g. "content://media/external/images/media/62872") contentUri = ContentUris.withAppendedId(IMAGE_CONTENT_URI, contentId)
val contentId = newUri.tryParseId() } else if (isVideo(mimeType)) {
if (contentId != null) { contentUri = ContentUris.withAppendedId(VIDEO_CONTENT_URI, contentId)
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) // prefer image/video content URI, fallback to original URI (possibly a file content URI)
val newFields = scanUri(contentUri) ?: scanUri(newUri) val newFields = scanUri(contentUri) ?: scanUri(newUri)
if (newFields != null) { if (newFields != null) {
cont.resume(newFields) cont.resume(newFields)
} else {
cont.resumeWithException(Exception("failed to get item details from provider at contentUri=$contentUri (from newUri=$newUri)"))
}
} else { } 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"))
} }
} }
} }

View file

@ -394,6 +394,8 @@ object StorageUtils {
* Misc * Misc
*/ */
fun canEditByFile(context: Context, path: String) = !requireAccessPermission(context, path)
fun requireAccessPermission(context: Context, anyPath: String): Boolean { fun requireAccessPermission(context: Context, anyPath: String): Boolean {
// on Android R, we should always require access permission, even on primary volume // on Android R, we should always require access permission, even on primary volume
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) {