Flutter如何在Android SDK 30+中读写外部存储?

4

使用Flutter开发面向Android SDK 30+的Android应用程序。

我想读写类似以下内容的数据(xml文件):

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

通读一些资料,我猜我应该使用Intent.ACTION_OPEN_DOCUMENT_TREE,所以我编写了一个MethodChannel,可以很好地打开SelectDialog。(为简洁起见,我已经删除了所有的try-catch和错误处理)

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())
}

我可以从Flutter中调用这个功能,它会打开“选择目录”对话框,然后我可以选择我的CustomDirectory,这将返回一个内容URI:

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

我如何将它转换为Flutter目录?
在Flutter中,我可以调用Directory.fromUri(...),但那只会抛出异常。
Unsupported operation: Cannot extract a file path from a content URI

所以,我有一点不确定接下来该怎么做,是需要更改我的Intent的标志,还是我在某个地方做错了什么?


@blackapps 感谢您对标志的注释,我会将其删除。问题是另一个应用程序实际上正在将这些文件写入该位置,因此它设置在我的控制范围之外。我猜想当那个应用程序更新时,它将无法再写入该位置? - Chris
难以置信。那个操作是安全的。你不应该尝试从URI中提取路径。抱歉,我不使用Flutter... - blackapps
@ChrisTurner,你能解决它了吗?如何从Directory.fromUri(...)获取文件列表!!?因为我也遇到了同样的问题,我真的想读取这些文件! - Ankit Parmar
1
最终我在本地Kotlin中实现了MethodChannel调用,因为我无法在Dart中使其正常工作。 - Chris
@ChrisTurner,你能分享一下那个MethodChannel的代码吗?因为我也想实现同样的功能 :) - Ankit Parmar
显示剩余4条评论
1个回答

2
这将是一个较长的答案,其中许多代码都与我的用例有关,因此如果有人想要重用它,可能需要进行一些调整。
基本上,在Android 30+中的更改中,我无法在用户手机上写入非自己应用程序目录的目录而不请求可怕的manage_external_storage权限。
我通过使用本地Kotlin来解决这个问题,然后通过Dart中的接口调用这些方法。
首先从Kotlin代码开始。
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()
            }
        }
    }

这将设置我们的MainActivity以便监听在setMethodCallHandler方法中命名的方法。

有很多示例可以找到如何在Kotlin中实现基本IO功能,因此我不会在这里全部发布它们,但是以下是如何打开设置内容根并处理结果的示例:

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
    }

//..

现在,为了在Dart中调用该代码,我创建了一个名为AndroidInterface的接口并实现了它。
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;
  }

//...

基本上,它通过_platform.invokeMethod发送请求,传递方法名和要发送的参数。使用工厂模式,您可以实现此接口设备运行30+并使用标准的Apple和设备运行29及以下的东西。类似于:
abstract class IOInterface {
  
//...

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

并且一个工厂来决定使用哪个接口

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;
  }
}

最后,30+的实现可能如下所示:
class AndroidSDKThirty implements IOInterface {
  final AndroidInterface _androidInterface = AndroidInterface();

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

希望这些足以让你入门并指向正确的方向。

非常感谢您的分享 :) - Ankit Parmar

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接