Android

Khắc phục FileUriExposedException trên Android 7 về sau

Từ thuở ban sơ tới phiên bản Android 6.0 Marshmallow, việc lấy Uri của một tập tin trên Android là vô cùng đơn giản. Tuy nhiên, nếu làm theo cách cũ trên Android 7.0 Nougat với mức API24 thì ứng dụng sẽ hiện cái bảng dừng bên dưới ra và các bạn chưa biết về điều mà tôi sắp nói sẽ xanh mặt lên như tàu lá chuối, quay đi quẩn lại, đọc code cả chục lần mà không thấy lỗi đâu, và quả thực là nó chạy ngon cơm trên các phiên bản Android với mức API23 trở xuống. Có sai chỗ nào đâu?

Unfortunately-app-has-stoppedTrước khi đọc tiếp, bạn có thể chửi bới đội phát triển Android của Google thoải mái, vì lí do họ đưa ra cho sự thay đổi không thuyết phục cho lắm. Nhưng thôi, hàng của họ, họ muốn làm gì mà chẳng được, nên cứ phải ngậm bồ hòn làm ngọt cho rồi.

1. Cách lấy Uri từ một tập tin theo cách “cổ điển”:

Thực ra không phải chỉ có một cách. Cách đầu tiên, bạn dùng một static method liên quan trực tiếp đến File class. Đó là Uri.fromFile(File). Chẳng hạn:

Tuy nhiên, cũng có một cách khác tương đối đơn giản hơn. Xuất phát từ việc bạn gọi Uri#toString sẽ return ra kết quả “file://” + path với path được định nghĩa như trong phần code, cách dùng như bên dưới có phần đơn giản, và tôi thấy cũng phổ biến hơn:

Vậy là bạn đã lấy được Uri từ một tập tin để “làm việc” với nó. Chẳng hạn, gửi kèm Intent qua putExtra(String key, Parcelable extra) do Uri implements Parcelable interface, sang Activity khác hay bind vào Service để Service tiến hành upload tập tin đó lên “sẹc-vơ” nào đó. Tuy nhiên, với Android 7.0 Nougat ứng với mức API24, thì nó sẽ throw một Exception vô cùng lạ lẫm với bạn: FileUriExposedException. Nó là cái con cá con tôm gì vậy? Exposed là sao? Có gì bị “phơi bày” đâu? Và tự dưng sao thảy ra tên này? Trước đây có đâu?

2. FileUriExposedException là gì?

(Added in API24) The exception that is thrown when an application exposes a file:// Uri to another app. This exposure is discouraged since the receiving app may not have access to the shared path. For example, the receiving app may not have requested the READ_EXTERNAL_STORAGE runtime permission, or the platform may be sharing the Uri across user profile boundaries. Instead, apps should use content:// Uris so the platform can extend temporary permission for the receiving app to access the resource.

Nếu bạn lười đọc hết phần giải thích, theo tôi, là không thỏa đáng chút nào, thì ngắn gọn là tên Exception này sẽ được thảy ra khi bạn gửi một Uri có tiền tố là “file://” sang ứng dụng khác. Lí do được đưa ra là vì trong đại đa số các trường hợp, bạn gửi Uri của file sang ứng dụng khác thì file đó đều nằm ở bộ nhớ chính và ứng dụng đích lại không khai báo quyền READ_EXTERNAL_STORAGE, dó đó ứng dụng đích không truy cập được tới tập tin kia. Mặt khác, hệ thống Android có thể sẽ làm “lộ hàng” địa chỉ tập tin một cách toàn cục trên hệ thống giữa các profiles. Tuy nhiên, rõ ràng lời giải thích cũng như việc ngăn chặn này là rất không thỏa đáng. Một là khi code một app với khả năng gửi Intent kèm Uri của một tập tin sang ứng dụng khác, bạn phải hiểu rõ cái ứng dụng bên kia có khả năng nhận Intent và “moi” cái tập tin ra được không, cụ thể hơn là ứng dụng đầu bên kia có quyền READ_EXTERNAL_STORAGE hay không chứ! Đâu phải là cứ code vô tội vạ, mù mịt được. Và nếu ứng dụng đầu bên kia không có quyền READ_EXTERNAL_STORAGE thì đó là lỗi của LTV bên kia, không phải của bạn, dù bạn đang gửi implicit Intent đi chăng nữa. Thứ hai, lỗi làm “lộ hàng” Uri trên toàn hệ thống là lỗi của hệ điều hành chứ và nếu có “lộ liễu” thì cũng không riêng gì (các) tập tin mà bạn đang truy cập tới. Một lí do khác là việc tránh né, tối ưu hóa code cho Android 7.0 về sau không đảm bảo tính tương thích ngược lên các phiên bản Android trước và bạn lại phải dùng lại code cũ với cặp if (Build.VERSION.SDK_INT <= 23) và else khá phiền phức, nếu không sẽ lại gây dừng trên các phiên bản Android cũ. Do đó, tôi nghĩ đây chỉ là hành động làm khó coder cho vui – vốn là xu hướng mới ở Google vì ngoài cái Exception này ra cũng có nhiều cái thay đổi khác mang tính “kì cục”.

