Android 10 / Q 先回顾

Android 中存储可以分为两大类:私有存储和共享存储

  • 私有存储 (Private Storage) : 每个应用在内部存储种都拥有自己的私有目录 (/data/data/packageName),其它应用看不到,彼此也无法访问到该目录
  • 共享存储 (Shared Storage) : 除了私有存储以外,其他的一切都被认定是共享存储,比如媒体集 (Media Collection) 和 SD卡外部应用存储目录

一、导火线

在分区存储之前,某些应用中,即使功能很简单,大部分都不需要这么宽泛的权限。
这就使得某些应用程序

1、乱占空间 :各种各样的文件散布在磁盘的各个地方,当用户卸载应用之后,这些被遗弃的 “孤儿 “ 被滞留在原地,无家可归,占用了磁盘空间,最终结果就会导致磁盘不足
2、随意读取用户的数据
3、随意读取应用的数据

因此 —— 分区存储 Baby 诞生了~

它的降临,限制了过于宽泛的 存储权限

成长的同时,在 Android 10 / Q 上它需要遵循三个原则 :

  • 更好的文件属性:系统应用知道什么文件属于哪一个 app, 让用户更加容易管理他们自己的文件。当 app 被卸载了,被应用创建的内容,除非用户希望保留,否则不应该保留下来。
  • 用户的数据安全 :当用户下载文件,比如敏感的电子邮件附件,这些文件对大多数应用程序都不应该可见
  • 应用的数据安全 :当 app 将特定于应用程序的文件写入外部存储时,其他应用程序不应该可见这些文件

二、规则点

  • 应用访问自己的应用目录不受限制 ( 包括内部和外部 ) 无需任何权限
  • 应用向媒体集和下载目录提供文件,如果您要想保存图片、视频、音频、文档,无需任何权限
  • 不再提供宽泛的共享存储 ( Share Storage ), 读写存储权限只能访问提供的媒体集 ( 图片集视频集音频集 )
  • 位置元数据限制,获取图片上的位置等信息需要请求权限,如果不请求权限,读取图片的信息的时候,位置元数据将会被删除
  • 读取 PDF 或其他类型的文件,需要调用系统的文件选择器 ( Storage Access Framerwork API )
  • 在媒体集或应用目录之外,写任何文件都需要系统的文件选择器 , 这样用户能选择并确认将文件存在哪里
  • requestLegacyExternalStorage 开关值,在清单文件配置,如果开启了,存储权限就会像之前版本中的 Android 一样运作

三、详细说明

1. 媒体文件集

用于和其它应用分享媒体文件 (图片、音频、视频文件)

Android 10 中,自己的 App 无需任何权限就能向媒体集添加文件,同时您也可以编辑和删除您自己添加的媒体文件。

如果你要读取并操作并非您的应用所创建的媒体文件,就需要读取外部存储权限,如果用户没有同意,您的应用将无法编辑和删除并非您的应用所创建的媒体文件。

这样一来,您的应用想编辑和删除媒体文件时,用户就能获得完整的控制权

2. 下载文件集

用于和其它应用分享非媒体文件 (非图片、非音频、非视频文件)

下载文件集,和媒体文件集一样,您无需请求任何权限就能在这个集中添加、编辑、删除非媒体文件。

与媒体文件不同的是,即使有读取外部存储权限,也是不允许您访问由其他应用创建添加的非媒体文件。想要取得权限,您需要通过调用 Storage Access Framerwork API,启动系统文件选择器,让用户可以进行选择可以访问哪些文件和目录。

如果用户允许您访问一个文件,那么这个权限将是完整的权限,你无需其他额外的任何权限,就可以任意的读取、编辑删除媒体文件和非媒体文件。

这样一来,用户就能获得完整的控制权,更好的管理应用在何时访问敏感的非媒体文件

3. 限制位置元数据

对媒体文件中敏感的元数据进行了访问权限的限制,主要限制位置元数据

可以在应用获取那些其他应用创建添加的媒体文件的时候,直接删除这些元数据

如果想要获取图片的位置信息,需要声明请求权限 ACCESS_MEDIA_LOCATION

MediaStore.setRequireOriginal () : 获取当前磁盘的体积容量

4. 媒体文件访问路径

Android 10 锁定了公共目录文件路径的权限。

所有的应用都尽量继续使用 MediaStore,因为后台文件路径 [ /sdcard/DCIM/xxx.JPG ] 请求或使用文件路径 [ /sdcard/DCIM/xxx.JPG ] 进行 I/O 流请求都是代理给 MediaStore 类的

不建议直接使用媒体文件访问路径 [/sdcard/DCIM/xxx.JPG] ,应用的性能会略有下降

5. MediaStore 的强制性

文件需要在适当的目录进行创建,

媒体文件需要媒体目录进行创建,不能在图片目录创建音频文件,如果您想访问由其他应用创建的媒体文件,您就要请求外部存储读取权限。如果您没有获得媒体位置权限,就仍然会在读取由其他应用创建的媒体文件时,被系统拿掉位置信息。

当然,非媒体文件必须在 Downloads 下创建,所有 Dowonloads 目录下也只能创建像 PDF 或其它的非媒体文件

读取由其他应用创建的非媒体文件,也将需要 Storage Access Framerwork API

四、总结

  • 特定于应用的目录 –> 无需权限 –> 访问方法 getExternalFilesDir () –> 卸载应用时移除文件
  • 媒体集合 (照片、视频、音频) –> 需要权限 READ_EXTERNAL_STORAGE (仅当访问其他应用的文件时) –> 访问方法 MediaStore –> 卸载应用时不移除文件
  • 下载内容(文档和电子书籍)–> 无需权限 –> 存储访问框架(加载系统的文件选择器)–> 卸载应用时不移除文件

Android R 再出发

一、变化

1. 分区存储强制执行

