译: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 将有很大风险在新系统上出现问题甚至崩溃。因此,当你升级 targetSdkVersion
到 24
时,针对性地检查并优化每一个功能模块。
Android N 在安全性方面有了大变化,以下就是一项需要注意之处:
Passing
file://
URIs outside the package domain may leave the receiver with an unaccessible path. Therefore, attempts to pass afile://
URI trigger aFileUriExposedException
. The recommended way to share the content of a private file is using theFileProvider
.跨包传递
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
异常。
请留心,这是个大问题。如果你升级了 targetSdkVersion
到 24
,那么在发布新版本 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
,以期最佳用户体验。
更多相关文章
- Android(安卓)获取包名,版本信息及VersionName名称
- Android定时器实现的几种方法
- Android(安卓)Intent 用法汇总
- Android(安卓)Audio代码分析13 - AudioTrack::getPosition函数
- Android:在Eclipse下开发android应用产生的问题及解决方法
- uiautomatorviewer.bat的使用
- Android(安卓)密码的隐藏和显示
- android获取应用基本信息
- Android(安卓)DEX安全攻防战