AndroidQ

| 隱私權(quán)變更 | 受影響的應(yīng)用 | 緩解策略 | |
|---|---|---|---|
| ? | 分區(qū)存儲(chǔ) 針對(duì)外部存儲(chǔ)的過濾視圖,可提供對(duì)特定于應(yīng)用的文件和媒體集合的訪問權(quán)限 | 訪問和共享外部存儲(chǔ)中的文件的應(yīng)用 | 使用特定于應(yīng)用的目錄和媒體集合目錄 了解詳情 |
| ? | 增強(qiáng)了用戶對(duì)位置權(quán)限的控制力 僅限前臺(tái)權(quán)限,可讓用戶更好地控制應(yīng)用對(duì)設(shè)備位置信息的訪問權(quán)限 | 在后臺(tái)時(shí)請(qǐng)求訪問用戶位置信息的應(yīng)用 | 確保在沒有后臺(tái)位置信息更新的情況下優(yōu)雅降級(jí) 使用 Android 10 中引入的權(quán)限在后臺(tái)獲取位置信息 了解詳情 |
| ? | 系統(tǒng)執(zhí)行后臺(tái) Activity 針對(duì)從后臺(tái)啟動(dòng) Activity 實(shí)施了限制 | 不需要用戶互動(dòng)就啟動(dòng) Activity 的應(yīng)用 | 使用通知觸發(fā)的 Activity 了解詳情 |
| ? | 不可重置的硬件標(biāo)識(shí)符 針對(duì)訪問設(shè)備序列號(hào)和 IMEI 實(shí)施了限制 | 訪問設(shè)備序列號(hào)或 IMEI 的應(yīng)用 | 使用用戶可以重置的標(biāo)識(shí)符 了解詳情 |
| ? | 無線掃描權(quán)限 訪問某些 WLAN、WLAN 感知和藍(lán)牙掃描方法需要獲得精確位置權(quán)限 | 使用 WLAN API 和藍(lán)牙 API 的應(yīng)用 | 針對(duì)相關(guān)使用場景請(qǐng)求 ACCESS_FINE_LOCATION 權(quán)限 了解詳情
|
上面是官網(wǎng)的AndroidQ的隱私權(quán)變更鏈接,本文章只針對(duì)部分重大隱私權(quán)限變更做出解釋說明。
從后臺(tái)啟動(dòng) Activity 的限制
Android10中, 當(dāng)App無前臺(tái)顯示的Activity時(shí),其啟動(dòng)Activity會(huì)被系統(tǒng)攔截, 導(dǎo)致啟動(dòng)無效。
對(duì)此官方給予的折中方案是使用全屏Intent(full-screen intent), 既創(chuàng)建通知欄通知時(shí), 加入full-screen intent設(shè)置, 示例代碼如下(基于官方文檔修改):
Intent fullScreenIntent = new Intent(this, CallActivity.class);
PendingIntent fullScreenPendingIntent = PendingIntent.getActivity(this, 0,
fullScreenIntent, PendingIntent.FLAG_UPDATE_CURRENT);
NotificationCompat.Builder notificationBuilder =
new NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.notification_icon)
.setContentTitle("Incoming call")
.setContentText("(919) 555-1234")
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_CALL)
// Use a full-screen intent only for the highest-priority alerts where you
// have an associated activity that you would like to launch after the user
// interacts with the notification. Also, if your app targets Android 10
// or higher, you need to request the USE_FULL_SCREEN_INTENT permission in
// order for the platform to invoke this notification.
.setFullScreenIntent(fullScreenPendingIntent, true);
Notification incomingCallNotification = notificationBuilder.build();
注意:在Target SDk為29及以上時(shí),需要在AndroidManifest上增加USE_FULL_SCREEN_INTENT申明
//AndroidManifest中
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
當(dāng)手機(jī)處于亮屏狀態(tài)時(shí), 會(huì)顯示一個(gè)通知欄, 當(dāng)手機(jī)處于鎖屏或者滅屏狀態(tài)時(shí),會(huì)亮屏并直接進(jìn)入到CallActivity中。
不可重置的設(shè)備標(biāo)識(shí)符實(shí)施了限制
從 Android 10 開始,應(yīng)用必須具有 READ_PRIVILEGED_PHONE_STATE 特許權(quán)限才能訪問設(shè)備的不可重置標(biāo)識(shí)符(包含 IMEI 和序列號(hào))。
受影響的方法包括:
-
Build -
TelephonyManager
ANDROID_ID 生成規(guī)則:簽名+設(shè)備信息+設(shè)備用戶
ANDROID_ID 重置規(guī)則:設(shè)備恢復(fù)出廠設(shè)置時(shí),ANDROID_ID 將被重置當(dāng)前獲取設(shè)備唯一ID的方式為使用ANDROID_ID, 若獲取為空的話則使用UUID.randomUUID().toString()獲得一個(gè)隨機(jī)ID并存儲(chǔ)起來, 該ID保證唯一, 但App卸載重裝之后就會(huì)改變。
String id = android.provider.Settings.Secure.getString(context.getContentResolver(), android.provider.Settings.Secure.ANDROID_ID);
限制了對(duì)剪貼板數(shù)據(jù)的訪問權(quán)限
除非您的應(yīng)用是默認(rèn)輸入法 (IME) 或是目前處于焦點(diǎn)的應(yīng)用,否則它無法訪問 Android 10 或更高版本平臺(tái)上的剪貼板數(shù)據(jù)。
因?yàn)槎际菓?yīng)用處于前臺(tái)的時(shí)候進(jìn)行剪貼板數(shù)據(jù)的獲取,對(duì)于大部分業(yè)務(wù)不受影響。
定位權(quán)限
Android Q引入了新的位置權(quán)限ACCESS_BACKGROUND_LOCATION,該權(quán)限僅會(huì)影響應(yīng)用在后臺(tái)運(yùn)行時(shí)對(duì)位置信息的訪問權(quán)。如果應(yīng)用targetSDK<=P,請(qǐng)求了ACCESS_FINE_LOCATION或ACCESS_COARSE_LOCATION權(quán)限,AndroidQ設(shè)備會(huì)自動(dòng)幫你申請(qǐng)ACCESS_BACKGROUND_LOCATION權(quán)限。
如果應(yīng)用以Android 10或更高版本為目標(biāo)平臺(tái),則您必須在應(yīng)用的清單文件中聲明ACCESS_BACKGROUND_LOCATION權(quán)限并接收用戶權(quán)限,才能在應(yīng)用位于后臺(tái)時(shí)接收定期位置信息更新。
以下代碼段展示了如何在應(yīng)用中請(qǐng)求在后臺(tái)訪問位置信息:
<manifest ... >
<!--允許獲得精確的GPS定位-->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<!--允許獲得粗略的基站網(wǎng)絡(luò)定位-->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<!-- 兼容10.0系統(tǒng),允許App在后臺(tái)獲得位置信息 -->
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/>
</manifest>
以下代碼段中顯示了定位權(quán)限檢查邏輯的示例:
boolean permissionAccessCoarseLocationApproved =
ActivityCompat.checkSelfPermission(this, permission.ACCESS_COARSE_LOCATION)
== PackageManager.PERMISSION_GRANTED;
if (permissionAccessCoarseLocationApproved) {
boolean backgroundLocationPermissionApproved =
ActivityCompat.checkSelfPermission(this,
permission.ACCESS_BACKGROUND_LOCATION)
== PackageManager.PERMISSION_GRANTED;
if (backgroundLocationPermissionApproved) {
// App can access location both in the foreground and in the background.
// Start your service that doesn't have a foreground service type
// defined.
} else {
// App can only access location in the foreground. Display a dialog
// warning the user that your app must have all-the-time access to
// location in order to function properly. Then, request background
// location.
ActivityCompat.requestPermissions(this, new String[] {
Manifest.permission.ACCESS_BACKGROUND_LOCATION},
your-permission-request-code);
}
} else {
// App doesn't have access to the device's location at all. Make full request
// for permission.
ActivityCompat.requestPermissions(this, new String[] {
Manifest.permission.ACCESS_COARSE_LOCATION,
Manifest.permission.ACCESS_BACKGROUND_LOCATION
},
your-permission-request-code);
}
如果您的應(yīng)用通常需要在被置于后臺(tái)后(如當(dāng)用戶按設(shè)備上的主屏幕按鈕或關(guān)閉設(shè)備的顯示屏?xí)r)訪問設(shè)備的位置信息。
要在這種特定類型的用例中保留對(duì)設(shè)備位置信息的訪問權(quán),請(qǐng)啟動(dòng)您已在應(yīng)用的清單中聲明前臺(tái)服務(wù)類型為 "location" 的前臺(tái)服務(wù):
<service
android:name="MyNavigationService"
android:foregroundServiceType="location" ... >
...
</service>
在啟動(dòng)該前臺(tái)服務(wù)之前,請(qǐng)確保您的應(yīng)用仍可訪問設(shè)備的位置信息:
boolean permissionAccessCoarseLocationApproved =
ActivityCompat.checkSelfPermission(this,
permission.ACCESS_COARSE_LOCATION) ==
PackageManager.PERMISSION_GRANTED;
if (permissionAccessCoarseLocationApproved) {
// App has permission to access location in the foreground. Start your
// foreground service that has a foreground service type of "location".
} else {
// Make a request for foreground-only location access.
ActivityCompat.requestPermissions(this, new String[] {
Manifest.permission.ACCESS_COARSE_LOCATION},
your-permission-request-code);
}
分區(qū)存儲(chǔ)
為了讓用戶更好地控制自己的文件,并限制文件混亂的情況,Android Q修改了APP訪問外部存儲(chǔ)中文件的方法。外部存儲(chǔ)的新特性被稱為Scoped Storage。
Android Q仍然使用READ_EXTERNAL_STORAGE和WRITE_EXTERNAL_STORAGE作為面向用戶的存儲(chǔ)相關(guān)運(yùn)行時(shí)權(quán)限,但現(xiàn)在即使獲取了這些權(quán)限,訪問外部存儲(chǔ)也受到了限制。
APP需要這些運(yùn)行時(shí)權(quán)限的情景發(fā)生了變化,且各種情況下外部存儲(chǔ)對(duì)APP的可見性也發(fā)生了變化。
在Scoped Storage新特性中,外部存儲(chǔ)空間被分為兩部分:
● 公共目錄:Downloads、Documents、Pictures、DCIM、Movies、Music、Ringtones等
公共目錄下的文件在APP卸載后,不會(huì)刪除。
APP可以通過SAF(System Access Framework)、MediaStore接口訪問其中的文件。
● App-specific目錄:存儲(chǔ)應(yīng)用私有數(shù)據(jù),外部存儲(chǔ)應(yīng)用私有目錄對(duì)應(yīng) Android/data/packagename,內(nèi)部存儲(chǔ)應(yīng)用私有目錄對(duì)應(yīng) data/data/packagename;
APP卸載后,數(shù)據(jù)會(huì)清除。
APP的私密目錄,APP訪問自己的App-specific目錄時(shí)無需任何權(quán)限。

