译:Android N不再支持通过Intent传递“file://”scheme

此文为笔者译文,版权归原作者所有。原文载于 The Cheese Factory :: Blog,作者 nuuneoi,时间 2016-07-23,file:// scheme is now not allowed to be attached with Intent on targetSdkVersion 24 (Android Nougat). And here is the solution.

Android N 即将正式发布。作为 Android 开发者,我们需要准备好将 targetSdkVersion 升级到最新的 24,以使 APP 在新系统上也正常运行。

和以往每次升级 targetSdkVersion 一样,我们需要仔细检查每一块代码并确保其依旧工作正常。简单修改 targetSdkVersion 值是不行的,如果只这么干,那么你的 APP 将有很大风险在新系统上出现问题甚至崩溃。因此,当你升级 targetSdkVersion24 时,针对性地检查并优化每一个功能模块。

Android N 在安全性方面有了大变化,以下就是一项需要注意之处:

Passing file:// URIs outside the package domain may leave the receiver with an unaccessible path. Therefore, attempts to pass a file:// URI trigger a FileUriExposedException. The recommended way to share the content of a private file is using the FileProvider.

跨包传递 file:// 可能造成接收方拿到一个不可访问的文件路径。而且,尝试传递 file:// URI 会引发 FileUriExposedException 异常。因此,我们建议使用 FileProvider 来分享私有文件。

也就是说,通过 Intent 传递 file:// 已不再被支持,否则会引发 FileUriExposedException 异常。如果未作应对,这将导致你的 APP 直接崩溃。

此文将分析这个问题,并且给出解决方案。

案例分析

你可能好奇在什么情况会出现这个问题。为了尽可能简单地说清楚,我们从一个例子入手。这个例子通过 Intent(action:ACTION_IMAGE_CAPTURE)来获取一张图片。以前我们只需要将目标文件路径以 file:// 格式作为 Intent extra 就能在 Android N 以下的系统上正常传递,但是会在 Android N 上造成 APP 崩溃。

核心代码如下(你也可以在 GitHub 上浏览或下载):

@RuntimePermissions
public class MainActivity extends AppCompatActivity implements View.OnClickListener {

    private static final int REQUEST_TAKE_PHOTO = 1;

    Button btnTakePhoto;
    ImageView ivPreview;

    String mCurrentPhotoPath;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        initInstances();
    }

    private void initInstances() {
        btnTakePhoto = (Button) findViewById(R.id.btnTakePhoto);
        ivPreview = (ImageView) findViewById(R.id.ivPreview);

        btnTakePhoto.setOnClickListener(this);
    }

    /////////////////////
    // OnClickListener //
    /////////////////////

    @Override
    public void onClick(View view) {
        if (view == btnTakePhoto) {
            MainActivityPermissionsDispatcher.startCameraWithCheck(this);
        }
    }

    ////////////
    // Camera //
    ////////////

    @NeedsPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)
    void startCamera() {
        try {
            dispatchTakePictureIntent();
        } catch (IOException e) {
        }
    }

    @OnShowRationale(Manifest.permission.WRITE_EXTERNAL_STORAGE)
    void showRationaleForCamera(final PermissionRequest request) {
        new AlertDialog.Builder(this)
                .setMessage("Access to External Storage is required")
                .setPositiveButton("Allow", new DialogInterface.OnClickListener() {
                    @Override
                    public void onClick(DialogInterface dialogInterface, int i) {
                        request.proceed();
                    }
                })
                .setNegativeButton("Deny", new DialogInterface.OnClickListener() {
                    @Override
                    public void onClick(DialogInterface dialogInterface, int i) {
                        request.cancel();
                    }
                })
                .show();
    }

    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        super.onActivityResult(requestCode, resultCode, data);
        if (requestCode == REQUEST_TAKE_PHOTO && resultCode == RESULT_OK) {
            // Show the thumbnail on ImageView
            Uri imageUri = Uri.parse(mCurrentPhotoPath);
            File file = new File(imageUri.getPath());
            try {
                InputStream ims = new FileInputStream(file);
                ivPreview.setImageBitmap(BitmapFactory.decodeStream(ims));
            } catch (FileNotFoundException e) {
                return;
            }

            // ScanFile so it will be appeared on Gallery
            MediaScannerConnection.scanFile(MainActivity.this,
                    new String[]{imageUri.getPath()}, null,
                    new MediaScannerConnection.OnScanCompletedListener() {
                        public void onScanCompleted(String path, Uri uri) {
                        }
                    });
        }
    }

    @Override
    public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
        MainActivityPermissionsDispatcher.onRequestPermissionsResult(this, requestCode, grantResults);
    }

    private File createImageFile() throws IOException {
        // Create an image file name
        String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date());
        String imageFileName = "JPEG_" + timeStamp + "_";
        File storageDir = new File(Environment.getExternalStoragePublicDirectory(
                Environment.DIRECTORY_DCIM), "Camera");
        File image = File.createTempFile(
                imageFileName,  /* prefix */
                ".jpg",         /* suffix */
                storageDir      /* directory */
        );

        // Save a file: path for use with ACTION_VIEW intents
        mCurrentPhotoPath = "file:" + image.getAbsolutePath();
        return image;
    }

    private void dispatchTakePictureIntent() throws IOException {
        Intent takePictureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
        // Ensure that there's a camera activity to handle the intent
        if (takePictureIntent.resolveActivity(getPackageManager()) != null) {
            // Create the File where the photo should go
            File photoFile = null;
            try {
                photoFile = createImageFile();
            } catch (IOException ex) {
                // Error occurred while creating the File
                return;
            }
            // Continue only if the File was successfully created
            if (photoFile != null) {
                Uri photoURI = Uri.fromFile(createImageFile());
                takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, photoURI);
                startActivityForResult(takePictureIntent, REQUEST_TAKE_PHOTO);
            }
        }
    }
}

