專案中有下面這樣一段程式碼,在 Android T 版本執行正常,現在適配到 Android U 上之後,執行時 crash 了。。。。
...
values.put(MediaStore.Images.Media.DATA, file.absolutePath)
values.put(MediaStore.Images.Media.DISPLAY_NAME, file.name)
...
resolver.update(uri, values, null, null)
大概的錯誤資訊如下:
因為涉及到 Android 的媒體許可權,這篇文章主要是針對 Android 媒體許可權做的一些總結。
隨著 Android 版本迭代,官方也在不斷優化 Android 資料儲存方式,其中涉及到資料儲存的效能、安全性、使用者隱私等諸多因素。
如今,最新的官方檔案的介紹如下:
Android 使用的檔案系統提供瞭如下幾種儲存應用資料的選項:
檔案型別 | 內容型別 | 存取方法 | 所需許可權 | 其它應用是否可以存取 | 解除安裝應用時是否移除檔案 |
---|---|---|---|---|---|
應用專屬檔案 | 僅供您的應用使用的檔案 | 從內部儲存空間存取,可以使用 getFilesDir() 或 getCacheDir() 方法從外部儲存空間存取,可以使用 getExternalFilesDir() 或 getExternalCacheDir() 方法 | 從內部儲存空間存取,可以使用 getFilesDir() 或 getCacheDir() 方法從外部儲存空間存取,可以使用 getExternalFilesDir() 或 getExternalCacheDir() 方法 | 否 | 是 |
媒體檔案 | 可共用的媒體檔案(圖片、音訊檔、視訊) | 可共用的媒體檔案(圖片、音訊檔、視訊) | 在 Android 11(API 級別 30)或更高版本中,存取其他應用的檔案需要 READ_EXTERNAL_STORAGE。在 Android 10(API 級別 29)中,存取其他應用的檔案需要 READ_EXTERNAL_STORAGE 或 WRITE_EXTERNAL_STORAGE。在 Android 9(API 級別 28)或更低版本中,存取所有檔案均需要相關許可權 | 是,但其他應用需要 READ_EXTERNAL_STORAGE 許可權 | 否 |
檔案和其它檔案 | 其他型別的可共用內容,包括已下載的檔案 | 儲存存取框架 | 無 | 是,可以通過系統檔案選擇器存取 | 否 |
應用偏好設定 | 鍵值對 | Jetpack Preferences 庫 | 無 | 否 | 是 |
資料庫 | 結構化資料 | Room 永續性庫 | 無 | 否 | 是 |
Android 6.0 為了防止應用申請不必要的許可權,對許可權進行了分組,對於危險許可權,需要動態申請許可權,這裡就不展開了,現在市場是 Android 6.0 以下的機器可以忽略了。
Android 10 開始引入了作用域儲存的概念。
什麼是作用域儲存呢?在 Android 10 以前,外部儲存屬於公共空間,不計入在應用程式佔用的空間,所用應用都有許可權隨意存取,並且使用者解除安裝了應用,對於該應用建立的檔案也會被保留下來。
從 Android 10 開始,對 SD 卡的使用做了很大的限制,每個應用只有許可權讀取自己的外接儲存空間關聯的目錄。獲取該關聯目錄的程式碼是:
/storage/emulated/0/Android/data/<包名>/files
該目錄下的檔案會被記入應用程式所佔用的空間。同時也會隨應用解除安裝而被刪除。
那如何存取其它的目錄呢?比如讀取手機相簿中的圖片,或者想手機相簿中新增一張圖片。為此, Android 系統針對檔案型別進行了分類,圖片、音訊、視訊這三類檔案可以通過 MediaStore API 來進行存取,其它型別的檔案需要使用系統的檔案選擇器來進行存取。
另外,當我們的應用程式向媒體庫貢獻的圖片、音訊或者視訊會自動擁有其讀寫許可權,不需要額外申請 READ_EXTERNAL_STORAGE
和 WRITE_EXTERNAL_STORAGE
許可權。而如果你要讀取其它應用程式向媒體庫貢獻的圖片、音訊或者視訊,則必須要申請 READ_EXTERNAL_STORAGE
許可權才行。而 WRITE_EXTERNAL_STORAGE
許可權似乎也沒什麼用了,官方表示將會在未來的 Android 版本中被廢棄。
在 Android 10 中對於作用域儲存適配的要求不是那麼嚴格,沒有強制要求。此前的使用方式,也可以在 Android 10 手機上成功執行。而即便 targetSdkVersion 已經指定成了 29, 如果你還不想進行作用域儲存的適配,只需要在 AndroidManifest.xml 檔案中加入如下設定即可:
<manifest ... >
<application android:requestLegacyExternalStorage="true" ...>
...
</application>
</manifest>
然鵝, Android 11 中已經開始強制啟用作用域儲存。所以上面的僅做了解即可。
過去直接獲取相簿中圖片的絕對路徑,現在在作用域儲存當中,我們只能藉助 MediaStore API 獲取到圖片的 Uri 。以圖片為例:
val cursor = ContentResolverCompat.query(
context.contentResolver,
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
null,
null,
null,
"${MediaStore.MediaColumns.DATE_ADDED} desc",
null
)
cursor?.use {
while (it.moveToNext()) {
val id = it.getLong(it.getColumnIndexOrThrow(MediaStore.MediaColumns._ID))
val uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
println("image uri is $uri")
}
}
上面的程式碼是通過 ContentResolver 獲取到相簿中所有圖片的 id,再借助 ContentUris 將 id 拼裝成一個完整的 Uri 物件,一張圖片的格式大致如下:
content://media/external/images/media/321
向媒體庫中寫入檔案要複雜一些,因為不同系統版本之間處理方式不太一樣。
還是以圖片為例。
fun saveBitmapToAlbum(context: Context, bitmap: Bitmap, displayName:String, mimeType: String, compressFormat: CompressFormat) {
val values = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DCIM)
} else {
put(
MediaStore.MediaColumns.DATA,
"${Environment.getExternalStorageDirectory().path}/${Environment.DIRECTORY_DCIM}/$displayName"
)
}
}
val uri = context.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
uri?.let {
val outputStream = context.contentResolver.openOutputStream(uri)
outputStream?.use {
bitmap.compress(compressFormat, 100, it)
}
}
}
首先需要構建一個 ContentValues 物件,然後向這個物件新增三個重要資料:
RELATIVE_PATH
常數,標識檔案儲存的相對路徑,可選值有RELATIVE_PATH
, 需要我們使用 DATA
常數(在 Android 10 中廢棄),並拼裝出一個檔案儲存的絕對路徑才行。有了 ContentValues 物件之後,接下來呼叫 ContentResolver 的 insert() 方法,插入圖片的 Uri。有了 Uri 之後,再向該 Uri 所對應的圖片寫入資料。呼叫 ContentResolver 的 openOutputStream() 方法獲得檔案的輸出流,然後將 Bitmap 物件寫入到該輸出流中即可。
在 Android 10 之前我們下載檔案,通常會下載到 Download 目錄,這是一個專門用於存放下載檔案的目錄。而從 Android 10 開始,我們已經不能以絕對路徑的方式存取外接儲存空間了。主要有以下兩種方式:
suspend fun downloadFile(context: Context, fileUrl: String, fileName: String) = withContext(Dispatchers.IO) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
// Android Q 以前的使用方式,指定決對路徑進行下載
// ...
} else {
val values = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
}
val uri =
context.contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values)
uri?.let {
runCatching {
val url = URL(fileUrl)
val connection = (url.openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
connectTimeout = 8000
readTimeout = 8000
}
val inputStream = connection.inputStream
val bis = BufferedInputStream(inputStream)
val outputStream = context.contentResolver.openOutputStream(it)
outputStream?.let { os ->
val bos = BufferedOutputStream(os)
val buffer = ByteArray(1024)
var bytes = bis.read(buffer)
while (bytes >= 0) {
bos.write(buffer, 0, bytes)
bos.flush()
bytes = bis.read(buffer)
}
bos.close()
os.close()
}
bis.close()
}.onSuccess {
}.onFailure {
}
}
}
}
主要的注意點在於, MediaStore.Downloads 是 Android 10 中新增的 API, 如果要相容 Android 10 以下,還需要使用之前的絕對路徑方式進行檔案下載。
我們要讀取 SD 卡上非圖片、音訊、視訊類的檔案,比如開啟一個 PDF 檔案,則不能再使用 MediaStore API 了,需要使用檔案選擇器。且必須是手機系統內建的檔案選擇器。
val pickFileLauncher: ActivityResultLauncher<Intent> =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == AppCompatActivity.RESULT_OK) {
val uri = result.data?.data // 選擇的檔案的 uri
// ... 處理結果
}
}
fun pickFile(context: Context) {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "*/*"
}
pickFileLauncher.launch(intent)
}
啟動系統的檔案選擇器,這裡 Intent 的 action 和 category 都是固定不變的。Type 屬性可以用於對檔案型別進行過濾。比如 image/*
標識只顯示圖片型別的檔案,注意 type 必須要指定,否則會產生崩潰。
通常,我們選擇照片時可以使用特定程式選擇器:
val selectPhotoIntent = Intent(Intent.ACTION_PICK).apply{
setDataAndType(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, "image/*")
}
mRequestPhotoLauncher.launch(selectPhotoIntent)
這裡說明:
Intent.ACTION_OPEN_DOCUMENT
和 Intent.ACTION_PICK
都是用於獲取資料的 Intent Action,但是它們的使用場景和功能略有不同。
Intent.ACTION_PICK 主要用於從已安裝的應用程式中選擇資料並返回結果,通常用於選擇特定型別的資料,如影象、視訊、音訊等。比如在使用系統相簿應用時,就可以使用 Intent.ACTION_PICK 來選擇需要展示的照片。
而 Intent.ACTION_OPEN_DOCUMENT 則是用於從系統檔案提供程式中選擇檔案並返回結果,通常用於選擇任何型別的檔案,如 PDF、Word 檔案等。通過使用 Intent.ACTION_OPEN_DOCUMENT,使用者可以存取系統的檔案系統,並選擇任何型別的檔案。除了選擇檔案外,Intent.ACTION_OPEN_DOCUMENT 還可以為選定的檔案提供讀寫許可權,這對於應用程式需要讀寫檔案時非常有用。
因此,Intent.ACTION_PICK
更適合選擇特定型別的資料,而 Intent.ACTION_OPEN_DOCUMENT
更適合存取系統檔案和選擇任何型別的檔案。
Google 在 Android 13 上對本地資料存取做了更進一步的細化。
WRITE_EXTERNAL_STORAGE
許可權還沒有被廢棄,但是我們幾乎不可能使用它了。
但是,Google 對 READ_EXTRERNAL_STORGE
許可權下手了。從 Android 13 開始,如果你的應用程式 targetSdk 指定到了 33 或以上,那麼 READ_EXTRERNAL_STORGE
許可權就完全失去了作用,申請它將不會產生任何效果。
與此相對應的,Google 新增了 READ_MEDIA_IMAGES
、READ_MEDIA_VIDEO
和 READ_MEDIA_AUDIO
這三個執行時許可權,分別用於管理手機的照片、視訊和音訊檔。
以前只要申請 READ_EXTRERNAL_STORGE
許可權就可以了,現在不行了,得按需申請。使用者從而能夠更加精細地瞭解你的應用到底申請了哪些媒體許可權。
為了考慮向下的相容性,在 AndroidManifest.xml
檔案中應該這樣寫:
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
也就是說,在 Android 12 及一下的系統,我們仍然要宣告 READ_EXTERNAL_STORAGE
許可權,在程式碼中動態申請許可權時也要做同樣的邏輯處理才行。
前面提到,從 Android 10 開始,申請 READ_EXTERNAL_STORAGE
許可權,也只能讀取到其它應用的媒體型別檔案,如果想要獲取共用儲存空間中的所有檔案,怎麼辦?比如檔案管理器類的應用或者病毒掃描類應用。莫慌, Android 11 開始, Google 引出了一個特殊的許可權,MANAGE_EXTERNAL_STORAGE
, 該許可權將授權讀寫所有共用儲存內容,同時包含非媒體型別的檔案。注意:獲得這個許可權的應用還是無法存取其它應用的專屬目錄,無論是外部儲存還是內部儲存,及私有檔案以及關聯目錄檔案,都無法存取。因為這些目錄在儲存捲上顯示為 Android/data/
的子目錄。
Google Play 通知, 這是 Android 11 引入的一項新的隱私政策限制。如果你在你的應用中申請了該許可權,你會看到這樣一條警告資訊:
The Google Play store has a policy that limits usage of MANAGE_EXTERNAL_STORAGE
為了限制對共用儲存的廣泛存取,Google Play 商店已更新其政策,用來評估以 Android 11(API 級別 30)或更高版本為目標平臺且通過 MANAGE_EXTERNAL_STORAGE 許可權請求「所有檔案存取權」的應用
大多數情況下存取其它應用程式的私有檔案,更應該考慮使用 FileProvider
或者 ContentProvider
。
要使用"所有檔案存取權",步驟如下:
MANAGE_EXTERNAL_STORAGE
許可權。Intent.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION
操作將使用者引導至一個系統設定頁面,在該頁面上,使用者可以為您的應用啟用以下選項:授予所有檔案的管理許可權。MANAGE_EXTERNAL_STORAGE
許可權,請呼叫 Environment.isExternalStorageManager()
。/Android/data/
、/sdcard/Android
以及 /sdcard/Android
的大多數子目錄外,對所有內部儲存目錄的寫入許可權。該寫入許可權包括檔案路徑存取許可權。一般來說,如下型別的應用才必須使用 MANAGE_EXTERNAL_STORAGE
許可權。
前面說了這麼多,跟開頭提到的 bug 有什麼關係?
從紀錄檔資訊來看: Mutation of _data is not allowed.
,這個問題還得從原始碼來分析。找到這個異常丟擲的地方:
/packages/providers/MediaProvider/src/com/android/providers/media/MediaStore.java
insertInternal()
方法中:
好傢伙!還真是判斷了 targetSdk >= 34 才會丟擲這個異常,這也解釋了為什麼 T 版本上執行正常,升級到 Android U 之後會 crash。
看這段程式碼邏輯,首先如果我們更新的 values 的 column 資訊不包含 sDataColumns
中的 column。就不會觸發這個異常,那要看看這個 sDataColumns
是什麼。
private static final ArrayMap<String, Object> sDataColumns = new ArrayMap<>();
static {
sDataColumns.put(MediaStore.MediaColumns.DATA, null);
sDataColumns.put(MediaStore.Images.Thumbnails.DATA, null);
sDataColumns.put(MediaStore.Video.Thumbnails.DATA, null);
sDataColumns.put(MediaStore.Audio.PlaylistsColumns.DATA, null);
sDataColumns.put(MediaStore.Audio.AlbumColumns.ALBUM_ART, null);
}
剛好,紀錄檔中的 _data
剛好就是這個 MerdiaStore.MediaColumns.DATA.
其次, 如果 isCallingPackageManager
也不會觸發這個 bug
private boolean isCallingPackageManager() {
return mCallingIdentity.get().hasPermission(PERMISSION_IS_MANAGER);
}
/packages/providers/MediaProvider/src/com/android/providers/media/LocalCallingIdentity.java
private boolean hasPermissionInternal(int permission) {
boolean targetSdkIsAtLeastT = getTargetSdkVersion() > Build.VERSION_CODES.S_V2;
// While we're here, enforce any broad user-level restrictions
if ((uid == Process.SHELL_UID) && context.getSystemService(UserManager.class)
.hasUserRestriction(UserManager.DISALLOW_USB_FILE_TRANSFER)) {
throw new SecurityException(
"Shell user cannot access files for user " + UserHandle.myUserId());
}
switch (permission) {
case PERMISSION_IS_SELF:
return checkPermissionSelf(context, pid, uid);
case PERMISSION_IS_SHELL:
return checkPermissionShell(uid);
case PERMISSION_IS_MANAGER:
return checkPermissionManager(context, pid, uid, getPackageName(), attributionTag);
case PERMISSION_IS_DELEGATOR:
return checkPermissionDelegator(context, pid, uid);
...
/packages/providers/MediaProvider/src/com/android/providers/media/util/PermissionUtils.java
/**
* Check if the given package has been granted the "file manager" role on
* the device, which should grant them certain broader access.
*/
public static boolean checkPermissionManager(@NonNull Context context, int pid,
int uid, @NonNull String packageName, @Nullable String attributionTag) {
return checkPermissionForDataDelivery(context, MANAGE_EXTERNAL_STORAGE, pid, uid,
packageName, attributionTag,
generateAppOpMessage(packageName,sOpDescription.get()));
}
可以看到,如果使用者授予了應用 MANAGE_EXTERNAL_STORAGE
許可權,則也不會觸發這個異常。
自此,真相大白,針對該問題,有兩種解決方案: 第一,申請 MANAGE_EXTERNAL_STORAGE
許可權,第二,程式碼中去掉 values.put(MediaStore.Images.Media.DATA, file.absolutePath)
這個。
綜合前面許可權講解,顯然我們應該使用第二種解決方案。 MediaStore.Images.Media.DATA
這一列,我們沒有必要去更新。