Android 11 应用间文件共享

7

Android 11对存储方面有些规则,相关链接请参考:Android 11中的存储更新

使用案例: 我有两个应用程序,应用程序A将文件(.txt)写入外部存储器,应用程序B将在没有用户交互的情况下从外部存储器读取该文件。但在Android 11上读取/写入时抛出异常,指出权限被拒绝。

因此,我进行了一些研究,并发现只有MediaStore APIStorage Access Framework允许访问其他应用程序创建的文件,请参考链接:数据和文件存储概述

但是这两种方法都不适合我的用例:

  • MediaStore API只能访问媒体文件(图片、音频文件、视频)
  • Storage Access Framework需要用户交互

那么,在Android 11上,是否存在其他方式可以访问由不同应用程序创建的外部存储器上的非媒体文件?

尽管我进行了各种研究,但我并没有找到解决我的问题的方法。

谢谢您的帮助。

更新

我尝试了FileProvider,但当我尝试启动活动时,它总是显示错误:

E/AndroidRuntime: FATAL EXCEPTION: main Process: com.example.testapp, PID: 20141 android.content.ActivityNotFoundException: No Activity found to handle Intent { act=com.example.app2.action.RECEIVE dat=content://com.example.testapp.fileprovider/myfiles/default_user.txt flg=0x1 } at android.app.Instrumentation.checkStartActivityResult(Instrumentation.java:2067) at android.app.Instrumentation.execStartActivity(Instrumentation.java:1727) at android.app.Activity.startActivityForResult(Activity.java:5320)

这是我从应用程序1启动应用程序2活动的方式:

File filePath = new File(getFilesDir(), "files");
File newFile = new File(filePath, "default_user.txt");
Intent intent = new Intent();
intent.setAction("com.example.app2.action.RECEIVE");
intent.setData(FileProvider.getUriForFile(this, "com.example.testapp.fileprovider", newFile));
intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
startActivity(intent);

应用程序1清单

<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

<application
    android:allowBackup="true"
    android:requestLegacyExternalStorage="true"
    android:icon="@mipmap/ic_launcher"
    android:label="@string/app_name"
    android:roundIcon="@mipmap/ic_launcher_round"
    android:supportsRtl="true"
    android:theme="@style/Theme.TestApp">
    <activity android:name=".StorageActivity">
        <intent-filter>
            <action android:name="android.intent.action.MAIN" />

            <category android:name="android.intent.category.LAUNCHER" />
        </intent-filter>
    </activity>
    <activity android:name=".MainActivity">
    </activity>

    <service
        android:name=".service.TestService"
        android:enabled="true"
        android:exported="true" />

    <provider
        android:name="androidx.core.content.FileProvider"
        android:authorities="com.example.testapp.fileprovider"
        android:grantUriPermissions="true"
        android:exported="false">
        <meta-data
            android:name="android.support.FILE_PROVIDER_PATHS"
            android:resource="@xml/filepaths" />
    </provider>
    
</application>

应用程序转换成清单

<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

<application
    android:allowBackup="true"
    android:icon="@mipmap/ic_launcher"
    android:label="@string/app_name"
    android:requestLegacyExternalStorage="true"
    android:roundIcon="@mipmap/ic_launcher_round"
    android:supportsRtl="true"
    android:theme="@style/Theme.TestApp">
    <activity android:name=".ReceiverActivity">
        <intent-filter>
            <action android:name="com.example.app2.action.RECEIVE"/>

            <category android:name="android.intent.category.DEFAULT"/>
            <data android:scheme="content"
                android:host="com.example.testapp" />
        </intent-filter>
    </activity>
    <activity android:name=".MainActivity">
        <intent-filter>
            <action android:name="android.intent.action.MAIN" />

            <category android:name="android.intent.category.LAUNCHER" />
        </intent-filter>
    </activity>
</application>

嗨,@MorrisonChang,这两个应用程序都是我编写/控制的,谢谢! - Chong Choon Hong
1
请参阅:内容提供者 - Morrison Chang
嗨,@MorrisonChang,我测试了FileProvider和ContentProvider,对于ContentProvider,它似乎是使用MediaStore API 从共享存储访问媒体文件,而对于FileProvider,则始终无法启动Activity。 - Chong Choon Hong
嗨@blackapps,App1出现了错误,你是正确的,当我从App1启动App2活动时,错误发生了,你能帮我解决这个问题吗?我指的是明确启动活动,而且我可以在不使用setData()和setFlag()的情况下开始。 - Chong Choon Hong
嗨@blackapps,我已经掌握了如何在没有setData()和setFlag()的情况下从app1显式启动app2的活动,但是在添加setData(Uri uri)和setFlag()后,应用程序出现错误。 - Chong Choon Hong
显示剩余14条评论
4个回答

1
import android.content.res.AssetManager;
import android.os.Bundle;
import android.util.Log;
import android.view.KeyEvent;

import androidx.appcompat.app.AppCompatActivity;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;

public class MainActivity extends AppCompatActivity {


    PDFiumHelper mPdFiumHelper;
    private void copyAssets() {
        AssetManager assetManager = getAssets();
        String[] files = null;
        try {
            files = assetManager.list("");
        } catch (IOException e) {
            Log.e("tag", "Failed to get asset file list.", e);
        }
        for (String filename : files) {
            InputStream in = null;
            OutputStream out = null;
            try {
                in = assetManager.open(filename);

                String outDir = getFilesDir().getAbsolutePath();

                File outFile = new File(outDir, filename);

                out = new FileOutputStream(outFile);
                copyFile(in, out);
                in.close();
                in = null;
                out.flush();
                out.close();
                out = null;
            } catch (IOException e) {
                Log.e("tag", "Failed to copy asset file: " + filename, e);
            }
        }
    }

    private void copyFile(InputStream in, OutputStream out) throws IOException {
        byte[] buffer = new byte[1024];
        int read;
        while ((read = in.read(buffer)) != -1) {
            out.write(buffer, 0, read);
        }
    }

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        copyAssets();
        mPdFiumHelper = new PDFiumHelper(this);
    }

    @Override
    public boolean onKeyDown(int keyCode, KeyEvent event) {
       return mPdFiumHelper.onKeyDown(keyCode,event);
    }
}

