此文为笔者译文,版权归原作者所有。原文载于 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 上浏览或下载):
@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 以下正常),如图:
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
中 <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
,以期最佳用户体验。