Android N不再支持通過Intent傳遞“file://”scheme
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 a file:// URI trigger a FileUriExposedException . The recommended way to share the content of a private file is using the FileProvider .
跨包傳遞 file:// 可能造成接收方拿到一個不可訪問的文件路徑。而且,嘗試傳遞 file:// URI 會引發 FileUriExposedException 異常。因此,我們建議使用 FileProvider 來分享私有文件。
也就是說,通過 Intent 傳遞 file:// 已不再被支持,否則會引發 FileUriExposedException 異常。如果未作應對,這將導致你的 APP 直接崩潰。
此文將分析這個問題,并且給出解決方案。
案例分析
你可能好奇在什么情況會出現這個問題。為了盡可能簡單地說清楚,我們從一個例子入手。 這個例子通過 Intent (action: ACTION_IMAGE_CAPTURE )來獲取一張圖片。 以前我們只需要將目標文件路徑以 file:// 格式作為 Intent extra 就能在 Android N 以下的系統上正常傳遞,但是會在 Android N 上造成 APP 崩潰。
核心代碼如下:
@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 以下正常),如圖:
在 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 值為 22 或更小,它在 Android N 上也是不會出問題的。
盡管如此,按照 Android 最佳實踐 的要求,每當一個新的 API Level 發布時,我們最好跟著升級 targetSdkVersion ,以期最佳用戶體驗。
來自:http://www.jianshu.com/p/0daa171be9dd