前言

隨安卓版越來越高,對隱私保護力度亦越來越大。從Android6.0動態許可權控制(Runtime Permissions)到Android7.0私有目錄限訪、StrictMode API政策等的更改,這些更改在為使用者帶來更加安全的作業系統的同時也為開發者做應用適配帶來了一些新的任務,所以我們有必要對其進行了解,這也是我整理這篇文章的原因。

錯誤描述

在Android7.0系統上。Android 框架強制運行了 StrictMode API 政策禁止向你的應用外公開 file:// URI。
給其他應用傳遞 file:// URI 型別的Uri,可能會導致接受者無法訪問該路徑。 因此,如果在Android7.0及以上系統嘗試傳遞 file:// URI 就會觸發 FileUriExposedException異常,如呼叫系統相機拍照或者裁剪照片,呼叫系統的安裝程式執行apk的安裝等等操作,如果不適配在Android 7.0 及以上系統就會出現應用崩潰的現象。

關於Android 7.0應用間共享檔案的適配方案

在Android 7.0 之前我們通過File生成Uri的程式碼通常是這樣的:

File picFile = new File(pathString);
Uri picUri = Uri.fromFile(picFile);

這樣生成的Uri的路徑格式為file://xxx。這種Uri是無法在應用之間共享的,我們需要生成content://xxx型別的Uri,要獲取這種型別的Uri最簡單方式是使用 FileProvider類。

FileProvider類簡介

FileProvider是一個特殊的ContentProvider子類,它通過為檔案建立一個Content:/ / Uri而不是file:/ / Uri,從而促進與應用程式關聯的檔案的安全共享。

FileProvider的使用

FileProvider使用大概分為以下幾個步驟:
  1,在manifest清單檔案裡註冊provider
  2,res/xml中定義對外暴露的資料夾路徑
  3,生成content://型別的Uri
  4,給Uri授予臨時許可權
  5,使用Intent傳遞Uri

1,在manifest清單檔案裡註冊provider

<manifest>
  ...
  <application>
    ...
    <provider
        android:name="android.support.v4.content.FileProvider"
        android:authorities="com.demo.fileprovider"
        android:exported="false"  // 必須為false
        android:grantUriPermissions="true">   // 必須為true才具有臨時共享許可權
        <meta-data
            android:name="android.support.FILE_PROVIDER_PATHS"
            android:resource="@xml/file_paths" />
    </provider>
    ...
  </application>
</manifest>

exported:要求必須為false,為true則會報安全異常。grantUriPermissions:true,設定為true你才能獲取臨時共享許可權。
android:name:provider你可以使用v4包提供的FileProvider,或者自定義的,只需要在name申明就好了,一般使用系統的就足夠了。
android:authorities:類似schema,名稱空間之類,可以自定義,後面會用到。
2,res/xml中定義對外暴露的資料夾路徑
為了指定共享的資料夾我們須要在資源(res)資料夾下建立一個xml資料夾,然後建立一個名為“file_paths”(名字能夠隨便起,但是要和在manifest註冊的provider所引用的resource保持一致)的資原始檔。內容例如以下:

<?xml version="1.0" encoding="utf-8"?>
<paths>
    <external-path
        name="external_files"
        path="" />
</paths>

name:一個引用字串,用於給訪問路徑起個名字。
path:資料夾“相對路徑”,完整路徑取決於當前的標籤型別。

上述程式碼中path="",是有特殊意義的,它代表根資料夾。也就是說你能夠向其它的應用共享根資料夾及其子資料夾下任何一個檔案了,假設你將path設為path=”pictures”,
那麼它代表著根資料夾下的pictures資料夾(eg:/storage/emulated/0/pictures),假設你向其它應用分享pictures資料夾範圍之外的檔案是不行的。

paths這個元素內可以包含以下一個或多個,具體如下:

<files-path name="name" path="path" />

物理路徑相當於Context.getFilesDir() + /path/。

<cache-path name="name" path="path" />

物理路徑相當於Context.getCacheDir() + /path/。

<external-path name="name" path="path" />

物理路徑相當於Environment.getExternalStorageDirectory() + /path/。

<external-files-path name="name" path="path" />

物理路徑相當於Context.getExternalFilesDir(String) + /path/。

<external-cache-path name="name" path="path" />

物理路徑相當於Context.getExternalCacheDir() + /path/。
3,生成content://型別的Uri

File apkFile=new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),"app-release.apk");
String authorities = "com.example.aa.fileProvider";
Uri uriForFile = FileProvider.getUriForFile(this, authorities, apkFile);

