You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Android跨应用访问Documents目录文件:Flutter/Java实现方案求助

合规跨应用访问Documents目录解决方案(Android 22+)

核心思路

Android 10+的分区存储机制限制了应用直接通过文件路径跨应用访问公共目录文件,合规方案需基于SAF(存储访问框架)持久化权限或ContentProvider共享,两种方式均符合Google Play规范,无需全文件访问权限或废弃的requestLegacyExternalStorage配置。


方案一:SAF持久化权限(双应用独立授权)

适用于需要遍历Documents目录多个文件的场景,仅需用户首次启动时授权一次,后续无需交互。

Java 实现

应用A:创建Documents子文件夹与配置文件

  1. 请求Documents目录持久化权限:
private static final int REQUEST_CODE_DOCUMENTS_DIR = 1001;

// 启动SAF选择Documents目录
private void requestDocumentsPermission() {
    Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
    intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
    intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
    intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION);
    startActivityForResult(intent, REQUEST_CODE_DOCUMENTS_DIR);
}
  1. 处理权限回调并保存Uri:
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
    super.onActivityResult(requestCode, resultCode, data);
    if (requestCode == REQUEST_CODE_DOCUMENTS_DIR && resultCode == RESULT_OK) {
        Uri treeUri = data.getData();
        // 持久化权限
        getContentResolver().takePersistableUriPermission(treeUri,
                Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
        // 保存Uri到SharedPreferences
        SharedPreferences prefs = getSharedPreferences("AppAPrefs", MODE_PRIVATE);
        prefs.edit().putString("documents_tree_uri", treeUri.toString()).apply();
        // 创建子文件夹与文件
        createConfigFile(treeUri);
    }
}
  1. 创建子文件夹与写入文件:
private void createConfigFile(Uri treeUri) {
    DocumentFile rootDir = DocumentFile.fromTreeUri(this, treeUri);
    // 创建AppA专属子文件夹
    DocumentFile subFolder = rootDir.createDirectory("AppA_Configs");
    if (subFolder == null) return;
    // 创建配置文件
    DocumentFile configFile = subFolder.createFile("application/octet-stream", "config.dat");
    if (configFile == null) return;
    // 写入数据为byte[]
    try (OutputStream os = getContentResolver().openOutputStream(configFile.getUri())) {
        byte[] configData = "sample_config_content".getBytes();
        os.write(configData);
    } catch (IOException e) {
        e.printStackTrace();
    }
}

应用B:遍历并读取文件

  1. 读取已授权的Documents目录Uri,未授权则请求权限:
private static final int REQUEST_CODE_DOCUMENTS_DIR = 1002;

private void readAppAConfig() {
    SharedPreferences prefs = getSharedPreferences("AppBPrefs", MODE_PRIVATE);
    String treeUriStr = prefs.getString("documents_tree_uri", null);
    
    if (treeUriStr != null) {
        Uri treeUri = Uri.parse(treeUriStr);
        scanAndReadConfig(treeUri);
    } else {
        // 未授权,启动SAF请求
        Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
        intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
        intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION);
        startActivityForResult(intent, REQUEST_CODE_DOCUMENTS_DIR);
    }
}
  1. 遍历子文件夹并读取文件为byte[]:
private void scanAndReadConfig(Uri treeUri) {
    DocumentFile rootDir = DocumentFile.fromTreeUri(this, treeUri);
    DocumentFile appAFolder = rootDir.findFile("AppA_Configs");
    if (appAFolder == null || !appAFolder.isDirectory()) return;
    
    // 遍历文件夹内文件
    for (DocumentFile file : appAFolder.listFiles()) {
        if (file.isFile() && "config.dat".equals(file.getName())) {
            byte[] fileBytes = readFileToBytes(file.getUri());
            // 处理读取到的byte数组
            break;
        }
    }
}

// 将文件读取为byte[]的工具方法
private byte[] readFileToBytes(Uri fileUri) {
    try (InputStream is = getContentResolver().openInputStream(fileUri);
         ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
        byte[] buffer = new byte[1024];
        int len;
        while ((len = is.read(buffer)) != -1) {
            baos.write(buffer, 0, len);
        }
        return baos.toByteArray();
    } catch (IOException e) {
        e.printStackTrace();
        return null;
    }
}

Flutter 实现

依赖插件:flutter_file_dialog(处理SAF交互)、shared_preferences(保存授权Uri)

应用A:创建文件

import 'dart:typed_data';
import 'package:flutter_file_dialog/flutter_file_dialog.dart';
import 'package:shared_preferences/shared_preferences.dart';

class AppAStorage {
  static const _uriKey = 'documents_tree_uri';

  // 请求Documents目录权限并创建配置文件
  static Future<void> setupConfigFile() async {
    final prefs = await SharedPreferences.getInstance();
    String? treeUriStr = prefs.getString(_uriKey);

    // 未授权则请求权限
    if (treeUriStr == null) {
      final result = await FlutterFileDialog.pickFolder(
        params: OpenFileDialogParams(title: '授权访问Documents目录'),
      );
      if (result == null) return;
      treeUriStr = result;
      await prefs.setString(_uriKey, treeUriStr);
    }

    final treeUri = Uri.parse(treeUriStr);
    // 创建子文件夹
    final folderUri = await FlutterFileDialog.createFile(
      params: CreateFileDialogParams(
        parentUri: treeUri,
        fileName: 'AppA_Configs',
        isDirectory: true,
      ),
    );
    if (folderUri == null) return;

    // 创建并写入配置文件
    await FlutterFileDialog.createFile(
      params: CreateFileDialogParams(
        parentUri: folderUri,
        fileName: 'config.dat',
        mimeType: 'application/octet-stream',
        data: Uint8List.fromList('sample_config_content'.codeUnits),
      ),
    );
  }
}

