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

getDirectoryPath() succeeding but cannot list directory contents #1552

Closed rohinton-collins closed 2 months ago

rohinton-collins commented 4 months ago

The Problem Calling getDirectoryPath() will invoke a system Folder Picker, and will even ask the user for permission for access to the selected folder. But the returned path cannot be used with file:io to list directory contents or read files in Android 12 and up where Scoped Storage is now mandatory. Directory listing and reading files is possible on Android versions prior to Android 12 if the requestLegacyExternalStorage attribute is used in the build.gradle file.

The Solution I have a fix for this. I have implemented a method channel which uses the Scoped Storage API to:

The API does not use paths but instead uses content URIs which can be converted to Strings when passed back to the Flutter layer (for subsequent calls across the method channel).

I have this working on Android 9-14 and have removed the requestLegacyExternalStorage attribute given that I am now using the Scoped Storage API for this functionality.

If anyone needs the code please send a message here. If I get time and there is a call for it I will submit a PR to this repository with the code which supports the above functionality.

github-actions[bot] commented 4 months ago

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

rohintonc commented 3 months ago

Related issue on iOS: https://github.com/miguelpruivo/flutter_file_picker/issues/1568

Fallenhh commented 3 months ago

Hi @rohinton-collins , I am developing an app for sharing files. Sadly getDirectoryPath() does not work the way we expected, only files owns by the app can be accessed although the full permission for the folder is asked. Your code would be helpful to us

rohintonc commented 3 months ago

@Fallenhh iOS code was very simple. You just need to call start/stopAccessingSecurityScopedResource() on the picked folder. You need to implement a platform channel or fork this repo to do it. Platform channel code can be found here: https://github.com/miguelpruivo/flutter_file_picker/issues/1568#issuecomment-2269483847

Android is a bit trickier as I had to implement a Kotlin/Dart platform channel for iterating folder contents and streaming files using the Scoped Storage API. I built a DocumentPickerActivity:

DocumentPickerActivity.kt

package com.yourCompany

import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts

class DirectoryPickerActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
        intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION)
        resultLauncher.launch(intent)
    }

    private val resultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
        val returnIntent = Intent()
        if (result.resultCode == RESULT_OK) {
            val uri: Uri? = result.data?.data
            if (uri != null) {
                contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
                returnIntent.putExtra("getDirectoryUri", uri.toString())
            }
        }
        setResult(RESULT_OK, returnIntent)
        finish()
    }
}

Make sure you register the new activity in the AndroidManifest:

        <activity android:name=".DirectoryPickerActivity"
            android:theme="@style/LaunchTheme"
            android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
            android:hardwareAccelerated="true"
            android:windowSoftInputMode="adjustResize"
            />

My MainActivity looks like this:

MainActivity.kt

package com.yourCompany

import android.app.Activity
import android.content.Intent
import android.net.Uri
import androidx.documentfile.provider.DocumentFile
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel

class MainActivity : FlutterActivity() {
    private val channel = "com.yourCompany.app/documents"
    private lateinit var result: MethodChannel.Result

    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)
        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channel)
            .setMethodCallHandler { call, result ->
                when (call.method) {
                    "getDirectoryPath" -> {
                        this.result = result
                        val intent = Intent(this, DirectoryPickerActivity::class.java)
                        startActivityForResult(intent, PICK_DIRECTORY_REQUEST_CODE)
                    }
                    "listDirectories" -> {
                        val uriString = call.argument<String>("uri")!!
                        val uri = Uri.parse(uriString)
                        val directories = listDirectories(uri)
                        result.success(directories)
                    }
                    "listFiles" -> {
                        val uriString = call.argument<String>("uri")!!
                        val uri = Uri.parse(uriString)
                        val files = listFiles(uri)
                        result.success(files)
                    }
                    "getFileChunk" -> {
                        val uriString = call.argument<String>("uri")!!
                        val offset = call.argument<Int>("offset")!!
                        val chunkSize = call.argument<Int>("chunkSize")!!
                        val uri = Uri.parse(uriString)
                        val chunk = readFileChunk(uri, offset, chunkSize)
                        if (chunk != null) {
                            result.success(chunk)
                        } else {
                            result.error("READ_ERROR", "Failed to read file chunk", null)
                        }
                    }
                    else -> result.notImplemented()
                }
            }
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        if (requestCode == PICK_DIRECTORY_REQUEST_CODE && resultCode == Activity.RESULT_OK) {
            val pickedDirectoryUri = data?.getStringExtra("getDirectoryUri")
            if (pickedDirectoryUri != null) {
                result.success(pickedDirectoryUri)
            } else {
                result.error("NO_DIRECTORY_URI", "No directory URI found", null)
            }
        }
    }

    private fun listDirectories(uri: Uri): List<String> {
        val directory = DocumentFile.fromTreeUri(this, uri)
        val directories = mutableListOf<String>()

        if (directory != null && directory.isDirectory) {
            for (item in directory.listFiles()) {
                if (item.isDirectory) {
                    directories.add(item.uri.toString())
                }
            }
        }

        return directories
    }

    private fun listFiles(uri: Uri): List<Map<String, Any>> {
        val directory = DocumentFile.fromTreeUri(this, uri)
        val files = mutableListOf<Map<String, Any>>()

        if (directory != null && directory.isDirectory) {
            for (item in directory.listFiles()) {
                if (item.isFile) {
                    val fileInfo: Map<String, Any> = mapOf(
                        "uri" to item.uri.toString(),
                        "name" to (item.name ?: "Unknown"),
                        "size" to item.length(),
                        "modified" to item.lastModified()
                    )
                    files.add(fileInfo)
                }
            }
        }

        return files
    }

    private fun readFileChunk(uri: Uri, offset: Int, chunkSize: Int): ByteArray? {
        return try {
            contentResolver.openInputStream(uri)?.use { inputStream ->
                inputStream.skip(offset.toLong())
                val buffer = ByteArray(chunkSize)
                val bytesRead = inputStream.read(buffer, 0, chunkSize)
                if (bytesRead != -1) {
                    buffer.copyOf(bytesRead)
                } else {
                    null
                }
            }
        } catch (e: Exception) {
            e.printStackTrace()
            null
        }
    }

    companion object {
        const val PICK_DIRECTORY_REQUEST_CODE = 1
    }
}

