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

ifon9247 7年前
   <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>