存儲(chǔ)空間視圖模式
Android Q規(guī)定了APP有兩種外部存儲(chǔ)空間視圖模式:Legacy View、Filtered View。
● Filtered View:App可以直接訪問App-specific目錄,但不能直接訪問App-specific外的文件。訪問公共目錄或其他APP的App-specific目錄,只能通過MediaStore、SAF、或者其他APP 提供的ContentProvider、FileProvider等訪問。
● Legacy View: 兼容模式。與Android Q以前一樣,申請(qǐng)權(quán)限后App可訪問外部存儲(chǔ),擁有完整的訪問權(quán)限
requestLegacyExternalStorage和preserveLegacyExternalStorage
requestLegacyExternalStorage 是Anroid10引入的,如果你進(jìn)行適配Android 10之后,應(yīng)用通過升級(jí)安裝,那么還會(huì)使用以前的儲(chǔ)存模式Legacy View,只有通過首次安裝或是卸載重新安裝才能啟用新模式Filtered View。
而android:requestLegacyExternalStorage="true"讓適配了Android10的app新安裝在Android 10系統(tǒng)上也繼續(xù)訪問舊的存儲(chǔ)模型。
Environment.isExternalStorageLegacy();//存儲(chǔ)是否為兼容模式
在適配Android11的時(shí)候requestLegacyExternalStorage 標(biāo)簽會(huì)在Android11以上的設(shè)備上被忽略,preserveLegacyExternalStorage只是讓覆蓋安裝的app能繼續(xù)使用舊的存儲(chǔ)模型,如果之前是舊的存儲(chǔ)模型的話。
- Android10適配的時(shí)候可以通過
requestLegacyExternalStoragec使用兼容模式; - Android11適配可以通過
preserveLegacyExternalStorage讓Android10及一下的設(shè)備使用兼容模式,但Android11及以上的設(shè)備無論是覆蓋安裝還是重新安裝都無法使用兼容模式;
可以通過調(diào)用 Environment.getExternalStorageState() 查詢?cè)摼淼臓顟B(tài)。如果返回的狀態(tài)為 MEDIA_MOUNTED,那么您就可以在外部存儲(chǔ)空間中讀取和寫入應(yīng)用專屬文件。如果返回的狀態(tài)為 MEDIA_MOUNTED_READ_ONLY,您只能讀取這些文件。
分區(qū)存儲(chǔ)的影響
圖片位置信息
一些圖片會(huì)包含位置信息,因?yàn)槲恢脤?duì)于用戶屬于敏感信息, Android 10 應(yīng)用在分區(qū)存儲(chǔ)模式下圖片位置信息默認(rèn)獲取不到,應(yīng)用通過以下兩項(xiàng)設(shè)置可以獲取圖片位置信息:
- 在
manifest中申請(qǐng)ACCESS_MEDIA_LOCATION; - 調(diào)用
MediaStore.setRequireOriginal(Uri uri)接口更新圖片Uri;
// Get location data from the ExifInterface class.
val photoUri = MediaStore.setRequireOriginal(photoUri)
contentResolver.openInputStream(photoUri).use { stream ->
ExifInterface(stream).run {
// If lat/long is null, fall back to the coordinates (0, 0).
val latLong = ?: doubleArrayOf(0.0, 0.0)
}
}
訪問數(shù)據(jù)
私有目錄:
應(yīng)用私有目錄文件訪問方式與之前 Android 版本一致,可以通過 File path 獲取資源。
共享目錄:
共享目錄文件需要通過 MediaStore API 或者 Storage Access Framework 方式訪問。
MediaStore API 在共享目錄指定目錄下創(chuàng)建文件或者訪問應(yīng)用自己創(chuàng)建文件,不需要申請(qǐng)存儲(chǔ)權(quán)限
MediaStore API 訪問其他應(yīng)用在共享目錄創(chuàng)建的媒體文件(圖片、音頻、視頻), 需要申請(qǐng)存儲(chǔ)權(quán)限,未申請(qǐng)存儲(chǔ)權(quán)限,通過 ContentResolver 查詢不到文件 Uri,即使通過其他方式獲取到文件 Uri,讀取或創(chuàng)建文件會(huì)拋出異常;
MediaStore API 不能夠訪問其他應(yīng)用創(chuàng)建的非媒體文件(pdf、office、doc、txt 等), 只能夠通過 Storage Access Framework 方式訪問;
File路徑訪問受影響接口
FileOutputStream和FileInputStream
在分區(qū)存儲(chǔ)模型下,SD卡的公共目錄是不讓訪問的,除了共享媒體的那幾個(gè)文件夾。所以,用一個(gè)公共目錄的路徑實(shí)例化FileOutputStream或者FileInputStream會(huì)報(bào)FileNotFoundException異常。
W/System.err: java.io.FileNotFoundException: /storage/emulated/0/Log01-28-18-10.txt: open failed: EACCES (Permission denied)
W/System.err: at libcore.io.IoBridge.open(IoBridge.java:496)
W/System.err: at java.io.FileInputStream.<init>(FileInputStream.java:159)
File.createNewFile
W/System.err: java.io.IOException: Permission denied
W/System.err: at java.io.UnixFileSystem.createFileExclusively0(Native Method)
W/System.err: at java.io.UnixFileSystem.createFileExclusively(UnixFileSystem.java:317)
W/System.err: at java.io.File.createNewFile(File.java:1008)
File.renameToFile.deleteFile.renameToFile.mkdirFile.mkdirs
以上File的方法都返回false。
BitmapFactory.decodeFile生成的Bitmap為null。
適配指導(dǎo)
Android Q Scoped Storage新特性谷歌官方適配文檔:https://developer.android.google.cn/preview/privacy/scoped-storage
適配指導(dǎo)如下,分為:訪問APP自身App-specific目錄文件、使用MediaStore訪問公共目錄、使用SAF 訪問指定文件和目錄、分享App-specific目錄下文件和其他細(xì)節(jié)適配。
訪問App-specific目錄文件
無需任何權(quán)限,可以直接通過File的方式操作App-specific目錄下的文件。
| App-specific目錄 | 接口(所有存儲(chǔ)設(shè)備) | 接口(Primary External Storage) |
|---|---|---|
| Media | getExternalMediaDirs() | NA |
| Obb | getObbDirs() | getObbDir() |
| Cache | getExternalCacheDirs() | getExternalCacheDir() |
| Data | getExternalFilesDirs(String type) | getExternalFilesDir(String type) |
/**
* 在App-Specific目錄下創(chuàng)建文件
* 文件目錄:/Android/data/包名/files/Documents/
*/
private fun createAppSpecificFile() {
binding.createAppSpecificFileBtn.setOnClickListener {
val documents = getExternalFilesDirs(Environment.DIRECTORY_DOCUMENTS)
if (documents.isNotEmpty()) {
val dir = documents[0]
var os: FileOutputStream? = null
try {
val newFile = File(dir.absolutePath, "MyDocument")
os = FileOutputStream(newFile)
os.write("create a file".toByteArray(Charsets.UTF_8))
os.flush()
Log.d(TAG, "創(chuàng)建成功")
dir.listFiles()?.forEach { file: File? ->
if (file != null) {
Log.d(TAG, "Documents 目錄下的文件名:" + file.name)
}
}
} catch (e: IOException) {
e.printStackTrace()
Log.d(TAG, "創(chuàng)建失敗")
} finally {
closeIO(os)
}
}
}
}
/**
* 在App-Specific目錄下創(chuàng)建文件夾
* 文件目錄:/Android/data/包名/files/
*/
private fun createAppSpecificFolder() {
binding.createAppSpecificFolderBtn.setOnClickListener {
getExternalFilesDir("apk")?.let {
if (it.exists()) {
Log.d(TAG, "創(chuàng)建成功")
} else {
Log.d(TAG, "創(chuàng)建失敗")
}
}
}
}
使用MediaStore訪問公共目錄
MediaStore Uri和路徑對(duì)應(yīng)表