My Dart code is spread across several files:

import 'dart:async';

import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:yourApp/model/platform/platform_file.dart';

/// Android-specific. uses a platform channel which calls Scoped Storage APIs
/// so that we can list directory contents and read files from a selected folder
class AndroidPlatformFile implements PlatformFileInterface {
  ...

  @override Stream<List<int>> openRead() async* {
    const int chunkSize = 1024 * 1024; // 1MB
    int offset = 0;
    bool moreData = true;

    while (moreData) {
      try {
        final Map<String, dynamic> arguments = {
          'uri': uri,
          'offset': offset,
          'chunkSize': chunkSize,
        };
        final List<int>? chunk = await methodChannel.invokeMethod('getFileChunk', arguments);
        if (chunk == null || chunk.isEmpty) {
          moreData = false;
        } else {
          yield Uint8List.fromList(chunk);
          offset += chunk.length;
        }
      } on PlatformException catch (e) {
        if (kDebugMode) print("Failed to get file chunk: ${e.message}");
        moreData = false;
      }
    }
  }
}
import 'dart:io';

import 'package:file_picker/file_picker.dart' as fp;
import 'package:flutter/services.dart';

class UploadFiles implements UploadFilesInterface { 
  ...
  static const methodChannel = MethodChannel('com.yourCompany.app/documents');

  @override
  Future<List<PlatformFileInterface>?> selectFiles() async {
    fp.FilePickerResult? result = await fp.FilePicker.platform.pickFiles(allowMultiple: true, compressionQuality: 0);
    if (result == null) return null;

    return result.paths
      .map((path) => PlatformFile(File(path!)))
      .toList();
  }

  @override
  Future<PlatformFolderInterface?> selectFolder() async {
    if (platform.isMobile()) {
      // use Scoped Storage API for Android / startAccessingSecurityScopedResource() for iOS
      final uri = await methodChannel.invokeMethod('getDirectoryPath');
      if (uri == null) throw UserCancelled();
      if (uri == '/') throw ZCloudError.protectedFolderLocation;  // may not apply here
      return Platform.isAndroid ? PlatformFolder(uri: uri) : PlatformFolder(path: uri);
    } else {
      final path = await fp.FilePicker.platform.getDirectoryPath();
      if (path == null) throw UserCancelled();
      if (path == '/')  throw ZCloudError.protectedFolderLocation;
      return PlatformFolder(path: path);
    }
  }
}
import 'dart:io';

import 'package:flutter/services.dart';
import 'package:path/path.dart' as p;

class PlatformFolder implements PlatformFolderInterface {
  ...
  static const methodChannel = MethodChannel('com.yourCompany.app/documents');

  @override dispose() {
    if (Platform.isIOS) {
      methodChannel.invokeMethod('stopAccessingSecurityScopedResource');
    }
  }

  @override
  Future<List<PlatformFileInterface>> files() async {
    if (path != null) {
      final directory = Directory(path!);
      return (await directory.list().toList())
        .whereType<File>()
        .map((file) => PlatformFile(file))
        .toList();
    } else {
      // Android
      return (await methodChannel.invokeMethod('listFiles', { 'uri': uri! }))
        .map((file) => AndroidPlatformFile(uri: file['uri'], name: file['name'], size: file['size'], modifiedDate: DateTime.fromMillisecondsSinceEpoch(file['modified'])))
        .cast<PlatformFileInterface>()
        .toList();
    }
  }

  @override
  Future<List<PlatformFolderInterface>> folders() async {
    if (path != null) {
      final directory = Directory(path!);
      return (await directory.list().toList())
        .whereType<Directory>()
        .map((directory) => PlatformFolder(path: directory.path))
        .toList();
    } else {
      // Android
      return (await methodChannel.invokeMethod('listDirectories', { 'uri': uri! }))
        .map((folder) => PlatformFolder(uri: folder))
        .cast<PlatformFolderInterface>()
        .toList();
    }
  }

  @override String get name {
    if (path != null) return p.basename(path!);
    return Uri.decodeFull(uri!).split(RegExp(r'[/:]')).last;  // Android
  }
}

Hope that helps someone. Happy to answer questions.

github-actions[bot] commented 2 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.

azeem-swr commented 2 months ago

Hi, @rohintonc, currently facing the same issue, thanks a lot for the code! Do you by any chance have a fork for this packages with this functionality so its a bit easier and cleaner to use? Especially because I want to use this in quite a few projects. I would try doing it myself but I'm not really that familiar with Kotlin. Thanks either way!

rohintonc commented 2 months ago

@azeem-swr Unfortunately not. However, I am also not a Kotlin developer, but didn't have much problem integrating this solution. Good luck.

Bai-Jinlin commented 3 weeks ago

maybe you need to use the permission_handler package to request permissions,just like this.

import 'package:permission_handler/permission_handler.dart';
if (await Permission.manageExternalStorage.request().isDenied) {
    return;
}
// file operation

and put <uses-permission android:name=“android.permission.MANAGE_EXTERNAL_STORAGE” /> into AndroidManifest.xml