辅助类
import android.content.Intent;
import android.content.res.ColorStateList;
import android.graphics.Color;
import android.util.DisplayMetrics;
import android.util.Log;
import android.view.KeyEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.Toast;

import androidx.appcompat.app.AppCompatActivity;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.core.content.FileProvider;

import com.github.barteksc.pdfviewer.PDFView;
import com.github.barteksc.pdfviewer.listener.OnErrorListener;
import com.github.barteksc.pdfviewer.listener.OnLoadCompleteListener;
import com.github.barteksc.pdfviewer.util.FitPolicy;
import com.google.android.material.floatingactionbutton.FloatingActionButton;
import com.karumi.dexter.MultiplePermissionsReport;
import com.karumi.dexter.PermissionToken;
import com.karumi.dexter.listener.PermissionRequest;
import com.karumi.dexter.listener.multi.MultiplePermissionsListener;

import java.io.File;
import java.util.Calendar;
import java.util.List;

public class PDFiumHelper implements MultiplePermissionsListener {

    AppCompatActivity mAppCompatActivity;

    public PDFiumHelper(AppCompatActivity mAppCompatActivity) {
        this.mAppCompatActivity = mAppCompatActivity;
        onCreate();
    }
    public PDFiumHelper() {
    }
    Button btnFile1, btnFile2;

