diff --git a/android/build.gradle b/android/build.gradle index e809093..d82d2c4 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -28,6 +28,10 @@ android { namespace "codeux.design.filepicker.file_picker_writable" compileSdk 33 + kotlinOptions { + jvmTarget = '1.8' + } + sourceSets { main.java.srcDirs += 'src/main/kotlin' } diff --git a/android/src/main/kotlin/codeux/design/filepicker/file_picker_writable/FilePickerWritableImpl.kt b/android/src/main/kotlin/codeux/design/filepicker/file_picker_writable/FilePickerWritableImpl.kt index f4e224f..2e127b6 100644 --- a/android/src/main/kotlin/codeux/design/filepicker/file_picker_writable/FilePickerWritableImpl.kt +++ b/android/src/main/kotlin/codeux/design/filepicker/file_picker_writable/FilePickerWritableImpl.kt @@ -5,11 +5,11 @@ import android.app.Activity.RESULT_OK import android.content.ActivityNotFoundException import android.content.ContentResolver import android.content.Intent -import android.database.Cursor import android.net.Uri import android.os.Build -import android.provider.OpenableColumns +import android.provider.DocumentsContract import androidx.annotation.MainThread +import androidx.annotation.RequiresApi import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.PluginRegistry @@ -35,6 +35,7 @@ class FilePickerWritableImpl( companion object { const val REQUEST_CODE_OPEN_FILE = 40832 const val REQUEST_CODE_CREATE_FILE = 40833 + const val REQUEST_CODE_OPEN_DIRECTORY = 40834 } private var filePickerCreateFile: File? = null @@ -59,7 +60,7 @@ class FilePickerWritableImpl( activity.startActivityForResult(intent, REQUEST_CODE_OPEN_FILE) } catch (e: ActivityNotFoundException) { filePickerResult = null - plugin.logDebug("exception while launcing file picker", e) + plugin.logDebug("exception while launching file picker", e) result.error( "FilePickerNotAvailable", "Unable to start file picker, $e", @@ -68,6 +69,49 @@ class FilePickerWritableImpl( } } + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) + @MainThread + fun openDirectoryPicker(result: MethodChannel.Result, initialDirUri: String?) { + if (filePickerResult != null) { + throw FilePickerException("Invalid lifecycle, only one call at a time.") + } + filePickerResult = result + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (initialDirUri != null) { + try { + val parsedUri = Uri.parse(initialDirUri).let { + val context = requireActivity().applicationContext + if (DocumentsContract.isDocumentUri(context, it)) { + it + } else { + DocumentsContract.buildDocumentUriUsingTree( + it, + DocumentsContract.getTreeDocumentId(it) + ) + } + } + putExtra(DocumentsContract.EXTRA_INITIAL_URI, parsedUri) + } catch (e: Exception) { + plugin.logDebug("exception while preparing document picker initial dir", e) + } + } + } + } + val activity = requireActivity() + try { + activity.startActivityForResult(intent, REQUEST_CODE_OPEN_DIRECTORY) + } catch (e: ActivityNotFoundException) { + filePickerResult = null + plugin.logDebug("exception while launching directory picker", e) + result.error( + "DirectoryPickerNotAvailable", + "Unable to start directory picker, $e", + null + ) + } + } + @MainThread fun openFilePickerForCreate(result: MethodChannel.Result, path: String) { if (filePickerResult != null) { @@ -101,7 +145,7 @@ class FilePickerWritableImpl( resultCode: Int, data: Intent? ): Boolean { - if (!arrayOf(REQUEST_CODE_OPEN_FILE, REQUEST_CODE_CREATE_FILE).contains( + if (!arrayOf(REQUEST_CODE_OPEN_FILE, REQUEST_CODE_CREATE_FILE, REQUEST_CODE_OPEN_DIRECTORY).contains( requestCode )) { plugin.logDebug("Unknown requestCode $requestCode - ignore") @@ -152,6 +196,19 @@ class FilePickerWritableImpl( initialFileContent ) } + REQUEST_CODE_OPEN_DIRECTORY -> { + val directoryUri = data?.data + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + throw FilePickerException("illegal state - get a directory response on an unsupported OS version") + } + if (directoryUri != null) { + plugin.logDebug("Got result $directoryUri") + handleDirectoryUriResponse(result, directoryUri) + } else { + plugin.logDebug("Got RESULT_OK with null directoryUri?") + result.success(null) + } + } else -> { // can never happen, we already checked the result code. throw IllegalStateException("Unexpected requestCode $requestCode") @@ -192,6 +249,17 @@ class FilePickerWritableImpl( copyContentUriAndReturn(result, fileUri) } + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) + @MainThread + private suspend fun handleDirectoryUriResponse( + result: MethodChannel.Result, + directoryUri: Uri + ) { + result.success( + getDirectoryInfo(directoryUri) + ) + } + @MainThread suspend fun readFileWithIdentifier( result: MethodChannel.Result, @@ -200,6 +268,70 @@ class FilePickerWritableImpl( copyContentUriAndReturn(result, Uri.parse(identifier)) } + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) + @MainThread + suspend fun getDirectory( + result: MethodChannel.Result, + rootUri: String, + fileUri: String + ) { + val activity = requireActivity() + + val root = Uri.parse(rootUri) + val leaf = Uri.parse(fileUri) + val leafUnderRoot = DocumentsContract.buildDocumentUriUsingTree( + root, + DocumentsContract.getDocumentId(leaf) + ) + + if (!fileExists(leafUnderRoot, activity.applicationContext.contentResolver)) { + result.error( + "InvalidArguments", + "The supplied fileUri $fileUri is not a child of $rootUri", + null + ) + return + } + + val ret = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + getParent(leafUnderRoot, activity.applicationContext) + } else { + null + } ?: findParent(root, leaf, activity.applicationContext) + + + result.success(mapOf( + "identifier" to ret.toString(), + "persistable" to "true", + "uri" to ret.toString() + )) + } + + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) + @MainThread + suspend fun resolveRelativePath( + result: MethodChannel.Result, + parentIdentifier: String, + relativePath: String + ) { + val activity = requireActivity() + + val resolvedUri = resolveRelativePath(Uri.parse(parentIdentifier), relativePath, activity.applicationContext) + if (resolvedUri != null) { + val displayName = getDisplayName(resolvedUri, activity.applicationContext.contentResolver) + val isDirectory = isDirectory(resolvedUri, activity.applicationContext.contentResolver) + result.success(mapOf( + "identifier" to resolvedUri.toString(), + "persistable" to "true", + "fileName" to displayName, + "uri" to resolvedUri.toString(), + "isDirectory" to isDirectory.toString() + )) + } else { + result.error("FileNotFound", "$relativePath could not be located relative to $parentIdentifier", null) + } + } + @MainThread private suspend fun copyContentUriAndReturn( result: MethodChannel.Result, @@ -228,7 +360,7 @@ class FilePickerWritableImpl( plugin.logDebug("Couldn't take persistable URI permission on $fileUri", e) } - val fileName = readFileInfo(fileUri, contentResolver) + val fileName = getDisplayName(fileUri, contentResolver) val tempFile = File.createTempFile( @@ -254,31 +386,35 @@ class FilePickerWritableImpl( } } - private suspend fun readFileInfo( - uri: Uri, - contentResolver: ContentResolver - ): String = withContext(Dispatchers.IO) { - // The query, because it only applies to a single document, returns only - // one row. There's no need to filter, sort, or select fields, - // because we want all fields for one document. - val cursor: Cursor? = contentResolver.query( - uri, null, null, null, null, null - ) - - cursor?.use { - if (!it.moveToFirst()) { - throw FilePickerException("Cursor returned empty while trying to read file info for $uri") - } - - // Note it's called "Display Name". This is - // provider-specific, and might not necessarily be the file name. - val displayName: String = - it.getString(it.getColumnIndex(OpenableColumns.DISPLAY_NAME)) - plugin.logDebug("Display Name: $displayName") - displayName + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) + @MainThread + private suspend fun getDirectoryInfo(directoryUri: Uri): Map { + val activity = requireActivity() - } ?: throw FilePickerException("Unable to load file info from $uri") + val contentResolver = activity.applicationContext.contentResolver + return withContext(Dispatchers.IO) { + var persistable = false + try { + val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION + contentResolver.takePersistableUriPermission(directoryUri, takeFlags) + persistable = true + } catch (e: SecurityException) { + plugin.logDebug("Couldn't take persistable URI permission on $directoryUri", e) + } + // URI as returned from picker is just a tree URI, but we need a document URI for getting the display name + val treeDocUri = DocumentsContract.buildDocumentUriUsingTree( + directoryUri, + DocumentsContract.getTreeDocumentId(directoryUri) + ) + mapOf( + "identifier" to directoryUri.toString(), + "persistable" to persistable.toString(), + "uri" to directoryUri.toString(), + "fileName" to getDisplayName(treeDocUri, contentResolver) + ) + } } fun onDetachedFromActivity(binding: ActivityPluginBinding) { diff --git a/android/src/main/kotlin/codeux/design/filepicker/file_picker_writable/FilePickerWritablePlugin.kt b/android/src/main/kotlin/codeux/design/filepicker/file_picker_writable/FilePickerWritablePlugin.kt index 9371a4b..9159e93 100644 --- a/android/src/main/kotlin/codeux/design/filepicker/file_picker_writable/FilePickerWritablePlugin.kt +++ b/android/src/main/kotlin/codeux/design/filepicker/file_picker_writable/FilePickerWritablePlugin.kt @@ -2,6 +2,7 @@ package codeux.design.filepicker.file_picker_writable import android.app.Activity import android.net.Uri +import android.os.Build import android.util.Log import androidx.annotation.MainThread import androidx.annotation.NonNull @@ -89,11 +90,44 @@ class FilePickerWritablePlugin : FlutterPlugin, MethodCallHandler, ?: throw FilePickerException("Expected argument 'path'") impl.openFilePickerForCreate(result, path) } + "isDirectoryAccessSupported" -> { + result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) + } + "openDirectoryPicker" -> { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + val initialDirUri = call.argument("initialDirUri") + impl.openDirectoryPicker(result, initialDirUri) + } else { + throw FilePickerException("${call.method} is not supported on Android ${Build.VERSION.RELEASE}") + } + } "readFileWithIdentifier" -> { val identifier = call.argument("identifier") ?: throw FilePickerException("Expected argument 'identifier'") impl.readFileWithIdentifier(result, identifier) } + "getDirectory" -> { + val rootIdentifier = call.argument("rootIdentifier") + ?: throw FilePickerException("Expected argument 'rootIdentifier'") + val fileIdentifier = call.argument("fileIdentifier") + ?: throw FilePickerException("Expected argument 'fileIdentifier'") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + impl.getDirectory(result, rootIdentifier, fileIdentifier) + } else { + throw FilePickerException("${call.method} is not supported on Android ${Build.VERSION.RELEASE}") + } + } + "resolveRelativePath" -> { + val directoryIdentifier = call.argument("directoryIdentifier") + ?: throw FilePickerException("Expected argument 'directoryIdentifier'") + val relativePath = call.argument("relativePath") + ?: throw FilePickerException("Expected argument 'relativePath'") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + impl.resolveRelativePath(result, directoryIdentifier, relativePath) + } else { + throw FilePickerException("${call.method} is not supported on Android ${Build.VERSION.RELEASE}") + } + } "writeFileWithIdentifier" -> { val identifier = call.argument("identifier") ?: throw FilePickerException("Expected argument 'identifier'") diff --git a/android/src/main/kotlin/codeux/design/filepicker/file_picker_writable/Query.kt b/android/src/main/kotlin/codeux/design/filepicker/file_picker_writable/Query.kt new file mode 100644 index 0000000..88452fe --- /dev/null +++ b/android/src/main/kotlin/codeux/design/filepicker/file_picker_writable/Query.kt @@ -0,0 +1,300 @@ +package codeux.design.filepicker.file_picker_writable + +import android.content.ContentResolver +import android.content.Context +import android.database.Cursor +import android.net.Uri +import android.os.Build +import android.provider.DocumentsContract +import android.provider.OpenableColumns +import androidx.annotation.RequiresApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Get the display name for [uri]. + * + * - Expects: {Tree+}document URI + */ +suspend fun getDisplayName( + uri: Uri, + contentResolver: ContentResolver +): String = withContext(Dispatchers.IO) { + // The query, because it only applies to a single document, returns only + // one row. There's no need to filter, sort, or select fields, + // because we want all fields for one document. + val cursor: Cursor? = contentResolver.query( + uri, null, null, null, null, null + ) + + cursor?.use { + if (!it.moveToFirst()) { + throw FilePickerException("Cursor returned empty while trying to read file info for $uri") + } + + // Note it's called "Display Name". This is + // provider-specific, and might not necessarily be the file name. + val displayName: String = + it.getString(it.getColumnIndex(OpenableColumns.DISPLAY_NAME)) + displayName + + } ?: throw FilePickerException("Unable to load file info from $uri") +} + +/** + * Determine whether [uri] is a directory. + * + * - Expects: {Tree+}document URI + */ +suspend fun isDirectory( + uri: Uri, + contentResolver: ContentResolver +): Boolean = withContext(Dispatchers.IO) { + // Like DocumentsContractApi19#isDirectory + contentResolver.query( + uri, arrayOf(DocumentsContract.Document.COLUMN_MIME_TYPE), null, null, null, null + )?.use { + if (!it.moveToFirst()) { + throw FilePickerException("Cursor returned empty while trying to read info for $uri") + } + val typeColumn = it.getColumnIndex(DocumentsContract.Document.COLUMN_MIME_TYPE) + val childType = it.getString(typeColumn) + DocumentsContract.Document.MIME_TYPE_DIR == childType + } ?: throw FilePickerException("Unable to query info for $uri") +} + + +/** + * Directly compute the URI of the parent directory of the supplied child URI. + * Efficient, but only available on Android O or later. + * + * - Expects: Tree{+document} URI + * - Returns: Tree{+document} URI + */ +@RequiresApi(Build.VERSION_CODES.O) +suspend fun getParent( + child: Uri, + context: Context +): Uri? = withContext(Dispatchers.IO) { + val uri = when { + DocumentsContract.isDocumentUri(context, child) -> { + // Tree+document URI (probably from getDirectory) + child + } + DocumentsContract.isTreeUri(child) -> { + // Just a tree URI (probably from pickDirectory) + DocumentsContract.buildDocumentUriUsingTree(child, DocumentsContract.getTreeDocumentId(child)) + } + else -> { + throw Exception("Unknown URI type") + } + } + val path = try { + DocumentsContract.findDocumentPath(context.contentResolver, uri) + } catch (_: UnsupportedOperationException) { + // Some providers don't support this method + null + } ?: return@withContext null + val parents = path.path + if (parents.size < 2) { + return@withContext null + } + // Last item is the child itself, so get second-to-last item + val parent = parents[parents.lastIndex - 1] + when { + DocumentsContract.isTreeUri(child) -> { + DocumentsContract.buildDocumentUriUsingTree(child, parent) + } + else -> { + DocumentsContract.buildTreeDocumentUri(child.authority, parent) + } + } +} + +/** + * Starting at [root], perform a breadth-wise search through all children to + * locate the immediate parent of [leaf]. + * + * This is extremely inefficient compared to [getParent], but it is available on + * older systems. + * + * - Expects: [root] is Tree{+document} URI; [leaf] is {tree+}document URI + * - Returns: Tree+document URI + */ +@RequiresApi(Build.VERSION_CODES.LOLLIPOP) +suspend fun findParent( + root: Uri, + leaf: Uri, + context: Context +): Uri? { + val leafDocId = DocumentsContract.getDocumentId(leaf) + val children = getChildren(root, context) + // Do breadth-first search because hopefully the leaf is not too deep + // relative to the root + for (child in children) { + if (DocumentsContract.getDocumentId(child) == leafDocId) { + return root + } + } + for (child in children) { + if (isDirectory(child, context.contentResolver)) { + val result = findParent(child, leaf, context) + if (result != null) { + return result + } + } + } + return null +} + +/** + * Return URIs of all children of [uri]. + * + * - Expects: Tree{+document} or tree URI + * - Returns: Tree+document URI + */ +@RequiresApi(Build.VERSION_CODES.LOLLIPOP) +suspend fun getChildren( + uri: Uri, + context: Context +): List = withContext(Dispatchers.IO) { + // Like TreeDocumentFile#listFiles + val docId = when { + DocumentsContract.isDocumentUri(context, uri) -> { + DocumentsContract.getDocumentId(uri) + } + else -> { + DocumentsContract.getTreeDocumentId(uri) + } + } + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(uri, docId) + context.contentResolver.query( + childrenUri, + arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID), null, null, null, null + )?.use { + val results = mutableListOf() + val idColumn = it.getColumnIndex(DocumentsContract.Document.COLUMN_DOCUMENT_ID) + while (it.moveToNext()) { + val childDocId = it.getString(idColumn) + val childUri = DocumentsContract.buildDocumentUriUsingTree(uri, childDocId) + results.add(childUri) + } + results + } ?: throw FilePickerException("Unable to query info for $uri") +} + +/** + * Check whether the file pointed to by [uri] exists. + * + * - Expects: {Tree+}document URI + */ +suspend fun fileExists( + uri: Uri, + contentResolver: ContentResolver +): Boolean = withContext(Dispatchers.IO) { + // Like DocumentsContractApi19#exists + contentResolver.query( + uri, arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID), null, null, null, null + )?.use { + it.count > 0 + } ?: throw FilePickerException("Unable to query info for $uri") +} + +/** + * From the [start] point, compute the URI of the entity pointed to by + * [relativePath]. + * + * - Expects: Tree{+document} URI + * - Returns: Tree{+document} URI + */ +@RequiresApi(Build.VERSION_CODES.LOLLIPOP) +suspend fun resolveRelativePath( + start: Uri, + relativePath: String, + context: Context +): Uri? = withContext(Dispatchers.IO) { + val stack = mutableListOf(start) + for (segment in relativePath.split('/', '\\')) { + when (segment) { + "" -> { + } + "." -> { + } + ".." -> { + val last = stack.removeAt(stack.lastIndex) + if (stack.isEmpty()) { + val parent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + getParent(last, context) + } else { + null + } + if (parent != null) { + stack.add(parent) + } else { + return@withContext null + } + } + } + else -> { + val next = getChildByDisplayName(stack.last(), segment, context) + if (next == null) { + return@withContext null + } else { + stack.add(next) + } + } + } + } + stack.last() +} + +/** + * Compute the URI of the named [child] under [parent]. + * + * - Expects: Tree{+document} URI + * - Returns: Tree+document URI + */ +@RequiresApi(Build.VERSION_CODES.LOLLIPOP) +suspend fun getChildByDisplayName( + parent: Uri, + child: String, + context: Context +): Uri? = withContext(Dispatchers.IO) { + val parentDocumentId = when { + DocumentsContract.isDocumentUri(context, parent) -> { + // Tree+document URI (probably from getDirectory) + DocumentsContract.getDocumentId(parent) + } + else -> { + // Just a tree URI (probably from pickDirectory) + DocumentsContract.getTreeDocumentId(parent) + } + } + val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(parent, parentDocumentId) + context.contentResolver.query( + childrenUri, + arrayOf(DocumentsContract.Document.COLUMN_DOCUMENT_ID, DocumentsContract.Document.COLUMN_DISPLAY_NAME), + "${DocumentsContract.Document.COLUMN_DISPLAY_NAME} = ?", + arrayOf(child), + null + )?.use { + val idColumn = it.getColumnIndex(DocumentsContract.Document.COLUMN_DOCUMENT_ID) + val nameColumn = it.getColumnIndex(DocumentsContract.Document.COLUMN_DISPLAY_NAME) + var documentId: String? = null + while (it.moveToNext()) { + val name = it.getString(nameColumn) + // FileSystemProvider doesn't respect our selection so we have to + // manually filter here to be safe + if (name == child) { + documentId = it.getString(idColumn) + break + } + } + + if (documentId != null) { + DocumentsContract.buildDocumentUriUsingTree(parent, documentId) + } else { + null + } + } +} diff --git a/example/lib/main.dart b/example/lib/main.dart index 81ea408..9939501 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -22,27 +22,42 @@ Future main() async { class AppDataBloc { final store = SimpleJsonPersistence.getForTypeWithDefault( (json) => AppData.fromJson(json), - defaultCreator: () => AppData(files: []), + defaultCreator: () => AppData(files: [], directories: []), name: 'AppData', ); } class AppData implements HasToJson { - AppData({required this.files}); + AppData({required this.files, required this.directories}); final List files; + final List directories; static AppData fromJson(Map json) => AppData( - files: (json['files'] as List) - .where((dynamic element) => element != null) - .map((dynamic e) => FileInfo.fromJson(e as Map)) - .toList()); + files: (json['files'] as List? ?? []) + .where((dynamic element) => element != null) + .map((dynamic e) => FileInfo.fromJson(e as Map)) + .toList(), + directories: (json['directories'] as List? ?? []) + .where((dynamic element) => element != null) + .map((dynamic e) => + DirectoryInfo.fromJson(e as Map)) + .toList(), + ); @override Map toJson() => { 'files': files, + 'directories': directories, }; - AppData copyWith({required List files}) => AppData(files: files); + AppData copyWith({ + List? files, + List? directories, + }) => + AppData( + files: files ?? this.files, + directories: directories ?? this.directories, + ); } class MyApp extends StatefulWidget { @@ -146,14 +161,25 @@ class MainScreenState extends State { onPressed: FilePickerWritable().disposeAllIdentifiers, child: const Text('Dispose All IDs'), ), + const SizedBox(width: 32), + ElevatedButton( + onPressed: _openDirectoryPicker, + child: const Text('Open Directory Picker'), + ), ], ), - ...?(!snapshot.hasData - ? null - : snapshot.data!.files.map((fileInfo) => FileInfoDisplay( - fileInfo: fileInfo, - appDataBloc: _appDataBloc, - ))), + if (snapshot.hasData) + for (final fileInfo in snapshot.data!.files) + EntityInfoDisplay( + entityInfo: fileInfo, + appDataBloc: _appDataBloc, + ), + if (snapshot.hasData) + for (final directoryInfo in snapshot.data!.directories) + EntityInfoDisplay( + entityInfo: directoryInfo, + appDataBloc: _appDataBloc, + ), ], ), ), @@ -193,17 +219,29 @@ class MainScreenState extends State { await _appDataBloc.store .save(data.copyWith(files: data.files + [fileInfo])); } + + Future _openDirectoryPicker() async { + final directoryInfo = await FilePickerWritable().openDirectory(); + if (directoryInfo == null) { + _logger.fine('User cancelled.'); + } else { + _logger.fine('Got picker result: $directoryInfo'); + final data = await _appDataBloc.store.load(); + await _appDataBloc.store + .save(data.copyWith(directories: data.directories + [directoryInfo])); + } + } } -class FileInfoDisplay extends StatelessWidget { - const FileInfoDisplay({ +class EntityInfoDisplay extends StatelessWidget { + const EntityInfoDisplay({ Key? key, - required this.fileInfo, + required this.entityInfo, required this.appDataBloc, }) : super(key: key); final AppDataBloc appDataBloc; - final FileInfo fileInfo; + final EntityInfo entityInfo; @override Widget build(BuildContext context) { @@ -216,26 +254,28 @@ class FileInfoDisplay extends StatelessWidget { padding: const EdgeInsets.all(16.0), child: Column( children: [ - const Text('Selected File:'), + if (entityInfo is FileInfo) const Text('Selected File:'), + if (entityInfo is DirectoryInfo) + const Text('Selected Directory:'), Text( - fileInfo.fileName ?? 'null', + entityInfo.fileName ?? 'null', maxLines: 4, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodySmall?.apply(fontSizeFactor: 0.75), ), Text( - fileInfo.identifier, + entityInfo.identifier, maxLines: 1, overflow: TextOverflow.ellipsis, ), Text( - 'uri:${fileInfo.uri}', + 'uri:${entityInfo.uri}', style: theme.textTheme.bodyMedium ?.apply(fontSizeFactor: 0.7) .copyWith(fontWeight: FontWeight.bold), ), Text( - 'fileName: ${fileInfo.fileName}', + 'fileName: ${entityInfo.fileName}', style: theme.textTheme.bodyMedium ?.apply(fontSizeFactor: 0.7) .copyWith(fontWeight: FontWeight.bold), @@ -243,46 +283,59 @@ class FileInfoDisplay extends StatelessWidget { ButtonBar( alignment: MainAxisAlignment.end, children: [ - TextButton( - onPressed: () async { - try { - await FilePickerWritable().readFile( - identifier: fileInfo.identifier, - reader: (fileInfo, file) async { - await SimpleAlertDialog - .readFileContentsAndShowDialog( - fileInfo, file, context); + if (entityInfo is FileInfo) ...[ + TextButton( + onPressed: () async { + try { + await FilePickerWritable().readFile( + identifier: entityInfo.identifier, + reader: (fileInfo, file) async { + await SimpleAlertDialog + .readFileContentsAndShowDialog( + fileInfo, file, context); + }); + } on Exception catch (e) { + if (context.mounted) { + await SimpleAlertDialog.showErrorDialog(e, context); + } + } + }, + child: const Text('Read'), + ), + TextButton( + onPressed: () async { + await FilePickerWritable().writeFile( + identifier: entityInfo.identifier, + writer: (file) async { + final content = + 'New Content written at ${DateTime.now()}.\n\n'; + await file.writeAsString(content); + // ignore: use_build_context_synchronously + await SimpleAlertDialog( + bodyText: 'Written: $content', + ).show(context); }); - } on Exception catch (e) { - if (!context.mounted) { - return; + }, + child: const Text('Overwrite'), + ), + TextButton( + onPressed: () async { + final directoryInfo = await FilePickerWritable() + .openDirectory(initialDirUri: entityInfo.uri); + if (directoryInfo != null) { + final data = await appDataBloc.store.load(); + await appDataBloc.store.save(data.copyWith( + directories: data.directories + [directoryInfo])); } - await SimpleAlertDialog.showErrorDialog(e, context); - } - }, - child: const Text('Read'), - ), - TextButton( - onPressed: () async { - await FilePickerWritable().writeFile( - identifier: fileInfo.identifier, - writer: (file) async { - final content = - 'New Content written at ${DateTime.now()}.\n\n'; - await file.writeAsString(content); - // ignore: use_build_context_synchronously - await SimpleAlertDialog( - bodyText: 'Written: $content', - ).show(context); - }); - }, - child: const Text('Overwrite'), - ), + }, + child: const Text('Pick dir'), + ), + ], IconButton( onPressed: () async { try { await FilePickerWritable() - .disposeIdentifier(fileInfo.identifier); + .disposeIdentifier(entityInfo.identifier); } on Exception catch (e) { if (!context.mounted) { return; @@ -290,10 +343,16 @@ class FileInfoDisplay extends StatelessWidget { await SimpleAlertDialog.showErrorDialog(e, context); } final appData = await appDataBloc.store.load(); - await appDataBloc.store.save(appData.copyWith( + await appDataBloc.store.save( + appData.copyWith( files: appData.files - .where((element) => element != fileInfo) - .toList())); + .where((element) => element != entityInfo) + .toList(), + directories: appData.directories + .where((element) => element != entityInfo) + .toList(), + ), + ); }, icon: const Icon(Icons.remove_circle_outline), ), diff --git a/ios/Classes/SwiftFilePickerWritablePlugin.swift b/ios/Classes/SwiftFilePickerWritablePlugin.swift index 271d99f..416b7b3 100644 --- a/ios/Classes/SwiftFilePickerWritablePlugin.swift +++ b/ios/Classes/SwiftFilePickerWritablePlugin.swift @@ -40,7 +40,7 @@ public class SwiftFilePickerWritablePlugin: NSObject, FlutterPlugin { let eventChannel = FlutterEventChannel(name: "design.codeux.file_picker_writable/events", binaryMessenger: registrar.messenger()) eventChannel.setStreamHandler(self) } - + private func logDebug(_ message: String) { print("DEBUG", "FilePickerWritablePlugin:", message) sendEvent(event: ["type": "log", "level": "DEBUG", "message": message]) @@ -69,6 +69,13 @@ public class SwiftFilePickerWritablePlugin: NSObject, FlutterPlugin { throw FilePickerError.invalidArguments(message: "Expected 'args'") } openFilePickerForCreate(path: path, result: result) + case "isDirectoryAccessSupported": + result(true) + case "openDirectoryPicker": + guard let args = call.arguments as? Dictionary else { + throw FilePickerError.invalidArguments(message: "Expected 'args'") + } + openDirectoryPicker(result: result, initialDirUrl: args["initialDirUri"] as? String) case "readFileWithIdentifier": guard let args = call.arguments as? Dictionary, @@ -76,6 +83,22 @@ public class SwiftFilePickerWritablePlugin: NSObject, FlutterPlugin { throw FilePickerError.invalidArguments(message: "Expected 'identifier'") } try readFile(identifier: identifier, result: result) + case "getDirectory": + guard + let args = call.arguments as? Dictionary, + let rootIdentifier = args["rootIdentifier"] as? String, + let fileIdentifier = args["fileIdentifier"] as? String else { + throw FilePickerError.invalidArguments(message: "Expected 'rootIdentifier' and 'fileIdentifier'") + } + try getDirectory(rootIdentifier: rootIdentifier, fileIdentifier: fileIdentifier, result: result) + case "resolveRelativePath": + guard + let args = call.arguments as? Dictionary, + let directoryIdentifier = args["directoryIdentifier"] as? String, + let relativePath = args["relativePath"] as? String else { + throw FilePickerError.invalidArguments(message: "Expected 'directoryIdentifier' and 'relativePath'") + } + try resolveRelativePath(directoryIdentifier: directoryIdentifier, relativePath: relativePath, result: result) case "writeFileWithIdentifier": guard let args = call.arguments as? Dictionary, let identifier = args["identifier"] as? String, @@ -95,7 +118,7 @@ public class SwiftFilePickerWritablePlugin: NSObject, FlutterPlugin { result(FlutterError(code: "UnknownError", message: "\(error)", details: nil)) } } - + func readFile(identifier: String, result: @escaping FlutterResult) throws { guard let bookmark = Data(base64Encoded: identifier) else { result(FlutterError(code: "InvalidDataError", message: "Unable to decode bookmark.", details: nil)) @@ -104,19 +127,97 @@ public class SwiftFilePickerWritablePlugin: NSObject, FlutterPlugin { var isStale: Bool = false let url = try URL(resolvingBookmarkData: bookmark, bookmarkDataIsStale: &isStale) logDebug("url: \(url) / isStale: \(isStale)"); - let securityScope = url.startAccessingSecurityScopedResource() + DispatchQueue.global(qos: .userInitiated).async { [self] in + let securityScope = url.startAccessingSecurityScopedResource() + defer { + if securityScope { + url.stopAccessingSecurityScopedResource() + } + } + if !securityScope { + logDebug("Warning: startAccessingSecurityScopedResource is false for \(url).") + } + do { + let copiedFile = try _copyToTempDirectory(url: url) + DispatchQueue.main.async { [self] in + result(_fileInfoResult(tempFile: copiedFile, originalURL: url, bookmark: bookmark)) + } + } catch { + DispatchQueue.main.async { + result(FlutterError(code: "UnknownError", message: "\(error)", details: nil)) + } + } + } + } + + func getDirectory(rootIdentifier: String, fileIdentifier: String, result: @escaping FlutterResult) throws { + // In principle these URLs could be opaque like on Android, in which + // case this analysis would not work. But it seems that URLs even for + // cloud-based content providers are always file:// (tested with iCloud + // Drive, Google Drive, Dropbox, FileBrowser) + guard let rootUrl = restoreUrl(from: rootIdentifier) else { + result(FlutterError(code: "InvalidDataError", message: "Unable to decode root bookmark.", details: nil)) + return + } + guard let fileUrl = restoreUrl(from: fileIdentifier) else { + result(FlutterError(code: "InvalidDataError", message: "Unable to decode file bookmark.", details: nil)) + return + } + guard fileUrl.absoluteString.starts(with: rootUrl.absoluteString) else { + result(FlutterError(code: "InvalidArguments", message: "The supplied file \(fileUrl) is not a child of \(rootUrl)", details: nil)) + return + } + let securityScope = rootUrl.startAccessingSecurityScopedResource() defer { if securityScope { - url.stopAccessingSecurityScopedResource() + rootUrl.stopAccessingSecurityScopedResource() } } - if !securityScope { - logDebug("Warning: startAccessingSecurityScopedResource is false for \(url).") + let dirUrl = fileUrl.deletingLastPathComponent() + result([ + "identifier": try dirUrl.bookmarkData().base64EncodedString(), + "persistable": "true", + "uri": dirUrl.absoluteString, + "fileName": dirUrl.lastPathComponent, + ]) + } + + func resolveRelativePath(directoryIdentifier: String, relativePath: String, result: @escaping FlutterResult) throws { + guard let url = restoreUrl(from: directoryIdentifier) else { + result(FlutterError(code: "InvalidDataError", message: "Unable to restore URL from identifier.", details: nil)) + return + } + let childUrl = url.appendingPathComponent(relativePath).standardized + logDebug("Resolved to \(childUrl)") + DispatchQueue.global(qos: .userInitiated).async { + var coordError: NSError? = nil + var bookmarkError: Error? = nil + var identifier: String? = nil + // Coordinate reading the item here because it might be a + // not-yet-downloaded file, in which case we can't get a bookmark for + // it--bookmarkData() fails with a "file doesn't exist" error + NSFileCoordinator().coordinate(readingItemAt: childUrl, error: &coordError) { url in + do { + identifier = try childUrl.bookmarkData().base64EncodedString() + } catch let error { + bookmarkError = error + } + } + DispatchQueue.main.async { [self] in + if let error = coordError ?? bookmarkError { + result(FlutterError(code: "UnknownError", message: "\(error)", details: nil)) + } + result([ + "identifier": identifier, + "persistable": "true", + "uri": childUrl.absoluteString, + "fileName": childUrl.lastPathComponent, + "isDirectory": "\(isDirectory(childUrl))", + ]) + } } - let copiedFile = try _copyToTempDirectory(url: url) - result(_fileInfoResult(tempFile: copiedFile, originalURL: url, bookmark: bookmark)) } - + func writeFile(identifier: String, path: String, result: @escaping FlutterResult) throws { guard let bookmark = Data(base64Encoded: identifier) else { throw FilePickerError.invalidArguments(message: "Unable to decode bookmark/identifier.") @@ -128,11 +229,11 @@ public class SwiftFilePickerWritablePlugin: NSObject, FlutterPlugin { let sourceFile = URL(fileURLWithPath: path) result(_fileInfoResult(tempFile: sourceFile, originalURL: url, bookmark: bookmark)) } - + // TODO: skipDestinationStartAccess is not doing anything right now. maybe get rid of it. private func _writeFile(path: String, destination: URL, skipDestinationStartAccess: Bool = false) throws { let sourceFile = URL(fileURLWithPath: path) - + let destAccess = destination.startAccessingSecurityScopedResource() if !destAccess { logDebug("Warning: startAccessingSecurityScopedResource is false for \(destination) (destination); skipDestinationStartAccess=\(skipDestinationStartAccess)") @@ -154,7 +255,7 @@ public class SwiftFilePickerWritablePlugin: NSObject, FlutterPlugin { let data = try Data(contentsOf: sourceFile) try data.write(to: destination, options: .atomicWrite) } - + func openFilePickerForCreate(path: String, result: @escaping FlutterResult) { if (_filePickerResult != nil) { result(FlutterError(code: "DuplicatedCall", message: "Only one file open call at a time.", details: nil)) @@ -181,6 +282,24 @@ public class SwiftFilePickerWritablePlugin: NSObject, FlutterPlugin { _viewController.present(ctrl, animated: true, completion: nil) } + func openDirectoryPicker(result: @escaping FlutterResult, initialDirUrl: String?) { + if (_filePickerResult != nil) { + result(FlutterError(code: "DuplicatedCall", message: "Only one file open call at a time.", details: nil)) + return + } + _filePickerResult = result + _filePickerPath = nil + let ctrl = UIDocumentPickerViewController(documentTypes: [kUTTypeFolder as String], in: .open) + ctrl.delegate = self + if #available(iOS 13.0, *) { + if let initialDirUrl = initialDirUrl { + ctrl.directoryURL = URL(string: initialDirUrl) + } + } + ctrl.modalPresentationStyle = .currentContext + _viewController.present(ctrl, animated: true, completion: nil) + } + private func _copyToTempDirectory(url: URL) throws -> URL { let tempDir = NSURL.fileURL(withPath: NSTemporaryDirectory(), isDirectory: true) let tempFile = tempDir.appendingPathComponent("\(UUID().uuidString)_\(url.lastPathComponent)") @@ -215,7 +334,7 @@ public class SwiftFilePickerWritablePlugin: NSObject, FlutterPlugin { } return tempFile } - + private func _prepareUrlForReading(url: URL, persistable: Bool) throws -> [String: String] { let securityScope = url.startAccessingSecurityScopedResource() defer { @@ -231,7 +350,26 @@ public class SwiftFilePickerWritablePlugin: NSObject, FlutterPlugin { let bookmark = try url.bookmarkData() return _fileInfoResult(tempFile: tempFile, originalURL: url, bookmark: bookmark, persistable: persistable) } - + + private func _prepareDirUrlForReading(url: URL) throws -> [String:String] { + let securityScope = url.startAccessingSecurityScopedResource() + defer { + if securityScope { + url.stopAccessingSecurityScopedResource() + } + } + if !securityScope { + logDebug("Warning: startAccessingSecurityScopedResource is false for \(url)") + } + let bookmark = try url.bookmarkData() + return [ + "identifier": bookmark.base64EncodedString(), + "persistable": "true", + "uri": url.absoluteString, + "fileName": url.lastPathComponent, + ] + } + private func _fileInfoResult(tempFile: URL, originalURL: URL, bookmark: Data, persistable: Bool = true) -> [String: String] { let identifier = bookmark.base64EncodedString() return [ @@ -244,51 +382,80 @@ public class SwiftFilePickerWritablePlugin: NSObject, FlutterPlugin { } private func _sendFilePickerResult(_ result: Any?) { - if let _result = _filePickerResult { - _result(result) + DispatchQueue.main.async { [self] in + if let _result = _filePickerResult { + _result(result) + } + _filePickerResult = nil } - _filePickerResult = nil } } extension SwiftFilePickerWritablePlugin : UIDocumentPickerDelegate { public func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentAt url: URL) { - do { - if let path = _filePickerPath { - _filePickerPath = nil - guard url.startAccessingSecurityScopedResource() else { - throw FilePickerError.readError(message: "Unable to acquire acces to \(url)") + DispatchQueue.global(qos: .userInitiated).async { [self] in + do { + if let path = _filePickerPath { + _filePickerPath = nil + guard url.startAccessingSecurityScopedResource() else { + throw FilePickerError.readError(message: "Unable to acquire acces to \(url)") + } + logDebug("Need to write \(path) to \(url)") + let sourceFile = URL(fileURLWithPath: path) + let targetFile = url.appendingPathComponent(sourceFile.lastPathComponent) +// if !targetFile.startAccessingSecurityScopedResource() { +// logDebug("Warning: Unnable to acquire acces to \(targetFile)") +// } +// defer { +// targetFile.stopAccessingSecurityScopedResource() +// } + try _writeFile(path: path, destination: targetFile, skipDestinationStartAccess: true) + + let tempFile = try _copyToTempDirectory(url: targetFile) + // Get bookmark *after* ensuring file has been created! + let bookmark = try targetFile.bookmarkData() + _sendFilePickerResult(_fileInfoResult(tempFile: tempFile, originalURL: targetFile, bookmark: bookmark)) + return + } + if isDirectory(url) { + _sendFilePickerResult(try _prepareDirUrlForReading(url: url)) + } else { + _sendFilePickerResult(try _prepareUrlForReading(url: url, persistable: true)) } - logDebug("Need to write \(path) to \(url)") - let sourceFile = URL(fileURLWithPath: path) - let targetFile = url.appendingPathComponent(sourceFile.lastPathComponent) -// if !targetFile.startAccessingSecurityScopedResource() { -// logDebug("Warning: Unnable to acquire acces to \(targetFile)") -// } -// defer { -// targetFile.stopAccessingSecurityScopedResource() -// } - try _writeFile(path: path, destination: targetFile, skipDestinationStartAccess: true) - - let tempFile = try _copyToTempDirectory(url: targetFile) - // Get bookmark *after* ensuring file has been created! - let bookmark = try targetFile.bookmarkData() - _sendFilePickerResult(_fileInfoResult(tempFile: tempFile, originalURL: targetFile, bookmark: bookmark)) + } catch { + _sendFilePickerResult(FlutterError(code: "ErrorProcessingResult", message: "Error handling result url \(url): \(error)", details: nil)) return } - _sendFilePickerResult(try _prepareUrlForReading(url: url, persistable: true)) - } catch { - _sendFilePickerResult(FlutterError(code: "ErrorProcessingResult", message: "Error handling result url \(url): \(error)", details: nil)) - return } - } - + public func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { _sendFilePickerResult(nil) } - + + private func isDirectory(_ url: URL) -> Bool { + if #available(iOS 9.0, *) { + return url.hasDirectoryPath + } else if let resVals = try? url.resourceValues(forKeys: [.isDirectoryKey]), + let isDir = resVals.isDirectory { + return isDir + } else { + return false + } + } + + private func restoreUrl(from identifier: String) -> URL? { + guard let bookmark = Data(base64Encoded: identifier) else { + return nil + } + var isStale: Bool = false + guard let url = try? URL(resolvingBookmarkData: bookmark, bookmarkDataIsStale: &isStale) else { + return nil + } + logDebug("url: \(url) / isStale: \(isStale)"); + return url + } } // application delegate methods.. @@ -305,13 +472,13 @@ extension SwiftFilePickerWritablePlugin: FlutterApplicationLifeCycleDelegate { } return _handle(url: url, persistable: persistable) } - + public func application(_ application: UIApplication, handleOpen url: URL) -> Bool { logDebug("handleOpen for \(url)") // This is an old API predating open-in-place support(?) return _handle(url: url, persistable: false) } - + public func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([Any]) -> Void) -> Bool { // (handle universal links) // Get URL components from the incoming user activity @@ -324,7 +491,7 @@ extension SwiftFilePickerWritablePlugin: FlutterApplicationLifeCycleDelegate { // TODO: Confirm that persistable should be true here return _handle(url: incomingURL, persistable: true) } - + private func _handle(url: URL, persistable: Bool) -> Bool { // if (!url.isFileURL) { // logDebug("url \(url) is not a file url. ignoring it for now.") @@ -337,7 +504,7 @@ extension SwiftFilePickerWritablePlugin: FlutterApplicationLifeCycleDelegate { _handleUrl(url: url, persistable: persistable) return true } - + private func _handleUrl(url: URL, persistable: Bool) { do { if (url.isFileURL) { @@ -386,18 +553,20 @@ extension SwiftFilePickerWritablePlugin: FlutterStreamHandler { } return nil } - + public func onCancel(withArguments arguments: Any?) -> FlutterError? { _eventSink = nil return nil } - + private func sendEvent(event: [String: String]) { if let _eventSink = _eventSink { - _eventSink(event) + DispatchQueue.main.async { + _eventSink(event) + } } else { _eventQueue.append(event) } } - + } diff --git a/lib/src/file_picker_writable.dart b/lib/src/file_picker_writable.dart index a56d350..5c77adf 100644 --- a/lib/src/file_picker_writable.dart +++ b/lib/src/file_picker_writable.dart @@ -11,50 +11,50 @@ import 'package:synchronized/synchronized.dart'; final _logger = Logger('file_picker_writable'); -/// Contains information about a user selected file. -class FileInfo { - FileInfo({ +/// Contains information about a user-selected filesystem entity, e.g. a +/// [FileInfo] or [DirectoryInfo]. +abstract class EntityInfo { + EntityInfo({ required this.identifier, required this.persistable, required this.uri, this.fileName, }); - static FileInfo fromJson(Map json) => FileInfo( - identifier: json['identifier'] as String, - persistable: (json['persistable'] as String?) == 'true', - uri: json['uri'] as String, - fileName: json['fileName'] as String?, - ); + EntityInfo.fromJson(Map json) + : this( + identifier: json['identifier'] as String, + persistable: (json['persistable'] as String?) == 'true', + uri: json['uri'] as String, + fileName: json['fileName'] as String?, + ); - static FileInfo fromJsonString(String jsonString) => - fromJson(json.decode(jsonString) as Map); + EntityInfo.fromJsonString(String jsonString) + : this.fromJson(json.decode(jsonString) as Map); - /// Identifier which can be used for reading at a later time, or used for - /// writing back data. See [persistable] for details on the valid lifetime of - /// the identifier. + /// Identifier which can be used for accessing at a later time, or, for files, + /// used for writing back data. See [persistable] for details on the valid + /// lifetime of the identifier. final String identifier; /// Indicates whether [identifier] is persistable. When true, it is safe to /// retain this identifier for access at any later time. /// - /// When false, you cannot assume that access will be granted in the - /// future. In particular, for files received from outside the app, the - /// identifier may only be valid until the [FileOpenHandler] returns. + /// When false, you cannot assume that access will be granted in the future. + /// In particular, for files received from outside the app, the identifier may + /// only be valid until the [FileOpenHandler] returns. final bool persistable; - /// Platform dependent URI. - /// - On android either content:// or file:// url. + /// Platform-dependent URI. + /// - On Android either content:// or file:// url. /// - On iOS a file:// URL below a document provider (like iCloud). /// Not a really user friendly name. final String uri; - /// If available, contains the file name of the original file. - /// (ie. most of the time the last path segment). Especially useful - /// with android content providers which typically do not contain - /// an actual file name in the content uri. - /// - /// Might be null. + /// If available, contains the name of the original file or directory (i.e. + /// most of the time the last path segment). Especially useful with Android + /// content providers which typically do not contain an actual file name in + /// the content URI. final String? fileName; @override @@ -74,6 +74,41 @@ class FileInfo { String toJsonString() => json.encode(toJson()); } +class FileInfo extends EntityInfo { + FileInfo({ + required String identifier, + required bool persistable, + required String uri, + String? fileName, + }) : super( + identifier: identifier, + persistable: persistable, + uri: uri, + fileName: fileName, + ); + + FileInfo.fromJson(Map json) : super.fromJson(json); + FileInfo.fromJsonString(String jsonString) : super.fromJsonString(jsonString); +} + +class DirectoryInfo extends EntityInfo { + DirectoryInfo({ + required String identifier, + required bool persistable, + required String uri, + String? fileName, + }) : super( + identifier: identifier, + persistable: persistable, + uri: uri, + fileName: fileName, + ); + + DirectoryInfo.fromJson(Map json) : super.fromJson(json); + DirectoryInfo.fromJsonString(String jsonString) + : super.fromJsonString(jsonString); +} + typedef FileReader = Future Function(FileInfo fileInfo, File file); /// Singleton to accessing services of the FilePickerWritable plugin. @@ -97,7 +132,7 @@ class FilePickerWritable { if (call.method == 'openFile') { final result = (call.arguments as Map).cast(); - final fileInfo = _resultToFileInfo(result); + final fileInfo = FileInfo.fromJson(result); final file = _resultToFile(result); await _filePickerState._fireFileOpenHandlers(fileInfo, file); return true; @@ -151,7 +186,7 @@ class FilePickerWritable { _logger.finer('User cancelled file picker.'); return null; } - return _resultToFileInfo(result); + return FileInfo.fromJson(result); } /// Use [openFileForCreate] instead. @@ -165,7 +200,7 @@ class FilePickerWritable { _logger.finer('User cancelled file picker.'); return null; } - return _resultToFileInfo(result); + return FileInfo.fromJson(result); } /// Shows a file picker so the user can select a file and calls [reader] @@ -179,7 +214,7 @@ class FilePickerWritable { _logger.finer('User cancelled file picker.'); return null; } - final fileInfo = _resultToFileInfo(result); + final fileInfo = FileInfo.fromJson(result); final file = _resultToFile(result); try { return await reader(fileInfo, file); @@ -208,10 +243,44 @@ class FilePickerWritable { _logger.finer('User cancelled file picker.'); return null; } - return _resultToFileInfo(result); + return FileInfo.fromJson(result); }); } + /// See if the the directory picker and directory tree access is supported on + /// the current platform. If this returns `false` then [openDirectory], + /// [getDirectory], and [resolveRelativePath] will fail with an exception. + Future isDirectoryAccessSupported() async { + _logger.finest('isDirectoryAccessSupported()'); + final result = + await _channel.invokeMethod('isDirectoryAccessSupported'); + if (result == null) { + throw StateError('Error while checking if directory access is supported'); + } + return result; + } + + /// Shows a directory picker so the user can select a directory. + /// + /// [initialDirUri] is the URI indicating where the picker should start by + /// default. This is only honored on a best-effort basis and even then is not + /// supported on all systems. It can be a [FileInfo.uri] or a + /// [DirectoryInfo.uri]. + /// + /// An exception will be thrown if invoked on a system that does not support + /// directory access, i.e. if [isDirectoryAccessSupported] returns `false`. + Future openDirectory({String? initialDirUri}) async { + _logger.finest('openDirectoryPicker()'); + final result = await _channel.invokeMapMethod( + 'openDirectoryPicker', {'initialDirUri': initialDirUri}); + if (result == null) { + // User cancelled. + _logger.finer('User cancelled directory picker.'); + return null; + } + return DirectoryInfo.fromJson(result); + } + /// Reads the file previously picked by the user. /// Expects a [FileInfo.identifier] string for [identifier]. /// @@ -225,7 +294,7 @@ class FilePickerWritable { if (result == null) { throw StateError('Error while reading file with identifier $identifier'); } - final fileInfo = _resultToFileInfo(result); + final fileInfo = FileInfo.fromJson(result); final file = _resultToFile(result); try { return await reader(fileInfo, file); @@ -237,6 +306,57 @@ class FilePickerWritable { } } + /// Get info for the immediate parent directory of [fileIdentifier], making + /// use of access permissions to [rootIdentifier] some arbitrary number of + /// levels higher in the hierarchy. + /// + /// [rootIdentifier] should be a [DirectoryInfo.identifier] obtained from + /// [pickDirectory]. [fileIdentifier] should be a [FileInfo.identifier]. + /// + /// An exception will be thrown if invoked on a system that does not support + /// directory access, i.e. if [isDirectoryAccessSupported] returns `false`. + Future getDirectory({ + required String rootIdentifier, + required String fileIdentifier, + }) async { + _logger.finest('getDirectory()'); + final result = await _channel.invokeMapMethod( + 'getDirectory', + {'rootIdentifier': rootIdentifier, 'fileIdentifier': fileIdentifier}); + if (result == null) { + throw StateError( + 'Error while getting directory of $fileIdentifier relative to $rootIdentifier'); + } + return DirectoryInfo.fromJson(result); + } + + /// Get info for the entity identified by [relativePath] starting from + /// [directoryIdentifier]. + /// + /// [directoryIdentifier] should be a [DirectoryInfo.identifier] obtained from + /// [pickDirectory] or [getDirectory]. + /// + /// An exception will be thrown if invoked on a system that does not support + /// directory access, i.e. if [isDirectoryAccessSupported] returns `false`. + Future resolveRelativePath({ + required String directoryIdentifier, + required String relativePath, + }) async { + _logger.finest('resolveRelativePath()'); + final result = await _channel.invokeMapMethod( + 'resolveRelativePath', { + 'directoryIdentifier': directoryIdentifier, + 'relativePath': relativePath + }); + if (result == null) { + throw StateError( + 'Error while resolving relative path $relativePath from directory $directoryIdentifier'); + } + return result['isDirectory'] == 'true' + ? DirectoryInfo.fromJson(result) + : FileInfo.fromJson(result); + } + /// Writes the file previously picked by the user. /// Expects a [FileInfo.identifier] string for [identifier]. Future writeFileWithIdentifier(String identifier, File file) async { @@ -249,7 +369,7 @@ class FilePickerWritable { if (result == null) { throw StateError('Got null response for writeFileWithIdentifier'); } - return _resultToFileInfo(result); + return FileInfo.fromJson(result); } /// Writes data to a file previously picked by the user. @@ -273,7 +393,7 @@ class FilePickerWritable { }); return result!; }); - return _resultToFileInfo(result); + return FileInfo.fromJson(result); } /// Dispose of a persistable identifier, removing it from your app's list of @@ -300,15 +420,6 @@ class FilePickerWritable { return _channel.invokeMethod('disposeAllIdentifiers'); } - FileInfo _resultToFileInfo(Map result) { - return FileInfo( - identifier: result['identifier']!, - persistable: result['persistable'] == 'true', - uri: result['uri']!, - fileName: result['fileName'], - ); - } - File _resultToFile(Map result) { return File(result['path']!); }