3. Tránh FileUriExposedException:

Phàm là vậy nhưng rốt cục chúng ta vẫn phải chơi theo luật của “The Big G” nếu muốn nâng targetSdk lên mức 24 trở lên. Còn nếu “cố thủ” ở mức 23 thì không cần vụ này. Bây giờ, việc đầu tiên của chúng ta cần làm là “thuê” tên FileProvider vào qua việc tạo một class extends tên này, vốn thuộc package android.support.v4.content. Nếu bạn đã có bộ thư viện appcompat-v7 trong build.gradle module app hoặc lib thì không cần yêu cầu implementation vào, còn nếu chưa có thì hãy implementation package V4 Support Library. Thông tin thêm là tên FileProvider này extends ContentProvider. Tạm thời bạn để trống class này, ngoài ra bạn có thể đặt tên khác cho class tùy thích.

Tiếp tới, bạn khai báo một thẻ <provider> trong Manifest. Trong đó, phần android:name sẽ trỏ tới class <? extends FileProvider> bên trên, và phần android:authorities sẽ gồm packageName của bạn và một dãy kí tự tùy ý bạn ở phía sau. Ở đây để tiện theo dõi thì tôi sẽ gán một giá trị mặc định. Các phần khác thì bạn phải cóp pát “sao y bản chính” vào vì đó là bắt buộc rồi. Chẳng hạn:

Bạn lưu ý chỗ ${applicationId}, bạn dùng luôn chuỗi này thì nó sẽ tự chuyển thành packageName khi build ra APK. Do đó bạn không cần đánh lại tên packageName, và cũng không nên, để tránh sai sót và quan trọng hơn hết, là tránh bảng dừng hắc ám. Vấn đề còn lại là bạn sẽ có những gì trong tập tin @xml/provider_paths? Thực ra cũng không có gì phức tạp, nó chỉ chứa mấy dòng đơn giản như bên dưới và bạn phải cóp pát:

Vậy là xong phần khai báo và định nghĩa. Bây giờ, ta sang phần sử dụng. Để lấy Uri của một tập tin, bạn sẽ định nghĩa một method trong class vừa tạo, tương tự như tôi làm:

Bạn để ý tham số String thứ hai trong FileProvider.getUriForFile(Context context, String authorities, File file). Nó chính là giá trị của android:authorities đã khai báo trong Manifest. Nhưng chưa xong đâu. Dave Burke và đồng bọn (xin mời Google ông này) vẫn còn chưa buông tha chúng ta đâu. Khi bạn gửi một Intent kèm Uri của file sang nơi khác, bạn sẽ phải làm một trong hai công việc sau:

Một là phải kèm theo ít nhất một trong các flag sau trong class Intent: FLAG_GRANT_READ_URI_PERMISSION, FLAG_GRANT_WRITE_URI_PERMISSION, FLAG_GRANT_PERSISTABLE_URI_PERMISSION hoặc FLAG_GRANT_PREFIX_URI_PERMISSION. Thông thường thì người ta thường dùng tên đầu tiên (=1), hoặc nếu cần thì dùng tổ hợp của các flag với toán từ bitwise ( | ).

Hai là gọi Context#grantUriPermission(String toPackage, Uri uri, int modeFlags) với toPackage là package nhận Intentflags như bên trên. Cách này có hạn chế là chỉ dùng được với explicit Intent do phải gọi đúng tên toPackage ra, còn implicit Intent thì tên package được nhận là do người dùng chọn nên ở đây không thể lấy chính xác được. Tuy nhiên nó cũng có cái hay là nếu toPackage không có thì nó sẽ thảy ra PackageManager.NameNotFoundException và bạn có thể thông báo tới người dùng là hãy tìm cài đặt app nhận Intent đi.

