ระวัง ! file:// ใช้งานแนบกับ Intent ไม่ได้แล้วบน Android Nougat พร้อมวิธีแก้ด้วย FileProvider

Posted on 23 Jul 2016 15:11 | 48576 reads | 0 shares
 

Android N กำลังจะออกแล้ว ก็ใกล้ถึงเวลาแล้วที่จะปรับ targetSdkVersion ขึ้นเป็น 24 เพื่อให้สนับสนุนฟีเจอร์ของ Android N อย่างสมบูรณ์แบบ

และเช่นเคย ทุกครั้งที่เราปรับ targetSdkVersion ขึ้น เราจะต้องไล่ดูก่อนว่ามีส่วนไหนที่เปลี่ยนแปลงและอาจจะทำให้แอปเกิดปัญหาบนแอนดรอยด์รุ่นใหม่บ้าง ถ้าแค่ปรับขึ้นแล้วปล่อยเลยแอปแครชแน่นอน

และนี่เป็นหนึ่งใน Checklist ของสิ่งที่ต้องตรวจสอบก่อนจะ Release แอปเวอร์ชั่นใหม่ กับข้อห้ามข้อนี้ที่เพิ่มขึ้นมาใน 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.

หรือพูดง่ายๆสั้นๆในภาษาไทยคือ ระบบห้ามส่ง URI แนบไปกับ Intent ในรูปแบบ file:// แล้ว และถ้าทำผลที่เกิดขึ้นคือมันจะ throw FileUriExposedException ออกมา ซึ่งจะส่งผลให้ไม่สามารถใช้งาน Intent นั้นได้ หรือถ้าไม่ได้ try catch ไว้แอปก็จะแครชเลยทันที

บล็อกนี้เลยจะมาพูดถึงปัญหานี้และวิธีการแก้ไขกันว่าเราจะแชร์การเข้าถึงไฟล์ระหว่างแอปกันอย่างไรถ้า file:// ไม่สามารถใช้งานได้แล้ว มาดูกันครับ

ตัวอย่างของปัญหาที่อาจเกิดขึ้น

หลายๆคนฟังแล้วอาจจะยังไม่เข้าใจว่าจะเกิดปัญหากับเคสไหน เพื่อให้เข้าใจง่ายทางเราก็ขอยกตัวอย่างเป็น Use Case ที่ใช้งานจริงเลยละกัน ตัวอย่างที่เห็นชัดที่สุดคือ การถ่ายรูปผ่าน Intent ชนิด ACTION_IMAGE_CAPTURE ซึ่งก่อนหน้านี้โค้ดตัวอย่างก่อน Android N ออกเราจะส่ง URI ด้วย file://  กัน ซึ่งโค้ดที่ว่าจะใช้งานได้บนทุกรุ่นแต่กลับ Crash บน Android N

อันนี้เป็นโค้ดที่ว่าครับ สามารถโหลดจาก GitHub เพื่อเริ่มต้น Tutorial ตัวนี้ได้เลย ทางเราเอาขึ้นไว้ให้เรียบร้อยแล้ว

@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);
            }
        }
    }
}

ซึ่งโค้ดด้านบนเมื่อรันแล้วจะได้แอปที่มี Button กับ ImageView เมื่อกดปุ่มแอปจะเปิดกล้องขึ้นมาและเมื่อกดถ่ายเสร็จก็จะได้ผลลัพธ์กลับมาแสดงผลบน ImageView เป็นอันเรียบร้อย

การทำงานก็ไม่มีอะไรมาก มันเป็นการ Generate Path ของไฟล์ใน External Storage ภายใต้โฟลเดอร์ DCIM จากนั้นก็ส่ง Path ที่ได้ไปให้แอปกล้องผ่านทาง file:// scheme พอกล้องถ่ายเสร็จก็จะบันทึกลง Path นั้นก่อนจะแจ้งกลับมาที่แอปเราว่าบันทึกเรียบร้อยแล้วก่อนที่เราจะเอามาแสดงผลกัน

โค้ดด้านบนใช้งานได้ปกติดีบนทุกรุ่นรวมถึง Android Nougat เพราะว่าเรากำหนด targetSdkVersion เป็น 23 ไว้ แต่ถ้าเราลองปรับ targetSdkVersion ขึ้นเป็น 24

android {
    ...
    defaultConfig {
        ...
        targetSdkVersion 24
    }
}

โค้ดก็จะทำงานได้ตามปกติบนทุกรุ่นก่อน N แต่จะ Crash บน Nougat ซะอย่างงั้น แบบนี้

ด้วย Stack Trace ดังนี้

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)
    ...

ก็จะเห็นว่า Error ชัดเจนมากว่า "file:// ห้ามใช้งานบน Nougat แล้ว" นั่นเอง ถ้าเกิดฝืนใช้จะเกิด FileUriExposedException ทันที

และนี่เองเป็นสิ่งที่คุณอาจจะเจอหากคุณมีการแนบ URI ด้วยรูปแบบ file:// ไปกับ Intent และต้องได้รับการแก้ไขก่อนจะปรับ targetSdkVersion เป็น 24 มิฉะนั้นแอปของท่านจะแครชแน่นอน

ทำไม Nougat ถึงห้ามส่ง URI ด้วย file:// แล้ว?

หลายคนอาจจะสงสัย ทำไมหนอทำไม แบบเดิมก็ใช้งานได้อยู่แล้ว ทำไมต้องห้ามกันด้วย?

