From c637c6eb8e56ed31003ae2fb55ad0324568f47ef Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sun, 10 Apr 2022 10:56:48 +0900 Subject: [PATCH] set initial directory when requesting access --- .../deckers/thibault/aves/MainActivity.kt | 3 +- .../channel/streams/ImageOpStreamHandler.kt | 5 +- .../streams/StorageAccessStreamHandler.kt | 3 +- .../thibault/aves/utils/PermissionManager.kt | 28 ++++----- .../thibault/aves/utils/StorageUtils.kt | 60 ++++++++++++------- lib/services/storage_service.dart | 6 +- lib/utils/android_file_utils.dart | 2 + .../action_mixins/permission_aware.dart | 2 +- 8 files changed, 63 insertions(+), 46 deletions(-) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt index 13f1d7800..75d1385a1 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt @@ -16,6 +16,7 @@ import app.loup.streams_channel.StreamsChannel import deckers.thibault.aves.channel.calls.* import deckers.thibault.aves.channel.streams.* import deckers.thibault.aves.utils.LogUtils +import deckers.thibault.aves.utils.PermissionManager import io.flutter.embedding.android.FlutterActivity import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.MethodCall @@ -324,7 +325,7 @@ class MainActivity : FlutterActivity() { var pendingScopedStoragePermissionCompleter: CompletableFuture? = null private fun onStorageAccessResult(requestCode: Int, uri: Uri?) { - Log.d(LOG_TAG, "onStorageAccessResult with requestCode=$requestCode, uri=$uri") + Log.i(LOG_TAG, "onStorageAccessResult with requestCode=$requestCode, uri=$uri") val handler = pendingStorageAccessResultHandlers.remove(requestCode) ?: return if (uri != null) { handler.onGranted(uri) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt index 568b0be85..dadbda2f1 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt @@ -14,6 +14,7 @@ import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider import deckers.thibault.aves.model.provider.MediaStoreImageProvider import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.StorageUtils +import deckers.thibault.aves.utils.StorageUtils.ensureTrailingSeparator import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel.EventSink import kotlinx.coroutines.CoroutineScope @@ -154,7 +155,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments return } - destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir) + destinationDir = ensureTrailingSeparator(destinationDir) val entries = entryMapList.map(::AvesEntry) provider.exportMultiple(activity, mimeType, destinationDir, entries, width, height, nameConflictStrategy, object : ImageOpCallback { override fun onSuccess(fields: FieldMap) = success(fields) @@ -181,7 +182,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments rawEntryMap.forEach { var destinationDir = it.key as String if (destinationDir != StorageUtils.TRASH_PATH_PLACEHOLDER) { - destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir) + destinationDir = ensureTrailingSeparator(destinationDir) } @Suppress("unchecked_cast") val rawEntries = it.value as List diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt index 1e742403c..097590816 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/StorageAccessStreamHandler.kt @@ -14,6 +14,7 @@ import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.PermissionManager import deckers.thibault.aves.utils.StorageUtils +import deckers.thibault.aves.utils.StorageUtils.ensureTrailingSeparator import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel.EventSink import kotlinx.coroutines.CoroutineScope @@ -64,7 +65,7 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any? return } - PermissionManager.requestDirectoryAccess(activity, path, { + PermissionManager.requestDirectoryAccess(activity, ensureTrailingSeparator(path), { success(true) endOfStream() }, { diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt index 1e1a70203..2595b56d8 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/PermissionManager.kt @@ -9,7 +9,7 @@ import android.net.Uri import android.os.Binder import android.os.Build import android.os.Environment -import android.os.storage.StorageManager +import android.provider.DocumentsContract import android.provider.MediaStore import android.util.Log import androidx.annotation.RequiresApi @@ -33,22 +33,16 @@ object PermissionManager { suspend 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 - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - val sm = activity.getSystemService(Context.STORAGE_SERVICE) as? StorageManager - val storageVolume = sm?.getStorageVolume(File(path)) - if (storageVolume != null) { - intent = storageVolume.createOpenDocumentTreeIntent() - } else { - MainActivity.notifyError("failed to get storage volume for path=$path on volumes=${sm?.storageVolumes?.joinToString(", ")}") + // `StorageVolume.createOpenDocumentTreeIntent` is an alternative, + // and it helps with initial volume, but not with initial directory + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // initial URI should not be a `tree document URI`, but a simple `document URI` + StorageUtils.convertDirPathToDocumentUri(activity, path)?.let { + intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, it) } } - // fallback to basic open document tree intent - if (intent == null) { - intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) - } - if (intent.resolveActivity(activity.packageManager) != null) { MainActivity.pendingStorageAccessResultHandlers[MainActivity.DOCUMENT_TREE_ACCESS_REQUEST] = PendingStorageAccessResultHandler(path, onGranted, onDenied) activity.startActivityForResult(intent, MainActivity.DOCUMENT_TREE_ACCESS_REQUEST) @@ -156,7 +150,7 @@ object PermissionManager { @RequiresApi(Build.VERSION_CODES.LOLLIPOP) fun revokeDirectoryAccess(context: Context, path: String): Boolean { - return StorageUtils.convertDirPathToTreeUri(context, path)?.let { + return StorageUtils.convertDirPathToTreeDocumentUri(context, path)?.let { releaseUriPermission(context, it) true } ?: false @@ -167,7 +161,7 @@ object PermissionManager { val grantedDirs = HashSet() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { for (uriPermission in context.contentResolver.persistedUriPermissions) { - val dirPath = StorageUtils.convertTreeUriToDirPath(context, uriPermission.uri) + val dirPath = StorageUtils.convertTreeDocumentUriToDirPath(context, uriPermission.uri) dirPath?.let { grantedDirs.add(it) } } } @@ -234,7 +228,7 @@ object PermissionManager { try { for (uriPermission in context.contentResolver.persistedUriPermissions) { val uri = uriPermission.uri - val path = StorageUtils.convertTreeUriToDirPath(context, uri) + val path = StorageUtils.convertTreeDocumentUriToDirPath(context, uri) if (path != null && !File(path).exists()) { Log.d(LOG_TAG, "revoke URI permission for obsolete path=$path") releaseUriPermission(context, uri) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt index b2980e11b..333816f10 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/StorageUtils.kt @@ -30,7 +30,12 @@ import java.util.regex.Pattern object StorageUtils { private val LOG_TAG = LogUtils.createTag() - private const val TREE_URI_ROOT = "content://com.android.externalstorage.documents/tree/" + // from `DocumentsContract` + private const val EXTERNAL_STORAGE_PROVIDER_AUTHORITY = "com.android.externalstorage.documents" + private const val EXTERNAL_STORAGE_PRIMARY_EMULATED_ROOT_ID = "primary" + + private const val TREE_URI_ROOT = "content://$EXTERNAL_STORAGE_PROVIDER_AUTHORITY/tree/" + private val TREE_URI_PATH_PATTERN = Pattern.compile("(.*?):(.*)") const val TRASH_PATH_PLACEHOLDER = "#trash" @@ -242,12 +247,12 @@ object StorageUtils { // e.g. // /storage/emulated/0/ -> primary // /storage/10F9-3F13/Pictures/ -> 10F9-3F13 - private fun getVolumeUuidForTreeUri(context: Context, anyPath: String): String? { + private fun getVolumeUuidForDocumentUri(context: Context, anyPath: String): String? { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { val sm = context.getSystemService(Context.STORAGE_SERVICE) as? StorageManager sm?.getStorageVolume(File(anyPath))?.let { volume -> if (volume.isPrimary) { - return "primary" + return EXTERNAL_STORAGE_PRIMARY_EMULATED_ROOT_ID } volume.uuid?.let { uuid -> return uuid.uppercase(Locale.ROOT) @@ -258,7 +263,7 @@ object StorageUtils { // fallback for if (volumePath == getPrimaryVolumePath(context)) { - return "primary" + return EXTERNAL_STORAGE_PRIMARY_EMULATED_ROOT_ID } volumePath.split(File.separator).lastOrNull { it.isNotEmpty() }?.let { uuid -> return uuid.uppercase(Locale.ROOT) @@ -272,8 +277,8 @@ object StorageUtils { // e.g. // primary -> /storage/emulated/0/ // 10F9-3F13 -> /storage/10F9-3F13/ - private fun getVolumePathFromTreeUriUuid(context: Context, uuid: String): String? { - if (uuid == "primary") { + private fun getVolumePathFromTreeDocumentUriUuid(context: Context, uuid: String): String? { + if (uuid == EXTERNAL_STORAGE_PRIMARY_EMULATED_ROOT_ID) { return getPrimaryVolumePath(context) } @@ -309,37 +314,50 @@ object StorageUtils { // /storage/emulated/0/ -> content://com.android.externalstorage.documents/tree/primary%3A // /storage/10F9-3F13/Pictures/ -> content://com.android.externalstorage.documents/tree/10F9-3F13%3APictures @RequiresApi(Build.VERSION_CODES.LOLLIPOP) - fun convertDirPathToTreeUri(context: Context, dirPath: String): Uri? { - val uuid = getVolumeUuidForTreeUri(context, dirPath) + fun convertDirPathToTreeDocumentUri(context: Context, dirPath: String): Uri? { + val uuid = getVolumeUuidForDocumentUri(context, dirPath) if (uuid != null) { val relativeDir = removeTrailingSeparator(PathSegments(context, dirPath).relativeDir ?: "") - return DocumentsContract.buildTreeDocumentUri("com.android.externalstorage.documents", "$uuid:$relativeDir") + return DocumentsContract.buildTreeDocumentUri(EXTERNAL_STORAGE_PROVIDER_AUTHORITY, "$uuid:$relativeDir") } - Log.e(LOG_TAG, "failed to convert dirPath=$dirPath to tree URI") + Log.e(LOG_TAG, "failed to convert dirPath=$dirPath to tree document URI") + return null + } + + // e.g. + // /storage/emulated/0/ -> content://com.android.externalstorage.documents/document/primary%3A + // /storage/10F9-3F13/Pictures/ -> content://com.android.externalstorage.documents/document/10F9-3F13%3APictures + fun convertDirPathToDocumentUri(context: Context, dirPath: String): Uri? { + val uuid = getVolumeUuidForDocumentUri(context, dirPath) + if (uuid != null) { + val relativeDir = removeTrailingSeparator(PathSegments(context, dirPath).relativeDir ?: "") + return DocumentsContract.buildDocumentUri(EXTERNAL_STORAGE_PROVIDER_AUTHORITY, "$uuid:$relativeDir") + } + Log.e(LOG_TAG, "failed to convert dirPath=$dirPath to document URI") return null } // e.g. // content://com.android.externalstorage.documents/tree/primary%3A -> /storage/emulated/0/ // content://com.android.externalstorage.documents/tree/10F9-3F13%3APictures -> /storage/10F9-3F13/Pictures/ - fun convertTreeUriToDirPath(context: Context, treeUri: Uri): String? { - val treeUriString = treeUri.toString() - if (treeUriString.length <= TREE_URI_ROOT.length) return null - val encoded = treeUriString.substring(TREE_URI_ROOT.length) + fun convertTreeDocumentUriToDirPath(context: Context, treeDocumentUri: Uri): String? { + val treeDocumentUriString = treeDocumentUri.toString() + if (treeDocumentUriString.length <= TREE_URI_ROOT.length) return null + val encoded = treeDocumentUriString.substring(TREE_URI_ROOT.length) val matcher = TREE_URI_PATH_PATTERN.matcher(Uri.decode(encoded)) with(matcher) { if (find()) { val uuid = group(1) val relativePath = group(2) if (uuid != null && relativePath != null) { - val volumePath = getVolumePathFromTreeUriUuid(context, uuid) + val volumePath = getVolumePathFromTreeDocumentUriUuid(context, uuid) if (volumePath != null) { return ensureTrailingSeparator(volumePath + relativePath) } } } } - Log.e(LOG_TAG, "failed to convert treeUri=$treeUri to path") + Log.e(LOG_TAG, "failed to convert treeDocumentUri=$treeDocumentUri to path") return null } @@ -365,7 +383,7 @@ object StorageUtils { } // fallback for older APIs - val df = getVolumePath(context, anyPath)?.let { convertDirPathToTreeUri(context, it) }?.let { getDocumentFileFromVolumeTree(context, it, anyPath) } + val df = getVolumePath(context, anyPath)?.let { convertDirPathToTreeDocumentUri(context, it) }?.let { getDocumentFileFromVolumeTree(context, it, anyPath) } if (df != null) return df // try to strip user info, if any @@ -389,8 +407,8 @@ object StorageUtils { val cleanDirPath = ensureTrailingSeparator(dirPath) return if (requireAccessPermission(context, cleanDirPath) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { val grantedDir = getGrantedDirForPath(context, cleanDirPath) ?: return null - val rootTreeUri = convertDirPathToTreeUri(context, grantedDir) ?: return null - var parentFile: DocumentFileCompat? = DocumentFileCompat.fromTreeUri(context, rootTreeUri) ?: return null + val rootTreeDocumentUri = convertDirPathToTreeDocumentUri(context, grantedDir) ?: return null + var parentFile: DocumentFileCompat? = DocumentFileCompat.fromTreeUri(context, rootTreeDocumentUri) ?: return null val pathIterator = getPathStepIterator(context, cleanDirPath, grantedDir) while (pathIterator?.hasNext() == true) { val dirName = pathIterator.next() @@ -420,8 +438,8 @@ object StorageUtils { } } - private fun getDocumentFileFromVolumeTree(context: Context, rootTreeUri: Uri, anyPath: String): DocumentFileCompat? { - var documentFile: DocumentFileCompat? = DocumentFileCompat.fromTreeUri(context, rootTreeUri) ?: return null + private fun getDocumentFileFromVolumeTree(context: Context, rootTreeDocumentUri: Uri, anyPath: String): DocumentFileCompat? { + var documentFile: DocumentFileCompat? = DocumentFileCompat.fromTreeUri(context, rootTreeDocumentUri) ?: return null // follow the entry path down the document tree val pathIterator = getPathStepIterator(context, anyPath, null) diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index 003deb434..f1af3e4d4 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -24,7 +24,7 @@ abstract class StorageService { Future deleteEmptyDirectories(Iterable dirPaths); // returns whether user granted access to a directory of his choosing - Future requestDirectoryAccess(String volumePath); + Future requestDirectoryAccess(String path); Future canRequestMediaFileAccess(); @@ -158,12 +158,12 @@ class PlatformStorageService implements StorageService { // returns whether user granted access to a directory of his choosing @override - Future requestDirectoryAccess(String volumePath) async { + Future requestDirectoryAccess(String path) async { try { final completer = Completer(); storageAccessChannel.receiveBroadcastStream({ 'op': 'requestDirectoryAccess', - 'path': volumePath, + 'path': path, }).listen( (data) => completer.complete(data as bool), onError: completer.completeError, diff --git a/lib/utils/android_file_utils.dart b/lib/utils/android_file_utils.dart index 429692b40..cc6fff4ea 100644 --- a/lib/utils/android_file_utils.dart +++ b/lib/utils/android_file_utils.dart @@ -183,6 +183,8 @@ class VolumeRelativeDirectory extends Equatable { @override List get props => [volumePath, relativeDir]; + String get dirPath => '$volumePath$relativeDir'; + const VolumeRelativeDirectory({ required this.volumePath, required this.relativeDir, diff --git a/lib/widgets/common/action_mixins/permission_aware.dart b/lib/widgets/common/action_mixins/permission_aware.dart index 329c325f0..340ee382f 100644 --- a/lib/widgets/common/action_mixins/permission_aware.dart +++ b/lib/widgets/common/action_mixins/permission_aware.dart @@ -89,7 +89,7 @@ mixin PermissionAwareMixin { return false; } - final granted = await storageService.requestDirectoryAccess(dir.volumePath); + final granted = await storageService.requestDirectoryAccess(dir.dirPath); if (!granted) { // abort if the user denies access from the native dialog return false;