set initial directory when requesting access

This commit is contained in:
Thibault Deckers 2022-04-10 10:56:48 +09:00
parent 7a2604f2db
commit c637c6eb8e
8 changed files with 63 additions and 46 deletions

View file

@ -16,6 +16,7 @@ import app.loup.streams_channel.StreamsChannel
import deckers.thibault.aves.channel.calls.* import deckers.thibault.aves.channel.calls.*
import deckers.thibault.aves.channel.streams.* import deckers.thibault.aves.channel.streams.*
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.PermissionManager
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
@ -324,7 +325,7 @@ class MainActivity : FlutterActivity() {
var pendingScopedStoragePermissionCompleter: CompletableFuture<Boolean>? = null var pendingScopedStoragePermissionCompleter: CompletableFuture<Boolean>? = null
private fun onStorageAccessResult(requestCode: Int, uri: Uri?) { 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 val handler = pendingStorageAccessResultHandlers.remove(requestCode) ?: return
if (uri != null) { if (uri != null) {
handler.onGranted(uri) handler.onGranted(uri)

View file

@ -14,6 +14,7 @@ import deckers.thibault.aves.model.provider.ImageProviderFactory.getProvider
import deckers.thibault.aves.model.provider.MediaStoreImageProvider import deckers.thibault.aves.model.provider.MediaStoreImageProvider
import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.StorageUtils 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
import io.flutter.plugin.common.EventChannel.EventSink import io.flutter.plugin.common.EventChannel.EventSink
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -154,7 +155,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
return return
} }
destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir) destinationDir = ensureTrailingSeparator(destinationDir)
val entries = entryMapList.map(::AvesEntry) val entries = entryMapList.map(::AvesEntry)
provider.exportMultiple(activity, mimeType, destinationDir, entries, width, height, nameConflictStrategy, object : ImageOpCallback { provider.exportMultiple(activity, mimeType, destinationDir, entries, width, height, nameConflictStrategy, object : ImageOpCallback {
override fun onSuccess(fields: FieldMap) = success(fields) override fun onSuccess(fields: FieldMap) = success(fields)
@ -181,7 +182,7 @@ class ImageOpStreamHandler(private val activity: Activity, private val arguments
rawEntryMap.forEach { rawEntryMap.forEach {
var destinationDir = it.key as String var destinationDir = it.key as String
if (destinationDir != StorageUtils.TRASH_PATH_PLACEHOLDER) { if (destinationDir != StorageUtils.TRASH_PATH_PLACEHOLDER) {
destinationDir = StorageUtils.ensureTrailingSeparator(destinationDir) destinationDir = ensureTrailingSeparator(destinationDir)
} }
@Suppress("unchecked_cast") @Suppress("unchecked_cast")
val rawEntries = it.value as List<FieldMap> val rawEntries = it.value as List<FieldMap>

View file

@ -14,6 +14,7 @@ import deckers.thibault.aves.utils.LogUtils
import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.PermissionManager import deckers.thibault.aves.utils.PermissionManager
import deckers.thibault.aves.utils.StorageUtils 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
import io.flutter.plugin.common.EventChannel.EventSink import io.flutter.plugin.common.EventChannel.EventSink
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -64,7 +65,7 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
return return
} }
PermissionManager.requestDirectoryAccess(activity, path, { PermissionManager.requestDirectoryAccess(activity, ensureTrailingSeparator(path), {
success(true) success(true)
endOfStream() endOfStream()
}, { }, {

View file

@ -9,7 +9,7 @@ import android.net.Uri
import android.os.Binder import android.os.Binder
import android.os.Build import android.os.Build
import android.os.Environment import android.os.Environment
import android.os.storage.StorageManager import android.provider.DocumentsContract
import android.provider.MediaStore import android.provider.MediaStore
import android.util.Log import android.util.Log
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
@ -33,22 +33,16 @@ object PermissionManager {
suspend fun requestDirectoryAccess(activity: Activity, path: String, onGranted: (uri: Uri) -> Unit, onDenied: () -> Unit) { 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") Log.i(LOG_TAG, "request user to select and grant access permission to path=$path")
var intent: Intent? = null // `StorageVolume.createOpenDocumentTreeIntent` is an alternative,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { // and it helps with initial volume, but not with initial directory
val sm = activity.getSystemService(Context.STORAGE_SERVICE) as? StorageManager val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
val storageVolume = sm?.getStorageVolume(File(path)) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
if (storageVolume != null) { // initial URI should not be a `tree document URI`, but a simple `document URI`
intent = storageVolume.createOpenDocumentTreeIntent() StorageUtils.convertDirPathToDocumentUri(activity, path)?.let {
} else { intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, it)
MainActivity.notifyError("failed to get storage volume for path=$path on volumes=${sm?.storageVolumes?.joinToString(", ")}")
} }
} }
// fallback to basic open document tree intent
if (intent == null) {
intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
}
if (intent.resolveActivity(activity.packageManager) != null) { if (intent.resolveActivity(activity.packageManager) != null) {
MainActivity.pendingStorageAccessResultHandlers[MainActivity.DOCUMENT_TREE_ACCESS_REQUEST] = PendingStorageAccessResultHandler(path, onGranted, onDenied) MainActivity.pendingStorageAccessResultHandlers[MainActivity.DOCUMENT_TREE_ACCESS_REQUEST] = PendingStorageAccessResultHandler(path, onGranted, onDenied)
activity.startActivityForResult(intent, MainActivity.DOCUMENT_TREE_ACCESS_REQUEST) activity.startActivityForResult(intent, MainActivity.DOCUMENT_TREE_ACCESS_REQUEST)
@ -156,7 +150,7 @@ object PermissionManager {
@RequiresApi(Build.VERSION_CODES.LOLLIPOP) @RequiresApi(Build.VERSION_CODES.LOLLIPOP)
fun revokeDirectoryAccess(context: Context, path: String): Boolean { fun revokeDirectoryAccess(context: Context, path: String): Boolean {
return StorageUtils.convertDirPathToTreeUri(context, path)?.let { return StorageUtils.convertDirPathToTreeDocumentUri(context, path)?.let {
releaseUriPermission(context, it) releaseUriPermission(context, it)
true true
} ?: false } ?: false
@ -167,7 +161,7 @@ object PermissionManager {
val grantedDirs = HashSet<String>() val grantedDirs = HashSet<String>()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
for (uriPermission in context.contentResolver.persistedUriPermissions) { 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) } dirPath?.let { grantedDirs.add(it) }
} }
} }
@ -234,7 +228,7 @@ object PermissionManager {
try { try {
for (uriPermission in context.contentResolver.persistedUriPermissions) { for (uriPermission in context.contentResolver.persistedUriPermissions) {
val uri = uriPermission.uri val uri = uriPermission.uri
val path = StorageUtils.convertTreeUriToDirPath(context, uri) val path = StorageUtils.convertTreeDocumentUriToDirPath(context, uri)
if (path != null && !File(path).exists()) { if (path != null && !File(path).exists()) {
Log.d(LOG_TAG, "revoke URI permission for obsolete path=$path") Log.d(LOG_TAG, "revoke URI permission for obsolete path=$path")
releaseUriPermission(context, uri) releaseUriPermission(context, uri)

View file

@ -30,7 +30,12 @@ import java.util.regex.Pattern
object StorageUtils { object StorageUtils {
private val LOG_TAG = LogUtils.createTag<StorageUtils>() private val LOG_TAG = LogUtils.createTag<StorageUtils>()
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("(.*?):(.*)") private val TREE_URI_PATH_PATTERN = Pattern.compile("(.*?):(.*)")
const val TRASH_PATH_PLACEHOLDER = "#trash" const val TRASH_PATH_PLACEHOLDER = "#trash"
@ -242,12 +247,12 @@ object StorageUtils {
// e.g. // e.g.
// /storage/emulated/0/ -> primary // /storage/emulated/0/ -> primary
// /storage/10F9-3F13/Pictures/ -> 10F9-3F13 // /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) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
val sm = context.getSystemService(Context.STORAGE_SERVICE) as? StorageManager val sm = context.getSystemService(Context.STORAGE_SERVICE) as? StorageManager
sm?.getStorageVolume(File(anyPath))?.let { volume -> sm?.getStorageVolume(File(anyPath))?.let { volume ->
if (volume.isPrimary) { if (volume.isPrimary) {
return "primary" return EXTERNAL_STORAGE_PRIMARY_EMULATED_ROOT_ID
} }
volume.uuid?.let { uuid -> volume.uuid?.let { uuid ->
return uuid.uppercase(Locale.ROOT) return uuid.uppercase(Locale.ROOT)
@ -258,7 +263,7 @@ object StorageUtils {
// fallback for <N // fallback for <N
getVolumePath(context, anyPath)?.let { volumePath -> getVolumePath(context, anyPath)?.let { volumePath ->
if (volumePath == getPrimaryVolumePath(context)) { if (volumePath == getPrimaryVolumePath(context)) {
return "primary" return EXTERNAL_STORAGE_PRIMARY_EMULATED_ROOT_ID
} }
volumePath.split(File.separator).lastOrNull { it.isNotEmpty() }?.let { uuid -> volumePath.split(File.separator).lastOrNull { it.isNotEmpty() }?.let { uuid ->
return uuid.uppercase(Locale.ROOT) return uuid.uppercase(Locale.ROOT)
@ -272,8 +277,8 @@ object StorageUtils {
// e.g. // e.g.
// primary -> /storage/emulated/0/ // primary -> /storage/emulated/0/
// 10F9-3F13 -> /storage/10F9-3F13/ // 10F9-3F13 -> /storage/10F9-3F13/
private fun getVolumePathFromTreeUriUuid(context: Context, uuid: String): String? { private fun getVolumePathFromTreeDocumentUriUuid(context: Context, uuid: String): String? {
if (uuid == "primary") { if (uuid == EXTERNAL_STORAGE_PRIMARY_EMULATED_ROOT_ID) {
return getPrimaryVolumePath(context) return getPrimaryVolumePath(context)
} }
@ -309,37 +314,50 @@ object StorageUtils {
// /storage/emulated/0/ -> content://com.android.externalstorage.documents/tree/primary%3A // /storage/emulated/0/ -> content://com.android.externalstorage.documents/tree/primary%3A
// /storage/10F9-3F13/Pictures/ -> content://com.android.externalstorage.documents/tree/10F9-3F13%3APictures // /storage/10F9-3F13/Pictures/ -> content://com.android.externalstorage.documents/tree/10F9-3F13%3APictures
@RequiresApi(Build.VERSION_CODES.LOLLIPOP) @RequiresApi(Build.VERSION_CODES.LOLLIPOP)
fun convertDirPathToTreeUri(context: Context, dirPath: String): Uri? { fun convertDirPathToTreeDocumentUri(context: Context, dirPath: String): Uri? {
val uuid = getVolumeUuidForTreeUri(context, dirPath) val uuid = getVolumeUuidForDocumentUri(context, dirPath)
if (uuid != null) { if (uuid != null) {
val relativeDir = removeTrailingSeparator(PathSegments(context, dirPath).relativeDir ?: "") 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 return null
} }
// e.g. // e.g.
// content://com.android.externalstorage.documents/tree/primary%3A -> /storage/emulated/0/ // content://com.android.externalstorage.documents/tree/primary%3A -> /storage/emulated/0/
// content://com.android.externalstorage.documents/tree/10F9-3F13%3APictures -> /storage/10F9-3F13/Pictures/ // content://com.android.externalstorage.documents/tree/10F9-3F13%3APictures -> /storage/10F9-3F13/Pictures/
fun convertTreeUriToDirPath(context: Context, treeUri: Uri): String? { fun convertTreeDocumentUriToDirPath(context: Context, treeDocumentUri: Uri): String? {
val treeUriString = treeUri.toString() val treeDocumentUriString = treeDocumentUri.toString()
if (treeUriString.length <= TREE_URI_ROOT.length) return null if (treeDocumentUriString.length <= TREE_URI_ROOT.length) return null
val encoded = treeUriString.substring(TREE_URI_ROOT.length) val encoded = treeDocumentUriString.substring(TREE_URI_ROOT.length)
val matcher = TREE_URI_PATH_PATTERN.matcher(Uri.decode(encoded)) val matcher = TREE_URI_PATH_PATTERN.matcher(Uri.decode(encoded))
with(matcher) { with(matcher) {
if (find()) { if (find()) {
val uuid = group(1) val uuid = group(1)
val relativePath = group(2) val relativePath = group(2)
if (uuid != null && relativePath != null) { if (uuid != null && relativePath != null) {
val volumePath = getVolumePathFromTreeUriUuid(context, uuid) val volumePath = getVolumePathFromTreeDocumentUriUuid(context, uuid)
if (volumePath != null) { if (volumePath != null) {
return ensureTrailingSeparator(volumePath + relativePath) 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 return null
} }
@ -365,7 +383,7 @@ object StorageUtils {
} }
// fallback for older APIs // 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 if (df != null) return df
// try to strip user info, if any // try to strip user info, if any
@ -389,8 +407,8 @@ object StorageUtils {
val cleanDirPath = ensureTrailingSeparator(dirPath) val cleanDirPath = ensureTrailingSeparator(dirPath)
return if (requireAccessPermission(context, cleanDirPath) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { return if (requireAccessPermission(context, cleanDirPath) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
val grantedDir = getGrantedDirForPath(context, cleanDirPath) ?: return null val grantedDir = getGrantedDirForPath(context, cleanDirPath) ?: return null
val rootTreeUri = convertDirPathToTreeUri(context, grantedDir) ?: return null val rootTreeDocumentUri = convertDirPathToTreeDocumentUri(context, grantedDir) ?: return null
var parentFile: DocumentFileCompat? = DocumentFileCompat.fromTreeUri(context, rootTreeUri) ?: return null var parentFile: DocumentFileCompat? = DocumentFileCompat.fromTreeUri(context, rootTreeDocumentUri) ?: return null
val pathIterator = getPathStepIterator(context, cleanDirPath, grantedDir) val pathIterator = getPathStepIterator(context, cleanDirPath, grantedDir)
while (pathIterator?.hasNext() == true) { while (pathIterator?.hasNext() == true) {
val dirName = pathIterator.next() val dirName = pathIterator.next()
@ -420,8 +438,8 @@ object StorageUtils {
} }
} }
private fun getDocumentFileFromVolumeTree(context: Context, rootTreeUri: Uri, anyPath: String): DocumentFileCompat? { private fun getDocumentFileFromVolumeTree(context: Context, rootTreeDocumentUri: Uri, anyPath: String): DocumentFileCompat? {
var documentFile: DocumentFileCompat? = DocumentFileCompat.fromTreeUri(context, rootTreeUri) ?: return null var documentFile: DocumentFileCompat? = DocumentFileCompat.fromTreeUri(context, rootTreeDocumentUri) ?: return null
// follow the entry path down the document tree // follow the entry path down the document tree
val pathIterator = getPathStepIterator(context, anyPath, null) val pathIterator = getPathStepIterator(context, anyPath, null)

View file

@ -24,7 +24,7 @@ abstract class StorageService {
Future<int> deleteEmptyDirectories(Iterable<String> dirPaths); Future<int> deleteEmptyDirectories(Iterable<String> dirPaths);
// returns whether user granted access to a directory of his choosing // returns whether user granted access to a directory of his choosing
Future<bool> requestDirectoryAccess(String volumePath); Future<bool> requestDirectoryAccess(String path);
Future<bool> canRequestMediaFileAccess(); Future<bool> canRequestMediaFileAccess();
@ -158,12 +158,12 @@ class PlatformStorageService implements StorageService {
// returns whether user granted access to a directory of his choosing // returns whether user granted access to a directory of his choosing
@override @override
Future<bool> requestDirectoryAccess(String volumePath) async { Future<bool> requestDirectoryAccess(String path) async {
try { try {
final completer = Completer<bool>(); final completer = Completer<bool>();
storageAccessChannel.receiveBroadcastStream(<String, dynamic>{ storageAccessChannel.receiveBroadcastStream(<String, dynamic>{
'op': 'requestDirectoryAccess', 'op': 'requestDirectoryAccess',
'path': volumePath, 'path': path,
}).listen( }).listen(
(data) => completer.complete(data as bool), (data) => completer.complete(data as bool),
onError: completer.completeError, onError: completer.completeError,

View file

@ -183,6 +183,8 @@ class VolumeRelativeDirectory extends Equatable {
@override @override
List<Object?> get props => [volumePath, relativeDir]; List<Object?> get props => [volumePath, relativeDir];
String get dirPath => '$volumePath$relativeDir';
const VolumeRelativeDirectory({ const VolumeRelativeDirectory({
required this.volumePath, required this.volumePath,
required this.relativeDir, required this.relativeDir,

View file

@ -89,7 +89,7 @@ mixin PermissionAwareMixin {
return false; return false;
} }
final granted = await storageService.requestDirectoryAccess(dir.volumePath); final granted = await storageService.requestDirectoryAccess(dir.dirPath);
if (!granted) { if (!granted) {
// abort if the user denies access from the native dialog // abort if the user denies access from the native dialog
return false; return false;