Android N กำลังจะออกแล้ว ก็ใกล้ถึงเวลาแล้วที่จะปรับ targetSdkVersion ขึ้นเป็น 24 เพื่อให้สนับสนุนฟีเจอร์ของ Android N อย่างสมบูรณ์แบบ
และเช่นเคย ทุกครั้งที่เราปรับ targetSdkVersion ขึ้น เราจะต้องไล่ดูก่อนว่ามีส่วนไหนที่เปลี่ยนแปลงและอาจจะทำให้แอปเกิดปัญหาบนแอนดรอยด์รุ่นใหม่บ้าง ถ้าแค่ปรับขึ้นแล้วปล่อยเลยแอปแครชแน่นอน
และนี่เป็นหนึ่งใน Checklist ของสิ่งที่ต้องตรวจสอบก่อนจะ Release แอปเวอร์ชั่นใหม่ กับข้อห้ามข้อนี้ที่เพิ่มขึ้นมาใน Android N
Passingfile://
URIs outside the package domain may leave the receiver with an unaccessible path. Therefore, attempts to pass afile://
URI trigger aFileUriExposedException
. The recommended way to share the content of a private file is using theFileProvider
.
หรือพูดง่ายๆสั้นๆในภาษาไทยคือ ระบบห้ามส่ง 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
|