    long firstTime;
    PDFView pdfView;
    CommonUtility mCommonUtility;
    boolean hasPermissions;
    FloatingActionButton ibtn_share;
    File localPDFFile;
    String pdfTitle = "Mathematics Paper 2020";
    String pdfExtraText = "this paper is made using the quantum paper.";
    ConstraintLayout root;
    String path1,path2;
    protected void onCreate() {

        path1 = mAppCompatActivity.getFilesDir() + "/SatsangDiksha.pdf";
        path2 = mAppCompatActivity.getFilesDir() + "/sample.pdf";

        btnFile1 = (Button) mAppCompatActivity.findViewById(R.id.btnFile1);
        btnFile2 = (Button) mAppCompatActivity.findViewById(R.id.btnFile2);

        root = mAppCompatActivity.findViewById(R.id.root);

        pdfView = new PDFView(mAppCompatActivity, null);
        pdfView.setLayoutParams(new ConstraintLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
        root.addView(pdfView);

        ibtn_share = new FloatingActionButton(mAppCompatActivity);
        ConstraintLayout.LayoutParams params = new ConstraintLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
        int margin = convertDpToPixel(20);
        params.setMargins(margin, margin, margin, margin);
        //this will constrain FAB programmatically as like XML.
        params.bottomToBottom = root.getId();
        params.endToEnd = root.getId();
        ibtn_share.setLayoutParams(params);
        //change FAB icon here
        ibtn_share.setImageResource(R.drawable.ic_share);
        //change background color of FAB here
        ibtn_share.setBackgroundTintList(ColorStateList.valueOf(mAppCompatActivity.getResources().getColor(R.color.purple_500)));
        //FAB icon color can be change from here
        ibtn_share.setColorFilter(Color.WHITE);

        root.addView(ibtn_share);


        btnFile1.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                loadPdfFromFile(path1);
            }
        });
        btnFile2.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                loadPdfFromFile(path2);
            }
        });


        mCommonUtility = new CommonUtility(mAppCompatActivity);
        if (hasPermissions) {
        } else {
            mCommonUtility.askForPermissionBeforeStart(this);
        }

        ibtn_share.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                if (hasPermissions) {
                    shareFile();
                } else {
                    mCommonUtility.askForPermissionBeforeStart(PDFiumHelper.this);
                }
            }
        });

    }

    boolean flag = false;

    public boolean onKeyDown(int keyCode, KeyEvent event) {
        if ((keyCode == KeyEvent.KEYCODE_DPAD_LEFT)) {
            if (flag) {
                loadPdfFromFile(path1);
                flag = false;
            } else {
                loadPdfFromFile(path2);
                flag = true;
            }
//            Toast.makeText(MainActivity.this, "Left Arrow Pressed", Toast.LENGTH_SHORT).show();
            return false;
        }
        return false;
    }

    public void shareFile() {
        Intent intentShareFile = new Intent(Intent.ACTION_SEND);
        if (localPDFFile.exists()) {
            intentShareFile.setType("application/pdf");
            intentShareFile.putExtra(Intent.EXTRA_STREAM, FileProvider.getUriForFile(mAppCompatActivity, BuildConfig.APPLICATION_ID + ".provider", localPDFFile));
            intentShareFile.putExtra(Intent.EXTRA_SUBJECT,
                    pdfTitle);
            intentShareFile.putExtra(Intent.EXTRA_TEXT, pdfExtraText);
            mAppCompatActivity.startActivity(Intent.createChooser(intentShareFile, "Share File using"));
        } else {
            Toast.makeText(mAppCompatActivity, "File not loaded.", Toast.LENGTH_SHORT).show();
        }
    }

    public long showCurrentTime() {
        Calendar c = Calendar.getInstance();
        int hours = c.get(Calendar.HOUR);
        int minutes = c.get(Calendar.MINUTE);
        int seconds = c.get(Calendar.SECOND);
        int mseconds = c.get(Calendar.MILLISECOND);
//        Log.e(TAG, "showCurrentTime: Current Time :- " + hours + ":" + minutes + ":" + seconds + ":" + mseconds);
        return System.currentTimeMillis();
    }


    public void loadPdfFromFile(String path) {
        firstTime = showCurrentTime();
        try {
            localPDFFile = new File(path);
            pdfView.fromFile(localPDFFile)
                    .enableAntialiasing(true)
                    .enableDoubletap(true)
                    .onLoad(new OnLoadCompleteListener() {
                        @Override
                        public void loadComplete(int nbPages) {
                            double timee = firstTime - showCurrentTime();
                            double ms = Math.abs(timee) / 1000;
                            Log.e("===", "onDocumentLoaded: Time Taken :- " + ms + " Seconds");
                            Toast.makeText(mAppCompatActivity, ms + " Seconds", Toast.LENGTH_SHORT).show();
                        }
                    })
                    .onError(new OnErrorListener() {
                        @Override
                        public void onError(Throwable t) {
                            t.printStackTrace();
                            Toast.makeText(mAppCompatActivity, "Load some file.", Toast.LENGTH_SHORT).show();
                        }
                    })
                    .scrollHandle(new DefaultScrollHandle(mAppCompatActivity))
                    .pageFitPolicy(FitPolicy.WIDTH)
                    .autoSpacing(false)
                    .load();
        } catch (Throwable th) {

//            th.printStackTrace();
        }
    }

    @Override
    public void onPermissionsChecked(MultiplePermissionsReport multiplePermissionsReport) {
        // check if all permissions are granted
        if (multiplePermissionsReport.areAllPermissionsGranted()) {
            // do work
            hasPermissions = true;
            if (hasPermissions) {
            }
        }
        if (multiplePermissionsReport.isAnyPermissionPermanentlyDenied()) {
            // permission is denied permenantly, navigate user to app settings
            mCommonUtility.showSettingsDialog();
        }
    }

    @Override
    public void onPermissionRationaleShouldBeShown(List<PermissionRequest> list, PermissionToken permissionToken) {
        mCommonUtility.showPermissionRationale(permissionToken);
    }

    public int convertDpToPixel(int dp) {
        return dp * (mAppCompatActivity.getResources().getDisplayMetrics().densityDpi / DisplayMetrics.DENSITY_DEFAULT);
    }

    public int convertPixelsToDp(int px) {
        return px / (mAppCompatActivity.getResources().getDisplayMetrics().densityDpi / DisplayMetrics.DENSITY_DEFAULT);
    }

}

