#108 use MediaStore API for restricted folders on Android R+
This commit is contained in:
parent
0a5a700ae7
commit
68af1b0156
24 changed files with 534 additions and 218 deletions
|
@ -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<Int, PendingStorageAccessResultHandler>()
|
||||
|
||||
var pendingScopedStoragePermissionCompleter: CompletableFuture<Boolean>? = null
|
||||
|
||||
private fun onStorageAccessResult(requestCode: Int, uri: Uri?) {
|
||||
Log.d(LOG_TAG, "onStorageAccessResult with requestCode=$requestCode, uri=$uri")
|
||||
val handler = pendingStorageAccessResultHandlers.remove(requestCode) ?: return
|
||||
|
|
|
@ -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<FieldMap>)
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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<String, FieldMap>()
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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<Map<String, Any>>()
|
||||
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<List<FieldMap>>("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"
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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<FieldMap>?
|
||||
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)
|
||||
|
|
|
@ -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<Int, Int?>?
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -76,7 +76,7 @@ internal class ContentImageProvider : ImageProvider() {
|
|||
companion object {
|
||||
private val LOG_TAG = LogUtils.createTag<ContentImageProvider>()
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
@Suppress("deprecation")
|
||||
const val PATH = MediaStore.MediaColumns.DATA
|
||||
|
||||
private val projection = arrayOf(
|
||||
|
|
|
@ -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<AvesEntry>, callback: ImageOpCallback) {
|
||||
open suspend fun moveMultiple(activity: Activity, copy: Boolean, targetDir: String, nameConflictStrategy: NameConflictStrategy, entries: List<AvesEntry>, 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<AvesEntry>,
|
||||
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<String, Any?> = 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<Boolean>()
|
||||
activity.startIntentSenderForResult(intentSender, DELETE_PERMISSION_REQUEST, null, 0, 0, 0, null)
|
||||
val granted = pendingDeleteCompleter!!.join()
|
||||
MainActivity.pendingScopedStoragePermissionCompleter = CompletableFuture<Boolean>()
|
||||
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<AvesEntry>,
|
||||
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<Boolean>? = 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
|
||||
}
|
||||
|
||||
|
|
|
@ -36,7 +36,7 @@ object ContextUtils {
|
|||
fun Context.isMyServiceRunning(serviceClass: Class<out Service>): 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 }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<PermissionManager>()
|
||||
|
||||
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<Uri>, mimeTypes: List<String>): Boolean {
|
||||
val safeUris = uris.mapIndexed { index, uri -> StorageUtils.getMediaStoreScopedStorageSafeUri(uri, mimeTypes[index]) }
|
||||
|
||||
val todoUris = ArrayList<Uri>()
|
||||
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<Boolean>()
|
||||
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<FieldMap>): 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 {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -23,8 +23,15 @@ abstract class StorageService {
|
|||
// returns number of deleted directories
|
||||
Future<int> deleteEmptyDirectories(Iterable<String> dirPaths);
|
||||
|
||||
// returns whether user granted access to volume root at `volumePath`
|
||||
Future<bool> requestVolumeAccess(String volumePath);
|
||||
// returns whether user granted access to a directory of his choosing
|
||||
Future<bool> requestDirectoryAccess(String volumePath);
|
||||
|
||||
Future<bool> canRequestMediaFileAccess();
|
||||
|
||||
Future<bool> canInsertMedia(Set<VolumeRelativeDirectory> directories);
|
||||
|
||||
// returns whether user granted access to URIs
|
||||
Future<bool> requestMediaFileAccess(List<String> uris, List<String> mimeTypes);
|
||||
|
||||
// return whether operation succeeded (`null` if user cancelled)
|
||||
Future<bool?> 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<bool> requestVolumeAccess(String volumePath) async {
|
||||
Future<bool> 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<bool> canInsertMedia(Set<VolumeRelativeDirectory> directories) async {
|
||||
try {
|
||||
final result = await platform.invokeMethod('canInsertMedia', <String, dynamic>{
|
||||
'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<bool> requestDirectoryAccess(String volumePath) async {
|
||||
try {
|
||||
final completer = Completer<bool>();
|
||||
storageAccessChannel.receiveBroadcastStream(<String, dynamic>{
|
||||
'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<bool> requestMediaFileAccess(List<String> uris, List<String> mimeTypes) async {
|
||||
try {
|
||||
final completer = Completer<bool>();
|
||||
storageAccessChannel.receiveBroadcastStream(<String, dynamic>{
|
||||
'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<bool?> createFile(String name, String mimeType, Uint8List bytes) async {
|
||||
try {
|
||||
|
|
|
@ -180,6 +180,11 @@ class VolumeRelativeDirectory extends Equatable {
|
|||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
'volumePath': volumePath,
|
||||
'relativeDir': relativeDir,
|
||||
};
|
||||
|
||||
// prefer static method over a null returning factory constructor
|
||||
static VolumeRelativeDirectory? fromPath(String dirPath) {
|
||||
final volume = androidFileUtils.getStorageVolume(dirPath);
|
||||
|
|
|
@ -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<CollectionSource>();
|
||||
final selection = context.read<Selection<AvesEntry>>();
|
||||
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<ImageOpEvent>(
|
||||
|
|
|
@ -8,21 +8,41 @@ import 'package:flutter/material.dart';
|
|||
|
||||
mixin PermissionAwareMixin {
|
||||
Future<bool> checkStoragePermission(BuildContext context, Set<AvesEntry> entries) {
|
||||
return checkStoragePermissionForAlbums(context, entries.map((e) => e.directory).whereNotNull().toSet());
|
||||
return checkStoragePermissionForAlbums(context, entries.map((e) => e.directory).whereNotNull().toSet(), entries: entries);
|
||||
}
|
||||
|
||||
Future<bool> checkStoragePermissionForAlbums(BuildContext context, Set<String> albumPaths) async {
|
||||
Future<bool> checkStoragePermissionForAlbums(BuildContext context, Set<String> albumPaths, {Set<AvesEntry>? 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 = <String>[], mimeTypes = <String>[];
|
||||
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<bool>(
|
||||
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;
|
||||
|
|
|
@ -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),
|
||||
|
|
Loading…
Reference in a new issue