本文是对 关于 Android 的文件存储目录的补充

在 Android Q 后,获得 External Storage 的权限后 使用 Environment.getExternalStorageDirectory 和 File Api 对外置存储中的文件进行操作 这种方式已经不被允许了,需要开发者进行适配,后续开发者需要通过 Storage Access Framework 或者 MediaStore 的 Api 来对 External Storage 中的文件进行操作

关于权限

READ_EXTERNAL_STORAGE WRITE_EXTERNAL_STORAGE

通过 MediaStore Api 访问应用自身存放到公共目录下的文件不需要申请权限,而如果要访问其他应用保存到公共目录下的文件则需要申请权限

关于 MimeType

com.android.media.MediaFormat 源码中我们可以找到 Android 定义好的一些 MineType

例如:
carbon

创建/保存文件

构造一个 ContentValues 对象,通过 ContentResolver.insert 插入到对应的目录中,该方法会返回一个 Uri,通过对该 Uri 进行文件流写入即可

示例:

1
2
3
4
5
6
7
8
9
private fun saveImage(bitmap: Bitmap){
val values = ContentValues()
val insertUri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI,values)
insertUri?.let {
contentResolver.openOutputStream(it).use {outputStream->
bitmap.compress(Bitmap.CompressFormat.PNG,100,outputStream)
}
}
}

注意:

  • ContentValues 其实是内部使用了一个 ArrayMap 的数据结构用来存放数据,所以我们可以根据我们需要保存的文件信息,给 ContentValues 设置对应的值
    例如:
1
2
3
4
5
val values = ContentValues().apply {
put(MediaStore.Images.Media.MIME_TYPE,"image/png")
put(MediaStore.Images.Media.DISPLAY_NAME,"${System.currentTimeMillis()}.png")
put(MediaStore.Images.Media.RELATIVE_PATH,"Pictures/DemoPicture")
}

具体举几个例子,可见下面的表格

key value
mime_type 设置文件的 MimeType
_display_name 指定保存的文件名,如果不设置,则系统会取当前的时间戳作为文件名
relative_path 指定保存的文件目录,例如上文我们将这个图片保存到了 Pictures/DemoPicture 文件夹下,如果不设置这个值,则会被默认保存到对应的媒体类型的文件夹下,例如,图片文件(mimeType = image/)会被保存到 Pictures(Environment#DIRECTORY_PICTURES) 中,需要注意的是,不能将文件放置到不对应的顶级文件夹下,比如将一个 mimeType 为 audio/mpeg 放大 Pictures 这样的行为是不被允许的,也就是如果设置 MIME_TYPE = audia/\ 并将 RELATIVE_PATH 设置为 Environment#DIRECTORY_PICTURES 这样是会 Throw IllegalArgumentException 的

例如:

1
2
3
4
5
6
val values = ContentValues().apply {
put(MediaStore.Images.Media.MIME_TYPE,"image/png")
//这里将 Movies 设置为了 Primary directory
put(MediaStore.Images.Media.RELATIVE_PATH,"${Environment.DIRECTORY_MOVIES}/DemoPicture")
}
val insertUri = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI,values)

结果是:

1
2
3
4
5
Caused by: java.lang.IllegalArgumentException: Primary directory Video not allowed for content://media/external/images/media; allowed directories are [DCIM, Pictures]
at android.database.DatabaseUtils.readExceptionFromParcel(DatabaseUtils.java:170)
at android.database.DatabaseUtils.readExceptionFromParcel(DatabaseUtils.java:140)
at android.content.ContentProviderProxy.insert(ContentProviderNative.java:481)
at android.content.ContentResolver.insert(ContentResolver.java:1828)