提供程序路径文件。

<?xml version="1.0" encoding="utf-8"?>
    <paths>
        <external-path
            name="external"
            path="." />
        <external-files-path
            name="external_files"
            path="." />
        <cache-path
            name="cache"
            path="." />
        <external-cache-path
            name="external_cache"
            path="." />
        <files-path
            name="files"
            path="." />
    </paths>

安卓清单文件。
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.qp.pdfiumproject">

    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
    <uses-permission android:name="android.permission.INTERNET" />

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:requestLegacyExternalStorage="true"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.PdfiumProject">
        <activity android:name=".MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

        <provider
            android:name="androidx.core.content.FileProvider"
            android:authorities="${applicationId}.provider"
            android:exported="false"
            android:grantUriPermissions="true">
            <meta-data
                android:name="android.support.FILE_PROVIDER_PATHS"
                android:resource="@xml/provider_paths" />
        </provider>

    </application>

</manifest>

1

在Android 11中,每个应用程序只能读写其自己私有目录下的文件。这适用于Java File类和本地代码。以下是我目前知道的绕过此限制的方法:

  1. 您可以使用SAF访问所有文件。在某些情况下不可能使用它,例如使用SQLite时。SQLite只接受File类型而不是DocumentFile。
  2. 您可以使用SAF将所有需要的文件复制到您的私有目录中,并在那里使用File或本地代码访问它们。如果要更改它们,必须随后使用SAF将它们复制回其原始位置。
  3. 您可以请求MANAGE_EXTERNAL_STORAGE权限(为什么是“external”???)。在这种情况下,您无法在Play Store中发布应用程序。
  4. 您可以使用目标SDK Android 10。在这种情况下,您无法在Play Store中发布应用程序。
  5. 您可以让Android相信您的文件(例如“bla.txt”和“labre.db”)是媒体文件。将它们重命名为“bla.txt.m4a”和“labre.db.jpg”。作为副作用,您的媒体数据库将被污染,即会出现不良音频和不良图片,这可能会无意中出现在图片或音乐专辑中。
在我看来,Google本可以找到更好的解决方案,而不会给开发者带来如此多的工作量。顺便说一下:即使只是传输原始数据,SAF也非常慢。