上面注意authorities應該與清單檔案中配置的authorities屬性值保持一致。
4,給Uri授予臨時許可權

intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
               | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);

FLAG_GRANT_READ_URI_PERMISSION:表示讀取許可權;
FLAG_GRANT_WRITE_URI_PERMISSION:表示寫入許可權。
5,使用Intent傳遞Uri
如在app版本更新適配7.0時:

install.setDataAndType(uriForFile,"application/vnd.android.package-archive");
startActivity(install);

app版本更新適配7.0時,呼叫系統安裝應用完成apk安裝的程式碼如下:

要在當前應用和系統安裝應用之間共享下載好的apk檔案,所以要適配。

private void installAPK() {
     Intent install = new Intent(Intent.ACTION_VIEW);
     // 找到下載好的apk
     File apkFile=new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),"new.apk");
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N){
               String authorities = "com.example.aa.fileProvider";
               Uri uriForFile = FileProvider.getUriForFile(this, authorities, apkFile);
               install.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);               
               install.setDataAndType(uriForFile,"application/vnd.android.package-archive");
          }else {
                install.setDataAndType(Uri.fromFile(apkFile), "applicati on/vnd.android.package-archive");
          }
      install.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
      startActivity(install);
    }

拍照適配

String cachePath = getApplicationContext().getExternalCacheDir().getPath();
File picFile = new File(cachePath, "test.jpg");
Uri picUri = Uri.fromFile(picFile);
Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
intent.putExtra(MediaStore.EXTRA_OUTPUT, picUri);
startActivityForResult(intent, 100);

這是常見的開啟系統相機拍照的程式碼,拍照成功後,照片會儲存在picFile檔案中。
這段程式碼在Android 7.0之前是沒有任何問題,但是如果你嘗試在7.0的系統上執行,會丟擲FileUriExposedException異常,為了適配7.0我們修改為如下所示:

File imagePath = new File(Context.getFilesDir(), "images");
if (!imagePath.exists()){imagePath.mkdirs();}
File newFile = new File(imagePath, "default_image.jpg");
Uri contentUri = getUriForFile(getContext(), 
                 "com.mydomain.fileprovider", newFile);
Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
intent.putExtra(MediaStore.EXTRA_OUTPUT, contentUri);
// 授予目錄臨時共享許可權
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
               | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
startActivityForResult(intent, 100); 

裁剪適配

File file=new File(Environment.getExternalStorageDirectory(), "/temp/"+System.currentTimeMillis() + ".jpg");
if (!file.getParentFile().exists())file.getParentFile().mkdirs();
Uri outputUri = Uri.fromFile(file);
Uri imageUri=Uri.fromFile(new File("/storage/emulated/0/temp/1474960080319.jpg"));
Intent intent = new Intent("com.android.camera.action.CROP");
intent.setDataAndType(imageUri, "image/*");
intent.putExtra("crop", "true");
intent.putExtra("aspectX", 1);
intent.putExtra("aspectY", 1);
intent.putExtra("scale", true);
intent.putExtra(MediaStore.EXTRA_OUTPUT, outputUri);
intent.putExtra("outputFormat", Bitmap.CompressFormat.JPEG.toString());
intent.putExtra("noFaceDetection", true); // no face detection
startActivityForResult(intent,1008);

和拍照一樣。上述程式碼在Android7.0上相同會引起android.os.FileUriExposedException異常,解決的方法就是上文說說的使用FileProvider,將上述程式碼改為例如以下就可以:

File file=new File(Environment.getExternalStorageDirectory(), "/temp/"+System.currentTimeMillis() + ".jpg");
if (!file.getParentFile().exists())file.getParentFile().mkdirs();
Uri outputUri = FileProvider.getUriForFile(context, "com.jph.takephoto.fileprovider",file);
Uri imageUri=FileProvider.getUriForFile(context, "com.jph.takephoto.fileprovider", new File("/storage/emulated/0/temp/1474960080319.jpg");//通過FileProvider建立一個content型別的Uri
Intent intent = new Intent("com.android.camera.action.CROP");
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
intent.setDataAndType(imageUri, "image/*");
intent.putExtra("crop", "true");
intent.putExtra("aspectX", 1);
intent.putExtra("aspectY", 1);
intent.putExtra("scale", true);
intent.putExtra(MediaStore.EXTRA_OUTPUT, outputUri);
intent.putExtra("outputFormat", Bitmap.CompressFormat.JPEG.toString());
intent.putExtra("noFaceDetection", true); // no face detection
startActivityForResult(intent,1008);