set initial directory when requesting access
This commit is contained in:
parent
7a2604f2db
commit
c637c6eb8e
8 changed files with 63 additions and 46 deletions
|
@ -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)
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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()
|
||||
}, {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in a new issue