miguelpruivo / flutter_file_picker

File picker plugin for Flutter, compatible with mobile (iOS & Android), Web, Desktop (Mac, Linux, Windows) platforms with Flutter Go support.
MIT License
1.35k stars 673 forks source link

Cannot list contents of directory returned from getDirectoryPath() on iOS #1568

Closed rohintonc closed 2 months ago

rohintonc commented 3 months ago

You can get a directory path, but cannot list the contents without calling

         [url startAccessingSecurityScopedResource];

This should be matched by a call to

        [url stopAccessingSecurityScopedResource];

Reference: https://developer.apple.com/documentation/uikit/view_controllers/providing_access_to_directories

I confirmed the problem/fix by adding startAccessingSecurityScopedResource to docmentPicker:didPickDocumentsAtURLs:

- (void)documentPicker:(UIDocumentPickerViewController *)controller
didPickDocumentsAtURLs:(NSArray<NSURL *> *)urls{
...
    if(controller.documentPickerMode == UIDocumentPickerModeOpen) {
        NSURL* url = newUrls.firstObject;
        [url startAccessingSecurityScopedResource];  // this enables us to list the directory contents back in the Dart code
        _result(url.path);
        _result = nil;
        return;
    }

However, another method channel will be required to call stopAccessingSecurityScopedResource on the selected folder when the Dart code has finished iterating the contents, as this code currently results in a resource leak.

rohintonc commented 3 months ago

Related issue on Android: https://github.com/miguelpruivo/flutter_file_picker/issues/1552

rohintonc commented 3 months ago

I got this working, by yes, you guessed it, implementing my own platform channel to show the directory picker using UIDocumentPickerViewController. I call startAccessingSecurityScopedResource on the picked directory and cache the URL. Then I create a dispose method on my folder class on the Dart side to call stopAccessingSecurityScopedResource on the cached URL when I am done reading the folder contents. Here is the code:

import UIKit
import Flutter
import UniformTypeIdentifiers

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate, UIDocumentPickerDelegate {
    var flutterResult: FlutterResult?
    var directoryPath: URL!

    override func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {
        GeneratedPluginRegistrant.register(with: self)

        let controller = window?.rootViewController as! FlutterViewController
        let channel = FlutterMethodChannel(name: "com.yourCompany.app/documents", binaryMessenger: controller.binaryMessenger)
        channel.setMethodCallHandler { (call, result) in
            if call.method == "getDirectoryPath" {
                self.flutterResult = result
                self.getDirectoryPath()
            } else if call.method == "stopAccessingSecurityScopedResource" {
                self.directoryPath?.stopAccessingSecurityScopedResource()
                self.directoryPath = nil
                result(nil)
            } else {
                result(FlutterMethodNotImplemented)
            }
        }

        return super.application(application, didFinishLaunchingWithOptions: launchOptions)
    }

    func getDirectoryPath() {
        let documentPicker = UIDocumentPickerViewController(forOpeningContentTypes: [UTType.folder], asCopy: false)
        documentPicker.delegate = self
        documentPicker.allowsMultipleSelection = false
        documentPicker.directoryURL = nil
        documentPicker.modalPresentationStyle = .formSheet

        if let rootViewController = window?.rootViewController {
            rootViewController.present(documentPicker, animated: true, completion: nil)
        }
    }

    func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
        self.directoryPath = urls.first
        if self.directoryPath == nil {
            flutterResult?(FlutterError(code: "NO_DIRECTORY_PICKED", message: "No directory was picked", details: nil))
            return
        }

        let success = self.directoryPath.startAccessingSecurityScopedResource()

        if success {
            flutterResult?(self.directoryPath.path)
        } else {
            flutterResult?(FlutterError(code: "ACCESS_DENIED", message: "Unable to access security scoped resource", details: nil))
        }
    }

    func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
        flutterResult?(FlutterError(code: "USER_CANCELLED", message: "User cancelled the picker", details: nil))
    }
}

And on the Dart side:

  static const methodChannel = MethodChannel('com.yourCompany.app/documents');
  final path = await methodChannel.invokeMethod('getDirectoryPath');
  final directory = Directory(path);
  final items = await directory.list().toList())
  methodChannel.invokeMethod('stopAccessingSecurityScopedResource');

Hope that helps someone. I also have the Android code if you need to do this on the Android platform which uses the Scoped Storage API.

rohintonc commented 3 months ago

@miguelpruivo Let me know if you want me to create a PR for this change. It would mean:

  1. Calling startAccessingSecurityScopedResource() on a picked directory on the platform channel before returning the path to the Dart code (1 line of code)
  2. Caching the picked folder URL (which has security scoping) in the platform layer.
  3. Implementing a new stopAccessingSecurityScopedResource platform channel which calls stopAccessingSecurityScopedResource() on the cached directory, then sets the variable to nil.

It is the responsibility of the Dart code to ensure that all picked folders are followed up with a call to the stopAccessingSecurityScopedResource platform channel, to avoid resource leaks.

github-actions[bot] commented 3 months ago

This issue is stale because it has been open for 7 days with no activity.

github-actions[bot] commented 2 months ago

This issue was closed because it has been inactive for 14 days since being marked as stale.

rohintonc commented 2 months ago

This issue should not have been closed. It still needs to be fixed with an improved API.