diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt index 7e4b6003b..9dd4032e5 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt @@ -15,12 +15,12 @@ import androidx.core.graphics.drawable.IconCompat import app.loup.streams_channel.StreamsChannel import deckers.thibault.aves.channel.calls.* import deckers.thibault.aves.channel.streams.* -import deckers.thibault.aves.model.provider.MediaStoreImageProvider import deckers.thibault.aves.utils.LogUtils import io.flutter.embedding.android.FlutterActivity import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel +import java.util.concurrent.CompletableFuture import java.util.concurrent.ConcurrentHashMap class MainActivity : FlutterActivity() { @@ -148,7 +148,8 @@ class MainActivity : FlutterActivity() { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { when (requestCode) { DOCUMENT_TREE_ACCESS_REQUEST -> onDocumentTreeAccessResult(data, resultCode, requestCode) - DELETE_PERMISSION_REQUEST -> onDeletePermissionResult(resultCode) + DELETE_SINGLE_PERMISSION_REQUEST, + MEDIA_WRITE_BULK_PERMISSION_REQUEST -> onScopedStoragePermissionResult(resultCode) CREATE_FILE_REQUEST, OPEN_FILE_REQUEST, SELECT_DIRECTORY_REQUEST -> onStorageAccessResult(requestCode, data?.data) @@ -173,10 +174,9 @@ class MainActivity : FlutterActivity() { onStorageAccessResult(requestCode, treeUri) } - private fun onDeletePermissionResult(resultCode: Int) { - // delete permission may be requested on Android 10+ only + private fun onScopedStoragePermissionResult(resultCode: Int) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - MediaStoreImageProvider.pendingDeleteCompleter?.complete(resultCode == RESULT_OK) + pendingScopedStoragePermissionCompleter?.complete(resultCode == RESULT_OK) } } @@ -287,15 +287,18 @@ class MainActivity : FlutterActivity() { const val VIEWER_CHANNEL = "deckers.thibault/aves/viewer" const val EXTRA_STRING_ARRAY_SEPARATOR = "###" const val DOCUMENT_TREE_ACCESS_REQUEST = 1 - const val DELETE_PERMISSION_REQUEST = 2 + const val OPEN_FROM_ANALYSIS_SERVICE = 2 const val CREATE_FILE_REQUEST = 3 const val OPEN_FILE_REQUEST = 4 const val SELECT_DIRECTORY_REQUEST = 5 - const val OPEN_FROM_ANALYSIS_SERVICE = 6 + const val DELETE_SINGLE_PERMISSION_REQUEST = 6 + const val MEDIA_WRITE_BULK_PERMISSION_REQUEST = 7 // request code to pending runnable val pendingStorageAccessResultHandlers = ConcurrentHashMap() + var pendingScopedStoragePermissionCompleter: CompletableFuture? = null + private fun onStorageAccessResult(requestCode: Int, uri: Uri?) { Log.d(LOG_TAG, "onStorageAccessResult with requestCode=$requestCode, uri=$uri") val handler = pendingStorageAccessResultHandlers.remove(requestCode) ?: return diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/SearchSuggestionsProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/SearchSuggestionsProvider.kt index 2323c9009..e045954f9 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/SearchSuggestionsProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/SearchSuggestionsProvider.kt @@ -85,7 +85,7 @@ class SearchSuggestionsProvider : MethodChannel.MethodCallHandler, ContentProvid "locale" to Locale.getDefault().toString(), ), object : MethodChannel.Result { override fun success(result: Any?) { - @Suppress("UNCHECKED_CAST") + @Suppress("unchecked_cast") cont.resume(result as List) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AccessibilityHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AccessibilityHandler.kt index da70320c6..77b5e015b 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AccessibilityHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AccessibilityHandler.kt @@ -22,7 +22,7 @@ class AccessibilityHandler(private val activity: Activity) : MethodCallHandler { } } - private fun areAnimationsRemoved(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { + private fun areAnimationsRemoved(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { var removed = false try { removed = Settings.Global.getFloat(activity.contentResolver, Settings.Global.TRANSITION_ANIMATION_SCALE) == 0f @@ -32,7 +32,7 @@ class AccessibilityHandler(private val activity: Activity) : MethodCallHandler { result.success(removed) } - private fun hasRecommendedTimeouts(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { + private fun hasRecommendedTimeouts(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt index f2a161440..d818180d3 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AppAdapterHandler.kt @@ -53,7 +53,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { } } - private fun getPackages(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { + private fun getPackages(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { val packages = HashMap() fun addPackageDetails(intent: Intent) { @@ -76,7 +76,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { // The following methods do not work: // - `resources.getConfiguration().setLocale(...)` // - getting a package manager from a custom context with `context.createConfigurationContext(config)` - @Suppress("DEPRECATION") + @Suppress("deprecation") resources.updateConfiguration(englishConfig, resources.displayMetrics) englishLabel = resources.getString(labelRes) } catch (e: Exception) { @@ -321,7 +321,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler { private fun isPinSupported() = ShortcutManagerCompat.isRequestPinShortcutSupported(context) - private fun canPin(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { + private fun canPin(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { result.success(isPinSupported()) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt index 0cac74386..29cca22e4 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DebugHandler.kt @@ -60,7 +60,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler { } } - private fun getContextDirs(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { + private fun getContextDirs(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { val dirs = hashMapOf( "cacheDir" to context.cacheDir, "filesDir" to context.filesDir, @@ -83,7 +83,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler { result.success(dirs) } - private fun getEnv(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { + private fun getEnv(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { result.success(System.getenv()) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DeviceHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DeviceHandler.kt index 2775cacc0..4240743d0 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DeviceHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/DeviceHandler.kt @@ -16,11 +16,11 @@ class DeviceHandler : MethodCallHandler { } } - private fun getDefaultTimeZone(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { + private fun getDefaultTimeZone(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { result.success(TimeZone.getDefault().id) } - private fun getPerformanceClass(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { + private fun getPerformanceClass(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { val performanceClass = Build.VERSION.MEDIA_PERFORMANCE_CLASS if (performanceClass > 0) { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaFileHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaFileHandler.kt index b3fe9d930..f039131e1 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaFileHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/MediaFileHandler.kt @@ -163,7 +163,7 @@ class MediaFileHandler(private val activity: Activity) : MethodCallHandler { }) } - private fun clearSizedThumbnailDiskCache(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { + private fun clearSizedThumbnailDiskCache(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { Glide.get(activity).clearDiskCache() result.success(null) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt index b501a568b..c8bb7a1db 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt @@ -6,6 +6,7 @@ import android.os.Environment import android.os.storage.StorageManager import androidx.core.os.EnvironmentCompat import deckers.thibault.aves.channel.calls.Coresult.Companion.safe +import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.utils.PermissionManager import deckers.thibault.aves.utils.StorageUtils.getPrimaryVolumePath import deckers.thibault.aves.utils.StorageUtils.getVolumePaths @@ -28,11 +29,13 @@ class StorageHandler(private val context: Context) : MethodCallHandler { "getRestrictedDirectories" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getRestrictedDirectories) } "revokeDirectoryAccess" -> safe(call, result, ::revokeDirectoryAccess) "deleteEmptyDirectories" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::deleteEmptyDirectories) } + "canRequestMediaFileBulkAccess" -> safe(call, result, ::canRequestMediaFileBulkAccess) + "canInsertMedia" -> safe(call, result, ::canInsertMedia) else -> result.notImplemented() } } - private fun getStorageVolumes(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { + private fun getStorageVolumes(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { val volumes = ArrayList>() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { val sm = context.getSystemService(Context.STORAGE_SERVICE) as? StorageManager @@ -100,7 +103,7 @@ class StorageHandler(private val context: Context) : MethodCallHandler { } } - private fun getGrantedDirectories(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { + private fun getGrantedDirectories(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { result.success(ArrayList(PermissionManager.getGrantedDirs(context))) } @@ -114,7 +117,7 @@ class StorageHandler(private val context: Context) : MethodCallHandler { result.success(PermissionManager.getInaccessibleDirectories(context, dirPaths)) } - private fun getRestrictedDirectories(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { + private fun getRestrictedDirectories(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { result.success(PermissionManager.getRestrictedDirectories(context)) } @@ -155,6 +158,20 @@ class StorageHandler(private val context: Context) : MethodCallHandler { result.success(deleted) } + private fun canRequestMediaFileBulkAccess(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { + result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) + } + + private fun canInsertMedia(call: MethodCall, result: MethodChannel.Result) { + val directories = call.argument>("directories") + if (directories == null) { + result.error("canInsertMedia-args", "failed because of missing arguments", null) + return + } + + result.success(PermissionManager.canInsertByMediaStore(directories)) + } + companion object { const val CHANNEL = "deckers.thibault/aves/storage" } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/WindowHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/WindowHandler.kt index bad2bf6eb..2494977f9 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/WindowHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/WindowHandler.kt @@ -40,7 +40,7 @@ class WindowHandler(private val activity: Activity) : MethodCallHandler { result.success(null) } - private fun isRotationLocked(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { + private fun isRotationLocked(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { var locked = false try { locked = Settings.System.getInt(activity.contentResolver, Settings.System.ACCELEROMETER_ROTATION) == 0 @@ -60,7 +60,7 @@ class WindowHandler(private val activity: Activity) : MethodCallHandler { result.success(true) } - private fun canSetCutoutMode(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) { + private fun canSetCutoutMode(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt index d3fad82aa..5c9d40242 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/fetchers/ThumbnailFetcher.kt @@ -99,10 +99,10 @@ class ThumbnailFetcher internal constructor( val contentId = uri.tryParseId() ?: return null val resolver = context.contentResolver return if (isVideo(mimeType)) { - @Suppress("DEPRECATION") + @Suppress("deprecation") MediaStore.Video.Thumbnails.getThumbnail(resolver, contentId, MediaStore.Video.Thumbnails.MINI_KIND, null) } else { - @Suppress("DEPRECATION") + @Suppress("deprecation") var bitmap = MediaStore.Images.Thumbnails.getThumbnail(resolver, contentId, MediaStore.Images.Thumbnails.MINI_KIND, null) // from Android Q, returned thumbnail is already rotated according to EXIF orientation if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && bitmap != null) { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt index a5358a839..d75c7b44f 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt @@ -29,7 +29,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments init { if (arguments is Map<*, *>) { op = arguments["op"] as String? - @Suppress("UNCHECKED_CAST") + @Suppress("unchecked_cast") val rawEntries = arguments["entries"] as List? if (rawEntries != null) { entryMapList.addAll(rawEntries) @@ -100,12 +100,13 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments for (entryMap in entryMapList) { val uri = (entryMap["uri"] as String?)?.let { Uri.parse(it) } val path = entryMap["path"] as String? - if (uri != null) { + val mimeType = entryMap["mimeType"] as String? + if (uri != null && mimeType != null) { val result: FieldMap = hashMapOf( "uri" to uri.toString(), ) try { - provider.delete(activity, uri, path) + provider.delete(activity, uri, path, mimeType) result["success"] = true } catch (e: Exception) { Log.w(LOG_TAG, "failed to delete entry with path=$path", e) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.kt index 027f26506..f90e5971b 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/MediaStoreStreamHandler.kt @@ -21,7 +21,7 @@ class MediaStoreStreamHandler(private val context: Context, arguments: Any?) : E init { if (arguments is Map<*, *>) { - @Suppress("UNCHECKED_CAST") + @Suppress("unchecked_cast") knownEntries = arguments["knownEntries"] as Map? } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt index 58f2729cf..4a63445bf 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt @@ -2,6 +2,7 @@ package deckers.thibault.aves.channel.streams import android.app.Activity import android.content.Intent +import android.net.Uri import android.os.Build import android.os.Handler import android.os.Looper @@ -39,7 +40,8 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any? handler = Handler(Looper.getMainLooper()) when (op) { - "requestVolumeAccess" -> GlobalScope.launch(Dispatchers.IO) { requestVolumeAccess() } + "requestDirectoryAccess" -> GlobalScope.launch(Dispatchers.IO) { requestDirectoryAccess() } + "requestMediaFileAccess" -> GlobalScope.launch(Dispatchers.IO) { requestMediaFileAccess() } "createFile" -> GlobalScope.launch(Dispatchers.IO) { createFile() } "openFile" -> GlobalScope.launch(Dispatchers.IO) { openFile() } "selectDirectory" -> GlobalScope.launch(Dispatchers.IO) { selectDirectory() } @@ -47,19 +49,19 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any? } } - private fun requestVolumeAccess() { + private fun requestDirectoryAccess() { val path = args["path"] as String? if (path == null) { - error("requestVolumeAccess-args", "failed because of missing arguments", null) + error("requestDirectoryAccess-args", "failed because of missing arguments", null) return } if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { - error("requestVolumeAccess-unsupported", "volume access is not allowed before Android Lollipop", null) + error("requestDirectoryAccess-unsupported", "directory access is not allowed before Android Lollipop", null) return } - PermissionManager.requestVolumeAccess(activity, path, { + PermissionManager.requestDirectoryAccess(activity, path, { success(true) endOfStream() }, { @@ -68,6 +70,28 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any? }) } + private fun requestMediaFileAccess() { + val uris = (args["uris"] as List<*>?)?.mapNotNull { if (it is String) Uri.parse(it) else null } + val mimeTypes = (args["mimeTypes"] as List<*>?)?.mapNotNull { if (it is String) it else null } + if (uris == null || uris.isEmpty() || mimeTypes == null || mimeTypes.size != uris.size) { + error("requestMediaFileAccess-args", "failed because of missing arguments", null) + return + } + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + error("requestMediaFileAccess-unsupported", "media file bulk access is not allowed before Android R", null) + return + } + + try { + val granted = PermissionManager.requestMediaFileAccess(activity, uris, mimeTypes) + success(granted) + } catch (e: Exception) { + error("requestMediaFileAccess-request", "failed to request access to uris=$uris", e.message) + } + endOfStream() + } + private fun createFile() { val name = args["name"] as String? val mimeType = args["mimeType"] as String? diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ContentImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ContentImageProvider.kt index 8c935b741..f780e0c2a 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ContentImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ContentImageProvider.kt @@ -76,7 +76,7 @@ internal class ContentImageProvider : ImageProvider() { companion object { private val LOG_TAG = LogUtils.createTag() - @Suppress("DEPRECATION") + @Suppress("deprecation") const val PATH = MediaStore.MediaColumns.DATA private val projection = arrayOf( 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 200e3f117..d129b187c 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 @@ -2,10 +2,14 @@ package deckers.thibault.aves.model.provider import android.app.Activity import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager import android.graphics.Bitmap 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 @@ -28,8 +32,6 @@ import deckers.thibault.aves.utils.MimeTypes.canEditXmp import deckers.thibault.aves.utils.MimeTypes.canRemoveMetadata import deckers.thibault.aves.utils.MimeTypes.extensionFor import deckers.thibault.aves.utils.MimeTypes.isVideo -import deckers.thibault.aves.utils.StorageUtils.createDirectoryIfAbsent -import deckers.thibault.aves.utils.StorageUtils.getDocumentFile import java.io.ByteArrayInputStream import java.io.File import java.io.IOException @@ -41,11 +43,11 @@ abstract class ImageProvider { callback.onFailure(UnsupportedOperationException("`fetchSingle` is not supported by this image provider")) } - open suspend fun delete(activity: Activity, uri: Uri, path: String?) { + open suspend fun delete(activity: Activity, uri: Uri, path: String?, mimeType: String) { throw UnsupportedOperationException("`delete` is not supported by this image provider") } - open suspend fun moveMultiple(activity: Activity, copy: Boolean, destinationDir: String, nameConflictStrategy: NameConflictStrategy, entries: List, callback: ImageOpCallback) { + open suspend fun moveMultiple(activity: Activity, copy: Boolean, targetDir: String, nameConflictStrategy: NameConflictStrategy, entries: List, callback: ImageOpCallback) { callback.onFailure(UnsupportedOperationException("`moveMultiple` is not supported by this image provider")) } @@ -64,7 +66,7 @@ abstract class ImageProvider { suspend fun exportMultiple( activity: Activity, imageExportMimeType: String, - destinationDir: String, + targetDir: String, entries: List, nameConflictStrategy: NameConflictStrategy, callback: ImageOpCallback, @@ -73,9 +75,15 @@ abstract class ImageProvider { callback.onFailure(Exception("unsupported export MIME type=$imageExportMimeType")) } - val destinationDirDocFile = createDirectoryIfAbsent(activity, destinationDir) - if (destinationDirDocFile == null) { - callback.onFailure(Exception("failed to create directory at path=$destinationDir")) + val targetDirDocFile = StorageUtils.createDirectoryDocIfAbsent(activity, targetDir) + if (!File(targetDir).exists()) { + callback.onFailure(Exception("failed to create directory at path=$targetDir")) + return + } + + // TODO TLAD [storage] allow inserting by Media Store + if (targetDirDocFile == null) { + callback.onFailure(Exception("failed to get tree doc for directory at path=$targetDir")) return } @@ -96,15 +104,15 @@ abstract class ImageProvider { val newFields = exportSingleByTreeDocAndScan( activity = activity, sourceEntry = entry, - destinationDir = destinationDir, - destinationDirDocFile = destinationDirDocFile, + targetDir = targetDir, + targetDirDocFile = targetDirDocFile, nameConflictStrategy = nameConflictStrategy, exportMimeType = exportMimeType, ) result["newFields"] = newFields result["success"] = true } catch (e: Exception) { - Log.w(LOG_TAG, "failed to export to destinationDir=$destinationDir entry with sourcePath=$sourcePath pageId=$pageId", e) + Log.w(LOG_TAG, "failed to export to targetDir=$targetDir entry with sourcePath=$sourcePath pageId=$pageId", e) } callback.onSuccess(result) } @@ -114,8 +122,8 @@ abstract class ImageProvider { private suspend fun exportSingleByTreeDocAndScan( activity: Activity, sourceEntry: AvesEntry, - destinationDir: String, - destinationDirDocFile: DocumentFileCompat, + targetDir: String, + targetDirDocFile: DocumentFileCompat, nameConflictStrategy: NameConflictStrategy, exportMimeType: String, ): FieldMap { @@ -135,9 +143,9 @@ abstract class ImageProvider { } val targetNameWithoutExtension = resolveTargetFileNameWithoutExtension( activity = activity, - dir = destinationDir, + dir = targetDir, desiredNameWithoutExtension = desiredNameWithoutExtension, - extension = extensionFor(exportMimeType), + mimeType = exportMimeType, conflictStrategy = nameConflictStrategy, ) ?: return skippedFieldMap @@ -145,12 +153,12 @@ abstract class ImageProvider { // but in order to open an output stream to it, we need to use a `SingleDocumentFile` // through a document URI, not a tree URI // note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first - val destinationTreeFile = destinationDirDocFile.createFile(exportMimeType, targetNameWithoutExtension) - val destinationDocFile = DocumentFileCompat.fromSingleUri(activity, destinationTreeFile.uri) + val targetTreeFile = targetDirDocFile.createFile(exportMimeType, targetNameWithoutExtension) + val targetDocFile = DocumentFileCompat.fromSingleUri(activity, targetTreeFile.uri) if (isVideo(sourceMimeType)) { val sourceDocFile = DocumentFileCompat.fromSingleUri(activity, sourceUri) - sourceDocFile.copyTo(destinationDocFile) + sourceDocFile.copyTo(targetDocFile) } else { val model: Any = if (MimeTypes.isHeic(sourceMimeType) && pageId != null) { MultiTrackImage(activity, sourceUri, pageId) @@ -178,7 +186,7 @@ abstract class ImageProvider { } bitmap ?: throw Exception("failed to get image for mimeType=$sourceMimeType uri=$sourceUri page=$pageId") - destinationDocFile.openOutputStream().use { output -> + targetDocFile.openOutputStream().use { output -> if (exportMimeType == MimeTypes.BMP) { BmpWriter.writeRGB24(bitmap, output) } else { @@ -193,7 +201,7 @@ abstract class ImageProvider { Bitmap.CompressFormat.WEBP_LOSSY } } else { - @Suppress("DEPRECATION") + @Suppress("deprecation") Bitmap.CompressFormat.WEBP } else -> throw Exception("unsupported export MIME type=$exportMimeType") @@ -203,8 +211,8 @@ abstract class ImageProvider { } } catch (e: Exception) { // remove empty file - if (destinationDocFile.exists()) { - destinationDocFile.delete() + if (targetDocFile.exists()) { + targetDocFile.delete() } throw e } finally { @@ -212,10 +220,10 @@ abstract class ImageProvider { } } - val fileName = destinationDocFile.name - val destinationFullPath = destinationDir + fileName + val fileName = targetDocFile.name + val targetFullPath = targetDir + fileName - return MediaStoreImageProvider().scanNewPath(activity, destinationFullPath, exportMimeType) + return MediaStoreImageProvider().scanNewPath(activity, targetFullPath, exportMimeType) } @Suppress("BlockingMethodInNonBlockingContext") @@ -224,13 +232,19 @@ abstract class ImageProvider { desiredNameWithoutExtension: String, exifFields: FieldMap, bytes: ByteArray, - destinationDir: String, + targetDir: String, nameConflictStrategy: NameConflictStrategy, callback: ImageOpCallback, ) { - val destinationDirDocFile = createDirectoryIfAbsent(activity, destinationDir) - if (destinationDirDocFile == null) { - callback.onFailure(Exception("failed to create directory at path=$destinationDir")) + val targetDirDocFile = StorageUtils.createDirectoryDocIfAbsent(activity, targetDir) + if (!File(targetDir).exists()) { + callback.onFailure(Exception("failed to create directory at path=$targetDir")) + return + } + + // TODO TLAD [storage] allow inserting by Media Store + if (targetDirDocFile == null) { + callback.onFailure(Exception("failed to get tree doc for directory at path=$targetDir")) return } @@ -238,9 +252,9 @@ abstract class ImageProvider { val targetNameWithoutExtension = try { resolveTargetFileNameWithoutExtension( activity = activity, - dir = destinationDir, + dir = targetDir, desiredNameWithoutExtension = desiredNameWithoutExtension, - extension = extensionFor(captureMimeType), + mimeType = captureMimeType, conflictStrategy = nameConflictStrategy, ) } catch (e: Exception) { @@ -258,12 +272,12 @@ abstract class ImageProvider { // but in order to open an output stream to it, we need to use a `SingleDocumentFile` // through a document URI, not a tree URI // note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first - val destinationTreeFile = destinationDirDocFile.createFile(captureMimeType, targetNameWithoutExtension) - val destinationDocFile = DocumentFileCompat.fromSingleUri(activity, destinationTreeFile.uri) + val targetTreeFile = targetDirDocFile.createFile(captureMimeType, targetNameWithoutExtension) + val targetDocFile = DocumentFileCompat.fromSingleUri(activity, targetTreeFile.uri) try { if (exifFields.isEmpty()) { - destinationDocFile.openOutputStream().use { output -> + targetDocFile.openOutputStream().use { output -> output.write(bytes) } } else { @@ -322,12 +336,12 @@ abstract class ImageProvider { exif.saveAttributes() // copy the edited temporary file back to the original - DocumentFileCompat.fromFile(editableFile).copyTo(destinationDocFile) + DocumentFileCompat.fromFile(editableFile).copyTo(targetDocFile) } - val fileName = destinationDocFile.name - val destinationFullPath = destinationDir + fileName - val newFields = MediaStoreImageProvider().scanNewPath(activity, destinationFullPath, captureMimeType) + val fileName = targetDocFile.name + val targetFullPath = targetDir + fileName + val newFields = MediaStoreImageProvider().scanNewPath(activity, targetFullPath, captureMimeType) callback.onSuccess(newFields) } catch (e: Exception) { callback.onFailure(e) @@ -339,9 +353,10 @@ abstract class ImageProvider { activity: Activity, dir: String, desiredNameWithoutExtension: String, - extension: String?, + mimeType: String, conflictStrategy: NameConflictStrategy, ): String? { + val extension = extensionFor(mimeType) val targetFile = File(dir, "$desiredNameWithoutExtension$extension") return when (conflictStrategy) { NameConflictStrategy.RENAME -> { @@ -359,7 +374,7 @@ abstract class ImageProvider { MediaStoreImageProvider().apply { val uri = getContentUriForPath(activity, path) uri ?: throw Exception("failed to find content URI for path=$path") - delete(activity, uri, path) + delete(activity, uri, path, mimeType) } } desiredNameWithoutExtension @@ -388,12 +403,6 @@ abstract class ImageProvider { return false } - val originalDocumentFile = getDocumentFile(context, path, uri) - if (originalDocumentFile == null) { - callback.onFailure(Exception("failed to get document file for path=$path, uri=$uri")) - return false - } - val originalFileSize = File(path).length() val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff } var videoBytes: ByteArray? = null @@ -419,7 +428,7 @@ abstract class ImageProvider { } } else { // copy original file to a temporary file for editing - originalDocumentFile.openInputStream().use { imageInput -> + StorageUtils.openInputStream(context, uri)?.use { imageInput -> imageInput.copyTo(output) } } @@ -439,7 +448,7 @@ abstract class ImageProvider { } // copy the edited temporary file back to the original - DocumentFileCompat.fromFile(editableFile).copyTo(originalDocumentFile) + copyTo(context, mimeType, sourceFile = editableFile, targetUri = uri, targetPath = path) if (!checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) { return false @@ -466,18 +475,12 @@ abstract class ImageProvider { return false } - val originalDocumentFile = getDocumentFile(context, path, uri) - if (originalDocumentFile == null) { - callback.onFailure(Exception("failed to get document file for path=$path, uri=$uri")) - return false - } - val originalFileSize = File(path).length() val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.let { it.toInt() + trailerDiff } val editableFile = File.createTempFile("aves", null).apply { deleteOnExit() try { - val xmp = originalDocumentFile.openInputStream().use { input -> PixyMetaHelper.getXmp(input) } + val xmp = StorageUtils.openInputStream(context, uri)?.use { input -> PixyMetaHelper.getXmp(input) } if (xmp == null) { callback.onFailure(Exception("failed to find XMP for path=$path, uri=$uri")) return false @@ -485,7 +488,7 @@ abstract class ImageProvider { outputStream().use { output -> // reopen input to read from start - originalDocumentFile.openInputStream().use { input -> + StorageUtils.openInputStream(context, uri)?.use { input -> val editedXmpString = edit(xmp.xmpDocString()) val extendedXmpString = if (xmp.hasExtendedXmp()) xmp.extendedXmpDocString() else null PixyMetaHelper.setXmp(input, output, editedXmpString, extendedXmpString) @@ -499,7 +502,7 @@ abstract class ImageProvider { try { // copy the edited temporary file back to the original - DocumentFileCompat.fromFile(editableFile).copyTo(originalDocumentFile) + copyTo(context, mimeType, sourceFile = editableFile, targetUri = uri, targetPath = path) if (!checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) { return false @@ -690,12 +693,6 @@ abstract class ImageProvider { return } - val originalDocumentFile = getDocumentFile(context, path, uri) - if (originalDocumentFile == null) { - callback.onFailure(Exception("failed to get document file for path=$path, uri=$uri")) - return - } - val originalFileSize = File(path).length() val videoSize = MultiPage.getMotionPhotoOffset(context, uri, mimeType, originalFileSize)?.toInt() val editableFile = File.createTempFile("aves", null).apply { @@ -703,7 +700,7 @@ abstract class ImageProvider { try { outputStream().use { output -> // reopen input to read from start - originalDocumentFile.openInputStream().use { input -> + StorageUtils.openInputStream(context, uri)?.use { input -> PixyMetaHelper.removeMetadata(input, output, types) } } @@ -716,7 +713,7 @@ abstract class ImageProvider { try { // copy the edited temporary file back to the original - DocumentFileCompat.fromFile(editableFile).copyTo(originalDocumentFile) + copyTo(context, mimeType, sourceFile = editableFile, targetUri = uri, targetPath = path) if (!types.contains(Metadata.TYPE_XMP) && !checkTrailerOffset(context, path, uri, mimeType, videoSize, editableFile, callback)) { return @@ -730,6 +727,22 @@ abstract class ImageProvider { scanPostMetadataEdit(context, path, uri, mimeType, newFields, callback) } + private fun copyTo( + context: Context, + mimeType: String, + sourceFile: File, + targetUri: Uri, + targetPath: String + ) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && 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 { + val targetDocumentFile = StorageUtils.getDocumentFile(context, targetPath, targetUri) ?: throw Exception("failed to get document file for path=$targetPath, uri=$targetUri") + DocumentFileCompat.fromFile(sourceFile).copyTo(targetDocumentFile) + } + } + interface ImageOpCallback { fun onSuccess(fields: FieldMap) fun onFailure(throwable: Throwable) @@ -744,5 +757,15 @@ 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) + + 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 + } } } 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 deb657bb1..14bdb0260 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 @@ -4,26 +4,29 @@ import android.annotation.SuppressLint import android.app.Activity import android.app.RecoverableSecurityException import android.content.ContentUris +import android.content.ContentValues import android.content.Context 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 androidx.annotation.RequiresApi import com.commonsware.cwac.document.DocumentFileCompat -import deckers.thibault.aves.MainActivity.Companion.DELETE_PERMISSION_REQUEST +import deckers.thibault.aves.MainActivity +import deckers.thibault.aves.MainActivity.Companion.DELETE_SINGLE_PERMISSION_REQUEST import deckers.thibault.aves.model.AvesEntry import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.model.NameConflictStrategy import deckers.thibault.aves.model.SourceEntry import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MimeTypes +import deckers.thibault.aves.utils.MimeTypes.extensionFor import deckers.thibault.aves.utils.MimeTypes.isImage import deckers.thibault.aves.utils.MimeTypes.isVideo -import deckers.thibault.aves.utils.StorageUtils.createDirectoryIfAbsent -import deckers.thibault.aves.utils.StorageUtils.ensureTrailingSeparator -import deckers.thibault.aves.utils.StorageUtils.getDocumentFile -import deckers.thibault.aves.utils.StorageUtils.requireAccessPermission +import deckers.thibault.aves.utils.StorageUtils +import deckers.thibault.aves.utils.StorageUtils.PathSegments import deckers.thibault.aves.utils.UriUtils.tryParseId import java.io.File import java.util.* @@ -243,37 +246,44 @@ class MediaStoreImageProvider : ImageProvider() { private fun needSize(mimeType: String) = MimeTypes.SVG != mimeType // `uri` is a media URI, not a document URI - override suspend fun delete(activity: Activity, uri: Uri, path: String?) { - path ?: throw Exception("failed to delete file because path is null") + 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)) { + Log.d(LOG_TAG, "delete document at uri=$uri path=$path") + val df = StorageUtils.getDocumentFile(activity, path, uri) - if (File(path).exists() && 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 if the app has the permission - val df = getDocumentFile(activity, path, uri) - - @Suppress("BlockingMethodInNonBlockingContext") - if (df != null && df.delete()) return - throw Exception("failed to delete file with df=$df") + @Suppress("BlockingMethodInNonBlockingContext") + if (df != null && df.delete()) return + throw Exception("failed to delete file with df=$df") + } } try { + Log.d(LOG_TAG, "delete content at uri=$uri") if (activity.contentResolver.delete(uri, null, null) > 0) return } 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+ // when the underlying file no longer exists and this is an orphaned entry in the Media Store if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + Log.w(LOG_TAG, "caught a security exception when attempting to delete uri=$uri", securityException) val rse = securityException as? RecoverableSecurityException ?: throw securityException val intentSender = rse.userAction.actionIntent.intentSender // request user permission for this item - pendingDeleteCompleter = CompletableFuture() - activity.startIntentSenderForResult(intentSender, DELETE_PERMISSION_REQUEST, null, 0, 0, 0, null) - val granted = pendingDeleteCompleter!!.join() + MainActivity.pendingScopedStoragePermissionCompleter = CompletableFuture() + activity.startIntentSenderForResult(intentSender, DELETE_SINGLE_PERMISSION_REQUEST, null, 0, 0, 0, null) + val granted = MainActivity.pendingScopedStoragePermissionCompleter!!.join() - pendingDeleteCompleter = null + MainActivity.pendingScopedStoragePermissionCompleter = null if (granted) { - delete(activity, uri, path) + delete(activity, uri, path, mimeType) } else { throw Exception("failed to get delete permission") } @@ -287,14 +297,14 @@ class MediaStoreImageProvider : ImageProvider() { override suspend fun moveMultiple( activity: Activity, copy: Boolean, - destinationDir: String, + targetDir: String, nameConflictStrategy: NameConflictStrategy, entries: List, callback: ImageOpCallback, ) { - val destinationDirDocFile = createDirectoryIfAbsent(activity, destinationDir) - if (destinationDirDocFile == null) { - callback.onFailure(Exception("failed to create directory at path=$destinationDir")) + val targetDirDocFile = StorageUtils.createDirectoryDocIfAbsent(activity, targetDir) + if (!File(targetDir).exists()) { + callback.onFailure(Exception("failed to create directory at path=$targetDir")) return } @@ -324,12 +334,12 @@ class MediaStoreImageProvider : ImageProvider() { // - there is no documentation regarding support for usage with removable storage // - the Media Store only allows inserting in specific primary directories ("DCIM", "Pictures") when using scoped storage try { - val newFields = moveSingleByTreeDocAndScan( + val newFields = moveSingle( activity = activity, sourcePath = sourcePath, sourceUri = sourceUri, - destinationDir = destinationDir, - destinationDirDocFile = destinationDirDocFile, + targetDir = targetDir, + targetDirDocFile = targetDirDocFile, nameConflictStrategy = nameConflictStrategy, mimeType = mimeType, copy = copy, @@ -337,26 +347,26 @@ class MediaStoreImageProvider : ImageProvider() { result["newFields"] = newFields result["success"] = true } catch (e: Exception) { - Log.w(LOG_TAG, "failed to move to destinationDir=$destinationDir entry with sourcePath=$sourcePath", e) + Log.w(LOG_TAG, "failed to move to targetDir=$targetDir entry with sourcePath=$sourcePath", e) } } callback.onSuccess(result) } } - private suspend fun moveSingleByTreeDocAndScan( + private suspend fun moveSingle( activity: Activity, sourcePath: String, sourceUri: Uri, - destinationDir: String, - destinationDirDocFile: DocumentFileCompat, + targetDir: String, + targetDirDocFile: DocumentFileCompat?, nameConflictStrategy: NameConflictStrategy, mimeType: String, copy: Boolean, ): FieldMap { val sourceFile = File(sourcePath) - val sourceDir = sourceFile.parent?.let { ensureTrailingSeparator(it) } - if (sourceDir == destinationDir && !(copy && nameConflictStrategy == NameConflictStrategy.RENAME)) { + val sourceDir = sourceFile.parent?.let { StorageUtils.ensureTrailingSeparator(it) } + if (sourceDir == targetDir && !(copy && nameConflictStrategy == NameConflictStrategy.RENAME)) { // nothing to do unless it's a renamed copy return skippedFieldMap } @@ -365,47 +375,98 @@ class MediaStoreImageProvider : ImageProvider() { val desiredNameWithoutExtension = sourceFileName.replaceFirst(FILE_EXTENSION_PATTERN, "") val targetNameWithoutExtension = resolveTargetFileNameWithoutExtension( activity = activity, - dir = destinationDir, + dir = targetDir, desiredNameWithoutExtension = desiredNameWithoutExtension, - extension = MimeTypes.extensionFor(mimeType), + mimeType = mimeType, conflictStrategy = nameConflictStrategy, ) ?: return skippedFieldMap - // the file created from a `TreeDocumentFile` is also a `TreeDocumentFile` - // but in order to open an output stream to it, we need to use a `SingleDocumentFile` - // through a document URI, not a tree URI - // note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first - @Suppress("BlockingMethodInNonBlockingContext") - val destinationTreeFile = destinationDirDocFile.createFile(mimeType, targetNameWithoutExtension) - val destinationDocFile = DocumentFileCompat.fromSingleUri(activity, destinationTreeFile.uri) + return moveSingleByTreeDoc( + activity = activity, + mimeType = mimeType, + sourceUri = sourceUri, + sourcePath = sourcePath, + targetDir = targetDir, + targetDirDocFile = targetDirDocFile, + targetNameWithoutExtension = targetNameWithoutExtension, + copy = copy + ) + } + private suspend fun moveSingleByTreeDoc( + activity: Activity, + mimeType: String, + sourceUri: Uri, + sourcePath: String, + targetDir: String, + targetDirDocFile: DocumentFileCompat?, + targetNameWithoutExtension: String, + copy: Boolean + ): FieldMap { // `DocumentsContract.moveDocument()` needs `sourceParentDocumentUri`, which could be different for each entry // `DocumentsContract.copyDocument()` yields "Unsupported call: android:copyDocument" - // when used with entry URI as `sourceDocumentUri`, and destinationDirDocFile URI as `targetParentDocumentUri` + // when used with entry URI as `sourceDocumentUri`, and targetDirDocFile URI as `targetParentDocumentUri` val source = DocumentFileCompat.fromSingleUri(activity, sourceUri) - @Suppress("BlockingMethodInNonBlockingContext") - source.copyTo(destinationDocFile) - // the source file name and the created document file name can be different when: - // - a file with the same name already exists, some implementations give a suffix like ` (1)`, some *do not* - // - the original extension does not match the extension added by the underlying provider - val fileName = destinationDocFile.name - val destinationFullPath = destinationDir + fileName + val targetPath = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && isDownloadDir(activity, targetDir)) { + val targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType)}" + val values = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, targetFileName) + put(MediaStore.MediaColumns.IS_PENDING, 1) + } + val resolver = activity.contentResolver + val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) + + uri?.let { + @Suppress("BlockingMethodInNonBlockingContext") + resolver.openOutputStream(uri)?.use { output -> + source.copyTo(output) + } + values.clear() + values.put(MediaStore.MediaColumns.IS_PENDING, 0) + resolver.update(uri, values, null, null) + } ?: throw Exception("MediaStore failed for some reason") + + File(targetDir, targetFileName).path + } else { + targetDirDocFile ?: throw Exception("failed to get tree doc for directory at path=$targetDir") + + // the file created from a `TreeDocumentFile` is also a `TreeDocumentFile` + // but in order to open an output stream to it, we need to use a `SingleDocumentFile` + // through a document URI, not a tree URI + // note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first + @Suppress("BlockingMethodInNonBlockingContext") + val targetTreeFile = targetDirDocFile.createFile(mimeType, targetNameWithoutExtension) + val targetDocFile = DocumentFileCompat.fromSingleUri(activity, targetTreeFile.uri) + + @Suppress("BlockingMethodInNonBlockingContext") + source.copyTo(targetDocFile) + + // the source file name and the created document file name can be different when: + // - a file with the same name already exists, some implementations give a suffix like ` (1)`, some *do not* + // - the original extension does not match the extension added by the underlying provider + val fileName = targetDocFile.name + targetDir + fileName + } - var deletedSource = false if (!copy) { // delete original entry try { - delete(activity, sourceUri, sourcePath) - deletedSource = true + delete(activity, sourceUri, sourcePath, mimeType) } catch (e: Exception) { Log.w(LOG_TAG, "failed to delete entry with path=$sourcePath", e) } } - return scanNewPath(activity, destinationFullPath, mimeType).apply { - put("deletedSource", deletedSource) + return scanNewPath(activity, targetPath, mimeType) + } + + private fun isDownloadDir(context: Context, dirPath: String): Boolean { + var relativeDir = PathSegments(context, dirPath).relativeDir ?: "" + if (relativeDir.endsWith(File.separator)) { + relativeDir = relativeDir.substring(0, relativeDir.length - 1) } + return relativeDir == Environment.DIRECTORY_DOWNLOADS } override suspend fun renameMultiple( @@ -428,10 +489,10 @@ class MediaStoreImageProvider : ImageProvider() { try { val newFields = renameSingle( activity = activity, - oldPath = sourcePath, - oldMediaUri = sourceUri, - newFileName = newFileName, mimeType = mimeType, + oldMediaUri = sourceUri, + oldPath = sourcePath, + newFileName = newFileName, ) result["newFields"] = newFields result["success"] = true @@ -445,10 +506,10 @@ class MediaStoreImageProvider : ImageProvider() { private suspend fun renameSingle( activity: Activity, - oldPath: String, - oldMediaUri: Uri, - newFileName: String, mimeType: String, + oldMediaUri: Uri, + oldPath: String, + newFileName: String, ): FieldMap { val oldFile = File(oldPath) val newFile = File(oldFile.parent, newFileName) @@ -457,17 +518,61 @@ class MediaStoreImageProvider : ImageProvider() { return skippedFieldMap } + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + && 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 { + 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) } + if (activity.contentResolver.update(uri, tempValues, null, null) == 0) { + throw Exception("failed to update fields for uri=$uri") + } + + val finalValues = ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, newFile.name) + // scanning the new file will not automatically update `TITLE` + put(MediaStore.MediaColumns.TITLE, newFile.nameWithoutExtension) + put(MediaStore.MediaColumns.IS_PENDING, 0) + } + if (activity.contentResolver.update(uri, finalValues, null, null) == 0) { + throw Exception("failed to update fields for uri=$uri") + } + + // URI should not change + return scanNewPath(activity, newFile.path, mimeType) + } + + private suspend fun renameSingleByTreeDoc( + activity: Activity, + mimeType: String, + oldMediaUri: Uri, + oldPath: String, + newFile: File + ): FieldMap { + Log.d(LOG_TAG, "rename document at uri=$oldMediaUri path=$oldPath") @Suppress("BlockingMethodInNonBlockingContext") - val renamed = getDocumentFile(activity, oldPath, oldMediaUri)?.renameTo(newFileName) ?: false + val renamed = StorageUtils.getDocumentFile(activity, oldPath, oldMediaUri)?.renameTo(newFile.name) ?: false if (!renamed) { throw Exception("failed to rename entry at path=$oldPath") } - // renaming may be successful and the file at the old path no longer exists - // but, in some situations, scanning the old path does not clear the Media Store entry - // e.g. for media owned by another package in the Download folder on API 29 - - // for higher chance of accurate obsolete item check, keep this order: + // Renaming may be successful and the file at the old path no longer exists + // but, in some situations, scanning the old path does not clear the Media Store entry. + // For higher chance of accurate obsolete item check, keep this order: // 1) scan obsolete item, // 2) scan current item, // 3) check obsolete item in Media Store @@ -475,19 +580,24 @@ class MediaStoreImageProvider : ImageProvider() { scanObsoletePath(activity, oldPath, mimeType) val newFields = scanNewPath(activity, newFile.path, mimeType) - var deletedSource = !hasEntry(activity, oldMediaUri) - if (!deletedSource) { - Log.w(LOG_TAG, "renaming item at uri=$oldMediaUri to newFileName=$newFileName did not clear the MediaStore entry for obsolete path=$oldPath") + if (hasEntry(activity, oldMediaUri)) { + Log.w(LOG_TAG, "renaming item at uri=$oldMediaUri to newFile=$newFile did not clear the MediaStore entry for obsolete path=$oldPath") - // delete obsolete entry - try { - delete(activity, oldMediaUri, oldPath) - deletedSource = true - } catch (e: Exception) { - Log.w(LOG_TAG, "failed to delete entry with path=$oldPath", e) + // On Android Q (emulator/Mi9TPro), the concept of owner package disrupts renaming and the Media Store keeps an obsolete entry, + // but we use legacy external storage, so at least we do not have to deal with a `RecoverableSecurityException` + // when deleting this obsolete entry which is not backed by a file anymore. + // On Android R (S10e), everything seems fine! + // On Android S (emulator), renaming always leaves an obsolete entry whatever the owner package, + // and we get a `RecoverableSecurityException` if we attempt to delete this obsolete entry, + // but the entry seems to be cleaned later automatically by the Media Store anyway. + if (Build.VERSION.SDK_INT == Build.VERSION_CODES.Q) { + try { + delete(activity, oldMediaUri, oldPath, mimeType) + } catch (e: Exception) { + Log.w(LOG_TAG, "failed to delete entry with path=$oldPath", e) + } } } - newFields["deletedSource"] = deletedSource return newFields } @@ -631,8 +741,6 @@ class MediaStoreImageProvider : ImageProvider() { MediaStore.MediaColumns.ORIENTATION, ) else emptyArray() ) - - var pendingDeleteCompleter: CompletableFuture? = null } } @@ -650,7 +758,7 @@ object MediaColumns { @SuppressLint("InlinedApi") const val DURATION = MediaStore.MediaColumns.DURATION - @Suppress("DEPRECATION") + @Suppress("deprecation") const val PATH = MediaStore.MediaColumns.DATA } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/ContextUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/ContextUtils.kt index 318bf234e..d31dd5c26 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/ContextUtils.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/ContextUtils.kt @@ -36,7 +36,7 @@ object ContextUtils { fun Context.isMyServiceRunning(serviceClass: Class): Boolean { val am = this.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager? am ?: return false - @Suppress("DEPRECATION") + @Suppress("deprecation") return am.getRunningServices(Integer.MAX_VALUE).any { it.service.className == serviceClass.name } } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt index 96da1ae62..1655f9430 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt @@ -3,24 +3,35 @@ package deckers.thibault.aves.utils import android.app.Activity import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.net.Uri +import android.os.Binder import android.os.Build import android.os.Environment import android.os.storage.StorageManager +import android.provider.MediaStore import android.util.Log import androidx.annotation.RequiresApi import deckers.thibault.aves.MainActivity import deckers.thibault.aves.PendingStorageAccessResultHandler +import deckers.thibault.aves.model.FieldMap import deckers.thibault.aves.utils.StorageUtils.PathSegments import java.io.File import java.util.* +import java.util.concurrent.CompletableFuture import kotlin.collections.ArrayList object PermissionManager { private val LOG_TAG = LogUtils.createTag() + private val MEDIA_STORE_INSERTION_PRIMARY_DIRS = listOf( + Environment.DIRECTORY_DCIM, + Environment.DIRECTORY_DOWNLOADS, + Environment.DIRECTORY_PICTURES, + ) + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) - fun requestVolumeAccess(activity: Activity, path: String, onGranted: (uri: Uri) -> Unit, onDenied: () -> Unit) { + fun requestDirectoryAccess(activity: Activity, path: String, onGranted: (uri: Uri) -> Unit, onDenied: () -> Unit) { Log.i(LOG_TAG, "request user to select and grant access permission to path=$path") var intent: Intent? = null @@ -43,6 +54,35 @@ object PermissionManager { } } + @RequiresApi(Build.VERSION_CODES.R) + fun requestMediaFileAccess(activity: Activity, uris: List, mimeTypes: List): Boolean { + val safeUris = uris.mapIndexed { index, uri -> StorageUtils.getMediaStoreScopedStorageSafeUri(uri, mimeTypes[index]) } + + val todoUris = ArrayList() + val pid = Binder.getCallingPid() + val uid = Binder.getCallingUid() + val flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + activity.checkUriPermissions(safeUris, pid, uid, flags) + } else { + safeUris.map { activity.checkUriPermission(it, pid, uid, flags) }.toIntArray() + }.forEachIndexed { index, permission -> + if (permission != PackageManager.PERMISSION_GRANTED) { + todoUris.add(safeUris[index]) + } + } + if (todoUris.isEmpty()) return true + + Log.i(LOG_TAG, "request user to select and grant access permission to uris=$todoUris") + val intentSender = MediaStore.createWriteRequest(activity.contentResolver, safeUris).intentSender + MainActivity.pendingScopedStoragePermissionCompleter = CompletableFuture() + activity.startIntentSenderForResult(intentSender, MainActivity.MEDIA_WRITE_BULK_PERMISSION_REQUEST, null, 0, 0, 0, null) + val granted = MainActivity.pendingScopedStoragePermissionCompleter!!.join() + MainActivity.pendingScopedStoragePermissionCompleter = null + + return granted + } + fun getGrantedDirForPath(context: Context, anyPath: String): String? { return getAccessibleDirs(context).firstOrNull { anyPath.startsWith(it) } } @@ -130,6 +170,18 @@ object PermissionManager { return dirs } + fun canInsertByMediaStore(directories: List): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + directories.all { + val relativeDir = it["relativeDir"] as String + val segments = relativeDir.split(File.separator) + segments.isNotEmpty() && MEDIA_STORE_INSERTION_PRIMARY_DIRS.contains(segments.first()) + } + } else { + true + } + } + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) fun revokeDirectoryAccess(context: Context, path: String): Boolean { return StorageUtils.convertDirPathToTreeUri(context, path)?.let { 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 f19896594..4691a6bee 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 @@ -23,6 +23,7 @@ import deckers.thibault.aves.utils.UriUtils.tryParseId import java.io.File import java.io.FileNotFoundException import java.io.InputStream +import java.io.OutputStream import java.util.* import java.util.regex.Pattern @@ -332,7 +333,7 @@ object StorageUtils { // returns the directory `DocumentFile` (from tree URI when scoped storage is required, `File` otherwise) // returns null if directory does not exist and could not be created - fun createDirectoryIfAbsent(context: Context, dirPath: String): DocumentFileCompat? { + fun createDirectoryDocIfAbsent(context: Context, dirPath: String): DocumentFileCompat? { val cleanDirPath = ensureTrailingSeparator(dirPath) return if (requireAccessPermission(context, cleanDirPath) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { val grantedDir = getGrantedDirForPath(context, cleanDirPath) ?: return null @@ -430,7 +431,14 @@ object StorageUtils { // This loader relies on `MediaStore.setRequireOriginal` but this yields a `SecurityException` // for some content URIs (e.g. `content://media/external_primary/downloads/...`) // so we build a typical `images` or `videos` content URI from the original content ID. - fun getGlideSafeUri(uri: Uri, mimeType: String): Uri { + fun getGlideSafeUri(uri: Uri, mimeType: String): Uri = normalizeMediaUri(uri, mimeType) + + // requesting access or writing to some MediaStore content URIs + // e.g. `content://0@media/...`, `content://media/external_primary/downloads/...` + // yields an exception with `All requested items must be referenced by specific ID` + fun getMediaStoreScopedStorageSafeUri(uri: Uri, mimeType: String): Uri = normalizeMediaUri(uri, mimeType) + + private fun normalizeMediaUri(uri: Uri, mimeType: String): Uri { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isMediaStoreContentUri(uri)) { // we cannot safely apply this to a file content URI, as it may point to a file not indexed // by the Media Store (via `.nomedia`), and therefore has no matching image/video content URI @@ -442,7 +450,11 @@ object StorageUtils { else -> uri } } + } else if (uri.userInfo != null) { + // strip user info, if any + return Uri.parse(uri.toString().replaceFirst("${uri.userInfo}@", "")) } + } return uri } @@ -454,7 +466,19 @@ object StorageUtils { } catch (e: Exception) { // among various other exceptions, // opening a file marked pending and owned by another package throws an `IllegalStateException` - Log.w(LOG_TAG, "failed to open file at uri=$effectiveUri", e) + Log.w(LOG_TAG, "failed to open input stream for uri=$uri effectiveUri=$effectiveUri", e) + null + } + } + + fun openOutputStream(context: Context, uri: Uri, mimeType: String): OutputStream? { + val effectiveUri = getMediaStoreScopedStorageSafeUri(uri, mimeType) + return try { + context.contentResolver.openOutputStream(effectiveUri) + } catch (e: Exception) { + // among various other exceptions, + // opening a file marked pending and owned by another package throws an `IllegalStateException` + Log.w(LOG_TAG, "failed to open output stream for uri=$uri effectiveUri=$effectiveUri", e) null } } @@ -469,7 +493,7 @@ object StorageUtils { } } catch (e: Exception) { // unsupported format - Log.w(LOG_TAG, "failed to initialize MediaMetadataRetriever for uri=$uri") + Log.w(LOG_TAG, "failed to initialize MediaMetadataRetriever for uri=$uri effectiveUri=$effectiveUri") null } } @@ -484,7 +508,7 @@ object StorageUtils { class PathSegments(context: Context, fullPath: String) { var volumePath: String? = null // `volumePath` with trailing "/" var relativeDir: String? = null // `relativeDir` with trailing "/" - private var fileName: String? = null // null for directories + var fileName: String? = null // null for directories init { volumePath = getVolumePath(context, fullPath) diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index 529e7f62a..529623a39 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -23,8 +23,15 @@ abstract class StorageService { // returns number of deleted directories Future deleteEmptyDirectories(Iterable dirPaths); - // returns whether user granted access to volume root at `volumePath` - Future requestVolumeAccess(String volumePath); + // returns whether user granted access to a directory of his choosing + Future requestDirectoryAccess(String volumePath); + + Future canRequestMediaFileAccess(); + + Future canInsertMedia(Set directories); + + // returns whether user granted access to URIs + Future requestMediaFileAccess(List uris, List mimeTypes); // return whether operation succeeded (`null` if user cancelled) Future createFile(String name, String mimeType, Uint8List bytes); @@ -127,13 +134,37 @@ class PlatformStorageService implements StorageService { return 0; } - // returns whether user granted access to volume root at `volumePath` @override - Future requestVolumeAccess(String volumePath) async { + Future canRequestMediaFileAccess() async { + try { + final result = await platform.invokeMethod('canRequestMediaFileBulkAccess'); + if (result != null) return result as bool; + } on PlatformException catch (e, stack) { + await reportService.recordError(e, stack); + } + return false; + } + + @override + Future canInsertMedia(Set directories) async { + try { + final result = await platform.invokeMethod('canInsertMedia', { + 'directories': directories.map((v) => v.toMap()).toList(), + }); + if (result != null) return result as bool; + } on PlatformException catch (e, stack) { + await reportService.recordError(e, stack); + } + return false; + } + + // returns whether user granted access to a directory of his choosing + @override + Future requestDirectoryAccess(String volumePath) async { try { final completer = Completer(); storageAccessChannel.receiveBroadcastStream({ - 'op': 'requestVolumeAccess', + 'op': 'requestDirectoryAccess', 'path': volumePath, }).listen( (data) => completer.complete(data as bool), @@ -150,6 +181,30 @@ class PlatformStorageService implements StorageService { return false; } + // returns whether user granted access to URIs + @override + Future requestMediaFileAccess(List uris, List mimeTypes) async { + try { + final completer = Completer(); + storageAccessChannel.receiveBroadcastStream({ + 'op': 'requestMediaFileAccess', + 'uris': uris, + 'mimeTypes': mimeTypes, + }).listen( + (data) => completer.complete(data as bool), + onError: completer.completeError, + onDone: () { + if (!completer.isCompleted) completer.complete(false); + }, + cancelOnError: true, + ); + return completer.future; + } on PlatformException catch (e, stack) { + await reportService.recordError(e, stack); + } + return false; + } + @override Future createFile(String name, String mimeType, Uint8List bytes) async { try { diff --git a/lib/utils/android_file_utils.dart b/lib/utils/android_file_utils.dart index 2e623d598..88e80e926 100644 --- a/lib/utils/android_file_utils.dart +++ b/lib/utils/android_file_utils.dart @@ -180,6 +180,11 @@ class VolumeRelativeDirectory extends Equatable { ); } + Map toMap() => { + 'volumePath': volumePath, + 'relativeDir': relativeDir, + }; + // prefer static method over a null returning factory constructor static VolumeRelativeDirectory? fromPath(String dirPath) { final volume = androidFileUtils.getStorageVolume(dirPath); diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index 06ca44bab..a46b63497 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -14,7 +14,6 @@ import 'package:aves/services/common/image_op_events.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/services/media/enums.dart'; import 'package:aves/theme/durations.dart'; -import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; @@ -87,21 +86,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware final source = context.read(); final selection = context.read>(); final selectedItems = _getExpandedSelectedItems(selection); - final selectionDirs = selectedItems.map((e) => e.directory).whereNotNull().toSet(); - if (moveType == MoveType.move) { - // check whether moving is possible given OS restrictions, - // before asking to pick a destination album - final restrictedDirs = await storageService.getRestrictedDirectories(); - for (final selectionDir in selectionDirs) { - final dir = VolumeRelativeDirectory.fromPath(selectionDir); - if (dir == null) return; - if (restrictedDirs.contains(dir)) { - await showRestrictedDirectoryDialog(context, dir); - return; - } - } - } final destinationAlbum = await Navigator.push( context, @@ -113,7 +98,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware if (destinationAlbum == null || destinationAlbum.isEmpty) return; if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return; - if (moveType == MoveType.move && !await checkStoragePermissionForAlbums(context, selectionDirs)) return; + if (moveType == MoveType.move && !await checkStoragePermissionForAlbums(context, selectionDirs, entries: selectedItems)) return; if (!await checkFreeSpaceForMove(context, selectedItems, destinationAlbum, moveType)) return; @@ -256,7 +241,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware ); if (confirmed == null || !confirmed) return; - if (!await checkStoragePermissionForAlbums(context, selectionDirs)) return; + if (!await checkStoragePermissionForAlbums(context, selectionDirs, entries: selectedItems)) return; source.pauseMonitoring(); showOpReport( diff --git a/lib/widgets/common/action_mixins/permission_aware.dart b/lib/widgets/common/action_mixins/permission_aware.dart index 22b856062..9b44db144 100644 --- a/lib/widgets/common/action_mixins/permission_aware.dart +++ b/lib/widgets/common/action_mixins/permission_aware.dart @@ -8,21 +8,41 @@ import 'package:flutter/material.dart'; mixin PermissionAwareMixin { Future checkStoragePermission(BuildContext context, Set entries) { - return checkStoragePermissionForAlbums(context, entries.map((e) => e.directory).whereNotNull().toSet()); + return checkStoragePermissionForAlbums(context, entries.map((e) => e.directory).whereNotNull().toSet(), entries: entries); } - Future checkStoragePermissionForAlbums(BuildContext context, Set albumPaths) async { + Future checkStoragePermissionForAlbums(BuildContext context, Set albumPaths, {Set? entries}) async { final restrictedDirs = await storageService.getRestrictedDirectories(); while (true) { final dirs = await storageService.getInaccessibleDirectories(albumPaths); - if (dirs.isEmpty) return true; - final restrictedInaccessibleDir = dirs.firstWhereOrNull(restrictedDirs.contains); - if (restrictedInaccessibleDir != null) { - await showRestrictedDirectoryDialog(context, restrictedInaccessibleDir); - return false; + final restrictedInaccessibleDirs = dirs.where(restrictedDirs.contains).toSet(); + if (restrictedInaccessibleDirs.isNotEmpty) { + if (entries != null && await storageService.canRequestMediaFileAccess()) { + // request media file access for items in restricted directories + final uris = [], mimeTypes = []; + entries.where((entry) { + final dir = entry.directory; + return dir != null && restrictedInaccessibleDirs.contains(VolumeRelativeDirectory.fromPath(dir)); + }).forEach((entry) { + uris.add(entry.uri); + mimeTypes.add(entry.mimeType); + }); + final granted = await storageService.requestMediaFileAccess(uris, mimeTypes); + if (!granted) return false; + } else if (entries == null && await storageService.canInsertMedia(restrictedInaccessibleDirs)) { + // insertion in restricted directories + } else { + // cannot proceed further + await showRestrictedDirectoryDialog(context, restrictedInaccessibleDirs.first); + return false; + } + // clear restricted directories + dirs.removeAll(restrictedInaccessibleDirs); } + if (dirs.isEmpty) return true; + final dir = dirs.first; final confirmed = await showDialog( context: context, @@ -49,7 +69,7 @@ mixin PermissionAwareMixin { // abort if the user cancels in Flutter if (confirmed == null || !confirmed) return false; - final granted = await storageService.requestVolumeAccess(dir.volumePath); + final granted = await storageService.requestDirectoryAccess(dir.volumePath); if (!granted) { // abort if the user denies access from the native dialog return false; diff --git a/test/fake/media_store_service.dart b/test/fake/media_store_service.dart index 8702860dd..3447dbecb 100644 --- a/test/fake/media_store_service.dart +++ b/test/fake/media_store_service.dart @@ -49,7 +49,6 @@ class FakeMediaStoreService extends Fake implements MediaStoreService { success: true, uri: entry.uri, newFields: { - 'deletedSource': true, 'uri': 'content://media/external/images/media/$newContentId', 'contentId': newContentId, 'path': entry.path!.replaceFirst(sourceAlbum, destinationAlbum),