应用B:读取文件

import 'dart:typed_data';
import 'package:flutter_file_dialog/flutter_file_dialog.dart';
import 'package:shared_preferences/shared_preferences.dart';

class AppBStorage {
  static const _uriKey = 'documents_tree_uri';

  // 读取AppA的配置文件为byte[]
  static Future<Uint8List?> readAppAConfig() async {
    final prefs = await SharedPreferences.getInstance();
    String? treeUriStr = prefs.getString(_uriKey);

    if (treeUriStr == null) {
      final result = await FlutterFileDialog.pickFolder(
        params: OpenFileDialogParams(title: '授权访问Documents目录'),
      );
      if (result == null) return null;
      treeUriStr = result;
      await prefs.setString(_uriKey, treeUriStr);
    }

    final treeUri = Uri.parse(treeUriStr);
    // 遍历找到AppA的子文件夹
    final folderList = await FlutterFileDialog.listFiles(parentUri: treeUri);
    final appAFolder = folderList.firstWhere(
      (f) => f.name == 'AppA_Configs' && f.isDirectory,
      orElse: () => null,
    );
    if (appAFolder == null) return null;

    // 找到配置文件并读取
    final fileList = await FlutterFileDialog.listFiles(parentUri: appAFolder.uri);
    final configFile = fileList.firstWhere(
      (f) => f.name == 'config.dat' && f.isFile,
      orElse: () => null,
    );
    if (configFile == null) return null;

    return await FlutterFileDialog.readFile(fileUri: configFile.uri);
  }
}

方案二:ContentProvider共享(仅应用A授权)

适用于仅需共享单个/少量文件的场景,应用B无需SAF授权,仅需应用A暴露ContentProvider接口。

Java 实现

应用A:定义ContentProvider

public class ConfigProvider extends ContentProvider {
    public static final String AUTHORITY = "com.yourpackage.appa.configprovider";
    public static final Uri CONFIG_URI = Uri.parse("content://" + AUTHORITY + "/config");

    @Override
    public boolean onCreate() {
        return true;
    }

    @Override
    public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
        // 从SharedPreferences获取已授权的Documents目录Uri
        SharedPreferences prefs = getContext().getSharedPreferences("AppAPrefs", Context.MODE_PRIVATE);
        String treeUriStr = prefs.getString("documents_tree_uri", null);
        if (treeUriStr == null) throw new FileNotFoundException();

        Uri treeUri = Uri.parse(treeUriStr);
        DocumentFile rootDir = DocumentFile.fromTreeUri(getContext(), treeUri);
        DocumentFile subFolder = rootDir.findFile("AppA_Configs");
        DocumentFile configFile = subFolder.findFile("config.dat");
        
        if (configFile == null) throw new FileNotFoundException();
        return getContext().getContentResolver().openFileDescriptor(configFile.getUri(), mode);
    }

    // 其他未用到的ContentProvider方法返回默认值即可
    @Nullable
    @Override
    public Cursor query(@NonNull Uri uri, @Nullable String[] projection, @Nullable String selection, @Nullable String[] selectionArgs, @Nullable String sortOrder) {
        return null;
    }

    @Nullable
    @Override
    public String getType(@NonNull Uri uri) {
        return "application/octet-stream";
    }

    @Nullable
    @Override
    public Uri insert(@NonNull Uri uri, @Nullable ContentValues values) {
        return null;
    }

    @Override
    public int delete(@NonNull Uri uri, @Nullable String selection, @Nullable String[] selectionArgs) {
        return 0;
    }

    @Override
    public int update(@NonNull Uri uri, @Nullable ContentValues values, @Nullable String selection, @Nullable String[] selectionArgs) {
        return 0;
    }
}

在AndroidManifest.xml中注册:

<provider
    android:name=".ConfigProvider"
    android:authorities="com.yourpackage.appa.configprovider"
    android:exported="true"
    android:grantUriPermissions="true">
    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <data android:mimeType="application/octet-stream" />
    </intent-filter>
</provider>

应用B:通过ContentResolver读取

private byte[] readConfigFromAppA() {
    Uri configUri = Uri.parse("content://com.yourpackage.appa.configprovider/config");
    try (InputStream is = getContentResolver().openInputStream(configUri);
         ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
        byte[] buffer = new byte[1024];
        int len;
        while ((len = is.read(buffer)) != -1) {
            baos.write(buffer, 0, len);
        }
        return baos.toByteArray();
    } catch (IOException e) {
        e.printStackTrace();
        return null;
    }
}

为什么之前的尝试无效?

  • 外部存储权限:Android 10+仅允许访问媒体文件,非媒体类配置文件无法通过路径访问公共目录。
  • requestLegacyExternalStorage:仅在Android 10(API 29)生效,Android 11+强制分区存储,该配置被忽略。
  • 全文件访问权限:Google Play仅允许文件管理器类应用使用,普通应用提交会被拒绝。

注意事项

  1. SAF持久化权限在应用卸载后会失效,重新安装需再次授权。
  2. ContentProvider方案需两个应用约定好Authority和Uri,避免冲突。
  3. 首次授权是Google Play合规要求,无法绕过用户交互。

内容的提问来源于stack exchange,提问作者dusty

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.20 07:03:36