当上面这段代码运行,屏幕上会显示一个按钮。点击按钮,相机 APP 会被启动来拍取一张照片。然后照片会显示到 ImageView

流程

上面这段代码逻辑并不复杂:在存储卡 /DCIM/ 目录创建一个临时图片文件,并以 file:// 格式发送到相机 APP 作为将要拍取图片的保存路径。

targetSdkVersion 仍为 23 时,这段代码在 Android N 上也工作正常,现在,我们改为 24 再试试。

android {
    ...
    defaultConfig {
        ...
        targetSdkVersion 24
    }
}

结果在 Android N 上崩溃了(在 Android N 以下正常),如图:


在 Android N 上崩溃

LogCat 日志如下:

FATAL EXCEPTION: main
    Process: com.inthecheesefactory.lab.intent_fileprovider, PID: 28905
    android.os.FileUriExposedException: file:///storage/emulated/0/DCIM/Camera/JPEG_20160723_124304_642070113.jpg exposed beyond app through ClipData.Item.getUri()
    at android.os.StrictMode.onFileUriExposed(StrictMode.java:1799)
    at android.net.Uri.checkFileUriExposed(Uri.java:2346)
    at android.content.ClipData.prepareToLeaveProcess(ClipData.java:832)
    ...

原因很明显了:Android N 已不再支持通过 Intent 传递 file://,否则会引发 FileUriExposedException 异常。

请留心,这是个大问题。如果你升级了 targetSdkVersion24,那么在发布新版本 APP 之前务必确保与之相关的问题代码都已经被修复,否则你的部分用户可能在使用新版本过程中遭遇崩溃。

为什么 Android N 不再支持通过 Intent 传递 “file://” scheme?

你可能会疑惑 Android 系统开发团队为什么决定做出这样的改变。实际上,开发团队这样做是正确的。

如果真实文件路径被发送到了目标 APP(上文例子中为相机 APP),那么不只是发送方,目标方对该文件也拥有了完全的访问权。


发送文件

让我们就上文例子彻底分析一下。实际上相机 APP【笔者注:下文简称为“B”】只是被我们的 APP【笔者注:下文简称为“A”】启动来拍取一张照片并保存到 A 提供的文件。所以对该文件的访问权应该只属于 A 而非 B,任何对该文件的操作都应该由 A 来完成而不是 B。

因此我们不难理解为什么自 API 24 起要禁止使用 file://,并要求开发者采用正确的方法。

解决方案

既然 file:// 不能用了,那么我们该使用什么新方法?答案就是发送带 content:// 的 URI(Content Provider 提供的 URI scheme),具体则是通过过 FileProvider 来共享文件访问权限。新流程如图:

流程

现在,通过 FileProvider,文件操作将和预想一样只在我们 APP 进程内完成。

下面就开始写代码。在代码中继承 FileProvider 很容易。首先需要在 AndroidManifest.xml<application> 节点下添加 FileProvider 所需的 <provider> 节点:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    ...
    <application
        ...
        <provider
            android:name="android.support.v4.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>

然后,在 /res/xml/ 目录(不存在则创建它)新建文件 provider_paths.xml,内容如下。其中描述了通过名 external_files 来共享对存储卡目录的访问权限到根目录(path=".")。
/res/xml/provider_paths.xml

<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
    <external-path name="external_files" path="."/>
</paths>

好,FileProvider 就声明完成了,待用。

最后一步,将 MainActivity.java

Uri photoURI = Uri.fromFile(createImageFile());

修改为

Uri photoURI = FileProvider.getUriForFile(MainActivity.this,
        BuildConfig.APPLICATION_ID + ".provider",
        createImageFile());

搞定!现在你的 APP 应该在任何 Android 版本上都工作正常,包括 Android N。

成功

此前已安装的 APP 怎么办?

正如你所见,在上面的实例中,只有在将 targetSdkVersion 升级到 24 时才会出现这个问题。因此,你以前开发的 APP 如果 targetSdkVersion 值为 23 或更小,它在 Android N 上运行也是不会出问题的。

尽管如此,按照 Android 最佳实践的要求,每当一个新的 API Level 发布时,我们最好跟着升级 targetSdkVersion,以期最佳用户体验。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 161,601评论 4 369
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 68,367评论 1 305
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 111,249评论 0 254
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,539评论 0 217
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,967评论 3 295
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,929评论 1 224
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 32,098评论 2 317
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,825评论 0 207
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,609评论 1 249
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,796评论 2 253
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,282评论 1 265
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,603评论 3 261
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,277评论 3 242
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,159评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,959评论 0 201
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 36,079评论 2 285
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,874评论 2 277

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 170,594评论 25 707
  • ¥开启¥ 【iAPP实现进入界面执行逐一显】 〖2017-08-25 15:22:14〗 《//首先开一个线程,因...
    小菜c阅读 6,218评论 0 17
  • 年少时,我常做的梦是会飞 飞过流淌的小溪 飞过耸立的群山 飞到家人的怀抱 长大后,我常做的梦是逃跑 躲在漆黑的夜里...
    寒更雨歇阅读 183评论 0 4
  • 一直很怀念在深圳过春节的日子 怀念那里湿润的海风 怀念那里直冲云天的椰子树 更怀念那里谦让高素质的人文环境 我第一...
    点点星625阅读 198评论 0 0
  • 本来想起一个高大上的名字,奈何自身文化素养实在不高,只能按照小学生的标准规规矩矩的叫读某某有感,也是因为名字这种东...
    _Beryl阅读 658评论 0 5