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

SharpCJ發表於2023-11-04

一、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 這一列,我們沒有必要去更新。

相關文章