requestLegacyExternalStorage 是为了给开发人员更多的测试时间,在 Android 10 上设置的一个开关值

在 Android 11 上发生了变更

  • 目标版本 API < = 29 时 , 应用仍可请求 requestLegacyExternalStorage 属性。应用可以利用此标记暂时停用与分区存储相关的变更,例如授予对不同目录和不同类型的媒体文件的访问权限。
  • 目标版本 API > 29,将无法使用 requestLegacyExternalStorage,而且也没有其他标记可以提供该停用功能。

2. 媒体文件访问权限

为了在保证用户隐私的同时可以更轻松地访问媒体,Android 11 增加了以下功能。

(1) 执行批量操作

MediaStore API 新增方法

方法

说明

createWriteRequest (ContentResolver, Collection)

用户向应用授予对指定媒体文件组的写入访问权限的请求。

createFavoriteRequest (ContentResolver, Collection, boolean)

用户将设备上指定的媒体文件标记为 “收藏” 的请求。对该文件具有读取访问权限的任何应用都可以看到用户已将该文件标记为 “收藏”。

createTrashRequest (ContentResolver, Collection, boolean)

用户将指定的媒体文件放入设备垃圾箱的请求。垃圾箱中的内容在特定时间段(默认为 7 天)后会永久删除。

createDeleteRequest (ContentResolver, Collection)

用户立即永久删除指定的媒体文件(而不是先将其放入垃圾箱)的请求。

系统在调用以上任何一个方法后,会构建一个 PendingIntent 对象。应用调用此 intent 后,用户会看到一个对话框,请求用户同意应用更新或删除指定的媒体文件。

(2) 使用原始路径访问文件