MediaStore提供下列Uri,可以用MediaProvider查詢對(duì)應(yīng)的Uri數(shù)據(jù)。在AndroidQ上,所有的外部存儲(chǔ)設(shè)備都會(huì)被命令,即Volume Name。MediaStore可以通過Volume Name 獲取對(duì)應(yīng)的Uri。
MediaStore.getExternalVolumeNames(this).forEach { volumeName ->
Log.d(TAG, "uri:${MediaStore.Images.Media.getContentUri(volumeName)}")
}
Uri路徑格式: content:// media/<volumeName>/<Uri路徑>

使用MediaStore創(chuàng)建文件
通過ContentResolver的insert方法,將多媒體文件保存在公共集合目錄,不同的Uri對(duì)應(yīng)不同的公共目錄,詳見3.2.1;其中RELATIVE_PATH的一級(jí)目錄必須是Uri對(duì)應(yīng)的一級(jí)目錄,二級(jí)目錄或者二級(jí)以上的目錄,可以隨意的創(chuàng)建和指定。
private lateinit var createBitmapForActivityResult: ActivityResultLauncher<String>
//注冊(cè)ActivityResultLauncher
createBitmapForActivityResult =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {
createBitmap()
}
binding.createFileByMediaStoreBtn.setOnClickListener {
createBitmapForActivityResult.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
}
private fun createBitmap() {
val values = ContentValues()
val displayName = "NewImage.png"
values.put(MediaStore.Images.Media.DISPLAY_NAME, displayName)
values.put(MediaStore.Images.Media.DESCRIPTION, "This is an image")
values.put(MediaStore.Images.Media.MIME_TYPE, "image/png")
values.put(MediaStore.Images.Media.TITLE, "Image.png")
//適配AndroidQ及一下
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
values.put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/sl")
} else {
values.put(
MediaStore.MediaColumns.DATA,
"${Environment.getExternalStorageDirectory().path}/${Environment.DIRECTORY_DCIM}/$displayName"
)
}
//requires android.permission.WRITE_EXTERNAL_STORAGE, or grantUriPermission()
val external = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
//java.lang.UnsupportedOperationException: Writing to internal storage is not supported.
//val external = MediaStore.Images.Media.INTERNAL_CONTENT_URI
val insertUri = contentResolver.insert(external, values)
var os: OutputStream? = null
try {
if (insertUri != null) {
os = contentResolver.openOutputStream(insertUri)
}
if (os != null) {
val bitmap = Bitmap.createBitmap(400, 400, Bitmap.Config.ARGB_8888)
//創(chuàng)建了一個(gè)紅色的圖片
val canvas = Canvas(bitmap)
canvas.drawColor(Color.RED)
bitmap.compress(Bitmap.CompressFormat.PNG, 90, os)
Log.d(TAG, "創(chuàng)建Bitmap成功")
if (insertUri != null) {
values.clear()
//適配AndroidQ及一下
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
values.put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/sl2")
} else {
values.put(
MediaStore.MediaColumns.DATA,
"${Environment.getExternalStorageDirectory().path}/${Environment.DIRECTORY_DCIM}/$displayName"
)
}
contentResolver.update(insertUri, values, null, null)
}
}
} catch (e: IOException) {
Log.d(TAG, "創(chuàng)建失?。?{e.message}")
} finally {
closeIO(os)
}
}
使用MediaStore查詢文件
通過
Cursor query(@RequiresPermission.Read @NonNull Uri uri,@Nullable String[] projection, @Nullable String selection,@Nullable String[] selectionArgs, @Nullable String sortOrder)方法。
參數(shù)解釋:
| 參數(shù) | 類型 | 釋義 |
|---|---|---|
| uri | Uri | 提供檢索內(nèi)容的 Uri,其 scheme 是content://
|
| projection | String[] | 返回的列,如果傳遞 null 則所有列都返回(效率低下) |
| selection | String | 過濾條件,即 SQL 中的 WHERE 語句(但不需要寫 where 本身),如果傳 null 則返回所有的數(shù)據(jù) |
| selectionArgs | String[] | 如果你在 selection 的參數(shù)加了 ? 則會(huì)被本字段中的數(shù)據(jù)按順序替換掉 |
| sortOrder | String | 用來對(duì)數(shù)據(jù)進(jìn)行排序,即 SQL 語句中的 ORDER BY(單不需要寫ORDER BY 本身),如果傳 null 則按照默認(rèn)順序排序(可能是無序的) |
通過ContentResolver.query接口查詢文件Uri,查詢其他App創(chuàng)建的文件是需要READ_EXTERNAL_STORAGE權(quán)限;
該查詢使用的是手機(jī)系統(tǒng)的數(shù)據(jù)庫查詢,可能會(huì)出現(xiàn)有些圖片文件存在但是依舊查詢不到~?。≒S:使用adb命令push的圖片就查詢不到)
/**
* 通過MediaStore查詢文件
*/
private fun queryFileByMediaStore() {
queryPictureForActivityResult = registerForActivityResult(ActivityResultContracts.RequestPermission()) {
queryUri = queryImageUri("yellow.jpg")
}
binding.queryFileByMediaStoreBtn.setOnClickListener {
queryPictureForActivityResult.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
}
}
/**
* @param displayName 查詢的圖片文件名稱
* @return 第一個(gè)遍歷到的該文件名的uri
*/
private fun queryImageUri(displayName: String): Uri? {
val external = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
val selection = "${MediaStore.Images.Media.DISPLAY_NAME}=?"
val args = arrayOf(displayName)
val projection = arrayOf(MediaStore.Images.Media._ID)
val cursor = contentResolver.query(external, projection, selection, args, null)
var queryUri: Uri? = null
if (cursor != null) {
//可能查詢到多個(gè)同名圖片
while (cursor.moveToNext()) {
queryUri = ContentUris.withAppendedId(external, cursor.getLong(0))
Log.d(TAG, "查詢成功,Uri路徑$queryUri")
queryUri?.let {
cursor.close()
return it
}
}
cursor.close()
}
return queryUri;
}
使用MediaStore讀取文件
首先需要文件存儲(chǔ)權(quán)限,通過ContentResolver.query查詢得到的Uri之后,可以通過contentResolver.openFileDescriptor,根據(jù)文件描述符選擇對(duì)應(yīng)的打開方式。"r"表示讀,"w"表示寫;
private lateinit var readPictureForActivityResult: ActivityResultLauncher<IntentSenderRequest>
/**
* 根據(jù)查詢到的uri,獲取bitmap
*/
private fun readFileByMediaStore() {
readPictureForActivityResult = registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) {
readBitmapNotException()
}
binding.readFileByMediaStoreBtn.setOnClickListener {
readBitmapNotException()
}
}
private fun readBitmapNotException() {
val queryUri = queryImageUri("20221018_113937.jpg")
if (queryUri != null) {
var pfd: ParcelFileDescriptor? = null
try {
pfd = contentResolver.openFileDescriptor(queryUri, "r")
if (pfd != null) {
// 第一次解析將inJustDecodeBounds設(shè)置為true,來獲取圖片大小
val options = BitmapFactory.Options()
options.inJustDecodeBounds = true
BitmapFactory.decodeFileDescriptor(pfd.fileDescriptor, null, options)
// 調(diào)用上面定義的方法計(jì)算inSampleSize值
options.inSampleSize = calculateInSampleSize(options, 500, 500)
// 使用獲取到的inSampleSize值再次解析圖片
options.inJustDecodeBounds = false
val bitmap = BitmapFactory.decodeFileDescriptor(pfd.fileDescriptor, null, options)
binding.imageIv.setImageBitmap(bitmap)
}
} catch (e: Exception) {
e.printStackTrace()
} finally {
closeIO(pfd)
}
} else {
Log.d(TAG, "還未查詢到Uri")
}
}
獲取圖片的縮略圖:
訪問Thumbnail,通過ContentResolver.loadThumbnail傳入size,返回指定大小的縮略圖。
/**
* 根據(jù)查詢到的Uri,獲取Thumbnail
*/
private fun loadThumbnail() {
binding.loadThumbnailBtn.setOnClickListener {
queryUri?.let {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val bitmap = contentResolver.loadThumbnail(it, Size(100, 200), null)
binding.imageIv.setImageBitmap(bitmap)
} else {
MediaStore.Images.Thumbnails.getThumbnail(
contentResolver,
ContentUris.parseId(it),
MediaStore.Images.Thumbnails.MINI_KIND,
null)?.let { bitmap ->
binding.imageIv.setImageBitmap(bitmap)
}
}
}
}
}
使用MediaStore修改文件
PS:僅限AndroidQ及以上系統(tǒng)版本,低版本國產(chǎn)手機(jī)使用ContentResolver進(jìn)行數(shù)據(jù)更新存在數(shù)據(jù)和文件不同步問題以及縮略圖和原圖不同步問題;
當(dāng)應(yīng)用擁有了
WRITE_EXTERNAL_STORAGE權(quán)限后,當(dāng)修改其他 App 的文件時(shí),會(huì) throw 另一個(gè) Exception:
android.app.RecoverableSecurityException: com.tzx.androidsystemversionadapter has no access to content://media/external/images/media/21
如果我們將這個(gè) RecoverableSecurityException給Catch住,并向用戶申請(qǐng)修改該圖片的權(quán)限,用戶操作后,我們就可以在 onActivityResult回調(diào)中拿到結(jié)果進(jìn)行操作了。