Android 外部存储中的标准存储文件目录如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
sdcard
audio/* ├── Alarms
------ ├── Audiobooks
image/* ├── DCIM
file/* ├── Documents
NA ├── Download
video/* ├── Movies
audio/* ├── Music
audio/* ├── Notifications
image/* ├── Pictures
│   └── Screenshots
audio/* ├── Podcasts
audio/* └── Ringtones

前面一列为 MimeType ,后一列为其对应的 Primary Directory
—— 表示未知

需要注意的是,对于 Android 中的媒体类型,如果是需要提供给其他应用使用的,在卸载后仍需保留的媒体文件,按照规范,应当放到对应的公共目录媒体文件夹下

MimeType 对应文件夹
图片(image/*) DCIM,Pictures
音频(audio/*) Alarms, Music, Notifications, Podcasts, Ringtones
视频(video/*) Movies
文档(file/*) Documents,Download

当然,这些也都可以通过 MediaStore 放到 Downloads 文件夹下

  • ContentValues 的 key 值可以通过 MediaStore.XXX.Media.YYY 获取到
    XXX: 对应的媒体类型
    YYY: 对应的字段常量
  • RELATIVE_PATH 的 String 值不需要以 / 开头
  • insert(uri: Uri,value: ContentValues) 的第一个参数可以通过 MediaStore 中的常量获取,具体如下

获取 insert 方法中的第一个入参的方式

1
2
3
4
5
6
7
8
9
10
11
* Images
MediaStore.Images.Media.EXTERNAL_CONTENT_URI
* Audio
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
* Video
MediaStore.Video.Media.EXTERNAL_CONTENT_URI
* Download
MediaStore.Downloads.EXTERNAL_CONTENT_URI
* Documents
//Documents 稍微有些特殊,需要通过 Files 获取
MediaStore.Files.getContentUri("external")

疑问 1

有个疑问是前文图标中标记为 ------ 未知的地方,根据官方文档,Audiobooks 是可以存放 audio/* 类型的文件的,但通过以下代码插入一个 audio/* 类型的文件却抛出异常了

1
2
3
4
5
6
7
8
val values = ContentValues().apply {
put(MediaStore.Audio.Media.MIME_TYPE,"audio/*")
put(MediaStore.Audio.Media.RELATIVE_PATH,"${Environment.DIRECTORY_AUDIOBOOKS}")
}
val insertUri = contentResolver.insert(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,values)
//Exception 信息
Primary directory Audiobooks not allowed for content://media/external/audio/media; allowed directories are [Alarms, Music, Notifications, Podcasts, Ringtones]

其实就是 Android 会帮我们将这些媒体数据存放到一个数据库中,这些我们设置的数据都是数据库表中的字段,除了上面表格中罗列的一些常用的信息,还有很多数据可以设置,详细可以参考 android.media.MediaStore.MediaColumns 类,在这个类中我们发现了一个 owner_package_name 的字段,这个字段的作用,我们后面再说

删除自己应用创建的文件

同 SAF ,获取到 Uri 后即可通过
contentResolver.delete(uri,null,null) 删除即可

查询自己应用的文件

通过 Cursor query(@RequiresPermission.Read @NonNull Uri uri,@Nullable String[] projection, @Nullable String selection,@Nullable String[] selectionArgs, @Nullable String sortOrder) 方法

参数解释:

参数 类型 释义
uri Uri 提供检索内容的 Uri,其 scheme 是content://
projection String[] 返回的列,如果传递 null 则所有列都返回(效率低下)
selection String 过滤条件,即 SQL 中的 WHERE 语句(但不需要写 where 本身),如果传 null 则返回所有的数据
selectionArgs String[] 如果你在 selection 的参数加了 ? 则会被本字段中的数据按顺序替换掉
sortOrder String 用来对数据进行排序,即 SQL 语句中的 ORDER BY(单不需要写ORDER BY 本身),如果传 null 则按照默认顺序排序(可能是无序的)

举个🌰

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private fun getImages(): List<Uri>{
val external = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
val filesUris = mutableListOf<Uri>()
contentResolver.query(
//从图片媒体信息中进行查询
external,
//只返回 |_ID|WIDTH|HEIGHT| 图片的 id和宽高的列信息
arrayOf(MediaStore.Images.Media._ID,MediaStore.Images.Media.WIDTH,MediaStore.Images.Media.HEIGHT) ,
//过滤掉 id 不满足大于 230 的图片
"${MediaStore.Images.Media._ID} > ? ",
arrayOf("230"),
//返回的数据按照 id 降序排序
"${MediaStore.Images.Media._ID} DESC")
?.use {
while (it.moveToNext()){
val index = it.getColumnIndex(MediaStore.Images.Media._ID)
filesUris.add(ContentUris.withAppendedId(external,it.getLong(index)))
}
}
return filesUris
}

访问其他 App 的文件

不知道大家有没有看到上面的标题写的都是「自己应用」的文件,这是因为我们的 App 至今还未申请 READ_EXTERNAL_STORAGEWRITE_EXTERNAL_STORAGE 权限,通过 MediaStore Api 对自己应用创建的文件,是不需要权限的。这时因为在创建的时候系统会将我们的应用的 packageName 写入 owner_package_name 字段从而在后续的使用中判断这个文件是哪个应用创建的。

那如果应用需要访问或者修改其他应用的文件怎么办呢。

  1. 如果只是要读取,则申请 READ_EXTERNAL_STORAGE 权限后即可通过 MediaStore Api 进行读取

    例如我们上述的查询自己应用的文件中的查询语句,如果申请了读取外置存储的权限后,返回的数据就会包含了其他 App 提供给 Media 的图片了
    如下图:
    没有读取权限时:

    获得读取权限后:

多出来几张其他 App 产生的图片

  1. 如果需要编辑修改甚至删除其他应用的文件,则需要申请 WRITE_EXTERNAL_STORAGE 权限。

    如果当应用没有 WRITE_EXTERNAL_STORAGE 权限时,去修改其他 App 的文件时,则会 throw java.lang.SecurityException: xxxx has no access to content://media/external/images/media/243 的异常

    当应用拥有了 WRITE_EXTERNAL_STORAGE 权限后,当修改其他 App 的文件时,会 throw 另一个 Exception android.app.RecoverableSecurityException: xxxxxx has no access to content://media/external/images/media/243

    如果我们将这个 RecoverableSecurityException 给 Catch 住,并向用户申请修改该图片的权限,用户操作后,我们就可以在 onActivityResult 回调中拿到结果进行操作了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
try {
val editImageUri = Uri.parse("content://media/external/images/media/${editOtherAppMediaId.text}")
editImage(editImageUri)
}catch (rse : RecoverableSecurityException){
rse.printStackTrace()
requestForOtherAppFiles(REQUEST_CODE_FOR_EDIT_IMAGE,rse)
}
private fun requestForOtherAppFiles(requestCode: Int, rse: RecoverableSecurityException){
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
// In your code, handle IntentSender.SendIntentException.
startIntentSenderForResult(
rse.userAction.actionIntent.intentSender
, requestCode,
null, 0, 0, 0, null)
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (resultCode == Activity.RESULT_OK){
when(requestCode){
REQUEST_CODE_FOR_EDIT_IMAGE ->{
editImage(editImageUri)
}
REQUEST_CODE_FOR_DELETE_IMAGE ->{
contentResolver.delete(deleteImageUri,null,null)
}
}
}
}

如下图,用户会收到这样的提示框。

PS. 当用户授权后,我们对该文件进行修改后,后续对这个文件的修改就不再会抛出 RecoverableSecurityException