从 Android 11 开始,具有 READ_EXTERNAL_STORAGE 权限的应用可以使用直接文件路径(如: /sdcard/DCIM/xxx.JPG` 的格式)和原生库来读取设备的媒体文件。通过这项新功能,应用可以更顺畅地使用第三方媒体库。

注意:使用直接路径和原生库保存媒体文件时,应用的性能会略有下降。请尽可能改用 MediaStore API。

(3) 测试原始文件路径访问

要激活此功能以进行测试,请执行以下操作:

  1. 打开系统设置。
  2. 导航到 系统 > 开发者选项 > 功能标记
  3. 找到 settings_fuse 并启用。settings_fuse 下的说明现在应显示为 true
  4. 重启设备。

3. 文件和目录访问限制

以下是存储访问框架 (SAF: Storage Access Framerwork API) 相关的变更

(1) 访问目录

您无法再使用 ACTION_OPEN_DOCUMENT_TREE intent 操作来请求访问以下目录:

  • Downloads 根目录
  • 设备制造商认为可靠的各个 SD 卡卷的根目录,无论该卡是模拟卡还是可移除的卡。

(2) 访问文件

您无法再使用 ACTION_OPEN_DOCUMENT_TREE 或 ACTION_OPEN_DOCUMENT intent 操作来请求用户从以下目录中选择单独的文件:

  • Android/data/ 目录及其所有子目录。
  • Android/obb/ 目录及其所有子目录

(3) 测试变更

要测试此行为变更,请在应用的清单文件中将 requestLegacyExternalStorage 的值设置为 false。您可通过执行以下操作来确认行为变更是否已对应用生效:

  • 通过 ACTION_OPEN_DOCUMENT_TREE 操作调用 intent。检查 Downloads 目录是否显示并呈灰显状态。
  • 通过 ACTION_OPEN_DOCUMENT 操作调用 intent。检查 Android/data/ 和 Android/obb/ 目录是否都不显示。

4. 权限

在 Android 11 上,您会看到两种权限请求的字符串,这个取决于应用的目标 SDK 版本,如果目标版本在 Android 11,那么只请求在外部存储中访问媒体文件,如果是其他目标版本,那么将显示的是旧版本的权限请求字符串

(1) 以任何版本为目标平台

不管应用的目标 SDK 版本是什么,以下变更均会在 Android 11 中生效:

  • 存储 运行时权限已重命名为 文件 和 媒体
  • 如果应用未选择停用分区存储,并且请求 READ_EXTERNAL_STORAGE 权限,则用户会看到不同于 Android 10 的对话框。该对话框会指示应用正在请求访问照片、视频、音频剪辑和文件。在系统设置的设置 > 隐私 > 权限管理器 > 文件和媒体页面中,如果已授予权限,应用会列在允许存储所有文件下。

(2) 以 Android 11 为目标平台

则 WRITE_EXTERNAL_STORAGE 权限和 WRITE_MEDIA_STORAGE 特许权限将不再提供任何其他访问权限。

5. 所有文件访问权限

像文件管理操作或备份和还原操作等需要访问大量的文件,通过执行以下操作,这些应用可以获得” 所有文件访问权限”:

  • 声明 MANAGE_EXTERNAL_STORAGE 权限
  • 将用户引导至系统设置页面,在该页面上,用户可以对应用启用授予所有文件的管理权限选项

简单说,像这种文件管理器、备份及存储类应用。你需要在 Google Play Developer Console 上填写声明表格说明为什么需要 MANAGE_EXTERNAL_STORAGE 权限,提交之后会被审核是否加入白名单,一旦加入成功以后,您的应用就可以向用户索要权限了,如果用户也同意您应用的访问权限请求,MadiaStore 访问将不再过滤,包括非媒体库文件。但是获得此权限的应用仍然无法访问这些目录在存储卷上显示为 Android/data/ 的子目录,也就是属于其他应用的应用专属目录。

MANAGE_EXTERNAL_STORAGE 权限允许应用访问共享的存储空间中的潜在敏感数据。

二、示例

11存储变化 android 安卓11存储机制_存储

scoped-storage-on-android-11-1.png

按照上面 Q ~ R 这一些列规则点,我们可以将示例进行分类如下

  • 按照系统版本分类:Android Q 、 Android R
  • 按照分区存储分类:媒体文件集 、下载文件集、App 应用程序专属特定目录
  • 按照文件归属分类:自己应用和其他应用
  • 按照文件类型分类:媒体集(图片、视频、音频 .etc)、非媒体(PDF,DOC,XLS .etc)
  • 按照操作行为分类:增、删、改、查

那么接下来示例,我将会按照以上的 5大分类,结合两个版本代码简单说明

然后呢?我们该怎么做?

其实逻辑代码我们可以简化理解这么去定义

那么接下来示例,我将会按照以上的 5大分类,结合两个版本代码简单说明

然后呢?我们该怎么做?

其实逻辑代码我们可以简化理解这么去定义

  • 一个 Media 操作类:包括了(图片、视频、音频)他们 3 类分别的增、删、改、查
  • 一个 Download 操作类:包括了非媒体的增、删、改、查
  • 一个 Assets 操作类:负责从 asset 下拷贝文件到本地演示

开发环境:

  • Android Studio 4.1 canary 2
  • Android R 模拟器 / 真机

Media 类

[MyMediaCollection.kt]

class MyMediaCollection {

    //图片实体类
    data class ImageBean(
        val uri: Uri,
        val name: String,
        val mimeType: String,
        val size: Int
    )

    //视频实体类
    data class VideoBean(
        val uri: Uri,
        val name: String,
        val mimeType: String,
        val duration: Int,
        val size: Int
    )

    //音频实体类
    data class AudioBean(
        val uri: Uri,
        val name: String,
        val mimeType: String,
        val duration: Int,
        val size: Int
    )


    /**
     * Query [ 图片媒体集 ] 包括: DCIM/ 和 Pictures/ 目录
     */
    fun queryImageCollection(context: Context): MutableList<ImageBean> {
        Log.d(TAG, "########### 图片媒体集 ############")
        val imageBeanList = mutableListOf<ImageBean>()
        //定义内容解析器
        val contentResolver = context.contentResolver
        //指定查询的列名
        val photoColumns = arrayOf(
            MediaStore.Images.Media._ID,
            MediaStore.Images.Media.TITLE,
            MediaStore.Images.Media.MIME_TYPE,
            MediaStore.Images.Media.SIZE
        )
        val cursor = contentResolver.query(
            MediaStore.Images.Media.EXTERNAL_CONTENT_URI, //指定查询哪张表的URI
            photoColumns, // 指定查询的列明
            null, //指定where的约束条件
            null, //为where中的占位符提供具体的值
            null // 指定查询结果的排序方式
        )

        val count = cursor!!.count
        Log.d(TAG, "imageCollection count: --> $count")

        cursor.use {
            while (cursor.moveToNext()) {
                val id = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID))
                val title =
                    cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.TITLE))
                val mimeType =
                    cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.MIME_TYPE))
                val size =
                    cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE))
                val contentUri = ContentUris.withAppendedId(
                    MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
                    cursor.getLong(cursor.getColumnIndex(BaseColumns._ID))
                )
                Log.d(
                    TAG,
                    "imageCollection id =$id\ntitle = $title\nmime_type: =$mimeType\nsize: =\t$size\ncontentUri: =\t$contentUri\n"
                )
                val imageBean = ImageBean(
                    uri = contentUri,
                    name = title,
                    mimeType = mimeType,
                    size = size.toInt()
                )
                imageBeanList += imageBean

            }
            cursor.close()
        }
        return imageBeanList
    }

    /**
     * Query [ 视频媒体集 ] 包括: DCIM/, Movies/, 和 Pictures/ 目录
     */
    fun queryVideoCollection(context: Context): MutableList<VideoBean> {
        Log.d(TAG, "########### 视频媒体集 ############")
        val videoBeanList = mutableListOf<VideoBean>()
        val contentResolver = context.contentResolver
        val videoColumns = arrayOf(
            MediaStore.Video.Media._ID,
            MediaStore.Video.Media.TITLE,
            MediaStore.Video.Media.DURATION,
            MediaStore.Video.Media.MIME_TYPE,
            MediaStore.Video.Media.SIZE
        )
        val cursor = contentResolver.query(
            MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
            videoColumns,
            null,
            null,
            null
        )

        val count = cursor!!.count
        Log.d(TAG, "videoCollection count: --> $count")

        cursor.use {
            while (cursor.moveToNext()) {
                val id = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Video.Media._ID))
                val title =
                    cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Video.Media.TITLE))
                val mimeType =
                    cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Video.Media.MIME_TYPE))
                val duration =
                    cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DURATION))
                val size =
                    cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Video.Media.SIZE))
                val contentUri = ContentUris.withAppendedId(
                    MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
                    cursor.getLong(cursor.getColumnIndex(BaseColumns._ID))
                )
                Log.d(
                    TAG,
                    "imageCollection id =$id\ntitle = $title\nmime_type: =$mimeType\nduration=$duration\nsize: =\t$size\ncontentUri: =\t$contentUri\n"
                )
                val videoBean = VideoBean(
                    uri = contentUri,
                    name = title,
                    mimeType = mimeType,
                    duration = duration,
                    size = size
                )
                videoBeanList += videoBean

            }
            cursor.close()
        }
        return videoBeanList
    }

    /**
     * Query [ 音频媒体集 ] 包括: Alarms/, Audiobooks/, Music/, Notifications/, Podcasts/, 和 Ringtones/ 目录
     * 以及 Music/ 和 Movies/ 目录中的音频文件
     */
    fun queryAudioCollection(context: Context): MutableList<AudioBean> {
        Log.d(TAG, "########### 音频媒体集 ############")
        val audioBeanList = mutableListOf<AudioBean>()
        val contentResolver = context.contentResolver
        val videoColumns = arrayOf(
            MediaStore.Audio.Media._ID,
            MediaStore.Audio.Media.TITLE,
            MediaStore.Audio.Media.DURATION,
            MediaStore.Audio.Media.MIME_TYPE,
            MediaStore.Audio.Media.SIZE
        )
        val cursor = contentResolver.query(
            MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
            videoColumns,
            null,
            null,
            null
        )

        val count = cursor!!.count
        Log.d(TAG, "audioCollection count: --> $count")

        cursor.use {
            while (cursor.moveToNext()) {
                val id = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Video.Media._ID))
                val title =
                    cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Video.Media.TITLE))
                val mimeType =
                    cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Video.Media.MIME_TYPE))
                val duration =
                    cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DURATION))
                val size =
                    cursor.getInt(cursor.getColumnIndexOrThrow(MediaStore.Video.Media.SIZE))
                val contentUri = ContentUris.withAppendedId(
                    MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
                    cursor.getLong(cursor.getColumnIndex(BaseColumns._ID))
                )
                Log.d(
                    TAG,
                    "audioCollection id =$id\ntitle = $title\nmime_type: =$mimeType\nduration=$duration\nsize: =\t$size\ncontentUri: =\t$contentUri\n"
                )
                val audioBean = AudioBean(
                    uri = contentUri,
                    name = title,
                    mimeType = mimeType,
                    duration = duration,
                    size = size
                )
                audioBeanList += audioBean

            }
            cursor.close()
        }
        return audioBeanList
    }

    /**
     *  Insert [ 图片媒体集 ]
     */
    fun insertImageToCollection(context: Context, disPlayName: String) {
        Log.d(
            TAG,
            "insertImageToCollection() called with: context = $context, disPlayName = $disPlayName"
        )
        val contentResolver = context.contentResolver
        //在主要外部存储设备上查找所有图片文件 (API <= 28 使用 VOLUME_EXTERNAL 代替)
        val imageCollection = MediaStore.Images.Media.getContentUri(
            MediaStore.VOLUME_EXTERNAL_PRIMARY
        )
        val contentValues = ContentValues().apply {
            //配置图片的显示名称
            put(MediaStore.Images.Media.DISPLAY_NAME, disPlayName)
            //配置图片的状态为:等待中...
            put(MediaStore.Images.Media.IS_PENDING, 1)
        }

        //开始插入图片
        val imageUri = contentResolver.insert(imageCollection, contentValues)
        imageUri.let {
            contentResolver.openFileDescriptor(imageUri!!, "w", null).use {
                AssetHelper.copyAssetSingleFileToMedia(context, disPlayName, it!!)
                contentValues.clear()
                contentValues.put(MediaStore.Images.Media.IS_PENDING, 0)
                contentResolver.update(imageUri, contentValues, null, null)
                it.close()
            }
        }
    }

    /**
     *  Insert [ 视频媒体集 ]
     */
    fun insertVideoToCollection(context: Context, disPlayName: String) {
        Log.d(
            TAG,
            "insertVideoToCollection() called with: context = $context, disPlayName = $disPlayName"
        )
        val contentResolver = context.contentResolver
        //在主要外部存储设备上查找所有视频文件 (API <= 28 使用 VOLUME_EXTERNAL 代替)
        val videoCollection = MediaStore.Video.Media.getContentUri(
            MediaStore.VOLUME_EXTERNAL_PRIMARY
        )
        val contentValues = ContentValues().apply {
            //配置视频的显示名称
            put(MediaStore.Video.Media.DISPLAY_NAME, disPlayName)
            //配置视频的状态为:等待中...
            put(MediaStore.Video.Media.IS_PENDING, 1)
        }

        //开始插入视频
        val videoUri = contentResolver.insert(videoCollection, contentValues)
        videoUri.let {
            contentResolver.openFileDescriptor(videoUri!!, "w", null).use {
                AssetHelper.copyAssetSingleFileToMedia(context, disPlayName, it!!)
                contentValues.clear()
                contentValues.put(MediaStore.Video.Media.IS_PENDING, 0)
                contentResolver.update(videoUri, contentValues, null, null)
                it.close()
            }
        }
    }

    /**
     *  Insert [ 音频媒体集 ]
     */
    fun insertAudioToCollection(context: Context, disPlayName: String) {
        Log.d(
            TAG,
            "insertAudioToCollection() called with: context = $context, disPlayName = $disPlayName"
        )
        val contentResolver = context.contentResolver
        //在主要外部存储设备上查找所有音频文件 (API <= 28 使用 VOLUME_EXTERNAL 代替)
        val audioCollection = MediaStore.Audio.Media.getContentUri(
            MediaStore.VOLUME_EXTERNAL_PRIMARY
        )
        val contentValues = ContentValues().apply {
            //配置音频的显示名称
            put(MediaStore.Audio.Media.DISPLAY_NAME, disPlayName)
            //配置音频的状态为:等待中...
            put(MediaStore.Audio.Media.IS_PENDING, 1)
        }

        //开始插入音频
        val audioUri = contentResolver.insert(audioCollection, contentValues)
        audioUri.let {
            contentResolver.openFileDescriptor(audioUri!!, "w", null).use {
                AssetHelper.copyAssetSingleFileToMedia(context, disPlayName, it!!)
                contentValues.clear()
                contentValues.put(MediaStore.Audio.Media.IS_PENDING, 0)
                contentResolver.update(audioUri, contentValues, null, null)
                it.close()
            }
        }
    }

    /**
     *  Update [ 图片媒体集 ]
     */
    fun updateImageCollection(context: Context, id: Long, newName: String) {
        val contentResolver = context.contentResolver
        val selection = "${MediaStore.Images.Media._ID} = ?"
        val selectionArgs = arrayOf(id.toString())
        val contentValues = ContentValues().apply {
            put(MediaStore.Images.Media.DISPLAY_NAME, newName)
        }
        val imageUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
        imageUri.let {
            context.contentResolver.openFileDescriptor(imageUri, "w")?.use {
                val result =
                    contentResolver.update(imageUri, contentValues, selection, selectionArgs)
                Log.d(TAG, "updateImageCollection() called : $result")
            }
        }
    }

    /**
     *  Update [ 视频媒体集 ]
     */
    fun updateVideoCollection(context: Context, id: Long, newName: String) {
        val contentResolver = context.contentResolver
        val selection = "${MediaStore.Video.Media._ID} = ?"
        val selectionArgs = arrayOf(id.toString())
        val contentValues = ContentValues().apply {
            put(MediaStore.Video.Media.DISPLAY_NAME, newName)
        }
        val videoUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id)
        videoUri.let {
            context.contentResolver.openFileDescriptor(videoUri, "w")?.use {
                val result =
                    contentResolver.update(videoUri, contentValues, selection, selectionArgs)
                Log.d(TAG, "updateVideoCollection() called : $result")
                it.close()
            }
        }

    }

    /**
     *  Update [ 音频媒体集 ]
     */
    fun updateAudioCollection(context: Context, id: Long, newName: String) {
        val contentResolver = context.contentResolver
        val selection = "${MediaStore.Audio.Media._ID} = ?"
        val selectionArgs = arrayOf(id.toString())
        val contentValues = ContentValues().apply {
            put(MediaStore.Audio.Media.DISPLAY_NAME, newName)
        }
        val audioUri = ContentUris.withAppendedId(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, id)
        audioUri.let {
            context.contentResolver.openFileDescriptor(audioUri, "w")?.use {
                val result =
                    contentResolver.update(audioUri, contentValues, selection, selectionArgs)
                Log.d(TAG, "updateAudioCollection() called : $result")
                it.close()
            }
        }

    }

    /**
     *  Delete [ 图片媒体集 ]
     */
    fun deleteImageCollection(context: Context, id: Long) {
        val contentResolver = context.contentResolver
        val selection = "${MediaStore.Images.Media._ID} = ?"
        val selectionArgs = arrayOf(id.toString())
        val imageUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
        imageUri.let {
            context.contentResolver.openFileDescriptor(imageUri, "w")?.use {
                val result = contentResolver.delete(imageUri, selection, selectionArgs)
                Log.d(TAG, "deleteImageCollection() called : $result")
                it.close()
            }
        }
    }

    /**
     *  Delete [ 视频媒体集 ]
     */
    fun deleteVideoCollection(context: Context, id: Long) {
        val contentResolver = context.contentResolver
        val selection = "${MediaStore.Video.Media._ID} = ?"
        val selectionArgs = arrayOf(id.toString())
        val videoUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id)
        videoUri.let {
            context.contentResolver.openFileDescriptor(videoUri, "w")?.use {
                val result = contentResolver.delete(videoUri, selection, selectionArgs)
                Log.d(TAG, "deleteVideoCollection() called : $result")
                it.close()
            }
        }

    }

    /**
     *  Delete [ 音频媒体集 ]
     */
    fun deleteAudioCollection(context: Context, id: Long) {
        val contentResolver = context.contentResolver
        val selection = "${MediaStore.Audio.Media._ID} = ?"
        val selectionArgs = arrayOf(id.toString())
        val audioUri = ContentUris.withAppendedId(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, id)
        audioUri.let {
            context.contentResolver.openFileDescriptor(audioUri, "w")?.use {
                val result = contentResolver.delete(audioUri, selection, selectionArgs)
                Log.d(TAG, "deleteAudioCollection() called : $result")
                it.close()
            }
        }

    }

    /**
     * 加载特定媒体文件的缩略图
     */
    fun loadThumbnail(context: Context, uri: Uri, width: Int, height: Int): Bitmap {
        val size = Size(width, height)
        return context.contentResolver.loadThumbnail(
            uri, size, null
        )
    }

    /**
     * 获取图片位置元数据信息
     */
    fun getImageLocationMataData(context: Context, imageUri: Uri) {
        // 获取位置数据使用 ExifInterface 库.
        // 如果 ACCESS_MEDIA_LOCATION 没有被授予,将会发生异常
        val uri = MediaStore.setRequireOriginal(imageUri)
        context.contentResolver.openInputStream(uri)?.use {
            ExifInterface(it).run {
                //如果经纬度为Null,将会回退到坐标(0,0)
                this.latLong?.let {
                    Log.d(TAG, "getImageLocationMataData() called : Latitude =  ${latLong?.get(0)}")
                    Log.d(TAG, "getImageLocationMataData() called : Longitude = ${latLong?.get(1)}")
                }
            }
        }
    }


    companion object {
        private const val TAG = "MyMediaCollection"
    }
}

Download 类

[ MyDownloadCollection.kt ]

class MyDownloadCollection {

    /**
     * 访问目录
     */
    fun openDirectory(activity: Activity, requestCode: Int) {
        Log.d(TAG, "openDirectory() called")
        val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
        }
        activity.startActivityForResult(intent, requestCode)
    }

    /**
     * 访问文件
     */
    fun openDocument(activity: Activity, fileType: String, requestCode: Int) {
        Log.d(
            TAG,
            "openDocument() called with: activity = $activity, fileType = $fileType, requestCode = $requestCode"
        )
        val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
            addCategory(Intent.CATEGORY_OPENABLE)
            type = fileType
        }
        activity.startActivityForResult(intent, requestCode)
    }

    /**
     * 创建文档
     */
    fun createDocument(activity: Activity, fileType: String, title: String, requestCode: Int) {
        Log.d(
            TAG,
            "createDocument() called with: activity = $activity, fileType = $fileType, title = $title, requestCode = $requestCode"
        )
        val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
            addCategory(Intent.CATEGORY_OPENABLE)
            type = fileType
            putExtra(Intent.EXTRA_TITLE, title)
        }
        activity.startActivityForResult(intent, requestCode)
    }

    /**
     * 写入数据
     */
    fun writeDataToDocument(context: Context, uri: Uri, content: String) {
        Log.d(
            TAG,
            "writeDataToDocument() called with: context = $context, uri = $uri, content = $content"
        )
        try {
            context.contentResolver.openFileDescriptor(uri, "w").use { parcelFileDescriptor ->
                FileOutputStream(parcelFileDescriptor?.fileDescriptor).use { fos ->
                    fos.write(content.toByteArray())
                    fos.close()
                    parcelFileDescriptor?.close()
                }
            }
        } catch (e: FileNotFoundException) {
            e.printStackTrace()
        } catch (e: IOException) {
            e.printStackTrace()
        }
    }

    /**
     * 删除文档
     */
    fun deleteDocument(context: Context, uri: Uri) {
        Log.d(TAG, "deleteDocument() called with: context = $context, uri = $uri")
        val result = DocumentsContract.deleteDocument(context.contentResolver,uri)
        Log.d(TAG, "deleteDocument() result = $result")
    }

    /***
     * 从 Uri中返回文本
     */
    private fun readTextFromUri(context: Context, uri: Uri): String {
        val stringBuilder = StringBuilder()
        context.contentResolver.openInputStream(uri)?.use { inputStream ->
            BufferedReader(InputStreamReader(inputStream)).use { reader ->
                var line: String? = reader.readLine()
                while (line != null) {
                    stringBuilder.append(line)
                    line = reader.readLine()
                }
            }
        }
        return stringBuilder.toString()
    }

    /**
     * 从 Uri中返回Bitmap
     */
    @Throws(IOException::class)
    private fun getBitmapFromUri(context: Context, uri: Uri): Bitmap? {
        val parcelFileDescriptor = context.contentResolver.openFileDescriptor(uri, "r")
        return BitmapFactory.decodeFileDescriptor(parcelFileDescriptor?.fileDescriptor)
    }

    /**
     * 永久保存获取的目录权限
     * APP申请到目录的永久权限后,用户可以在该APP的设置页面(清除缓存页面下)取消目录的访问权限
     */
    fun keepDocumentPermission(context: Context, uri: Uri, intent: Intent) {
        val takeFlags: Int = intent.flags and
                (Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
        // 检查最新数据:文件是否被删除
        context.contentResolver.takePersistableUriPermission(uri, takeFlags)
        // 通过sharePreference保存该目录Uri
        // --------- 省略 代码 --------
    }

    /**
     * 使用获得永久保存获取的目录权限
     */
    fun useKeepDocument(context: Context,saveUri: String,intent: Intent){
        if (TextUtils.isEmpty(saveUri)) {
            // 打开目录, 重新请求永久保存获取的目录权限
            // --------- 省略 代码 --------
        } else {
            try {
                val uri = Uri.parse(saveUri)
                val takeFlags: Int = intent.flags and
                (Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
                context.contentResolver.takePersistableUriPermission(uri, takeFlags)
                //Uri 授予,执行下一步操作
                // --------- 省略 代码 --------
            } catch (e: SecurityException) {
                //Uri 未被授予, 打开目录, 重新请求永久保存获取的目录权限
                // --------- 省略 代码 --------
            }
        }
    }

    companion object {
        private const val TAG = "MyDownloadCollection"
    }
}

Assets 帮助类

11存储变化 android 安卓11存储机制_Android 11_02

scoped-storage-on-android-11-2.png

[ AssetHelper.kt ]

class AssetHelper {

    companion object {
        private const val TAG = "AssetHelper"

        /**
         * 复制单个文件的方法
         */
        fun copyAssetSingleFile(context: Context, fileName: String, savePath: File) {
            Log.d(
                TAG,
                "copyAssetSingleFile() called with: context = $context, fileName = $fileName, savePath = $savePath"
            )
            context.assets.open(fileName).use { fis ->
                FileOutputStream(savePath).use { fos ->
                    FileUtils.copy(fis, fos)
                    fos.close()
                    fis.close()
                }
            }

        }

        /**
         * 复制单个文件的方法
         */
        fun copyAssetSingleFileToMedia(
            context: Context,
            fileName: String,
            parcelFileDescriptor: ParcelFileDescriptor
        ) {
            Log.d(
                TAG,
                "copyAssetSingleFile() called with: context = $context, fileName = $fileName, parcelFileDescriptor = $parcelFileDescriptor"
            )
            context.assets.openFd(fileName).use { fis ->
                FileUtils.copy(fis.fileDescriptor, parcelFileDescriptor.fileDescriptor)
                parcelFileDescriptor.close()
                fis.close()

            }

        }

        /**
         * 复制多个文件的方法
         */
        fun copyAssetMultipleFile(context: Context, filePath: String, savePath: File) {
            Log.d(
                TAG,
                "copyAssetMultipleFile() called with: context = $context, filePath = $filePath, savePath = $savePath"
            )
            context.assets.list(filePath)?.let { fileList ->
                when (fileList.isNotEmpty()) {
                    true -> {
                        for (i in fileList.indices) {
                            if (!savePath.exists()) savePath.mkdir()
                            copyAssetMultipleFile(
                                context,
                                filePath + File.separator + fileList[i],
                                File(savePath, fileList[i])
                            )
                        }
                    }
                    else -> copyAssetSingleFile(context, filePath, savePath)
                }
            }

        }
    }


}

MainActivity

[ MainActivity.]

class MainActivity : AppCompatActivity() {

    @RequiresApi(10000)
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main2)
        initPermission()
        Log.d(TAG, "onCreate() called with:  = ${Build.VERSION.SDK_INT}")
        //使用Kotlin 协程方式
        btnStorage.setOnClickListener {
            GlobalScope.launch {
                //insertData()
                //updateData()
                //queryData()
                //updateOtherApp()
                //openDocument()
                //createDocument()
                //deleteImages(this@MainActivity)
            }
        }
    }

    /**
     * 添加媒体集数据 和 应用程序专属目录下数据
     */
    private suspend fun insertData() {
        Log.d(TAG, "copyAssets() called : ### START ####")

        val dataFile = File(getExternalFilesDir(null), "data")

        val imageFile =
            File(getExternalFilesDir(Environment.DIRECTORY_PICTURES), "my_app_media_image.jpg")

        val videoFile =
            File(getExternalFilesDir(Environment.DIRECTORY_MOVIES), "my_app_media_video.mp4")

        val audioFile =
            File(getExternalFilesDir(Environment.DIRECTORY_MUSIC), "my_app_media_audio.mp3")

        val downloadFile =
            File(getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), "my_app_download_file.pdf")

        withContext(Dispatchers.IO) {

            //插入数据到应用程序专属特定目录系统整理目录下
            AssetHelper.copyAssetSingleFile(
                this@MainActivity,
                "my_app_media_image.jpg",
                imageFile
            )
            AssetHelper.copyAssetSingleFile(
                this@MainActivity,
                "my_app_media_video.mp4",
                videoFile
            )
            AssetHelper.copyAssetSingleFile(
                this@MainActivity,
                "my_app_media_audio.mp3",
                audioFile
            )

            AssetHelper.copyAssetSingleFile(
                this@MainActivity,
                "my_app_download_file.pdf",
                downloadFile
            )

            // 插入数据到应用程序专属特定目录下自定义文件夹
            AssetHelper.copyAssetMultipleFile(this@MainActivity, "data", dataFile)


            // 插入数据到媒体集
            MyMediaCollection().insertImageToCollection(
                this@MainActivity,
                "my_app_media_image.jpg"
            )

            MyMediaCollection().insertVideoToCollection(
                this@MainActivity,
                "my_app_media_video.mp4"
            )
            MyMediaCollection().insertAudioToCollection(
                this@MainActivity,
                "my_app_media_audio.mp3"
            )
        }
        Log.d(TAG, "copyAssets() called : #### END ####")
    }

    /**
     * 查询媒体集数据
     */
    private suspend fun queryData() {
        withContext(Dispatchers.IO) {
            MyMediaCollection().queryImageCollection(this@MainActivity)
            MyMediaCollection().queryVideoCollection(this@MainActivity)
            MyMediaCollection().queryAudioCollection(this@MainActivity)
        }
    }


    /**
     * 修改媒体集数据
     */
    private suspend fun updateData() {
        withContext(Dispatchers.IO) {

            val imageId = 33
            val imageNewName = "my_app_media_image_new_name.jpg"

            val videoId = 34
            val videoNewName = "my_app_media_video_new_name.mp4"

            val audioId = 26
            val audioNewName = "my_app_media_audio_new_name.mp3"

            MyMediaCollection().updateImageCollection(
                this@MainActivity,
                imageId.toLong(),
                imageNewName
            )
            MyMediaCollection().updateVideoCollection(
                this@MainActivity,
                videoId.toLong(),
                videoNewName
            )
            MyMediaCollection().updateAudioCollection(
                this@MainActivity,
                audioId.toLong(),
                audioNewName
            )
        }
    }


    /**
     * 删除媒体集数据
     */
    private suspend fun deleteData() {
        withContext(Dispatchers.IO) {

            val imageId = 33
            val videoId = 34
            val audioId = 26

            MyMediaCollection().deleteImageCollection(this@MainActivity, imageId.toLong())
            MyMediaCollection().deleteVideoCollection(this@MainActivity, videoId.toLong())
            MyMediaCollection().deleteAudioCollection(this@MainActivity, audioId.toLong())
        }
    }

    /**
     * 删除 / 编辑 其他应用程序的媒体集数据(需要申请权限)
     */
    @RequiresApi(Build.VERSION_CODES.Q)
    private fun changeOtherApp() {
        try {
            val imageId = 37
            val imageNewName = "other_app__media_image_renamed_by_own_app.jpg"

            val videoId = 39
            val videoNewName = "other_app__media_video_renamed_by_own_app.mp4"

            val audioId = 36
            val audioNewName = "other_app__media_audio_renamed_by_own_app.mp3"

            MyMediaCollection().updateImageCollection(
                this@MainActivity,
                imageId.toLong(),
                imageNewName
            )
            MyMediaCollection().updateVideoCollection(
                this@MainActivity,
                videoId.toLong(),
                videoNewName
            )
            MyMediaCollection().updateAudioCollection(
                this@MainActivity,
                audioId.toLong(),
                audioNewName
            )

            MyMediaCollection().deleteImageCollection(this@MainActivity, imageId.toLong())
            MyMediaCollection().deleteVideoCollection(this@MainActivity, videoId.toLong())
            MyMediaCollection().deleteAudioCollection(this@MainActivity, audioId.toLong())

        } catch (securityException: SecurityException) {
            // 这种方式是使用抛出一个异常的方式,弹出操作处理框,让用户处理
            // 第二种方式就是删除之前进行权限检查,参考下面的deleteImages方法
            Log.d(TAG, "updateOtherApp() called : " + securityException.message)
            val recoverableSecurityException = securityException as?
                    RecoverableSecurityException
                ?: throw RuntimeException(securityException.message, securityException)

            // Android 10 上面的方法,但每次处理一个文件会弹出一个框让用户操作,不能一次性大批量
            val intentSender =
                recoverableSecurityException.userAction.actionIntent.intentSender
            intentSender?.let {

                //会弹出权限对话框,让用户是否确定要授予该APP应用程序改变其他应用创建的媒体文件权限
                ActivityCompat.startIntentSenderForResult(
                    this, intentSender, 200,
                    null, 0, 0, 0, null
                )
            }
        }
    }



    /***
     *  删除多张图片,一次请求 友好的方式
     *
     *  createDeleteRequest (Android 11 / R 上新增的 API ) --- 永久删除指定的媒体文件
     *
     *  参考该方法 , 其他新增 createWriteRequest 、createFavoriteRequest、createTrashRequest 这几个API就不写示例了,
     *
     */
    @RequiresApi(Build.VERSION_CODES.R)
    private fun deleteImages(context: Context) {

        val uri1 = Uri.parse("content://media/external/images/media/27855")
        val uri2 = Uri.parse("content://media/external/images/media/27856")
        val uri3 = Uri.parse("content://media/external/images/media/27857")
        val uri4 = Uri.parse("content://media/external/images/media/27858")
        val uri5 = Uri.parse("content://media/external/images/media/27859")

        val uris = arrayListOf(uri1,uri2,uri3,uri4,uri5)

        val pendingIntent = MediaStore.createDeleteRequest(context.contentResolver, uris.filter {
            // 使用时请求权限
            context.checkUriPermission(
                it,
                Binder.getCallingPid(),
                Binder.getCallingUid(),
                Intent.FLAG_GRANT_WRITE_URI_PERMISSION
            ) != PackageManager.PERMISSION_GRANTED
        })
        val intentSender = pendingIntent.intentSender
        intentSender?.let {
            //会弹出权限对话框,让用户是否确定要授予该APP应用程序改变其他应用创建的媒体文件权限
            ActivityCompat.startIntentSenderForResult(
                this, intentSender, 300,
                null, 0, 0, 0, null
            )
        }
    }


    /**
     * 访问目录
     */
    private fun openDocumentTree() {
        MyDownloadCollection().openDirectory(this@MainActivity, PICK_DIR)
    }

    /**
     * 访问文档
     */
    private fun openDocument() {
        MyDownloadCollection().openDocument(
            this@MainActivity,
            "*/*",//application/pdf
            PICK_DOCUMENT
        )
    }

    /**
     * 创建文档
     */
    private fun createDocument() {
        MyDownloadCollection().createDocument(
            this@MainActivity, "text/plain",
            "my_app_create.text", CREATE_DOCUMENT
        )
    }

    /**
     * 写入数据到文档
     */
    private fun writeDocument(uri: Uri) {
        MyDownloadCollection().writeDataToDocument(
            this,
            uri,
            "balabalabalabalabalabalabalabalabalabalabalabala"
        )
    }

    private fun deleteDocument(uri: Uri) {
        MyDownloadCollection().deleteDocument(this, uri)
    }


    override fun onResume() {
        super.onResume()
        Log.d(TAG, "onResume() called")
    }

    override fun onDestroy() {
        super.onDestroy()
        Log.d(TAG, "onDestroy() called")

    }

    /**
     * 初始化权限
     * READ_EXTERNAL_STORAGE
     * ACCESS_MEDIA_LOCATION   (访问图片位置元数据必须请求)
     */
    private fun initPermission() {
        Log.d(TAG, "initPermission() called")
        val permissions = arrayOf(
            Manifest.permission.READ_EXTERNAL_STORAGE,
            Manifest.permission.ACCESS_MEDIA_LOCATION
        )
        for (i in permissions.indices) {
            if (ContextCompat.checkSelfPermission(
                    this,
                    permissions[i]
                ) != PackageManager.PERMISSION_GRANTED
            ) {
                ActivityCompat.requestPermissions(this, permissions, PERMISSION_CODE);
            }
        }
    }


    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        if (requestCode == PICK_DOCUMENT && resultCode == Activity.RESULT_OK) {
            data?.data?.also {
                Log.d(TAG, "onActivityResult() called : PICK_DOCUMENT uri = $it")
                // 返回文件的Uri
                // 自定义下面的逻辑代码.....
                // ................
                // ................
                //示例:
                deleteDocument(it) // 删除文档
            }
        }
        if (requestCode == CREATE_DOCUMENT && resultCode == Activity.RESULT_OK) {
            data?.data?.also {
                Log.d(TAG, "onActivityResult() called : CREATE_DOCUMENT uri = $it")
                // 返回文件的Uri
                // 自定义下面的逻辑代码.....
                // ................
                // ................
                //示例:
                writeDocument(it) // 写入数据到文档
            }
        }
    }

    override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<String>,
        grantResults: IntArray
    ) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)

        if (requestCode == PERMISSION_CODE) {
            for (i in permissions.indices) {
                Log.d(TAG, "onRequestPermissionsResult: " + grantResults[i])
                if (grantResults[i] == -1) {
                    Toast.makeText(this@MainActivity, "${grantResults[i]}权限被拒绝", Toast.LENGTH_SHORT)
                        .show()
                } else {
                    Toast.makeText(this@MainActivity, "${grantResults[i]}权限被授予", Toast.LENGTH_SHORT)
                        .show()
                }
            }
        }
    }

    companion object {
        private const val TAG = "MainActivity"
        private const val PERMISSION_CODE = 999
        private const val CREATE_DOCUMENT = 2000
        private const val PICK_DOCUMENT = 1000
        private const val PICK_DIR = 3000
    }
}

build.gradle

minSdkVersion 'R'
 targetSdkVersion 'R'

dependencies {
     // Kotlin 协程依赖库
     implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.0"
     implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.0"
     // Exifinterface 库
     implementation 'androidx.exifinterface:exifinterface:1.1.0'
}

AndroidManifest.xml

<!-- Android 11 不再需要 WRITE_EXTERNAL_STORAGE -->
    <!--<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />-->
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
    <uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />


<!-- android:requestLegacyExternalStorage="false" 在 targetSdkVersion > 29 已无法使用,从下面application节点把属性配置去掉了 -->
<application
    android:allowBackup="false"
    android:icon="@mipmap/ic_launcher"
    android:label="@string/app_name"
    ....
    ..../>