4

Developing an Android app in Flutter targetting Android SDK 30+.

I want to read and write data (xml files) to something like:

/storage/emulated/0/CustomDirectory/example.xml

Reading around I guess I'm supposed to use Intent.ACTION_OPEN_DOCUMENT_TREE so I wrote a MethodChannel which allows me to open the SelectDialog just fine. (I've trimmed all the try-catch and error handling for brevity)

private fun selectDirectory() {
    val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
    intent.addFlags(
        Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or
                Intent.FLAG_GRANT_WRITE_URI_PERMISSION
    )
    startActivityForResult(intent, 100)
}

@RequiresApi(Build.VERSION_CODES.Q)
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    val uri = data.data!!
    contentResolver.takePersistableUriPermission(
        uri,
        Intent.FLAG_GRANT_WRITE_URI_PERMISSION
    )
    return uri.toString())
}

I can call this from Flutter, it opens the "Select Directory" dialog, and I can choose my CustomDirectory, which then returns me a Content URI of:

content://com.android.externalstorage.documents/tree/primary%3ACustomDirectory

How do I convert that into a Flutter Directory?

In Flutter, I can call Directory.fromUri(...) but that just throws

Unsupported operation: Cannot extract a file path from a content URI

So I'm a little unsure of where to go from here, do I need to change the flags of my Intent or am I doing something very wrong somewhere?

Chris
  • 3,437
  • 6
  • 40
  • 73
  • First: remove those flags fron that intent. They make no sense. They have no effect. – blackapps Oct 12 '21 at 06:26
  • `want to read and write data (xml files) to something like: /storage/emulated/0/CustomDirectory/example.xml ` On Android 30 devices use: /storage/emulated/0/Documents/CustomDirectory/example.xml – blackapps Oct 12 '21 at 06:30
  • @blackapps thanks for the notes on the flags, will remove. Problem is another application is actually writing those files into that location so it's set outside my control. I guess eventually when that updates it will no longer be able to write to that location? – Chris Oct 12 '21 at 06:43
  • Then you cannot write to that directory now with file io. Then indeed use ACTION_OPEN_DOCUMENT_TREE to let the user pick that directory and use saf code to access it. No file io any more. – blackapps Oct 12 '21 at 07:01
  • That is exactly what I'm trying to do, problem is Flutter doesn't seem to know what SAF is. – Chris Oct 12 '21 at 07:14
  • Hard to believe. That action is saf. You should not try to extract paths from an uri. Sorry i dont use Flutter... – blackapps Oct 12 '21 at 07:19
  • @ChrisTurner did you able figure it out ? how to get the files list from Directory.fromUri(...) !!?? As I am facing the same issue I really want to read the files ! – Ankit Parmar Nov 28 '21 at 21:59
  • 1
    I ended up implementing `MethodChannel` calls in native Kotlin as I wasn't able to get it working in Dart – Chris Nov 28 '21 at 22:06
  • @ChrisTurner Can you please share that MethodChannel code ? as I want to implement the same :) – Ankit Parmar Nov 29 '21 at 12:15

1 Answers1

2

This is going to be a long answer and a lot of the code is specific to my use case so if someone wants to reuse it, you might need to tweak things.

Basically with the changes in Android 30+ I wasn't able to get permissions to write to a directory on the user's phone that wasn't my apps own directory without requesting the dreaded manage_external_storage.

I solved this by doing this with native Kotlin then calling those methods via an interface in Dart.

First starting with the Kotlin code

class MainActivity : FlutterActivity() {
    private val CHANNEL = "package/Main"

    private var pendingResult: MethodChannel.Result? = null

    private var methodCall: MethodCall? = null

    override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)

        MethodChannel(
                flutterEngine.dartExecutor.binaryMessenger,
                CHANNEL
        ).setMethodCallHandler { call, result ->
            val handlers = mapOf(
                    "getSavedRoot" to ::getSavedRoot,
                    "selectDirectory" to ::copyDirectoryToCache,
                    "createDirectory" to ::createDirectory,
                    "writeFile" to ::writeFile,
            )
            if (call.method in handlers) {
                handlers[call.method]!!.invoke(call, result)
            } else {
                result.notImplemented()
            }
        }
    }

This sets up our MainActivity to listen for methods named in the setMethodCallHandler method.

