Android N不再支持通過Intent傳遞“file://”scheme

ifon9247 7年前發布 | 18K 次閱讀 Intent Scheme Android開發 移動開發

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

 

 本文由用戶 ifon9247 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!