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.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<Boolean>? = 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)

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.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<FieldMap>

View file

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

View file

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

View file

@ -30,7 +30,12 @@ import java.util.regex.Pattern
object 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("(.*?):(.*)")
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 <N
getVolumePath(context, anyPath)?.let { volumePath ->
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)

View file

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

View file

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

View file

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