/**
* 根據(jù)查詢得到的Uri,修改文件
*/
private fun updateFileByMediaStore() {
updatePictureForActivityResult =
registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) {
updateFileNameWithException()
}
registerForActivityResult =
registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
updateFileNameWithException()
}
binding.updateFileByMediaStoreBtn.setOnClickListener {
registerForActivityResult.launch(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE))
}
}
private fun updateFileNameWithException() {
val queryUri = queryImageUri("blue.jpg")
var os: OutputStream? = null
try {
queryUri?.let { uri ->
os = contentResolver.openOutputStream(uri)
os?.let {
val bitmap = Bitmap.createBitmap(400, 400, Bitmap.Config.ARGB_8888)
//創(chuàng)建了一個(gè)紅色的圖片
val canvas = Canvas(bitmap)
canvas.drawColor(Color.YELLOW)
//重新寫入文件內(nèi)容
bitmap.compress(Bitmap.CompressFormat.PNG, 90, os)
val contentValues = ContentValues()
//給改文件重命名
contentValues.put(MediaStore.Images.ImageColumns.DISPLAY_NAME, "yellow.jpg")
contentResolver.update(uri, contentValues, null, null)
}
}
} catch (e: Exception) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
if (e is RecoverableSecurityException) {
try {
updatePictureForActivityResult.launch(
IntentSenderRequest.Builder(e.userAction.actionIntent.intentSender)
.build()
)
} catch (e2: IntentSender.SendIntentException) {
e2.printStackTrace()
}
return
}
}
e.printStackTrace()
}
}
使用MediaStore刪除文件
刪除自己創(chuàng)建的多媒體文件不需要權(quán)限,其他APP創(chuàng)建的,與修改類型,需要用戶授權(quán)。
/**
* 刪除MediaStore文件
*/
private fun deleteFileByMediaStore() {
deletePictureRequestPermissionActivityResult = registerForActivityResult(ActivityResultContracts.RequestPermission()) {
if (it) {
deleteFile()
} else {
Log.d(TAG, "deleteFileByMediaStore: 授權(quán)失敗")
}
}
deletePictureSenderRequestActivityResult = registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) {
if (it.resultCode == RESULT_OK) {
deleteFile()
} else {
Log.d(TAG, "updateFileByMediaStore: 授權(quán)失敗")
}
}
binding.deleteFileByMediaStoreBtn.setOnClickListener {
deletePictureRequestPermissionActivityResult.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
}
}
private fun deleteFile() {
val queryUri = queryImageUri("2021-10-14_11.19.18.882.png")
try {
if (queryUri != null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val delete = contentResolver.delete(queryUri, null, null)
//delete=0刪除失敗,delete=1也不一定刪除成功,必須要授予文件的寫權(quán)限
Log.d(TAG, "contentResolver.delete:$delete")
} else {
val filePathByUri = UriTool.getFilePathByUri(this@ScopedStorageActivity, queryUri)
File(filePathByUri).delete()
}
}
} catch (e: Exception) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
if (e is RecoverableSecurityException) {
try {
deletePictureSenderRequestActivityResult.launch(
IntentSenderRequest.Builder(e.userAction.actionIntent.intentSender)
.build()
)
} catch (e2: IntentSender.SendIntentException) {
e2.printStackTrace()
}
return
}
}
e.printStackTrace()
}
}
使用Storage Access Framework
Android 4.4(API 級(jí)別 19)引入了存儲(chǔ)訪問框架Storage Access Framework (SAF)。借助 SAF,用戶可輕松在其所有首選文檔存儲(chǔ)提供程序中瀏覽并打開文檔、圖像及其他文件。用戶可通過易用的標(biāo)準(zhǔn)界面,以統(tǒng)一方式在所有應(yīng)用和提供程序中瀏覽文件,以及訪問最近使用的文件。
SAF google官方文檔 https://developer.android.google.cn/guide/topics/providers/document-provider