Vậy là chúng ta đã đối phó xong với các mức API 24, 25, 26 và 27 rồi. Nhưng vẫn chưa xong đâu. Vì như tôi nói lúc nãy, tính tương thích ngược là quá kém.

4. Đảm bảo tính tương thích ngược về các mức API cũ:

Bây giờ tôi lại phải tốn công giải thích lí thuyết suông và thực tế thì bạn cũng không cần nhớ “chính xác tới từng chi tiết” để làm chi cho mệt mỏi. Nếu rảnh rỗi thì bạn cho Log cái Uri lấy theo kiểu bên trên ra dưới dạng String, bạn sẽ thấy tên này sẽ bắt đầu bằng tiền tố “content://“. Đương nhiên rồi vì FileProvider extends ContentProvider mà. Và nếu ứng dụng đích không hỗ trợ việc lấy file qua ContentProvider hay đúng hơn là lấy file với uri của file bắt đầu bằng “content://” trên Android 6 về trước thì… Tèn ten ten, cái bảng dừng lại ám toán người dùng một lần nữa, mà ác đạn hơn nữa là cái hệ thống Android báo lỗi là do ứng dụng của bạn, ứng dụng của bạn bị dừng chứ không phải cái ứng dụng đích bị dừng đâu! Phức tạp chưa? Hơn nữa, việc lấy file qua uri dưới dạng provider chỉ khả dụng trên Android 5.0 về sau. Do đó, một lần nữa, bạn sẽ thấy việc restrict này chẳng có tác dụng gì ngoài việc đánh đố các LTV cả.

Cách tốt nhất là bạn nên dùng cách “mới” này trên API24 về sau. Còn API23 về trước thì cứ dùng cách cổ điển. Chẳng hạn, tôi thay đổi cái class của tôi như sau:

Xong xuôi và gọn gàng. Và bây giờ để lấy Uri từ một file thì bạn chỉ cần sử dụng method bên trên. Lưu ý là bạn phải có Context không null, chí ít là trên API24 về sau, còn với API23 ngược về trước thì param context có null cũng đâu ảnh hưởng gì vì có cần dùng tới nó đâu! Chúc các bạn xử lí thành công cái Exception không nên có này. Hẹn gặp các bạn trong các bài viết sau, nhưng trong thời gian chờ đợi, bạn có thể Log cái Uri dưới dạng provider ra để xem nó có gì khác với với Uri dạng trực tiếp. Lưu ý là bạn đừng quên grantUriPermission trước khi gửi Uri này kèm Intent sang nơi khác.

1 thought on “Khắc phục FileUriExposedException trên Android 7 về sau”

  1. Bài viết này tương đối OK về content cũng như support được cho rất nhiều bạn có thể xử lý file trên Android mới nếu gặp đúng vấn đề như bạn.

    Tuy nhiên, bản thân mình đánh giá, bài viết của bạn còn thiếu 1 vài yếu tố có thể khiến cho người đọc nếu không biết gì về sharing files và FileProvider có ý nghĩa gì trong android mới sẽ bị hiểu nhầm các điều sau:
    1. Có thể lấy Uri của bất kỳ file nào trong hệ thống cũng được miễn là làm y chang.
    2. Có thể lấy Uri của bất kỳ app nào hoặc ở đâu cũng được miễn làm y chang.
    3. Sau khi làm y chang, permission read/write sau khi lấy được Uri là vĩnh viễn và có thể dùng hoài Uri đó.

    Bản thân mình thấy bài viết đưa ra thiếu cái ngữ cảnh vì sao phải dùng FileProvider. Thực ra, bạn làm những điều trên chỉ khi bạn cần share resources dạng file của App bạn cho các ứng dụng khác xài. Tức là bạn hoàn toàn có thể share 1 folder/subfolder/cache-folder internal app của bạn (bình thường app khác không truy xuất vô directory/file của app bạn được) cho app khác xài. Các bản android mới sau này chia application thành app-client và app-server. App server là app có thể share dirctory/file cho app khác; App client là app dùng file được share bởi app khác. Ví dụ: bạn muốn request album, photo từ app photo, thì app bạn là app client và app photo là app server.

    Còn cụ thể như thế nào thì bạn đọc cần phải tìm hiểu kỹ và hiểu đúng qua các documents
    + Secure File sharing: https://developer.android.com/training/secure-file-sharing/
    + FileProvider: https://developer.android.com/reference/android/support/v4/content/FileProvider

    Mình post đây hi vọng giúp các bạn khác đọc xong không bị hiểu nhầm và có thêm thông tin để tham khảo.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.