非常感谢您基于实际经验提供的概述。如果多年后您能够补充其他内容,请延伸您的回答。 - Gleichmut
1
自从Android 13之后,写入和读取权限已经被弃用,并被更细粒度的权限所取代。请参考以下链接了解更多信息:https://developer.android.com/about/versions/13/behavior-changes-13#granular-media-permissions - Gleichmut
似乎安卓13在错误的方向上又迈出了一步。 - Andreas K. aus M.
就整体平台增长而言,我不这么认为,但当他们开始允许第三方应用程序仅访问特定照片而不是整个媒体数据集时,他们确实取得了很大的改进。我也不喜欢API版本之间的碎片化以及总体存储API变得繁琐,他们试图修复它(https://github.com/google/modernstorage),但这个库仍处于alpha阶段,并且一年多前就停止了工作。在我看来,在生产环境中使用它而没有持续维护是不值得的。 - Gleichmut

0

你应该使用启动意图直接启动app2。

                        try {
                            File file = new File( .... );
                            Uri uri = FileProvider.getUriForFile(context, getPackageName() + ".fileprovider", file);

                            String apkPackage = "com.example.app2";

                            Intent intent = context.getPackageManager().getLaunchIntentForPackage(apkPackage);

                            if ( intent==null )
                            {
                                Toast.makeText(context, "Sorry, could not get launch intent for: " + apkPackage, Toast.LENGTH_LONG).show();

                                return;
                            }

                            intent.setAction(Intent.ACTION_VIEW);
                            intent.setDataAndType(uri, mimeType);

                            intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
                            intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION);

                            context.startActivity(intent);
                        }
                        catch ( IllegalArgumentException e)
                        {
                            e.printStackTrace();
                            Toast.makeText(context, "IllegalArgumentException: " + e.getMessage(), Toast.LENGTH_LONG).show();

                        }
                        catch ( Exception e)
                        {
                            e.printStackTrace();

                            Toast.makeText(context, e.getMessage(), Toast.LENGTH_LONG).show();
                        }

在 app2 的清单文件中不需要使用意图过滤器。

接收方可以使用以下代码获取 URI:

Uri uri = getIntent().getData();

这种模式是自动的还是需要用户确认操作? - Bronz
我不知道你在问什么。 - blackapps
App2已启动。该标志不会生成任何内容。用户无需确认任何内容。而App2的其余操作将超出App1的控制范围。 - blackapps
这并不是与从另一个应用程序读取文件进行比较。这只是读取提供程序提供的内容。使用FileProvider。而app2对uri后面的文件一无所知。使用提供程序是为了克服这些限制。要测试这个,你只需要编写一个小的app2程序。启动app2的意图可以暂时添加到您已经制作的任何应用程序中。 - blackapps
如果app2试图读取app1的文件(而app1不知道),那么动作将从app2开始并且不会继续。但是,使用文件提供程序,app1告诉app2:“这是我的文件,请阅读它。”并且不使用路径而是使用URI。 - blackapps
显示剩余5条评论

0

过去一周我一直在为我的应用程序开发备份和恢复功能,似乎唯一安全可靠的选择是在所有 API 版本上使用 SAF 和 ACTION_CREATE_DOCUMENT 以及 ACTION_OPEN_DOCUMENT,从 Android 5Android 13

API 版本间的碎片化成为了与外部存储操作相关的严重问题。

存储访问框架是除媒体文件案例外最新 Android 版本上的唯一工具。

自 Android 30 开始,您无需写入权限即可写入公共文件夹。

至少自 Android 30(并且自 Android Kitkat 4.4 起就有这个说法),您不能删除由其他应用程序创建的公共文件夹中的文件。(这也意味着如果用户删除并重新安装应用程序,则其公共文件夹中的文件将不再属于该应用程序)。

直到 Android 30,您仍然需要请求运行时权限才能将文件写入公共文件夹,但在 Android 29 上甚至不够 - 应用程序具有权限,但仍无法写入文件。

默认下载应用程序在大多数Android版本上不显示由您的应用程序添加的文件。第三方文件浏览器可以显示这些文件,但至少在Android 30和Android 33上,在意图启动后,您可以选择它作为一个选项。
val intent  = Intent(Intent.ACTION_OPEN_DOCUMENT) 
intent.addCategory(Intent.CATEGORY_OPENABLE)

一位经验丰富的工程师建议只使用存储访问框架: https://dev59.com/IKjka4cB1Zd3GeqPDb-X#48592565 Google团队曾试图解决碎片化问题https://github.com/google/modernstorage,但这个库仍处于alpha版本,并且一年多前就停止了开发。在我看来,没有持续维护的情况下,在生产环境中使用它是不值得的。

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