From cfe3583d26ffa8c8eea5401ea82ea44d4e4b2393 Mon Sep 17 00:00:00 2001 From: Aaron Madlon-Kay Date: Thu, 8 Apr 2021 22:12:38 +0900 Subject: [PATCH 01/25] Fix typo --- .../filepicker/file_picker_writable/FilePickerWritableImpl.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 7b2c7d7..ba74157 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 @@ -59,7 +59,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", From 89641038faf36e013c542c4df8518a953e120235 Mon Sep 17 00:00:00 2001 From: Aaron Madlon-Kay Date: Thu, 8 Apr 2021 22:14:30 +0900 Subject: [PATCH 02/25] Introduce EntityInfo and DirectoryInfo --- lib/src/file_picker_writable.dart | 85 ++++++++++++++++++++++--------- 1 file changed, 60 insertions(+), 25 deletions(-) diff --git a/lib/src/file_picker_writable.dart b/lib/src/file_picker_writable.dart index bb7f212..3a29faf 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. From 028462e319d46aad7ed040ff88d8007428111d47 Mon Sep 17 00:00:00 2001 From: Aaron Madlon-Kay Date: Thu, 8 Apr 2021 22:15:16 +0900 Subject: [PATCH 03/25] Remove redundant FileInfo factory method --- lib/src/file_picker_writable.dart | 25 ++++++++----------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/lib/src/file_picker_writable.dart b/lib/src/file_picker_writable.dart index 3a29faf..f9aec65 100644 --- a/lib/src/file_picker_writable.dart +++ b/lib/src/file_picker_writable.dart @@ -132,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; @@ -186,7 +186,7 @@ class FilePickerWritable { _logger.finer('User cancelled file picker.'); return null; } - return _resultToFileInfo(result); + return FileInfo.fromJson(result); } /// Use [openFileForCreate] instead. @@ -200,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] @@ -214,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); @@ -243,7 +243,7 @@ class FilePickerWritable { _logger.finer('User cancelled file picker.'); return null; } - return _resultToFileInfo(result); + return FileInfo.fromJson(result); }); } @@ -260,7 +260,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); @@ -284,7 +284,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. @@ -308,7 +308,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 @@ -335,15 +335,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']!); } From e311a64e6adbca23b591ce98cc921688dadf514c Mon Sep 17 00:00:00 2001 From: Aaron Madlon-Kay Date: Thu, 8 Apr 2021 22:48:49 +0900 Subject: [PATCH 04/25] Move query to separate file --- .../FilePickerWritableImpl.kt | 31 +-------------- .../filepicker/file_picker_writable/Query.kt | 38 +++++++++++++++++++ 2 files changed, 39 insertions(+), 30 deletions(-) create mode 100644 android/src/main/kotlin/codeux/design/filepicker/file_picker_writable/Query.kt 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 ba74157..7e2a836 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,10 +5,8 @@ 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 androidx.annotation.MainThread import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.plugin.common.MethodChannel @@ -228,7 +226,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,33 +252,6 @@ 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 - - } ?: throw FilePickerException("Unable to load file info from $uri") - - } - fun onDetachedFromActivity(binding: ActivityPluginBinding) { binding.removeActivityResultListener(this) } 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..719cdf8 --- /dev/null +++ b/android/src/main/kotlin/codeux/design/filepicker/file_picker_writable/Query.kt @@ -0,0 +1,38 @@ +package codeux.design.filepicker.file_picker_writable + +import android.content.ContentResolver +import android.database.Cursor +import android.net.Uri +import android.provider.OpenableColumns +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") +} From 1015ba89b7ae6191edd2a060818ce8e986791db5 Mon Sep 17 00:00:00 2001 From: Aaron Madlon-Kay Date: Thu, 8 Apr 2021 22:20:25 +0900 Subject: [PATCH 05/25] Implement FilePickerWritable.openDirectory --- .../FilePickerWritableImpl.kt | 96 ++++++++++++++++++- .../FilePickerWritablePlugin.kt | 11 ++- .../SwiftFilePickerWritablePlugin.swift | 59 +++++++++++- lib/src/file_picker_writable.dart | 18 ++++ 4 files changed, 180 insertions(+), 4 deletions(-) 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 7e2a836..db3e0df 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 @@ -7,7 +7,9 @@ import android.content.ContentResolver import android.content.Intent import android.net.Uri import android.os.Build +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 @@ -33,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 @@ -66,6 +69,42 @@ 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) { + 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) + } + } + } + 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) { @@ -99,7 +138,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") @@ -150,6 +189,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") @@ -190,6 +242,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, @@ -252,6 +315,37 @@ class FilePickerWritableImpl( } } + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) + @MainThread + private suspend fun getDirectoryInfo(directoryUri: Uri): Map { + val activity = requireActivity() + + 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) { binding.removeActivityResultListener(this) } 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 b28cf30..18f2347 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,8 +2,7 @@ package codeux.design.filepicker.file_picker_writable import android.app.Activity import android.net.Uri -import android.os.Handler -import android.os.Looper +import android.os.Build import android.util.Log import androidx.annotation.MainThread import androidx.annotation.NonNull @@ -116,6 +115,14 @@ class FilePickerWritablePlugin : FlutterPlugin, MethodCallHandler, ?: throw FilePickerException("Expected argument 'path'") impl.openFilePickerForCreate(result, path) } + "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'") diff --git a/ios/Classes/SwiftFilePickerWritablePlugin.swift b/ios/Classes/SwiftFilePickerWritablePlugin.swift index 29daaf5..25b8c4f 100644 --- a/ios/Classes/SwiftFilePickerWritablePlugin.swift +++ b/ios/Classes/SwiftFilePickerWritablePlugin.swift @@ -69,6 +69,11 @@ public class SwiftFilePickerWritablePlugin: NSObject, FlutterPlugin { throw FilePickerError.invalidArguments(message: "Expected 'args'") } openFilePickerForCreate(path: path, result: result) + 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, @@ -181,6 +186,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)") @@ -231,6 +254,25 @@ public class SwiftFilePickerWritablePlugin: NSObject, FlutterPlugin { 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 [ @@ -275,7 +317,11 @@ extension SwiftFilePickerWritablePlugin : UIDocumentPickerDelegate { _sendFilePickerResult(_fileInfoResult(tempFile: tempFile, originalURL: targetFile, bookmark: bookmark)) return } - _sendFilePickerResult(try _prepareUrlForReading(url: url, persistable: true)) + if isDirectory(url) { + _sendFilePickerResult(try _prepareDirUrlForReading(url: url)) + } else { + _sendFilePickerResult(try _prepareUrlForReading(url: url, persistable: true)) + } } catch { _sendFilePickerResult(FlutterError(code: "ErrorProcessingResult", message: "Error handling result url \(url): \(error)", details: nil)) return @@ -287,6 +333,17 @@ extension SwiftFilePickerWritablePlugin : UIDocumentPickerDelegate { _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 + } + } + } // application delegate methods.. diff --git a/lib/src/file_picker_writable.dart b/lib/src/file_picker_writable.dart index f9aec65..423f3eb 100644 --- a/lib/src/file_picker_writable.dart +++ b/lib/src/file_picker_writable.dart @@ -247,6 +247,24 @@ class FilePickerWritable { }); } + /// 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]. + 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]. /// From 61cbaa13887dc1b49997c762a1ea15c8f41cb887 Mon Sep 17 00:00:00 2001 From: Aaron Madlon-Kay Date: Thu, 8 Apr 2021 22:55:27 +0900 Subject: [PATCH 06/25] Implement getDirectory, resolveRelativePath --- .../FilePickerWritableImpl.kt | 64 +++++ .../FilePickerWritablePlugin.kt | 22 ++ .../filepicker/file_picker_writable/Query.kt | 258 ++++++++++++++++++ .../SwiftFilePickerWritablePlugin.swift | 69 +++++ lib/src/file_picker_writable.dart | 45 +++ 5 files changed, 458 insertions(+) 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 db3e0df..12c9957 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 @@ -261,6 +261,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, 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 18f2347..d8390b7 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 @@ -128,6 +128,28 @@ class FilePickerWritablePlugin : FlutterPlugin, MethodCallHandler, ?: 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 index 719cdf8..a7d18c2 100644 --- 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 @@ -1,9 +1,13 @@ 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 @@ -36,3 +40,257 @@ suspend fun getDisplayName( } ?: 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 = DocumentsContract.findDocumentPath(context.contentResolver, uri) + ?: 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/ios/Classes/SwiftFilePickerWritablePlugin.swift b/ios/Classes/SwiftFilePickerWritablePlugin.swift index 25b8c4f..cad5905 100644 --- a/ios/Classes/SwiftFilePickerWritablePlugin.swift +++ b/ios/Classes/SwiftFilePickerWritablePlugin.swift @@ -81,6 +81,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, @@ -122,6 +138,48 @@ public class SwiftFilePickerWritablePlugin: NSObject, FlutterPlugin { result(_fileInfoResult(tempFile: copiedFile, originalURL: url, bookmark: bookmark)) } + 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 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)") + result([ + "identifier": try childUrl.bookmarkData().base64EncodedString(), + "persistable": "true", + "uri": childUrl.absoluteString, + "fileName": childUrl.lastPathComponent, + "isDirectory": "\(isDirectory(childUrl))", + ]) + } + 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.") @@ -344,6 +402,17 @@ extension SwiftFilePickerWritablePlugin : UIDocumentPickerDelegate { } } + 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.. diff --git a/lib/src/file_picker_writable.dart b/lib/src/file_picker_writable.dart index 423f3eb..2298b6b 100644 --- a/lib/src/file_picker_writable.dart +++ b/lib/src/file_picker_writable.dart @@ -290,6 +290,51 @@ 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]. + 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]. + 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 { From bfc9937af451ee7f87fe6ab2797f449a009f0802 Mon Sep 17 00:00:00 2001 From: Aaron Madlon-Kay Date: Sun, 11 Apr 2021 23:03:40 +0900 Subject: [PATCH 07/25] Implement isDirectoryAccessSupported --- .../FilePickerWritablePlugin.kt | 3 +++ .../SwiftFilePickerWritablePlugin.swift | 2 ++ lib/src/file_picker_writable.dart | 22 +++++++++++++++++++ 3 files changed, 27 insertions(+) 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 d8390b7..3be5923 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 @@ -115,6 +115,9 @@ 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") diff --git a/ios/Classes/SwiftFilePickerWritablePlugin.swift b/ios/Classes/SwiftFilePickerWritablePlugin.swift index cad5905..48e950b 100644 --- a/ios/Classes/SwiftFilePickerWritablePlugin.swift +++ b/ios/Classes/SwiftFilePickerWritablePlugin.swift @@ -69,6 +69,8 @@ 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'") diff --git a/lib/src/file_picker_writable.dart b/lib/src/file_picker_writable.dart index 2298b6b..dd4af0e 100644 --- a/lib/src/file_picker_writable.dart +++ b/lib/src/file_picker_writable.dart @@ -247,12 +247,28 @@ class FilePickerWritable { }); } + /// 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( @@ -296,6 +312,9 @@ class FilePickerWritable { /// /// [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, @@ -316,6 +335,9 @@ class FilePickerWritable { /// /// [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, From 09e96ee0b5384a9d69a3749d3e1c4cf4cf54f937 Mon Sep 17 00:00:00 2001 From: Aaron Madlon-Kay Date: Mon, 12 Apr 2021 22:06:34 +0900 Subject: [PATCH 08/25] Update example with directory stuff --- example/lib/main.dart | 172 ++++++++++++++++++++++++++++-------------- 1 file changed, 116 insertions(+), 56 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index e761a5d..e298b7c 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -22,26 +22,41 @@ Future main() async { class AppDataBloc { final store = SimpleJsonPersistence.getForTypeWithDefault( (json) => AppData.fromJson(json), - defaultCreator: () => AppData(files: []), + defaultCreator: () => AppData(files: [], directories: []), ); } 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 { @@ -143,14 +158,25 @@ class _MainScreenState extends State { child: const Text('Dispose All IDs'), onPressed: FilePickerWritable().disposeAllIdentifiers, ), + const SizedBox(width: 32), + ElevatedButton( + child: const Text('Open Directory Picker'), + onPressed: _openDirectoryPicker, + ), ], ), - ...?(!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, + ), ], ), ), @@ -190,17 +216,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) { @@ -213,26 +251,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.caption!.apply(fontSizeFactor: 0.75), ), Text( - fileInfo.identifier, + entityInfo.identifier, maxLines: 1, overflow: TextOverflow.ellipsis, ), Text( - 'uri:${fileInfo.uri}', + 'uri:${entityInfo.uri}', style: theme.textTheme.bodyText2! .apply(fontSizeFactor: 0.7) .copyWith(fontWeight: FontWeight.bold), ), Text( - 'fileName: ${fileInfo.fileName}', + 'fileName: ${entityInfo.fileName}', style: theme.textTheme.bodyText2! .apply(fontSizeFactor: 0.7) .copyWith(fontWeight: FontWeight.bold), @@ -240,50 +280,70 @@ 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) { + 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); + await SimpleAlertDialog( + bodyText: 'Written: $content', + ).show(context); }); - } on Exception catch (e) { - 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); - await SimpleAlertDialog( - bodyText: 'Written: $content', - ).show(context); - }); - }, - child: const Text('Overwrite'), - ), + }, + 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])); + } + }, + child: const Text('Pick dir'), + ), + ], IconButton( onPressed: () async { try { await FilePickerWritable() - .disposeIdentifier(fileInfo.identifier); + .disposeIdentifier(entityInfo.identifier); } on Exception catch (e) { 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), ), From a74e9ec63ffd3edbfd82c75de57d57a5c01aa736 Mon Sep 17 00:00:00 2001 From: Aaron Madlon-Kay Date: Sun, 18 Apr 2021 23:59:43 +0900 Subject: [PATCH 09/25] Coordinate file read in resolveRelativePath Otherwise we can't get a bookmark --- .../SwiftFilePickerWritablePlugin.swift | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/ios/Classes/SwiftFilePickerWritablePlugin.swift b/ios/Classes/SwiftFilePickerWritablePlugin.swift index 48e950b..a739c85 100644 --- a/ios/Classes/SwiftFilePickerWritablePlugin.swift +++ b/ios/Classes/SwiftFilePickerWritablePlugin.swift @@ -173,8 +173,24 @@ public class SwiftFilePickerWritablePlugin: NSObject, FlutterPlugin { } let childUrl = url.appendingPathComponent(relativePath).standardized logDebug("Resolved to \(childUrl)") + 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 + } + } + if let error = coordError ?? bookmarkError { + throw error + } result([ - "identifier": try childUrl.bookmarkData().base64EncodedString(), + "identifier": identifier, "persistable": "true", "uri": childUrl.absoluteString, "fileName": childUrl.lastPathComponent, From 966ce1adc1ebba0af6ad4cf8ba3a201a98b8c972 Mon Sep 17 00:00:00 2001 From: Aaron Madlon-Kay Date: Thu, 6 May 2021 21:38:52 +0900 Subject: [PATCH 10/25] Don't let invalid initial dir URI kill dir picker call --- .../FilePickerWritableImpl.kt | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) 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 12c9957..30691f5 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 @@ -79,15 +79,22 @@ class FilePickerWritableImpl( val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (initialDirUri != null) { - val parsedUri = Uri.parse(initialDirUri).let { - val context = requireActivity().applicationContext - if (DocumentsContract.isDocumentUri(context, it)) { - it - } else { - DocumentsContract.buildDocumentUriUsingTree(it, DocumentsContract.getTreeDocumentId(it)) + 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) } - putExtra(DocumentsContract.EXTRA_INITIAL_URI, parsedUri) } } } From e1fe4af1792d8f5d92dedd1311a46a1ffc3dfa4c Mon Sep 17 00:00:00 2001 From: Aaron Madlon-Kay Date: Thu, 24 Feb 2022 22:59:13 +0900 Subject: [PATCH 11/25] Obtain iOS bookmark only after ensuring the file exists locally The call to URL.bookmarkData() can fail with some file providers if the file is e.g. online only and not yet materialized to the local device. --- ios/Classes/SwiftFilePickerWritablePlugin.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ios/Classes/SwiftFilePickerWritablePlugin.swift b/ios/Classes/SwiftFilePickerWritablePlugin.swift index a739c85..becedbe 100644 --- a/ios/Classes/SwiftFilePickerWritablePlugin.swift +++ b/ios/Classes/SwiftFilePickerWritablePlugin.swift @@ -325,8 +325,9 @@ public class SwiftFilePickerWritablePlugin: NSObject, FlutterPlugin { if !securityScope { logDebug("Warning: startAccessingSecurityScopedResource is false for \(url)") } - let bookmark = try url.bookmarkData() let tempFile = try _copyToTempDirectory(url: url) + // Get bookmark *after* ensuring file has been materialized to local device! + let bookmark = try url.bookmarkData() return _fileInfoResult(tempFile: tempFile, originalURL: url, bookmark: bookmark, persistable: persistable) } @@ -388,8 +389,9 @@ extension SwiftFilePickerWritablePlugin : UIDocumentPickerDelegate { // } try _writeFile(path: path, destination: targetFile, skipDestinationStartAccess: true) - let bookmark = try targetFile.bookmarkData() 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 } From 535f377a40d8fd61ab56670093f8c8ad385506c4 Mon Sep 17 00:00:00 2001 From: Aaron Madlon-Kay Date: Thu, 12 May 2022 22:49:44 +0900 Subject: [PATCH 12/25] Fix build with Flutter 3.0 https://github.com/hpoul/file_picker_writable/issues/30 --- .../filepicker/file_picker_writable/FilePickerWritableImpl.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 30691f5..8eb4270 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 @@ -482,7 +482,7 @@ class FilePickerWritableImpl( ContentResolver.SCHEME_ANDROID_RESOURCE ) - override fun onNewIntent(intent: Intent?): Boolean { + override fun onNewIntent(intent: Intent): Boolean { val data = intent?.data val scheme = data?.scheme From 89608a6a09c342651af482a62137c46c511d1858 Mon Sep 17 00:00:00 2001 From: Aaron Madlon-Kay Date: Fri, 23 Sep 2022 21:41:39 +0900 Subject: [PATCH 13/25] Fix build with Android Gradle Plugin 7.3.0 --- android/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/build.gradle b/android/build.gradle index dcc929f..a281c87 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -2,7 +2,7 @@ group 'codeux.design.filepicker.file_picker_writable' version '1.0-SNAPSHOT' buildscript { - ext.kotlin_version = '1.3.72' + ext.kotlin_version = '1.5.20' repositories { google() jcenter() From 6a62dfe9fa0c9fde743b7dbc6de5d2aa76c9d35b Mon Sep 17 00:00:00 2001 From: Aaron Madlon-Kay Date: Wed, 12 Oct 2022 13:59:12 +0900 Subject: [PATCH 14/25] Fix finding parent when findDocumentPath is unsupported See https://github.com/amake/orgro/issues/68 --- .../design/filepicker/file_picker_writable/Query.kt | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 index a7d18c2..88452fe 100644 --- 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 @@ -89,8 +89,12 @@ suspend fun getParent( throw Exception("Unknown URI type") } } - val path = DocumentsContract.findDocumentPath(context.contentResolver, uri) - ?: return@withContext null + 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 From d8bdd1a0a7c7f700ad227ba05f087ae8b64f028a Mon Sep 17 00:00:00 2001 From: Aaron Madlon-Kay Date: Mon, 30 Oct 2023 19:18:51 +0900 Subject: [PATCH 15/25] Restore accidental deletions from Android plugin --- .../FilePickerWritablePlugin.kt | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) 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'") From bd690b21a5c6b10f2a872686a7e7de077640b8fb Mon Sep 17 00:00:00 2001 From: Aaron Madlon-Kay Date: Tue, 27 Aug 2024 21:22:14 +0900 Subject: [PATCH 16/25] Clean whitespace --- .../SwiftFilePickerWritablePlugin.swift | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/ios/Classes/SwiftFilePickerWritablePlugin.swift b/ios/Classes/SwiftFilePickerWritablePlugin.swift index 271d99f..ace3226 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]) @@ -95,7 +95,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)) @@ -116,7 +116,7 @@ public class SwiftFilePickerWritablePlugin: NSObject, FlutterPlugin { 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 +128,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 +154,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)) @@ -215,7 +215,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 +231,7 @@ public class SwiftFilePickerWritablePlugin: NSObject, FlutterPlugin { let bookmark = try url.bookmarkData() return _fileInfoResult(tempFile: tempFile, originalURL: url, bookmark: bookmark, persistable: persistable) } - + private func _fileInfoResult(tempFile: URL, originalURL: URL, bookmark: Data, persistable: Bool = true) -> [String: String] { let identifier = bookmark.base64EncodedString() return [ @@ -270,7 +270,7 @@ extension SwiftFilePickerWritablePlugin : UIDocumentPickerDelegate { // 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() @@ -282,13 +282,13 @@ extension SwiftFilePickerWritablePlugin : UIDocumentPickerDelegate { _sendFilePickerResult(FlutterError(code: "ErrorProcessingResult", message: "Error handling result url \(url): \(error)", details: nil)) return } - + } - + public func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { _sendFilePickerResult(nil) } - + } // application delegate methods.. @@ -305,13 +305,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 +324,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 +337,7 @@ extension SwiftFilePickerWritablePlugin: FlutterApplicationLifeCycleDelegate { _handleUrl(url: url, persistable: persistable) return true } - + private func _handleUrl(url: URL, persistable: Bool) { do { if (url.isFileURL) { @@ -386,12 +386,12 @@ 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) @@ -399,5 +399,5 @@ extension SwiftFilePickerWritablePlugin: FlutterStreamHandler { _eventQueue.append(event) } } - + } From d5b42d23765a380fb5caf070eef4dee91d015596 Mon Sep 17 00:00:00 2001 From: Aaron Madlon-Kay Date: Tue, 27 Aug 2024 20:36:24 +0900 Subject: [PATCH 17/25] Do long-running tasks off of UI thread on iOS --- .../SwiftFilePickerWritablePlugin.swift | 71 +++++++++++-------- 1 file changed, 41 insertions(+), 30 deletions(-) diff --git a/ios/Classes/SwiftFilePickerWritablePlugin.swift b/ios/Classes/SwiftFilePickerWritablePlugin.swift index ace3226..69684a4 100644 --- a/ios/Classes/SwiftFilePickerWritablePlugin.swift +++ b/ios/Classes/SwiftFilePickerWritablePlugin.swift @@ -113,8 +113,16 @@ public class SwiftFilePickerWritablePlugin: NSObject, FlutterPlugin { if !securityScope { logDebug("Warning: startAccessingSecurityScopedResource is false for \(url).") } - let copiedFile = try _copyToTempDirectory(url: url) - result(_fileInfoResult(tempFile: copiedFile, originalURL: url, bookmark: bookmark)) + DispatchQueue.global(qos: .userInitiated).async { [self] in + do { + let copiedFile = try _copyToTempDirectory(url: url) + DispatchQueue.main.async { [self] in + result(_fileInfoResult(tempFile: copiedFile, originalURL: url, bookmark: bookmark)) + } + } catch { + result(FlutterError(code: "UnknownError", message: "\(error)", details: nil)) + } + } } func writeFile(identifier: String, path: String, result: @escaping FlutterResult) throws { @@ -244,45 +252,48 @@ 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 } - 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)) + _sendFilePickerResult(try _prepareUrlForReading(url: url, persistable: true)) + } 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) { From a845daf3b51da7d9251530597eea37621b7dc45c Mon Sep 17 00:00:00 2001 From: Aaron Madlon-Kay Date: Tue, 10 Sep 2024 08:06:19 +0900 Subject: [PATCH 18/25] Fix getDirectory security scope access --- ios/Classes/SwiftFilePickerWritablePlugin.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ios/Classes/SwiftFilePickerWritablePlugin.swift b/ios/Classes/SwiftFilePickerWritablePlugin.swift index 8964278..9d61684 100644 --- a/ios/Classes/SwiftFilePickerWritablePlugin.swift +++ b/ios/Classes/SwiftFilePickerWritablePlugin.swift @@ -165,6 +165,12 @@ public class SwiftFilePickerWritablePlugin: NSObject, FlutterPlugin { 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 { + rootUrl.stopAccessingSecurityScopedResource() + } + } let dirUrl = fileUrl.deletingLastPathComponent() result([ "identifier": try dirUrl.bookmarkData().base64EncodedString(), From 289b0fae3e9daf4e099ffadb86f468ed3e2b3482 Mon Sep 17 00:00:00 2001 From: Aaron Madlon-Kay Date: Tue, 10 Sep 2024 08:15:04 +0900 Subject: [PATCH 19/25] Ensure error is reported on main thread --- .../SwiftFilePickerWritablePlugin.swift | 46 +++++++++---------- 1 file changed, 21 insertions(+), 25 deletions(-) diff --git a/ios/Classes/SwiftFilePickerWritablePlugin.swift b/ios/Classes/SwiftFilePickerWritablePlugin.swift index 9d61684..b93c88e 100644 --- a/ios/Classes/SwiftFilePickerWritablePlugin.swift +++ b/ios/Classes/SwiftFilePickerWritablePlugin.swift @@ -187,35 +187,31 @@ public class SwiftFilePickerWritablePlugin: NSObject, FlutterPlugin { } let childUrl = url.appendingPathComponent(relativePath).standardized logDebug("Resolved to \(childUrl)") - var coordError: NSError? = nil - var bookmarkError: Error? = nil - var identifier: String? = nil DispatchQueue.global(qos: .userInitiated).async { - do { - // 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 - } + 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 { - throw error + result(FlutterError(code: "UnknownError", message: "\(error)", details: nil)) } - DispatchQueue.main.async { [self] in - result([ - "identifier": identifier, - "persistable": "true", - "uri": childUrl.absoluteString, - "fileName": childUrl.lastPathComponent, - "isDirectory": "\(isDirectory(childUrl))", - ]) - } - } catch { - result(FlutterError(code: "UnknownError", message: "\(error)", details: nil)) + result([ + "identifier": identifier, + "persistable": "true", + "uri": childUrl.absoluteString, + "fileName": childUrl.lastPathComponent, + "isDirectory": "\(isDirectory(childUrl))", + ]) } } } From 8ebcd7b0bef5cc0bf14284a07782917dcbd97144 Mon Sep 17 00:00:00 2001 From: Aaron Madlon-Kay Date: Tue, 10 Sep 2024 08:20:51 +0900 Subject: [PATCH 20/25] Ensure error is reported on main thread --- ios/Classes/SwiftFilePickerWritablePlugin.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ios/Classes/SwiftFilePickerWritablePlugin.swift b/ios/Classes/SwiftFilePickerWritablePlugin.swift index 69684a4..5303fb1 100644 --- a/ios/Classes/SwiftFilePickerWritablePlugin.swift +++ b/ios/Classes/SwiftFilePickerWritablePlugin.swift @@ -120,7 +120,9 @@ public class SwiftFilePickerWritablePlugin: NSObject, FlutterPlugin { result(_fileInfoResult(tempFile: copiedFile, originalURL: url, bookmark: bookmark)) } } catch { - result(FlutterError(code: "UnknownError", message: "\(error)", details: nil)) + DispatchQueue.main.async { + result(FlutterError(code: "UnknownError", message: "\(error)", details: nil)) + } } } } From e66bc58bb3e7b6ace03b06a16877a404573b34e6 Mon Sep 17 00:00:00 2001 From: Aaron Madlon-Kay Date: Tue, 10 Sep 2024 08:34:33 +0900 Subject: [PATCH 21/25] Access security scope in same thread so defer fires at right time --- .../SwiftFilePickerWritablePlugin.swift | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/ios/Classes/SwiftFilePickerWritablePlugin.swift b/ios/Classes/SwiftFilePickerWritablePlugin.swift index 5303fb1..8d5450a 100644 --- a/ios/Classes/SwiftFilePickerWritablePlugin.swift +++ b/ios/Classes/SwiftFilePickerWritablePlugin.swift @@ -104,16 +104,16 @@ 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() - defer { - if securityScope { - url.stopAccessingSecurityScopedResource() - } - } - if !securityScope { - logDebug("Warning: startAccessingSecurityScopedResource is false for \(url).") - } 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 From 31586534bc0f62f16d4dee650750fb7ee5deb905 Mon Sep 17 00:00:00 2001 From: Aaron Madlon-Kay Date: Tue, 10 Sep 2024 08:46:18 +0900 Subject: [PATCH 22/25] Ensure events are sent on main thread --- ios/Classes/SwiftFilePickerWritablePlugin.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ios/Classes/SwiftFilePickerWritablePlugin.swift b/ios/Classes/SwiftFilePickerWritablePlugin.swift index 8d5450a..1af4004 100644 --- a/ios/Classes/SwiftFilePickerWritablePlugin.swift +++ b/ios/Classes/SwiftFilePickerWritablePlugin.swift @@ -407,7 +407,9 @@ extension SwiftFilePickerWritablePlugin: FlutterStreamHandler { private func sendEvent(event: [String: String]) { if let _eventSink = _eventSink { - _eventSink(event) + DispatchQueue.main.async { + _eventSink(event) + } } else { _eventQueue.append(event) } From ff1a0715872058707cbe1fe00ca76d234a986845 Mon Sep 17 00:00:00 2001 From: Aaron Madlon-Kay Date: Fri, 13 Sep 2024 00:36:35 +0900 Subject: [PATCH 23/25] Specify Kotlin JVM target --- android/build.gradle | 4 ++++ 1 file changed, 4 insertions(+) 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' } From 7b0206b748dcd9f78b678e5f0d389d31ba7cf07f Mon Sep 17 00:00:00 2001 From: Aaron Madlon-Kay Date: Tue, 22 Apr 2025 21:44:55 +0900 Subject: [PATCH 24/25] Don't handle universal links on iOS This matches the behavior with app links on Android Fixes #38 --- .../SwiftFilePickerWritablePlugin.swift | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/ios/Classes/SwiftFilePickerWritablePlugin.swift b/ios/Classes/SwiftFilePickerWritablePlugin.swift index 271d99f..798ff95 100644 --- a/ios/Classes/SwiftFilePickerWritablePlugin.swift +++ b/ios/Classes/SwiftFilePickerWritablePlugin.swift @@ -312,18 +312,19 @@ extension SwiftFilePickerWritablePlugin: FlutterApplicationLifeCycleDelegate { 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 - guard userActivity.activityType == NSUserActivityTypeBrowsingWeb, - let incomingURL = userActivity.webpageURL else { - logDebug("Unsupported user activity. \(userActivity)") - return false - } - logDebug("continue userActivity webpageURL: \(incomingURL)") - // TODO: Confirm that persistable should be true here - return _handle(url: incomingURL, persistable: true) - } + // This plugin should NOT handle this kind of link + // public func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([Any]) -> Void) -> Bool { + // // (handle universal links) + // // Get URL components from the incoming user activity + // guard userActivity.activityType == NSUserActivityTypeBrowsingWeb, + // let incomingURL = userActivity.webpageURL else { + // logDebug("Unsupported user activity. \(userActivity)") + // return false + // } + // logDebug("continue userActivity webpageURL: \(incomingURL)") + // // 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) { From 8a753d219a91a66df6e18cff0411ef3ad39cbed2 Mon Sep 17 00:00:00 2001 From: Aaron Madlon-Kay Date: Fri, 15 Aug 2025 17:14:04 +0900 Subject: [PATCH 25/25] Fix compatibility with UISceneDelegate --- .../SwiftFilePickerWritablePlugin.swift | 34 +++++++++++++------ 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/ios/Classes/SwiftFilePickerWritablePlugin.swift b/ios/Classes/SwiftFilePickerWritablePlugin.swift index 271d99f..fe2ae71 100644 --- a/ios/Classes/SwiftFilePickerWritablePlugin.swift +++ b/ios/Classes/SwiftFilePickerWritablePlugin.swift @@ -7,9 +7,28 @@ enum FilePickerError: Error { case invalidArguments(message: String) } +fileprivate func getRootViewController() -> UIViewController { + var vc: UIViewController? + if #available(iOS 13, *) { + for scene in UIApplication.shared.connectedScenes { + guard let scene = scene as? UIWindowScene else { continue } + for window in scene.windows { + guard window.isKeyWindow else { continue } + vc = window.rootViewController + } + } + } else { + vc = UIApplication.shared.keyWindow?.rootViewController + } + guard let vc = vc else { + NSLog("PANIC - no view controller available.") + fatalError("No viewController available.") + } + return vc +} + public class SwiftFilePickerWritablePlugin: NSObject, FlutterPlugin { - private let _viewController: UIViewController private let _channel: FlutterMethodChannel private var _filePickerResult: FlutterResult? private var _filePickerPath: String? @@ -19,17 +38,12 @@ public class SwiftFilePickerWritablePlugin: NSObject, FlutterPlugin { private var _eventQueue: [[String: String]] = [] public static func register(with registrar: FlutterPluginRegistrar) { - guard let vc = UIApplication.shared.delegate?.window??.rootViewController else { - NSLog("PANIC - unable to initialize plugin, no view controller available.") - fatalError("No viewController available.") - } - _ = SwiftFilePickerWritablePlugin(viewController: vc, registrar: registrar) + _ = SwiftFilePickerWritablePlugin(registrar: registrar) } - public init(viewController: UIViewController, registrar: FlutterPluginRegistrar) { + public init(registrar: FlutterPluginRegistrar) { let channel = FlutterMethodChannel(name: "design.codeux.file_picker_writable", binaryMessenger: registrar.messenger()) - _viewController = viewController; _channel = channel super.init() @@ -165,7 +179,7 @@ public class SwiftFilePickerWritablePlugin: NSObject, FlutterPlugin { let ctrl = UIDocumentPickerViewController(documentTypes: [kUTTypeFolder as String], in: UIDocumentPickerMode.open) ctrl.delegate = self ctrl.modalPresentationStyle = .currentContext - _viewController.present(ctrl, animated: true, completion: nil) + getRootViewController().present(ctrl, animated: true, completion: nil) } func openFilePicker(result: @escaping FlutterResult) { @@ -178,7 +192,7 @@ public class SwiftFilePickerWritablePlugin: NSObject, FlutterPlugin { let ctrl = UIDocumentPickerViewController(documentTypes: [kUTTypeItem as String], in: UIDocumentPickerMode.open) ctrl.delegate = self ctrl.modalPresentationStyle = .currentContext - _viewController.present(ctrl, animated: true, completion: nil) + getRootViewController().present(ctrl, animated: true, completion: nil) } private func _copyToTempDirectory(url: URL) throws -> URL {