記一個 Android 14 適配引發的Android 存儲權限問題

一、bug 背景

項目中有下面這樣一段代碼,在 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 數據存儲方式,其中涉及到數據存儲的性能、安全性、用戶隱私等諸多因素。

如今,最新的官方文檔的介紹如下:
Android 使用的文件系統提供瞭如下幾種保存應用數據的選項:

  • 應用專屬存儲空間: 存儲僅供應用使用的文件,可以存儲到內部存儲卷中的專屬目錄或外部存儲空間中的其他專屬目錄。使用內部存儲空間中的目錄保存其他應用不應訪問的敏感信息。
  • 共享存儲: 存儲您的應用打算與其他應用共享的文件,包括媒體、文檔和其他文件。
  • 偏好設置: 以鍵值對形式存儲私有原始數據。DataStore 提供了一種更現代的方式來存儲本地數據。您應該使用 DataStore 而非 SharedPreferences
  • 數據庫: 使用 Room 持久性庫將結構化數據存儲在專用數據庫中。
文件類型 內容類型 訪問方法 所需權限 其它應用是否可以訪問 卸載應用時是否移除文件
應用專屬文件 僅供您的應用使用的文件 從內部存儲空間訪問,可以使用 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 持久性庫

2.1 Android 6.0 動態申請權限

Android 6.0 爲了防止應用申請不必要的權限,對權限進行了分組,對於危險權限,需要動態申請權限,這裏就不展開了,現在市場是 Android 6.0 以下的機器可以忽略了。

2.2 Android 10 作用域存儲

Android 10 開始引入了作用域存儲的概念。

什麼是作用域存儲呢?在 Android 10 以前,外部存儲屬於公共空間,不計入在應用程序佔用的空間,所用應用都有權限隨意訪問,並且用戶卸載了應用,對於該應用創建的文件也會被保留下來。

從 Android 10 開始,對 SD 卡的使用做了很大的限制,每個應用只有權限讀取自己的外置存儲空間關聯的目錄。獲取該關聯目錄的代碼是:

/storage/emulated/0/Android/data/<包名>/files

該目錄下的文件會被記入應用程序所佔用的空間。同時也會隨應用卸載而被刪除。

那如何訪問其它的目錄呢?比如讀取手機相冊中的圖片,或者想手機相冊中添加一張圖片。爲此, Android 系統針對文件類型進行了分類,圖片、音頻、視頻這三類文件可以通過 MediaStore API 來進行訪問,其它類型的文件需要使用系統的文件選擇器來進行訪問。

另外,當我們的應用程序向媒體庫貢獻的圖片、音頻或者視頻會自動擁有其讀寫權限,不需要額外申請 READ_EXTERNAL_STORAGEWRITE_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 中已經開始強制啓用作用域存儲。所以上面的僅做了解即可。

2.2.1 讀取媒體庫的文件

過去直接獲取相冊中圖片的絕對路徑,現在在作用域存儲當中,我們只能藉助 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

2.2.2 寫入文件到媒體庫

向媒體庫中寫入文件要複雜一些,因爲不同系統版本之間處理方式不太一樣。
還是以圖片爲例。

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 對象,然後向這個對象添加三個重要數據:

  • DISPLAY_NAME: 圖片顯示的名稱
  • MIME_TYPE: 圖片的 mime 類型
  • 圖片的存儲路徑
    圖片的存儲路徑在 Android 10 和之前的系統版本處理方式不太一樣。在 Android 10 中,新增了一個 RELATIVE_PATH 常量,標識文件存儲的相對路徑,可選值有
  1. DIRECTORY_DCIM 表示相冊
  2. DIRECTORY_PICTURES 表示圖片
  3. DIRECTORY_MOVIES 表示電源
  4. DIRECTORY_MUSIC 表示音樂
    而在 Android 10 之前的系統版本中沒有 RELATIVE_PATH, 需要我們使用 DATA 常量(在 Android 10 中廢棄),並拼裝出一個文件存儲的絕對路徑纔行。

