Android N不再支持通过Intent传递“file://”scheme
ifon9247
8年前
<p>Android N即将正式发布。作为 Android 开发者,我们需要准备好将 targetSdkVersion 升级到最新的 24 ,以使 APP 在新系统上也正常运行。</p> <p>和以往每次升级 targetSdkVersion 一样,我们需要仔细检查每一块代码并确保其依旧工作正常。简单修改 targetSdkVersion 值是不行的,如果只这么干,那么你的 APP 将有很大风险在新系统上出现问题甚至崩溃。因此,当你升级 targetSdkVersion 到 24 时,针对性地检查并优化每一个功能模块。</p> <p>Android N 在安全性方面有了大变化,以下就是一项需要注意之处:</p> <p>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 .</p> <p>跨包传递 file:// 可能造成接收方拿到一个不可访问的文件路径。而且,尝试传递 file:// URI 会引发 FileUriExposedException 异常。因此,我们建议使用 FileProvider 来分享私有文件。</p> <p>也就是说,通过 Intent 传递 file:// 已不再被支持,否则会引发 FileUriExposedException 异常。如果未作应对,这将导致你的 APP 直接崩溃。</p> <p>此文将分析这个问题,并且给出解决方案。</p> <h3>案例分析</h3> <p>你可能好奇在什么情况会出现这个问题。为了尽可能简单地说清楚,我们从一个例子入手。 这个例子通过 Intent (action: ACTION_IMAGE_CAPTURE )来获取一张图片。 以前我们只需要将目标文件路径以 file:// 格式作为 Intent extra 就能在 Android N 以下的系统上正常传递,但是会在 Android N 上造成 APP 崩溃。</p> <p>核心代码如下:</p> <pre> <code class="language-java">@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); } } } }</code></pre> <p>当上面这段代码运行,屏幕上会显示一个按钮。点击按钮,相机 APP 会被启动来拍取一张照片。然后照片会显示到 ImageView 。</p> <p style="text-align:center"><img src="https://simg.open-open.com/show/7e4551f6681ce1a10c9a1ae633080144.jpg"></p> <p style="text-align:center">流程</p> <p>上面这段代码逻辑并不复杂:在存储卡 /DCIM/ 目录创建一个临时图片文件,并以 file:// 格式发送到相机 APP 作为将要拍取图片的保存路径。</p> <p>当 targetSdkVersion 仍为 23 时,这段代码在 Android N 上也工作正常,现在,我们改为 24 再试试。</p> <pre> <code class="language-java">android { ... defaultConfig { ... targetSdkVersion 24 } }</code></pre> <p>结果在 Android N 上崩溃了(在 Android N 以下正常),如图:</p> <p style="text-align:center"><img src="https://simg.open-open.com/show/422274a503ad05f22032668fea113316.jpg"></p> <p style="text-align:center">在 Android N 上崩溃</p> <p>LogCat 日志如下:</p> <pre> <code class="language-java">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) ...</code></pre> <p>原因很明显了:Android N 已不再支持通过 Intent 传递 file:// ,否则会引发 FileUriExposedException 异常。</p> <p>请留心,这是个大问题。如果你升级了 targetSdkVersion 到 24 ,那么在发布新版本 APP 之前务必确保与之相关的问题代码都已经被修复,否则你的部分用户可能在使用新版本过程中遭遇崩溃。</p> <h3>为什么 Android N 不再支持通过 Intent 传递 “file://” scheme?</h3> <p>你可能会疑惑 Android 系统开发团队为什么决定做出这样的改变。实际上,开发团队这样做是正确的。</p> <p>如果真实文件路径被发送到了目标 APP(上文例子中为相机 APP),那么不只是发送方,目标方对该文件也拥有了完全的访问权。</p> <p style="text-align:center"><img src="https://simg.open-open.com/show/c0d5eee73cacbf88f4cdd8fb503a215a.jpg"></p> <p style="text-align:center">发送文件</p> <p>让我们就上文例子彻底分析一下。实际上相机 APP <em>【笔者注:下文简称为“B”】</em> 只是被我们的 APP <em>【笔者注:下文简称为“A”】</em> 启动来拍取一张照片并保存到 A 提供的文件。所以对该文件的访问权应该只属于 A 而非 B,任何对该文件的操作都应该由 A 来完成而不是 B。</p> <p>因此我们不难理解为什么自 API 24 起要禁止使用 file:// ,并要求开发者采用正确的方法。</p> <h3>解决方案</h3> <p>既然 file:// 不能用了,那么我们该使用什么新方法?答案就是发送带 content:// 的 URI( <strong>Content Provider</strong> 提供的 URI scheme),具体则是通过过 FileProvider 来共享文件访问权限。新流程如图:</p> <p style="text-align:center"><img src="https://simg.open-open.com/show/ef4fe579497333709a88435991c56872.jpg"></p> <p style="text-align:center">流程</p> <p>现在,通过 FileProvider ,文件操作将和预想一样只在我们 APP 进程内完成。</p> <p>下面就开始写代码。在代码中继承 FileProvider 很容易。首先需要在 AndroidManifest.xml 中 <application> 节点下添加 FileProvider 所需的 <provider> 节点:</p> <pre> <code class="language-java"><?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></code></pre> <p>然后,在 /res/xml/ 目录(不存在则创建它)新建文件 provider_paths.xml ,内容如下。其中描述了通过名 external_files 来共享对存储卡目录的访问权限到根目录( path="." )。</p> <p>/res/xml/provider_paths.xml</p> <pre> <code class="language-java"><?xml version="1.0" encoding="utf-8"?> <paths xmlns:android="http://schemas.android.com/apk/res/android"> <external-path name="external_files" path="."/> </paths></code></pre> <p>好, FileProvider 就声明完成了,待用。</p> <p>最后一步,将 MainActivity.java 中</p> <pre> <code class="language-java">Uri photoURI = Uri.fromFile(createImageFile());</code></pre> <p>修改为</p> <pre> <code class="language-java">Uri photoURI = FileProvider.getUriForFile(MainActivity.this, BuildConfig.APPLICATION_ID + ".provider", createImageFile());</code></pre> <p>搞定!现在你的 APP 应该在任何 Android 版本上都工作正常,包括 Android N。</p> <p style="text-align:center"><img src="https://simg.open-open.com/show/bff6c9c5e8f3795acfc5f9b00beb34a8.jpg"></p> <p style="text-align:center">成功</p> <h3>此前已安装的 APP 怎么办?</h3> <p>正如你所见,在上面的实例中,只有在将 targetSdkVersion 升级到 24 时才会出现这个问题。因此,你以前开发的 APP 如果 targetSdkVersion 值为 22 或更小,它在 Android N 上也是不会出问题的。</p> <p>尽管如此,按照 <strong>Android 最佳实践</strong> 的要求,每当一个新的 <strong>API Level</strong> 发布时,我们最好跟着升级 targetSdkVersion ,以期最佳用户体验。</p> <p> </p> <p>来自:http://www.jianshu.com/p/0daa171be9dd</p> <p> </p>