此文为笔者译文,版权归原作者所有。原文载于 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 上浏览或下载):

@RuntimePermissionspublic 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 节点下添加 FileProvider 所需的 节点:

<?xml version="1.0" encoding="utf-8"?>            

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

<?xml version="1.0" encoding="utf-8"?>    

好,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,以期最佳用户体验。

更多相关文章

  1. Android(安卓)获取包名,版本信息及VersionName名称
  2. Android定时器实现的几种方法
  3. Android(安卓)Intent 用法汇总
  4. Android(安卓)Audio代码分析13 - AudioTrack::getPosition函数
  5. Android:在Eclipse下开发android应用产生的问题及解决方法
  6. uiautomatorviewer.bat的使用
  7. Android(安卓)密码的隐藏和显示
  8. android获取应用基本信息
  9. Android(安卓)DEX安全攻防战

随机推荐

  1. Android中Matrix介绍
  2. Android Video Player. 安卓视频播放器,封
  3. Android与JavaSE游戏引擎LGame-0.3.1版正
  4. 体验Android
  5. Android Launcher7.0首次数据加载逻辑
  6. android ArrayList和数组之间的转换
  7. StrictMode总结
  8. dl-ssl.google.com
  9. android面试题
  10. Eclipse下Android开发,如何查看 API源代码