有了 ContentValues 對象之後,接下來調用 ContentResolver 的 insert() 方法,插入圖片的 Uri。有了 Uri 之後,再向該 Uri 所對應的圖片寫入數據。調用 ContentResolver 的 openOutputStream() 方法獲得文件的輸出流,然後將 Bitmap 對象寫入到該輸出流中即可。

2.2.3 下載文件到 Download 目錄

在 Android 10 之前我們下載文件,通常會下載到 Download 目錄,這是一個專門用於存放下載文件的目錄。而從 Android 10 開始,我們已經不能以絕對路徑的方式訪問外置存儲空間了。主要有以下兩種方式:

  1. 將文件下載到應用程序的關聯目錄下。這樣也無需申請額外權限。前面說了應用關聯目錄,這樣的有以下幾個特點:
  • 下載的文件會被計入到應用程序的佔用控件當中
  • 如果應用程序被卸載了,改文件也會一同被刪除
  • 只能被當前應用訪問,其它程序沒有讀取權限
  1. 對 Android 10 系統進行適配。仍然將文件下載到 Download 目錄下。
    具體操作,和向相冊中添加一種圖片的過程差不多,Android 10 中新增了一種 Downloads 集合,專門用於執行文件下載操作。
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 以下,還需要使用之前的絕對路徑方式進行文件下載。

2.2.4 使用文件選擇器

我們要讀取 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 必須要指定,否則會產生崩潰。

2.2.5 特定程序選擇器

通常,我們選擇照片時可以使用特定程序選擇器:

val selectPhotoIntent = Intent(Intent.ACTION_PICK).apply{
    setDataAndType(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, "image/*")
}
mRequestPhotoLauncher.launch(selectPhotoIntent)

這裏說明:
Intent.ACTION_OPEN_DOCUMENTIntent.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 更適合訪問系統文檔和選擇任何類型的文件。

2.3 Android 13 細化的媒體權限

Google 在 Android 13 上對本地數據訪問做了更進一步的細化。

WRITE_EXTERNAL_STORAGE 權限還沒有被廢棄,但是我們幾乎不可能使用它了。
但是,Google 對 READ_EXTRERNAL_STORGE 權限下手了。從 Android 13 開始,如果你的應用程序 targetSdk 指定到了 33 或以上,那麼 READ_EXTRERNAL_STORGE 權限就完全失去了作用,申請它將不會產生任何效果。

與此相對應的,Google 新增了 READ_MEDIA_IMAGESREAD_MEDIA_VIDEOREAD_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 權限,在代碼中動態申請權限時也要做同樣的邏輯處理纔行。

2.4 特殊高級權限 MANAGE_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

要使用"所有文件訪問權",步驟如下:

  1. 在 AndroidManifest.xml 文件中聲明 MANAGE_EXTERNAL_STORAGE 權限。
  2. 使用 Intent.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION 操作將用戶引導至一個系統設置頁面,在該頁面上,用戶可以爲您的應用啓用以下選項:授予所有文件的管理權限
    如需確定你的應用是否已獲得 MANAGE_EXTERNAL_STORAGE 權限,請調用 Environment.isExternalStorageManager()
    具體的執行權限範圍包括:
  • 對共享存儲空間中的所有文件的讀寫訪問權限。
    注意:/sdcard/Android/media⁠ 目錄是共享存儲空間的一部分。
  • 對 MediaStore.Files 表的內容的訪問權限。
  • 對 USB On-The-Go (OTG) 驅動器和 SD 卡的根目錄的訪問權限。
  • /Android/data//sdcard/Android 以及 /sdcard/Android 的大多數子目錄外,對所有內部存儲目錄的寫入權限。該寫入權限包括文件路徑訪問權限。

一般來說,如下類型的應用才必須使用 MANAGE_EXTERNAL_STORAGE 權限。

  • 文件管理器
  • 備份和恢復應用
  • 防病毒應用
  • 文檔管理應用
  • 設備上的文件搜索
  • 磁盤和文件加密
  • 設備到設備數據遷移

三、解決 bug

前面說了這麼多,跟開頭提到的 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 這一列,我們沒有必要去更新。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章