There are plenty of examples you can find for how to implement basic IO functions in Kotlin so I won't post them all here, but an example of how to open a set a content root and handle the result:

class MainActivity : FlutterActivity() {

//...

    @RequiresApi(Build.VERSION_CODES.LOLLIPOP)
    private fun selectContentRoot(call: MethodCall, result: MethodChannel.Result) {

        pendingResult = result

        try {
            val browseIntent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
            startActivityForResult(browseIntent, 100)
        } catch (e: Throwable) {
            Log.e("selectDirectory", " error", e)
        }
    }

    @RequiresApi(Build.VERSION_CODES.LOLLIPOP)
    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)

        if (requestCode == 100 && resultCode == RESULT_OK) {

            val uri: Uri = data?.data!!

            contentResolver.takePersistableUriPermission(
                    uri,
                    Intent.FLAG_GRANT_READ_URI_PERMISSION
            )

            contentResolver.takePersistableUriPermission(
                    uri,
                    Intent.FLAG_GRANT_WRITE_URI_PERMISSION
            )

            return pendingResult!!.success(uri.toString())
        }

        return
    }

//..

Now to invoke that code in Dart I created an interface named AndroidInterface and implemented

class AndroidInterface {
  final _platform = const MethodChannel('package/Main');

  final _errors = {
    'no persist document tree': FileOperationError.noSavedPersistRoot,
    'pending': FileOperationError.pending,
    'access error': FileOperationError.accessError,
    'exists': FileOperationError.alreadyExists,
    'creation failed': FileOperationError.creationFailed,
    'canceled': FileOperationError.canceled,
  };

  String? _root;

  // invoke a method with given arguments
  Future<FileOperationResult<String>> _invoke(
    String method, {
    bool returnVoid = false,
    String? root,
    String? directory,
    String? subdir,
    String? name,
    Uint8List? bytes,
    bool? overwrite,
  }) async {
    try {
      final result = await _platform.invokeMethod<String>(method, {
        'root': root,
        'directory': directory,
        'subdir': subdir,
        'name': name,
        'bytes': bytes,
        'overwrite': overwrite,
      });

      if (result != null || returnVoid) {
        final fileOperationResult = FileOperationResult(result: result);

        fileOperationResult.result = result;

        return fileOperationResult;
      }

      return FileOperationResult(error: FileOperationError.unknown);
    } on PlatformException catch (e) {
      final error = _errors[e.code] ?? FileOperationError.unknown;

      return FileOperationResult(
        error: error,
        result: e.code,
        message: e.message,
      );
    }
  }

  Future<FileOperationResult<String>> selectContentRoot() async {
    final result = await _invoke('selectContentRoot');

    // release currently selected directory if new directory selected successfully
    if (result.error == FileOperationError.success) {
      if (_root != null) {
        await _invoke('releaseDirectory', root: _root, returnVoid: true);
      }
      _root = result.result;
    }

    return result;
  }

//...

Which basically sends the requests via _platform.invokeMethod passing the name of the method, and the arguments to send.

Using a factory pattern you can implement this interface device running 30+ and use standard stuff for Apple and devices running 29 and below.

Something like:

abstract class IOInterface {
  
//...

  /// Select a subdirectory of the root directory
  Future<void> selectDirectory(String? message, String? buttonText);
}

And a factory to decide what interface to use

class IOFactory {
  static IOInterface? _interface;

  static IOInterface? get instance => _interface;

  IOFactory._create();

  static Future<IOFactory> create() async {
    final component = IOFactory._create();

    if (Platform.isAndroid) {
      final androidInfo = await DeviceInfoPlugin().androidInfo;
      final sdkInt = androidInfo.version.sdkInt;

      _interface = sdkInt > 29 ? AndroidSDKThirty() : AndroidSDKTwentyNine();
    }

    if (Platform.isIOS) {
      _interface = AppleAll();
    }

    return component;
  }
}

Finally, the implementation for 30+ could look like

class AndroidSDKThirty implements IOInterface {
  final AndroidInterface _androidInterface = AndroidInterface();

  @override
  Future<void> selectDirectory(String? message, String? buttonText) async {
    final contentRoot = await _androidInterface.getContentRoot();
    //...
  }

Hopefully, this is enough to get you started and pointed in the right direction.

Chris
  • 3,437
  • 6
  • 40
  • 73