SAF本地存儲(chǔ)服務(wù)的圍繞 DocumentsProvider 實(shí)現(xiàn)的,通過Intent調(diào)用DocumentUI,由用戶在DocumentUI上選擇要?jiǎng)?chuàng)建、授權(quán)的文件以及目錄等,授權(quán)成功后再onActivityResult回調(diào)用拿到指定的Uri,根據(jù)這個(gè)Uri可進(jìn)行讀寫等操作,這時(shí)候已經(jīng)賦予文件讀寫權(quán)限,不需要再動(dòng)態(tài)申請(qǐng)權(quán)限。
使用SAF選擇單個(gè)文件
通過Intent.ACTION_OPEN_DOCUMENT調(diào)文件選擇界面,用戶選擇并返回一個(gè)或多個(gè)現(xiàn)有文檔,所有選定的文檔均具有持久的讀寫權(quán)限授予,直至設(shè)備重啟。
private lateinit var createFileActivityResult: ActivityResultLauncher<Intent>
/**
* 選擇一個(gè)文件,這里打開一個(gè)圖片作為演示
*/
private fun selectSingleFile() {
selectSingleFileActivityResult = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == RESULT_OK) {
//獲取文檔
val uri = it?.data?.data
if (uri != null) {
dumpImageMetaData(uri)//dump圖片的信息進(jìn)行打印
getBitmapFromUri(uri)?.let {
binding.showIv.setImageBitmap(it)
}
Log.d(TAG, "圖片的line :${readTextFromUri(uri)}")
}
}
}
binding.selectSingleFile.setOnClickListener {
safSelectSingleFileActivityResult.launch(Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
// Filter to only show results that can be "opened", such as a
// file (as opposed to a list of contacts or timezones)
addCategory(Intent.CATEGORY_OPENABLE)
// Filter to show only images, using the image MIME data type.
// If one wanted to search for ogg vorbis files, the type would be "audio/ogg".
// To search for all documents available via installed storage providers,
// it would be "*/*".
type = "image/*"
})
}
}
使用SAF創(chuàng)建文件
可通過使用Intent.ACTION_CREATE_DOCUMENT,可以提供 MIME 類型和文件名,但最終結(jié)果由用戶決定。
private fun createFile(mimeType: String, fileName: String) {
createFileActivityResult = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == Activity.RESULT_OK) {
//創(chuàng)建文檔
val uri = it?.data?.data
if (uri != null) {
Log.d(TAG, "創(chuàng)建文件成功")
binding.createFileUriTv.text = TAG
binding.createFileUriTv.visibility = View.VISIBLE
dumpImageMetaData(uri)//dump圖片的信息進(jìn)行打印
}
}
}
binding.createFileBtn.setOnClickListener {
createFileActivityResult.launch(Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
// Filter to only show results that can be "opened", such as
// a file (as opposed to a list of contacts or timezones).
addCategory(Intent.CATEGORY_OPENABLE)
// Create a file with the requested MIME type.
type = mimeType
putExtra(Intent.EXTRA_TITLE, fileName)
})
}
}
使用SAF刪除文件
需要注意的是此時(shí)的Uri是通過Document授權(quán)的,例如:content://com.android.providers.media.documents/document/image:14766。而不是這種content://media/external/images/media/14760。
如果您獲得了文檔的 URI,并且文檔Document.COLUMN_FLAGS 包含 FLAG_SUPPORTS_DELETE,則便可刪除該文檔。
private fun checkUriFlag(uri: Uri, flag: Int): Boolean {
try {
val cursor = contentResolver.query(uri, null, null, null, null)
if (cursor != null && cursor.moveToFirst()) {
val columnIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_FLAGS)
val columnFlags = cursor.getInt(columnIndex)
Log.i(TAG,"Column Flags:$columnFlags Flag:$flag")
if ((columnFlags and flag) == flag) {
return true
}
cursor.close()
}
} catch (e: Exception) {
e.printStackTrace()
}
return false
}
這里做一個(gè)解釋通過一下,我們可以先看DocumentsContract.java的源代碼。
//android.provider.DocumentsContract.java
public static final int FLAG_SUPPORTS_THUMBNAIL = 1;
public static final int FLAG_SUPPORTS_WRITE = 1 << 1;
public static final int FLAG_SUPPORTS_DELETE = 1 << 2;
public static final int FLAG_DIR_SUPPORTS_CREATE = 1 << 3;
/**
* Flags that apply to a document. This column is required.
* <p>
* Type: INTEGER (int)
*
* @see #FLAG_DIR_BLOCKS_OPEN_DOCUMENT_TREE
* @see #FLAG_DIR_PREFERS_GRID
* @see #FLAG_DIR_PREFERS_LAST_MODIFIED
* @see #FLAG_DIR_SUPPORTS_CREATE
* @see #FLAG_PARTIAL
* @see #FLAG_SUPPORTS_COPY
* @see #FLAG_SUPPORTS_DELETE
* @see #FLAG_SUPPORTS_METADATA
* @see #FLAG_SUPPORTS_MOVE
* @see #FLAG_SUPPORTS_REMOVE
* @see #FLAG_SUPPORTS_RENAME
* @see #FLAG_SUPPORTS_SETTINGS
* @see #FLAG_SUPPORTS_THUMBNAIL
* @see #FLAG_SUPPORTS_WRITE
* @see #FLAG_VIRTUAL_DOCUMENT
* @see #FLAG_WEB_LINKABLE
*/
public static final String COLUMN_FLAGS = "flags";
可以看出,flag是通過二進(jìn)制的位做區(qū)分的。所以判斷是否判斷包含某個(gè)flag可以使用位操作與Document.COLUMN_FLAGS做比較。
/**
* 如果您獲得了文檔的 URI,并且文檔的 Document.COLUMN_FLAGS 包含 FLAG_SUPPORTS_DELETE,則便可刪除該文檔
*/
private fun deleteFile() {
binding.deleteFileBtn.setOnClickListener {
queryUri = Uri.parse("content://com.android.providers.media.documents/document/image%3A14766")
queryUri?.let { url ->
if (checkUriFlag(url, DocumentsContract.Document.FLAG_SUPPORTS_DELETE)) {
val deleted = DocumentsContract.deleteDocument(contentResolver, url)
val s = "刪除$url$deleted"
Log.d(TAG, "deleteFile:$s")
if (deleted) {
binding.createFileUriTv.text = ""
}
}
}
}
}
使用SAF更新文件
這里的Uri,是通過用戶選擇授權(quán)的Uri,通過Uri獲取ParcelFileDescriptor或者打開OutputStream進(jìn)行修改。
private fun editDocument() {
editFileActivityResult =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == RESULT_OK) {
//編輯文檔
val uri = it?.data?.data
if (uri != null) {
alterDocument(uri)//更新文檔
}
}
}
binding.editDocumentBtn.setOnClickListener {
editFileActivityResult.launch(
// ACTION_OPEN_DOCUMENT is the intent to choose a file via the system's
// file browser.
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
// Filter to only show results that can be "opened", such as a
// file (as opposed to a list of contacts or timezones).
addCategory(Intent.CATEGORY_OPENABLE)
// Filter to show only text files.
type = "text/plain"
})
}
}
使用SAF獲取目錄&保存授權(quán)
使用ACTION_OPEN_DOCUMENT_TREE的intent,拉起DocumentUI讓用戶主動(dòng)授權(quán)的方式 獲取,獲得用戶主動(dòng)授權(quán)之后,應(yīng)用就可以臨時(shí)獲得該目錄下面的所有文件和目錄的讀寫權(quán)限,可以通過DocumentFile操作目錄和其下的文件。
在這個(gè)過程中通過用戶授權(quán)的Uri,就默認(rèn)獲取了該Uri的讀寫權(quán)限,直到設(shè)備重啟。可以通過保存權(quán)限來永久的獲取該權(quán)限,不需要每次重啟手機(jī)之后又要重新讓用戶主動(dòng)授權(quán)。
contentResolver.takePersistableUriPermission方法可以校驗(yàn)當(dāng)前的Uri是否有相關(guān)授權(quán)可以進(jìn)行文件讀寫;
/**
* 使用saf選擇目錄
*/
private fun getDocumentTree() {
selectDirActivityResult = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
//選擇目錄
val treeUri = it?.data?.data
if (treeUri != null) {
savePersistablePermission(treeUri)//將獲取的權(quán)限持久化保存
val root = DocumentFile.fromTreeUri(this, treeUri)
root?.listFiles()?.forEach { it ->
Log.d(TAG, "目錄下文件名稱:${it.name}")
}
}
}
binding.getDocumentTreeBtn.setOnClickListener {
val sp = getSharedPreferences("DirPermission", Context.MODE_PRIVATE)//獲取緩存的權(quán)限
val uriString = sp.getString("uri", "")
if (!uriString.isNullOrEmpty()) {
try {
val treeUri = Uri.parse(uriString)
// Check for the freshest data.
contentResolver.takePersistableUriPermission(treeUri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
Log.d(TAG, "已經(jīng)獲得永久訪問權(quán)限")
val root = DocumentFile.fromTreeUri(this, treeUri)
root?.listFiles()?.forEach { it ->
Log.d(TAG, "目錄下文件名稱:${it.name}")
}
} catch (e: SecurityException) {
Log.d(TAG, "uri 權(quán)限失效,調(diào)用目錄獲取")
selectDirActivityResult.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE))
}
} else {
Log.d(TAG, "沒有永久訪問權(quán)限,調(diào)用目錄獲取")
selectDirActivityResult.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE))
}
}
}
使用SAF進(jìn)行重命名
private fun renameFileName() {
binding.renameFileBtn.setOnClickListener {
queryUri?.let {
val uri = it
//小米8 Android9 拋出java.lang.UnsupportedOperationException: Rename not supported異常
//Pixel 6a Android13可以正常重命名
if (checkUriFlag(uri, DocumentsContract.Document.FLAG_SUPPORTS_RENAME)) {
try {
//如果文件名已存在,會(huì)報(bào)錯(cuò)java.lang.IllegalStateException: File already exists:
DocumentsContract.renameDocument(contentResolver, uri, "slzs.txt")
Log.d(TAG, "renameFileName" + "重命名成功")
} catch (e: FileNotFoundException) {
Log.d(TAG, "renameFileName" + "重命名失敗,文件不存在")
}
} else {
Log.d(TAG, "renameFileName" + "重命名失敗,權(quán)限校驗(yàn)失敗")
}
}
}
}
使用自定義DocumentsProvider
如果你希望自己應(yīng)用的數(shù)據(jù)也能在documentsui中打開,可以自定義一個(gè)document provider。APP可以實(shí)現(xiàn)自定義ContentProvider來向外提供APP私有文件。 一般的文件管理類的軟件都會(huì)使用自定義的DocumentsProvider。這種方式十分適用于內(nèi)部文件分享,不希望有UI交互的情況。 ContentProvider相關(guān)的Google官方文檔: https://developer.android.google.cn/guide/topics/providers/content-providers
下面介紹自定義DocumentsProvider的步驟:
- API版本為19或者更高
- 在manifest.xml中注冊(cè)該P(yáng)rovider
- Provider的name為類名加包名,比如: com.example.android.storageprovider.MyCloudProvider
- Authority為包名+provider的類型名,如: com.example.android.storageprovider.documents
- android:exported屬性的值為ture
<manifest... >
...
<uses-sdk
android:minSdkVersion="19"
android:targetSdkVersion="19" />
....
<provider
android:name="com.example.android.storageprovider.MyCloudProvider"
android:authorities="com.example.android.storageprovider.documents"
android:grantUriPermissions="true"
android:exported="true"
android:permission="android.permission.MANAGE_DOCUMENTS"
android:enabled="@bool/atLeastKitKat">
<intent-filter>
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
</intent-filter>
</provider>
</application>
</manifest>
兼容性影響
Scoped Storage對(duì)于APP訪問外部存儲(chǔ)方式、APP數(shù)據(jù)存放以及APP間數(shù)據(jù)共享,都產(chǎn)生很大影響。請(qǐng)開發(fā)者注意以下的兼容性影響事項(xiàng)。
無法新建文件
問題原因:
直接使用自身App-specific目錄以外的路徑新建文件。
問題分析:
在Android Q上,APP只允許在自身App-specific目錄以內(nèi)通過路徑生成的文件。
解決方案:
APP自身App-specific目錄下新建文件的方法與文件路徑,請(qǐng)參見訪問App-specific目錄文件;如果要在公共目錄下新建文件,使用MediaStore 接口,請(qǐng)參見使用MediaStore訪問公共目錄;如果要在任意目錄下新建文件,需要使用SAF,請(qǐng)參見[使用Storage Access Framework](#使用Storage Access Framework)。
無法訪問存儲(chǔ)設(shè)備上的文件
問題原因1:
直接使用路徑訪問公共目錄文件。
問題分析1:
在Android Q上,APP默認(rèn)只能訪問外部存儲(chǔ)設(shè)備上的App-specific目錄。
解決方法1:
參見使用MediaStore訪問公共目錄和使用SAF選擇單個(gè)文件,使用MediaStore接口訪問公共目錄中的多媒體文件,或者使用 SAF訪問公共目錄中的任意文件。
注意:從MediaStore接口中查詢到的DATA字段將在Android Q開始廢棄,不應(yīng)該利用它來訪問文件或者判斷文件是否存在;從 MediaStore接口或者SAF獲取到文件Uri后,請(qǐng)利用Uri打開FD 或者輸入輸出流,而不要轉(zhuǎn)換成文件路徑去訪問。
問題原因2:
使用MediaStore接口訪問非多媒體文件。
問題分析2:
在Android Q上,使用MediaStore接口只能訪問公共目錄中的多媒體文件。
解決方法2:
使用SAF向用戶申請(qǐng)文件或目錄的讀寫權(quán)限,請(qǐng)參見使用SAF選擇單個(gè)文件。
無法正確分享文件
問題原因:
APP將App-specific目錄中的私有文件分享給其他APP時(shí),使用了file://類型的 Uri。
問題分析:
在Android Q上,由于App-specific目錄中的文件是私有受保護(hù)的,其他APP無法通過文件路徑訪問。
解決方案:
參見分享處理,使用FileProvider,將content://類型的Uri分享給其他 APP。
無法修改存儲(chǔ)設(shè)備上的文件
問題原因1:
直接使用路徑訪問公共目錄文件。
問題分析1:
解決方案1:
同無法訪問存儲(chǔ)設(shè)備上的文件,請(qǐng)使用正確的公共目錄文件訪問方式。
問題原因2:
使用MediaStore接口獲取公共目錄多媒體文件的Uri后,直接使用該Uri打開 OutputStream或文件描述符。
問題分析2:
在Android Q上,修改公共目錄文件,需要用戶授權(quán)。
解決方案2:
從MediaStore接口獲取公共目錄多媒體文件Uri后,打開OutputStream或FD時(shí),注意catch RecoverableSecurityException,然后向用戶申請(qǐng)?jiān)摱嗝襟w文件的刪改權(quán)限,請(qǐng)參見使用MediaStore修改文件;使用SAF 獲取到文件或目錄的Uri時(shí),用戶已經(jīng)授權(quán)讀寫,可以直接使用,但要注意Uri權(quán)限的時(shí)效,請(qǐng)參見使用SAF獲取目錄&保存授權(quán)。
應(yīng)用卸載后文件意外刪除
問題原因:
將想要保留的文件保存在外部存儲(chǔ)的App-specific目錄下。
問題分析:
在Android Q上,卸載APP默認(rèn)刪除App-specific目錄下的數(shù)據(jù)。
解決方案:
APP應(yīng)該將想要保留的文件通過MediaStore接口保存到公共目錄下,請(qǐng)參見使用MediaStore訪問公共目錄。默認(rèn)情況下,MediaStore 接口會(huì)將非媒體類文件保存到Downloads目錄下,推薦APP指定一級(jí)目錄為Documents。如果APP 想要在卸載時(shí)保留App-specific目錄下的數(shù)據(jù),要在AndroidManifest.xml中聲明android:hasFragileUserData="true",這樣在 APP卸載時(shí)就會(huì)有彈出框提示用戶是否保留應(yīng)用數(shù)據(jù)。
無法訪問圖片文件中的地理位置數(shù)據(jù)
問題原因:
直接從圖片文件輸入流中解析地理位置數(shù)據(jù)。
問題分析:
由于圖片的地理位置信息涉及用戶隱私,Android Q上默認(rèn)不向APP提供該數(shù)據(jù)。
解決方案:
申請(qǐng)ACCESS_MEDIA_LOCATION權(quán)限,并使用MediaStore.setRequireOriginal()接口更新文件Uri,請(qǐng)參見圖片位置信息。
ota升級(jí)問題(數(shù)據(jù)遷移)
問題原因:
ota升級(jí)后,APP被卸載,重新安裝后無法訪問到APP數(shù)據(jù)。
問題分析:
Scoped Storage新特性只對(duì)Android Q上新安裝的APP生效。設(shè)備從Android Q之前的版本升級(jí)到Android Q,已安裝的APP獲得Legacy View視圖。
這些APP 如果直接通過路徑的方式將文件保存到了外部存儲(chǔ)上,例如外部存儲(chǔ)的根目錄,那么APP被卸載后重新安裝,新的APP獲得Filtered View視圖,無法直接通過路徑訪問到舊數(shù)據(jù),導(dǎo)致數(shù)據(jù)丟失。
解決方案:
APP應(yīng)該修改保存文件的方式,不再使用路徑的方式直接保存,而是采用
MediaStore接口將文件保存到對(duì)應(yīng)的公共目錄下。在ota升級(jí)前,可以將APP 的用戶歷史數(shù)據(jù)通過
MediaStore接口遷移到公共目錄下。此外,APP應(yīng)當(dāng)改變?cè)L問App-specific目錄以外的文件的方式,請(qǐng)使用MediaStore接口或者SAF。針對(duì)只有應(yīng)用自己訪問并且應(yīng)用卸載后允許刪除的文件,需要遷移文件到應(yīng)用私有目錄文件,可以通過
File path方式訪問文件資源,降低適配成本。允許其他應(yīng)用訪問,并且應(yīng)用卸載后不允許刪除的文件,文件需要存儲(chǔ)在共享目錄,應(yīng)用可以選擇是否進(jìn)行目錄整改,將文件遷移到
Androidq要求的media集合目錄。
分享處理
APP可以選擇以下的方式,將自身App-specific目錄下的文件分享給其他APP讀寫。
使用FileProvider
FileProvider相關(guān)的Google官方文檔: https://developer.android.google.cn/reference/androidx/core/content/FileProvider https://developer.android.com/training/secure-file-sharing/setup-sharing
FileProvider屬于在Android7.0的行為變更,各種帖子很多,這里就不詳細(xì)介紹了。 為了避免和已有的三方庫沖突,建議采用extends FileProvider的方式。
public class TakePhotoProvider extends FileProvider {...}
<application>
<provider
android:name=".TakePhotoProvider"
android:authorities="${applicationId}.fileProvider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/take_file_path" />
</provider>
</application>
使用ContentProvider
APP可以實(shí)現(xiàn)自定義ContentProvider來向外提供APP私有文件。這種方式十分適用于內(nèi)部文件分享,不希望有UI交互的情況。 ContentProvider相關(guān)的Google官方文檔: https://developer.android.google.cn/guide/topics/providers/content-providers
使用DocumentsProvider
相關(guān)API使用問題
MediaStore DATA字段不再可靠
在Android Q中DATA(即_data)字段開始廢棄,不再表示文件的真實(shí)路徑。
讀寫文件或判斷文件是否存在,不應(yīng)該使用DATA字段,而要使用openFileDescriptor。 同時(shí)也無法直接使用路徑訪問公共目錄的文件。
MediaStore 文件增加Pending狀態(tài)
AndroidQ上,MediaStore中添加MediaStore.Images.Media.IS_PENDING ,flag用來表示文件的Pending狀態(tài),0是可見,其他不可見,如果沒有設(shè)置setIncludePending接口,查詢不到設(shè)置IS_PENDIN的文件,可以用來下載,或者生產(chǎn)截圖等等。
ContentValues values = new ContentValues();
values.put(MediaStore.Images.Media.DISPLAY_NAME, "myImage.PNG");
values.put(MediaStore.Images.Media.MIME_TYPE, "image/png");
values.put(MediaStore.Images.Media.IS_PENDING, 1);
ContentResolver resolver = context.getContentResolver();
Uri uri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
Uri item = resolver.insert(uri, values);
try {
ParcelFileDescriptor pfd = resolver.openFileDescriptor(item, "w", null);
// write data into the pending image.
} catch (IOException e) {
LogUtil.log("write image fail");
}
// clear IS_PENDING flag after writing finished.
values.clear();
values.put(MediaStore.Images.Media.IS_PENDING, 0);
resolver.update(item, values, null, null);
MediaStore 相對(duì)路徑
AndroidQ中,通過MediaSore將多媒體沒見儲(chǔ)存在公共目錄下,除了默認(rèn)的一級(jí)目錄,還可以指定次級(jí)目錄,對(duì)應(yīng)的一級(jí)目錄詳見下表:

val values = ContentValues()
//Pictures為一級(jí)目錄對(duì)應(yīng)Environment.DIRECTORY_PICTURES,sl為二級(jí)目錄
values.put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/sl")
val external = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
val insertUri = contentResolver.insert(external, values)
values.clear()
//DCIM為一級(jí)目錄對(duì)應(yīng)Environment.DIRECTORY_DCIM,sl為二級(jí)目錄,sl2為三級(jí)目錄
values.put(MediaStore.Images.Media.RELATIVE_PATH, "DCIM/sl/sl2")
contentResolver.update(insertUri,values,null,null)