#108 use MediaStore API for restricted folders on Android R+

This commit is contained in:
Thibault Deckers 2021-10-20 18:36:22 +09:00
parent 0a5a700ae7
commit 68af1b0156
24 changed files with 534 additions and 218 deletions

View file

@ -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

View file

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

View file

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

View file

@ -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())
}

View file

@ -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())
}

View file

@ -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) {

View file

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

View file

@ -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"
}

View file

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

View file

@ -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) {

View file

@ -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)

View file

@ -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?>?
}
}

View file

@ -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?

View file

@ -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(

View file

@ -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
}
}
}

View file

@ -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
}

View file

@ -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 }
}
}

View file

@ -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 {

View file

@ -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)

View file

@ -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 {

View file

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

View file

@ -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>(

View file

@ -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;

View file

@ -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),