จริงๆแนวคิดคือการส่ง Path ในรูปแบบ file:// ไปจะทำให้แอปปลายทางได้รับแค่ Path ไป และการเข้าถึงไฟล์ๆนั้นก็จะทำโดยสิทธิ์ของ Process แอปปลายทางล้วนๆ อย่างเช่นในกรณีนี้จะทำงานดังภาพ

แต่หากลองพิจารณาดีๆแล้ว จริงอยู่ที่แอปกล้องถูกเรียกขึ้นมาเพื่อถ่ายภาพและบันทึกเป็นไฟล์ แต่ถามว่าจริงๆแล้วสิทธิ์ในการเข้าถึงไฟล์นั้นควรจะเป็นของ Process ของแอปไหน? คำตอบคือมันควรจะเป็นของแอปเราซึ่งเป็นผู้เรียกแอปกล้องขึ้นมา เพราะว่าแอปกล้องเป็นแค่เครื่องมือในการถ่ายภาพให้เราเท่านั้นเอง จริงๆแล้วงานทั้งหมดเป็นของแอปเรา แอปเราจึงควรจะเป็นเจ้าของการเข้าถึงไฟล์ตัวนั้นด้วยตัวเองไม่ใช่แค่ส่ง Path ไปให้แอปกล้องจัดการ

นี่เองเป็นสาเหตุว่า file:// จึงถูกห้ามไม่ให้ส่งแนบไปกับ Intent อีกต่อไปบน Android Nougat เพื่อให้สิทธิ์ในการเข้าถึงไฟล์เป็นไปอย่างถูกต้องอย่างที่ควรจะเป็น

วิธีการแก้ปัญหา

แล้วถามว่าถ้าส่งผ่าน file:// ไม่ได้แล้วเราจะส่งผ่านอะไร? คำตอบคือเราจะส่งผ่าน content:// กันครับ ซึ่งมันคือ URL Scheme ของ Content Provider นั่นเอง และในที่นี้เราจะส่งสิทธิ์การเข้าถึงไฟล์ เราจึงจะใช้ FileProvider กันในงานนี้ครับ ภาพ Flow การทำงานจึงเปลี่ยนไปดังนี้

เพียงเท่านี้แอปก็จะสามารถส่งสิทธิ์การเข้าถึงไฟล์ไปให้แอปอื่นได้แล้ว และการบันทึกก็จะทำผ่านแอปของเราอย่างถูกต้อง

สำหรับการ Implement ก็ทำได้ไม่ยาก เพียงแค่ทำในรูปแบบของ FileProvider เท่านั้นเอง ตามนี้

ก่อนอื่นให้แปะ FileProvider เข้าไปเพิ่มใน AndroidManifest.xml ภายใต้ tag <application> ดังโค้ดด้านล่างนี้

AndroidManifest.xml

<?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>

จากนั้นให้สร้างโฟลเดอร์ xml ไว้ในโฟลเดอร์ res และสร้างไฟล์ชื่อ provider_paths.xml ขึ้นมา ซึ่งเราจะประกาศการเข้าถึง External Storage กันด้วยชื่อ external_files และเพื่อให้เราสามารถเข้าถึง External Storage ได้ทั้งหมดเราจึงระบุ 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 ให้แอปอื่นสามารถเข้าถึงไฟล์ผ่าน Process ของแอปเราแล้ว

สุดท้ายให้เปลี่ยนโค้ดบรรทัดนี้ใน MainActivity.java

Uri photoURI = Uri.fromFile(createImageFile());

ให้เป็น

Uri photoURI = FileProvider.getUriForFile(MainActivity.this,
        BuildConfig.APPLICATION_ID + ".provider",
        createImageFile());

เพียงเท่านี้โค้ดก็จะสามารถใช้งานได้อย่างสมบูรณ์แบบแล้วครับ เย้! ลองรันดูได้เลย =D

แล้วแอปที่ปล่อยไปแล้วจะเกิดปัญหาบน Android N มั้ย?

ก็จะเห็นจากการทดลองเองแล้วด้านบนว่า Behavior ใหม่นี้จะเกิดก็ต่อเมื่อเราปรับ targetSdkVersion เป็น 24 เท่านั้น ดังนั้นถ้าแอปเก่าของท่านใช้ targetSdkVersion ต่ำกว่านั้นก็จะยังไม่เกิดปัญหานี้ครับ ยังคงสามารถส่งเป็น file:// ไปได้อยู่และใช้งานได้ตามปกติ

อย่างไรก็ตาม Best Practices ของการพัฒนาแอปแอนดรอยด์คือเราควรจะปรับโค้ดให้ทันกับ Android รุ่นใหม่ล่าสุดเสมอ ดังนั้นเราเลยแนะนำให้ปรับเป็น 24 แล้วแก้ปัญหาให้ครบให้หมดครับ แอปของท่านจะได้สามารถใช้งานได้บนมือถือทุกรุ่นอย่างสมบูรณ์แบบนั่นเองครับ =)

ผู้เขียน: nuuneoi (Android GDE, CTO & CEO at The Cheese Factory)
นักพัฒนาแบบ Full-Stack ที่มีประสบการณ์ในการพัฒนาแอพฯแอนดรอยด์มากว่า 6 ปีและอยู่ในวงการพัฒนาแอพฯมือถือมากว่า 12 ปี มีความสนใจทางด้าน Infrastucture, Service Side, Design, UI&UX, Hardware, Optimization, Cooking, Photographing, Blogging, Training, Public Speaking และรักที่จะแชร์เรื่องราวให้ผู้คนได้อ่านได้ฟังกันผ่าน Blog