AndroidQ
Privacy Changes | Affected apps | mitigation strategy | |
---|---|---|---|
✅ | Partitioned storage Filtered views for external storage, providing access to app-specific collections of files and media | Apps that access and share files in external storage | Use app-specific catalogs and media collection catalogs Learn more |
✅ | Enhanced user control over location permissions Foreground-only permissions give users more control over app access to device location information | Apps that request access to the user's location while in the background | Ensures graceful degradation without background location updates Uses permissions introduced in Android 10 to get location in the background Learn |
✅ | The system executes background activities and implements restrictions on starting activities from the background | An application that starts an Activity without user interaction | Use the Activity triggered by the notification to learn more |
✅ | Non-resettable hardware identifiers Enforce restrictions on access to device serial number and IMEI | Apps to access device serial number or IMEI | Use an identifier that the user can reset Learn more |
✅ | Wi-Fi Scanning Permissions Access to certain Wi-Fi, Wi-Fi Sense, and Bluetooth scanning methods requires precise location permission | Applications using WLAN API and Bluetooth API | Request ACCESS_FINE_LOCATION permissions Learn more |
The above is the link to the AndroidQ privacy change on the official website. This article only explains some major privacy changes.
Limitations of launching an Activity from the background
Create high priority notifications
In Android 10, when the app has no activity displayed in the foreground, its startup activity will be intercepted by the system, resulting in invalid startup.
The official compromise solution for this is to use full screen Intent(full-screen intent)
, which is to add settings when creating notification bar notifications. full-screen intent
The sample code is as follows (modified based on official documents):
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();
Note: When the Target SDk is 29 or above, you need to add USE_FULL_SCREEN_INTENT to the AndroidManifest
//AndroidManifest中
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
When the mobile phone is in the bright state, a notification bar will be displayed, and when the mobile phone is in the locked or off screen state, the screen will be brightened and directly entered into the CallActivity
.
Non-resettable device identifiers enforce restrictions
Starting with Android 10, apps must have READ_PRIVILEGED_PHONE_STATE
privileged permissions to access the device's non-resettable identifiers (including IMEI and serial number).
Affected methods include:
-
Build
-
TelephonyManager
ANDROID_ID generation rule: signature + device information + device user
ANDROID_ID reset rule: ANDROID_ID will be reset when the device is restored to factory settingsThe current way to obtain the unique ID of the device is to use ANDROID_ID. If it is empty, use UUID.randomUUID().toString() to obtain a random ID and store it. The ID is guaranteed to be unique, but it will change after the App is uninstalled and reinstalled.
String id = android.provider.Settings.Secure.getString(context.getContentResolver(), android.provider.Settings.Secure.ANDROID_ID);
Restricted access to clipboard data
Unless your app is the default input method (IME) or is currently in focus, it cannot access clipboard data on Android 10 or higher platforms.
Because the clipboard data is obtained when the application is in the foreground, it will not affect most businesses.
location permissions
Android Q
Introduced a new location permission ACCESS_BACKGROUND_LOCATION
that only affects an app's access to location information while it's running in the background. If you apply targetSDK<=P
, request ACCESS_FINE_LOCATION
or ACCESS_COARSE_LOCATION
permission, AndroidQ
the device will automatically apply for ACCESS_BACKGROUND_LOCATION
permission for you.
If your app Android 10
targets a platform version 2.0 or higher, you must declare permissions in your app's manifest file ACCESS_BACKGROUND_LOCATION
and receive user permissions to receive periodic location updates while your app is in the background.
The following code snippet shows how to request background location access in your app:
<manifest ... >
<!--允许获得精确的GPS定位-->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<!--允许获得粗略的基站网络定位-->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<!-- 兼容10.0系统,允许App在后台获得位置信息 -->
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/>
</manifest>
An example of targeting permission checking logic is shown in the following code snippet:
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);
}
If your app typically needs to access the device's location after being placed in the background, such as when the user presses the device's Home button or turns off the device's display.
To preserve access to device location information in this specific type of use case, start a foreground service that you have declared in your app's manifest with a foreground service type of "location":
<service
android:name="MyNavigationService"
android:foregroundServiceType="location" ... >
...
</service>
Before starting this foreground service, make sure your app still has access to the device's location:
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);
}
Partition storage
In order to allow users to better control their own files and limit file clutter, Android Q has modified the method for APP to access files in external storage. The new feature of external storage is called Scoped Storage
.
Android Q still uses runtime permissions READ_EXTERNAL_STORAGE
related WRITE_EXTERNAL_STORAGE
to user-facing storage, but access to external storage is now limited even when those permissions are obtained.
The scenarios in which apps need these runtime permissions have changed, and the visibility of external storage to apps in various cases has also changed.
In Scoped Storage
the new feature, the external storage space is divided into two parts:
● Public directories: Downloads
, Documents
, Pictures
, DCIM
, Movies
, Music
, Ringtones
etc.
The files in the public directory will not be deleted after the APP is uninstalled.
The APP can access the files in it through the interface SAF(System Access Framework)
.MediaStore
● App-specific
Directory: store application private data, the external storage application private directory corresponds to Android/data/packagename, and the internal storage application private directory corresponds to data/data/packagename;
After the APP is uninstalled, the data will be cleared.
APP's private directory, APP does not need any permission to access its own App-specific
directory.
storage view mode
Android Q stipulates that APP has two external storage space view modes: Legacy View
, Filtered View
.
● Filtered View
: App can directly access App-specific
the directory, but cannot directly access App-specific
external files. Access to the public directory or the directory of other APPs App-specific
can only be accessed through MediaStore
, , or SAF
provided by other APPs .ContentProvider
FileProvider
● Legacy View
: compatibility mode. Same as Android Q before, App can access external storage after applying for permission, and has full access permission
requestLegacyExternalStorage和preserveLegacyExternalStorage
requestLegacyExternalStorage
It was introduced by Android 10. If you upgrade and install the app after adapting to Android 10, the previous storage mode will still be used. The Legacy View
new mode can only be enabled by installing for the first time or uninstalling and reinstalling Filtered View
.
And android:requestLegacyExternalStorage="true"
let the app adapted to Android 10 continue to access the old storage model when it is newly installed on the Android 10 system.
Environment.isExternalStorageLegacy();//存储是否为兼容模式
When adapting to Android 11, requestLegacyExternalStorage
the tag will be ignored on devices above Android 11, preserveLegacyExternalStorage
just to allow the overlay installed app to continue to use the old storage model, if it was the old storage model before.
- When Android10 is adapted, you can
requestLegacyExternalStoragec
use the compatibility mode; - Android 11 adaptation can be made by
preserveLegacyExternalStorage
making Android 10 and below devices use the compatibility mode, but Android 11 and above devices cannot use the compatibility mode whether it is overwritten installation or reinstallation;
The status of the volume can be Environment.getExternalStorageState()
queried . If the returned status is MEDIA_MOUNTED
, then you can read and write app-specific files in external storage. You can only read these files if the returned status is MEDIA_MOUNTED_READ_ONLY
.
Impact of Partitioned Storage
Image location information
Some pictures will contain location information, because the location is sensitive information for the user, and the Android 10 application cannot obtain the image location information by default in the partition storage mode. The application can obtain the image location information through the following two settings:
manifest
Apply inACCESS_MEDIA_LOCATION
;- Call
MediaStore.setRequireOriginal(Uri uri)
the interface to update the pictureUri
;
// 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)
}
}
access data
Private directory:
The file access method of the app's private directory is consistent with the previous Android version, and resources can be obtained through the File path.
Shared directory:
Shared directory files need MediaStore API
to Storage Access Framework
be accessed through or .
MediaStore API
To create files in the specified directory of the shared directory or to access the files created by the application itself, there is no need to apply for storage permissions. To access
MediaStore API
media files (pictures, audio, and videos) created by other applications in the shared directory, you need to apply for storage permissions. If you do not apply for storage permissions, you can ContentResolver
query If the Uri of the file is not found, even if the Uri of the file is obtained by other means, an exception will be thrown when reading or creating the file;
MediaStore API
non-media files (pdf, office, doc, txt, etc.) created by other applications cannot be accessed, and can only be accessed through Storage Access Framework
the method ;
File path to access the affected interface
FileOutputStream
andFileInputStream
Under the partition storage model, the public directory of the SD card is not allowed to be accessed, except for the folders of the shared media. Therefore, instantiating with a path to a public directory FileOutputStream
may FileInputStream
cause 报FileNotFoundException
exceptions.
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.renameTo
File.delete
File.renameTo
File.mkdir
File.mkdirs
The above File
methods all return false
.
BitmapFactory.decodeFile
GeneratedBitmap
asnull
.
Adaptation guide
Android Q Scoped Storage
Google's official adaptation document for new features: https://developer.android.google.cn/preview/privacy/scoped-storage
The adaptation guide is as follows, divided into: accessing the app's own App-specific directory files, using MediaStore to access public directories, using SAF to access specified files and directories, sharing files in the App-specific directory and other detailed adaptations.
Access to App-specific directory files
Files in the App-specific directory can be directly manipulated through File without any permission.
App-specific directory | Interfaces (all storage devices) | Interface (Primary External Storage) |
---|---|---|
Media | getExternalMediaDirs() | THAT |
Obv | getObbDirs() | getObbDir() |
Cache | getExternalCacheDirs() | getExternalCacheDir() |
Data | getExternalFilesDirs(String type) | getExternalFilesDir(String type) |
/**
* 在App-Specific目录下创建文件
* 文件目录:/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, "创建成功")
dir.listFiles()?.forEach {
file: File? ->
if (file != null) {
Log.d(TAG, "Documents 目录下的文件名:" + file.name)
}
}
} catch (e: IOException) {
e.printStackTrace()
Log.d(TAG, "创建失败")
} finally {
closeIO(os)
}
}
}
}
/**
* 在App-Specific目录下创建文件夹
* 文件目录:/Android/data/包名/files/
*/
private fun createAppSpecificFolder() {
binding.createAppSpecificFolderBtn.setOnClickListener {
getExternalFilesDir("apk")?.let {
if (it.exists()) {
Log.d(TAG, "创建成功")
} else {
Log.d(TAG, "创建失败")
}
}
}
}
Use MediaStore to access the public directory
MediaStore Uri and Path Correspondence Table
MediaStore provides the following Uri, and MediaProvider can be used to query the corresponding Uri data. On AndroidQ, all external storage devices will be commanded, namely Volume Name. MediaStore can obtain the corresponding Uri through Volume Name.
MediaStore.getExternalVolumeNames(this).forEach {
volumeName ->
Log.d(TAG, "uri:${
MediaStore.Images.Media.getContentUri(volumeName)}")
}
Uri path format:content:// media/<volumeName>/<Uri路径>
Create files using MediaStore
Through the insert method of ContentResolver, multimedia files are saved in the public collection directory. Different Uri correspond to different public directories, see 3.2.1 for details; the first-level directory of RELATIVE_PATH must be the first-level directory corresponding to Uri, the second-level directory or the second Directories above level can be created and specified at will.
private lateinit var createBitmapForActivityResult: ActivityResultLauncher<String>
//注册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)
//创建了一个红色的图片
val canvas = Canvas(bitmap)
canvas.drawColor(Color.RED)
bitmap.compress(Bitmap.CompressFormat.PNG, 90, os)
Log.d(TAG, "创建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, "创建失败:${
e.message}")
} finally {
closeIO(os)
}
}
Query files using MediaStore
via
Cursor query(@RequiresPermission.Read @NonNull Uri uri,@Nullable String[] projection, @Nullable String selection,@Nullable String[] selectionArgs, @Nullable String sortOrder)
the method .
Parameter explanation:
parameter | type | paraphrase |
---|---|---|
uri | Uri | Provide the Uri to retrieve the content, its scheme iscontent:// |
projection | String[] | The returned columns, if null is passed then all columns are returned (inefficient) |
selection | String | Filter condition, that is, WHERE the statement (but you don't need to write where itself), if you pass null, all the data will be returned |
selectionArgs | String[] | If you add in the parameter of selection ? , it will be replaced by the data in this field in order |
sortOrder | String | Used to sort the data, that is, in the SQL statement ORDER BY (you don’t need to write ORDER BY itself), if you pass null, it will be sorted according to the default order (maybe unordered) |
ContentResolver.query
Query files through the interface Uri
, and query files created by other apps requires READ_EXTERNAL_STORAGE
permission;
This query uses the database query of the mobile phone system, there may be some picture files that exist but still cannot be queried~! (PS: The pictures pushed by the adb command cannot be queried)
/**
* 通过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 第一个遍历到的该文件名的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) {
//可能查询到多个同名图片
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;
}
Read files using MediaStore
First of all, the file storage permission is required. After ContentResolver.query
the query is obtained Uri
, you can contentResolver.openFileDescriptor
select the corresponding opening method according to the file descriptor. "r" means read, "w" means write;
private lateinit var readPictureForActivityResult: ActivityResultLauncher<IntentSenderRequest>
/**
* 根据查询到的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设置为true,来获取图片大小
val options = BitmapFactory.Options()
options.inJustDecodeBounds = true
BitmapFactory.decodeFileDescriptor(pfd.fileDescriptor, null, options)
// 调用上面定义的方法计算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")
}
}
Get a thumbnail of an image:
Access Thumbnai
l, and ContentResolver.loadThumbnail
return a thumbnail of the specified size by passing in size.
/**
* 根据查询到的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)
}
}
}
}
}
Modify files using MediaStore
PS: Only for AndroidQ and above system versions, there are data and file out-of-sync problems, as well as out-of-sync problems between thumbnails and original images when using low-version domestic mobile phones for data update;ContentResolver
After the application has
WRITE_EXTERNAL_STORAGE
the permission , another Exception will be thrown when modifying the files of other apps:
android.app.RecoverableSecurityException: com.tzx.androidsystemversionadapter has no access to content://media/external/images/media/21
If we RecoverableSecurityException
hold Catch
and apply to the user for permission to modify the picture, after the user operates, we can get the result in onActivityResult
the callback operate.
/**
* 根据查询得到的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)
//创建了一个红色的图片
val canvas = Canvas(bitmap)
canvas.drawColor(Color.YELLOW)
//重新写入文件内容
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()
}
}
Delete files using MediaStore
Deleting multimedia files created by oneself does not require permission, while those created by other APPs and modification types require user authorization.
/**
* 删除MediaStore文件
*/
private fun deleteFileByMediaStore() {
deletePictureRequestPermissionActivityResult = registerForActivityResult(ActivityResultContracts.RequestPermission()) {
if (it) {
deleteFile()
} else {
Log.d(TAG, "deleteFileByMediaStore: 授权失败")
}
}
deletePictureSenderRequestActivityResult = registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) {
if (it.resultCode == RESULT_OK) {
deleteFile()
} else {
Log.d(TAG, "updateFileByMediaStore: 授权失败")
}
}
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也不一定删除成功,必须要授予文件的写权限
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()
}
}
UsingStorage Access Framework
Android 4.4 (API level 19) introduced the Storage Access Framework Storage Access Framework (SAF)
. With SAF, users can easily browse and open documents, images and other files across all of their preferred document storage providers. Users can browse files and access recently used files in a unified way across all apps and providers through a standard, easy-to-use interface.
SAF google official documentation https://developer.android.google.cn/guide/topics/providers/document-provider
The SAF local storage service DocumentsProvider
is implemented . By Intent
calling DocumentUI
, the user DocumentUI
selects the files and directories to be created and authorized on the Internet. After the authorization is successful, the callback is onActivityResult
called back to get the specified ones Uri
. According to this, Uri
operations such as reading and writing can be performed. At this time, To grant read and write permissions to files, there is no need to dynamically apply for permissions.
Select individual files using SAF
Through Intent.ACTION_OPEN_DOCUMENT
the file selection interface, the user selects and returns one or more existing documents, and all selected documents have persistent read and write permissions granted until the device is restarted.
private lateinit var createFileActivityResult: ActivityResultLauncher<Intent>
/**
* 选择一个文件,这里打开一个图片作为演示
*/
private fun selectSingleFile() {
selectSingleFileActivityResult = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == RESULT_OK) {
//获取文档
val uri = it?.data?.data
if (uri != null) {
dumpImageMetaData(uri)//dump图片的信息进行打印
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/*"
})
}
}
Create files using SAF
By using Intent.ACTION_CREATE_DOCUMENT
, a MIME type and filename can be provided, but the end result is up to the user .
private fun createFile(mimeType: String, fileName: String) {
createFileActivityResult = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == Activity.RESULT_OK) {
//创建文档
val uri = it?.data?.data
if (uri != null) {
Log.d(TAG, "创建文件成功")
binding.createFileUriTv.text = TAG
binding.createFileUriTv.visibility = View.VISIBLE
dumpImageMetaData(uri)//dump图片的信息进行打印
}
}
}
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)
})
}
}
Delete files using SAF
It should be noted that at this time Uri
it is Document
authorized, for example: content://com.android.providers.media.documents/document/image:14766
. Instead of this content://media/external/images/media/14760
.
If you get the URI of the document, and the document Document.COLUMN_FLAGS
contains FLAG_SUPPORTS_DELETE
, you can delete the document.
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
}
Here is an explanation to go through, we can look at DocumentsContract.java
the source code first.
//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";
It can be seen that the flag is distinguished by binary bits. So to judge whether to judge whether a certain flag is included, you can use bit operation and Document.COLUMN_FLAGS
compare.
/**
* 如果您获得了文档的 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 = ""
}
}
}
}
}
Update files using SAF
Here Uri
, it is authorized through the user's choice Uri
, and can be modified by Uri
obtaining ParcelFileDescriptor
or opening .OutputStream
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"
})
}
}
Use SAF to get directory & save authorization
ACTION_OPEN_DOCUMENT_TREE
For use intent
, pull up DocumentUI
the method of allowing the user to actively authorize. After the user actively authorizes, the application can temporarily obtain the read and write permissions of all files and directories under the directory, and can operate the directory and the files under DocumentFile
it.
In this process, if the user authorizes Uri
, the read and write permissions of the device are obtained by default Uri
until the device is restarted. The permission can be obtained permanently by saving the permission, and there is no need to ask the user to actively authorize again after restarting the phone every time.
contentResolver.takePersistableUriPermission
The method can check whether the current file Uri
has relevant authorization to read and write files;
/**
* 使用saf选择目录
*/
private fun getDocumentTree() {
selectDirActivityResult = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
//选择目录
val treeUri = it?.data?.data
if (treeUri != null) {
savePersistablePermission(treeUri)//将获取的权限持久化保存
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)//获取缓存的权限
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, "已经获得永久访问权限")
val root = DocumentFile.fromTreeUri(this, treeUri)
root?.listFiles()?.forEach {
it ->
Log.d(TAG, "目录下文件名称:${
it.name}")
}
} catch (e: SecurityException) {
Log.d(TAG, "uri 权限失效,调用目录获取")
selectDirActivityResult.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE))
}
} else {
Log.d(TAG, "没有永久访问权限,调用目录获取")
selectDirActivityResult.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE))
}
}
}
Use SAF for renaming
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 {
//如果文件名已存在,会报错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" + "重命名失败,权限校验失败")
}
}
}
}
Using a custom DocumentsProvider
If you want the data of your application to be documentsui
opened in , you can customize one document provider
. APP can implement a custom ContentProvider to provide APP private files to the outside world. General file management software will use custom ones DocumentsProvider
. This method is very suitable for internal file sharing and does not want to have UI interaction. Google official documents related to ContentProvider: https://developer.android.google.cn/guide/topics/providers/content-providers
The steps for customization are described below DocumentsProvider
:
- API version 19 or higher
- Register the Provider in manifest.xml
- Provider name is the class name plus package name, for example: com.example.android.storageprovider.MyCloudProvider
- Authority is the package name + provider type name, such as: com.example.android.storageprovider.documents
- The value of the android:exported attribute is true
<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>
compatibility impact
Scoped Storage
It has a great impact on APP access to external storage methods, APP data storage, and data sharing between APPs. Developers are requested to pay attention to the following compatibility issues.
Unable to create new file
problem causes:
Create a new file directly using a path other than its own App-specific directory.
problem analysis:
On Android Q, an app only allows files generated by paths within its own App-specific directory.
solution:
For the method and file path of creating a new file in the App-specific directory of the app, see Accessing App-specific Directory Files ; if you want to create a new file in a public directory, use the MediaStore interface, see Using MediaStore to Access Public Directory ; if you want to create a file in any directory To create a new file, you need to use SAF, see [Using Storage Access Framework](#Using Storage Access Framework).
Unable to access files on storage device
Problem cause 1:
Access public directory files directly using path.
Problem Analysis 1:
On Android Q, apps can only access App-specific directories on external storage devices by default.
Solution 1:
See Using MediaStore to Access a Public Directory and Using SAF to Select a Single File , Using the MediaStore Interface to Access Multimedia Files in a Public Directory, or Using SAF to Access Arbitrary Files in a Public Directory.
Note: MediaStore
The DATA field queried from the interface will Android Q
be discarded at the beginning, and it should not be used to access files or determine whether a file exists; after obtaining the file Uri from MediaStore
the interface or SAF, please use Uri to open the FD or input and output stream instead of Convert it to a file path to access.
Problem reason 2:
Use the MediaStore interface to access non-multimedia files.
Problem Analysis 2:
On Android Q, only multimedia files in the public directory can be accessed using the MediaStore interface.
Solution 2:
Use SAF to apply to users for read and write permissions on files or directories. For details, see Using SAF to Select a Single File .
Couldn't share files properly
problem causes:
When the app App-specific
shares the private files in the directory with other apps, it uses file://
the type Uri.
problem analysis:
On Android Q, since the files in the App-specific directory are private and protected, other apps cannot access them through the file path.
solution:
See share processing , use FileProvider
, and content://
share the Uri of this type with other apps.
Unable to modify file on storage device
Problem cause 1:
Access public directory files directly using path.
Problem Analysis 1:
Also unable to access files on the storage device .
Solution 1:
If you cannot access the files on the storage device , please use the correct public directory file access method.
Problem reason 2:
After using MediaStore
the interface to obtain the Uri of the multimedia file in the public directory, directly use the Uri to open OutputStream
or file descriptor.
Problem Analysis 2:
On Android Q
the Internet, modifying public directory files requires user authorization.
Solution 2:
After obtaining the Uri of the multimedia file in the public directory from MediaStore
the interface, open OutputStream
or FD
pay attention catch RecoverableSecurityException
, and then apply to the user for permission to delete and modify the multimedia file . , can be used directly, but pay attention to the timeliness of the Uri permission, see Use SAF to obtain directory & save authorization .
Accidental file deletion after app uninstall
problem causes:
Save the files you want to keep in App-specific
a directory on your external storage.
problem analysis:
On Android Q
the Internet, uninstalling the app will delete App-specific
the data in the directory by default.
solution:
The APP should save the files you want to keep MediaStore
in the public directory through the interface, see Using MediaStore to access the public directory . By default, MediaStore
the interface will save non-media files to Downloads
a directory, and it is recommended that the APP specify the first-level directory as this Documents
. pp-specific
If the APP wants to keep the data in the A directory when uninstalling , it must AndroidManifest.xml
be declared in android:hasFragileUserData="true"
, so that when the APP is uninstalled, a pop-up box will prompt the user whether to keep the application data.
Geolocation data in image files cannot be accessed
problem causes:
Parse geolocation data directly from an image file input stream.
problem analysis:
Since the geographical location information of the picture involves user privacy, the Android Q
website does not provide this data to the APP by default.
solution:
Apply for ACCESS_MEDIA_LOCATION
permission, and use MediaStore.setRequireOriginal()
the interface to update the file Uri, please refer to the image location information .
ota upgrade problem (data migration)
problem causes:
After the ota upgrade, the APP is uninstalled, and the APP data cannot be accessed after reinstallation.
problem analysis:
Scoped Storage
The new features only Android Q
take effect for newly installed apps. The device is upgraded from Android Q
the previous version Android Q
, and the installed APP gets Legacy View
the view.
If these apps save files to the external storage directly through the path, such as the root directory of the external storage, then the app is uninstalled and reinstalled, and the new app obtains the view, and cannot directly access the old data through the path, resulting in data loss Filtered View
.
solution:
-
The APP should modify the way of saving files, no longer using the path to save directly, but using the
MediaStore
interface to save the files to the corresponding public directory. -
Before the ota upgrade, the APP user history data can
MediaStore
be migrated to the public directory through the interface. In addition, the APP should changeApp-specific
the way of accessing files outside the directory, please useMediaStore
the interface orSAF
. -
For files that can only be accessed by the app itself and are allowed to be deleted after the app is uninstalled, the files need to be migrated to the private directory of the app. File resources can be accessed through
File path
the method reduce adaptation costs. -
Files that are allowed to be accessed by other applications and not allowed to be deleted after the application is uninstalled must be stored in a shared directory. The application can choose whether to perform directory rectification and migrate the files to the
Androidq
requiredmedia
directory.
share processing
The APP can choose the following methods to App-specific
share the files in its own directory with other APPs for reading and writing.
Use FileProvider
Official Google documents related to FileProvider: https://developer.android.google.cn/reference/androidx/core/content/FileProvider https://developer.android.com/training/secure-file-sharing/setup-sharing
FileProvider
Belonging to Android7.0
the behavior change of Zai, there are many various posts, so I won’t introduce it in detail here. In order to avoid conflicts with existing three-party libraries, it is recommended to use extends FileProvider
the method.
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>
Use ContentProvider
APP can be customized ContentProvider
to provide APP private files to the outside world. This method is very suitable for internal file sharing and does not want to have UI interaction. ContentProvider
Related Google official documents: https://developer.android.google.cn/guide/topics/providers/content-providers
Use Documents Provider
See Using a Custom DocumentsProvider for details
Related API usage issues
MediaStore DATA field is no longer reliable
Android Q
The in ( DATA
ie _data) field is obsolete and no longer represents the real path of the file.
When reading and writing files or judging whether a file exists, DATA
fields should not be used, but fields should be used openFileDescriptor
. At the same time, it is not possible to directly use the path to access the files of the public directory.
Add Pending status to MediaStore files
AndroidQ
MediaStore
Added above and in MediaStore.Images.Media.IS_PENDING
, the flag is used to indicate the Pending status of the file, 0 is visible, others are invisible, if the setIncludePending
interface is not set, the set file cannot be queried IS_PENDIN
, it can be used for downloading, or producing screenshots, etc.
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 relative path
AndroidQ
In , multimedia files are stored in the public directory through MediaSore. In addition to the default first-level directory, you can also specify a sub-directory. The corresponding first-level directory is detailed in the table below:
val values = ContentValues()
//Pictures为一级目录对应Environment.DIRECTORY_PICTURES,sl为二级目录
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为一级目录对应Environment.DIRECTORY_DCIM,sl为二级目录,sl2为三级目录
values.put(MediaStore.Images.Media.RELATIVE_PATH, "DCIM/sl/sl2")
contentResolver.update(insertUri,values,null,null)
References
OPPO Open Platform Android Q Version Application Compatibility Adaptation Guide
If you want to read more articles by the author, you can check out my personal blog and public account: