Android 10分区存储权限变更及适配

一、前言

在Android 10中引入了分区储存功能,在外部存储设备中为每个应用提供了一个“隔离存储沙盒”。其他应用无法直接访问应用的沙盒文件。由于文件是应用的私有文件,不再需要任何权限即可访问和保存自己的文件。此变更并有助于减少应用所需的权限数量,同时保证用户文件的隐私性。

  • 目标版本targetSdkVersion设置为28或更低版本以下时,我们对外部存储空间的读写访问需要READ_EXTERNAL_STORAGEWRITE_EXTERNAL_STORAGE权限。
  • 目标版本targetSdkVersion设置为29时,可以在应用的清单文件中将加入 android:requestLegacyExternalStorage="true",暂时停用分区存储。继续按上面的方式读写文件。
  • 目标版本targetSdkVersion设置为30时,即要适配Android 11时,分区存储强制执行。

二、内部存储和外部存储

手机内存又有内部存储和外部存储,在以前手机容量不大时,会有外接SD卡,外接的SD卡就是外部存储。如今大部分手机不支持外接SD卡,手机内部就已经自带了外部存储,但不排除少量特殊机型。所以在使用外部存储前应该检查外部存储是否存在:

    /**
     * 检查外部存储是否可读写
     */
    fun isExternalStorageWritable(): Boolean {
        return Environment.getExternalStorageState() == Environment.MEDIA_MOUNTED
    }
    
     /**
     * 检查外部存储是否至少可读取
     */
    fun isExternalStorageReadable(): Boolean {
		return Environment.getExternalStorageState() in
        	setOf(Environment.MEDIA_MOUNTED, Environment.MEDIA_MOUNTED_READ_ONLY)
    }

三、访问应用专属文件

应用无需请求任何与存储空间相关的权限即可访问应用专属目录。卸载应用后,系统会移除这些目录中存储的文件。而该目录下又会有两个子目录,一个目录专为应用的持久性文件而设计,而另一个目录包含应用的缓存文件。

3.1 持久性文件

一般用来放一些长时间保存的数据。

a.内部存储
通过context.filesDir方法可以获取到 /data/data/【应用包名】/files 文件路径
您可以使用 File API 访问和存储文件:

val file = File(context.filesDir, filename)

//or
val file = context.getDir(dirName, Context.MODE_PRIVATE)

除使用 File API 之外,您还可以调用 openFileOutput() 获取会写入 filesDir 目录中的文件的 FileOutputStream

val filename = "myfile"
val fileContents = "Hello world!"
context.openFileOutput(filename, Context.MODE_PRIVATE).use {
        it.write(fileContents.toByteArray())
}

如需以信息流的形式读取文件,请使用 openFileInput()

context.openFileInput(filename).bufferedReader().useLines { lines ->
    lines.fold("") { some, text ->
        "$some\n$text"
    }
}

b.外部存储
通过context.getExternalFilesDir方法可以获取到 sdcard/Android/data/【应用包名】/files 文件路径

val file = File(context.getExternalFilesDir(), filename)

3.2 缓存文件

暂时存储敏感数据,应使用应用在内部存储空间中的指定缓存目录保存数据。当设备的内部存储空间不足时,Android 可能会删除这些缓存文件以回收空间。

a.内部存储
通过context.cacheDir方法可以获取到 sdcard/Android/data/【应用包名】/cache文件路径

val cacheFile = File(context.cacheDir, filename)

//or
val cacheFile = File.createTempFile(filename, null, context.cacheDir)

b.外部存储
通过context.externalCacheDir方法可以获取到 sdcard/Android/data/【应用包名】/cache 文件路径

val file = File(context.getExternalFilesDir(), filename)

四、访问共享存储空间

如果用户数据可供或应可供其他应用访问,并且即使在用户卸载应用后也可对其进行保存,请使用共享存储空间。Android 10更改了应用对设备外部存储设备中的文件(如:/sdcard )的访问方式。继续使用 READ_EXTERNAL_STORAGEWRITE_EXTERNAL_STORAGE 权限,只不过当拥有这些权限的时候,你只能访问媒体文件,无法访问其他文件。

Android 提供用于存储和访问以下类型的可共享数据的 API:

  • 媒体内容: 系统提供标准的公共目录来存储这些类型的文件,这样用户就可以将所有照片保存在一个公共位置,将所有音乐和音频文件保存在另一个公共位置,依此类推。您的应用可以使用此平台的 MediaStore API 访问此内容。
  • 文档和其他文件: 系统有一个特殊目录,用于包含其他文件类型,例如 PDF 文档和采用 EPUB 格式的图书。您的应用可以使用此平台的存储访问框架访问这些文件。

4.1 媒体内容

Android 10中使用ContentResolver进行文件的增删改查

4.1.2 查询媒体集合

如需查找满足一组特定条件(例如时长为 5 分钟或更长时间)的视频文件,请使用类似于以下代码段中所示的类似 SQL 的选择语句:

data class Video(val uri: Uri,
    val name: String,
    val duration: Int,
    val size: Int
)

val videoList = mutableListOf<Video>()


//媒体数据库列检索
val projection = arrayOf(
    MediaStore.Video.Media._ID,
    MediaStore.Video.Media.DISPLAY_NAME,
    MediaStore.Video.Media.DURATION,
    MediaStore.Video.Media.SIZE
)

// sql占位符与占位符变量
val selection = "${MediaStore.Video.Media.DURATION} >= ?"

// 占位符变量的值
val selectionArgs = arrayOf(
    TimeUnit.MILLISECONDS.convert(5, TimeUnit.MINUTES).toString()
)

// sql排序语句
val sortOrder = "${MediaStore.Video.Media.DISPLAY_NAME} ASC"

val query = ContentResolver.query(
    MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
    projection,
    selection,
    selectionArgs,
    sortOrder
)
query?.use { cursor ->
    // Cache column indices.
    val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media._ID)
    val nameColumn =
            cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DISPLAY_NAME)
    val durationColumn =
            cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DURATION)
    val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.SIZE)

    while (cursor.moveToNext()) {
        // Get values of columns for a given video.
        val id = cursor.getLong(idColumn)
        val name = cursor.getString(nameColumn)
        val duration = cursor.getInt(durationColumn)
        val size = cursor.getInt(sizeColumn)

        val contentUri: Uri = ContentUris.withAppendedId(
            MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
            id
        )

        // Stores column values and the contentUri in a local object
        // that represents the media file.
        videoList += Video(contentUri, name, duration, size)
    }
}

上述代码中,参数projectionselectionselectionArgssortOrder都有对应的说明,如果没有特殊要求,可以为null,对于media-type系统会自动扫描外部存储,并将媒体文件添加到以下明确定义的集合中:

  • 图片(包括照片和屏幕截图),存储在 DCIM/ 和 Pictures/ 目录中。系统将这些文件添加到 MediaStore.Images 表格中。
  • 视频,存储在 DCIM/、Movies/ 和 Pictures/ 目录中。系统将这些文件添加到 MediaStore.Video 表格中。
  • 音频文件,存储在 Alarms/、Audiobooks/、Music/、Notifications/、Podcasts/ 和 Ringtones/ 目录中,以及位于 Music/ 或 Movies/ 目录中的音频播放列表中。系统将这些文件添加到 MediaStore.Audio 表格中。
  • 下载的文件,存储在 Download/ 目录中。在搭载 Android 10(API 级别 29)及更高版本的设备上,这些文件存储在 MediaStore.Downloads 表格中。此表格在 Android 9(API 级别 28)及更低版本中不可用。

媒体库还包含一个名为 MediaStore.Files 的集合。其内容取决于您的应用是否使用分区存储(适用于以 Android 10 或更高版本为目标平台的应用):

  • 如果启用了分区存储,集合只会显示您的应用创建的照片、视频和音频文件。
  • 如果分区存储不可用或未使用,集合将显示所有类型的媒体文件。

在应用中执行此类查询时,请注意以下几点:

  • 在工作线程中调用 query() 方法。
  • 缓存列索引,以免每次处理查询结果中的行时都需要调用 getColumnIndexOrThrow()
  • 将 ID 附加到内容 URI,如代码段所示。

4.1.3 打开媒体文件

用于打开媒体文件的具体逻辑取决于媒体内容最佳表示形式是文件描述符还是文件流:

文件描述符
如果需要获取文件描述信息,使用文件描述符打开媒体文件

	// "rw" for read-and-write;
    // "rwt" for truncating or overwriting existing file contents.
    val readOnlyMode = "r"
    applicationContext.contentResolver.openFileDescriptor(uri, readOnlyMode).use {
        // Perform operations on "ParcelFileDescriptor".
        

    }

文件流
如需使用文件流打开媒体文件,请使用类似于以下代码段所示的逻辑:

	applicationContext.contentResolver.openInputStream(uri).use { stream ->
    	// Perform operations on "stream".
    	
	}

4.1.4 创建媒体文件

以保存图片到DCIM为例

    fun saveImage(bitmap:Bitmap){
        val details = ContentValues().apply {
            put(MediaStore.Audio.Media.DISPLAY_NAME, "image.jpg")
            put(MediaStore.MediaColumns.MIME_TYPE, "image/JPEG")
            
            //兼容Android Q和以下版本
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_DCIM)
            } else {
                put(MediaStore.Images.Media.DATA, Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM).path)
            }
        }
        val uri = applicationContext.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, details)

        uri?.let {
            contentResolver.openOutputStream(it)?.use { outputStream ->
                bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
                outputStream.close()
            }
        }
    }

上述代码主要分为三个步骤:

  1. 想要将一张图片添加到手机相册,我们需要构建一个ContentValues对象,然后向这个对象中添加三个重要的数据。
  • DISPLAY_NAME:图片显示的名称。
  • MIME_TYPE,:图片的mime类型。
  • RELATIVE_PATH/DATA:图片存储的路径,这个值在Android 10和之前的系统版本中的处理方式不一样。Android 10中新增了一个RELATIVE_PATH常量,表示文件存储的相对路径,可选值有DIRECTORY_DCIMDIRECTORY_PICTURESDIRECTORY_MOVIESDIRECTORY_MUSIC等,分别表示相册、图片、电影、音乐等目录。而在之前的系统版本中并没有RELATIVE_PATH,所以我们要使用DATA常量(已在Android 10中废弃),并拼装出一个文件存储的绝对路径才行。
  1. 调用ContentResolver的insert()方法即可获得插入图片的Uri。
  2. 向该Uri所对应的图片写入数据。调用ContentResolver的openOutputStream()方法获得文件的输出流,然后将Bitmap对象写入到该输出流当中即可。

如果您的应用执行可能非常耗时的操作,那么在处理文件时对其进行独占访问非常有用。在搭载 Android 10 或更高版本的设备上,您的应用可以通过将 IS_PENDING 标记的值设为 1 来获取此独占访问权限。如此一来,只有您的应用可以查看该文件,直到您的应用将 IS_PENDING 的值改回 0。上面的代码就可以改成:

    fun saveImage(bitmap:Bitmap){
        val details = ContentValues().apply {
            put(MediaStore.Audio.Media.DISPLAY_NAME, "image.jpg")
            put(MediaStore.MediaColumns.MIME_TYPE, "image/JPEG")
            
            //兼容Android Q和以下版本
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_DCIM)
                //标志媒体文件的待处理状态
                put(MediaStore.Audio.Media.IS_PENDING, 1)
            } else {
                put(MediaStore.Images.Media.DATA, Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM).path)
            }
        }
        val uri = applicationContext.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, details)

        uri?.let {
            contentResolver.openOutputStream(it)?.use { outputStream ->
                bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
                outputStream.close()
            }
        }
        
		if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
			// 写入完成后,清除媒体文件的待处理状态
			details.clear()
			details.put(MediaStore.Audio.Media.IS_PENDING, 0)
			applicationContext.contentResolver.update(uri, details, null, null)
        }
    }

4.1.5 修改媒体文件

如需更新应用拥有的媒体文件,请运行类似于以下内容的代码:

        val details = ContentValues().apply {
            //修改文件名字
            put(MediaStore.Audio.Media.DISPLAY_NAME, "image2.jpg")
            //移动文件
            //兼容Android Q和以下版本
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES)
            } else {
                put(MediaStore.Images.Media.DATA,
                    Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES).path)
            }
        }

        applicationContext.contentResolver.update(uri, details, null, null)

如果您的应用使用分区存储,它通常无法更新其他应用存放到媒体库中的媒体文件。
不过,您仍可通过捕获平台抛出的 RecoverableSecurityException 来征得用户同意修改文件。然后,您可以请求用户授予您的应用对此特定内容的写入权限,如以下代码段所示:

        try {
            applicationContext.contentResolver.openFileDescriptor(uri, "w")?.use {
                //修改媒体文件
            }
        } catch (securityException: SecurityException) {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                val recoverableSecurityException =
                    securityException as? RecoverableSecurityException ?: throw RuntimeException(
                        securityException.message,
                        securityException)

                val intentSender = recoverableSecurityException.userAction.actionIntent.intentSender
                intentSender?.let {
                    startIntentSenderForResult(intentSender, uri, null, 0, 0, 0, null)
                }
            } else {
                throw RuntimeException(securityException.message, securityException)
            }
        }

4.1.6 删除媒体文件

        // URI of the image to remove.
        val imageUri = "..."

        // WHERE clause.
        val selection = "..."
        val selectionArgs = "..."

        // Perform the actual removal.
        val numImagesRemoved = applicationContext.contentResolver.delete(
            imageUri,
            selection,
            selectionArgs)

如果分区存储不可用或未启用,您可以使用上述代码段移除其他应用拥有的文件。但是,如果启用了分区存储,您就需要为应用要移除的每个文件捕获 RecoverableSecurityException,如修改媒体文件所述。

4.2 访问文档和其他文件

Android 4.4(API 级别 19)引入了存储访问框架 (SAF)。借助 SAF,用户可轻松浏览和打开各种文档、图片及其他文件,而不用管这些文件来自其首选文档存储提供程序中的哪一个。用户可通过易用的标准界面,跨所有应用和提供程序以统一的方式浏览文件并访问最近用过的文件。

4.2.1 打开文件

Android 10分区存储权限变更及适配
使用 ACTION_OPEN_DOCUMENT intent 操作,打开文件选择器来选择需要打开的文件

    fun openFile(pickerInitialUri: Uri) {
        val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
            addCategory(Intent.CATEGORY_OPENABLE)
            type = "application/pdf"
            //指定文件选择器在首次加载时应显示的目录的 URI(可选)
            putExtra(DocumentsContract.EXTRA_INITIAL_URI, pickerInitialUri)
        }
        startActivityForResult(intent, 1)
    }

我们还有使用更为简便的方式,具体的内容可参考:Android onActivityResult的替代方法—registerForActivityResult

        registerForActivityResult(ActivityResultContracts.GetContent()){
 
        }.launch("application/pdf")

4.2.2 创建新文件

Android 10分区存储权限变更及适配
使用 ACTION_CREATE_DOCUMENT intent 操作,加载系统文件选择器,支持用户选择要写入文件内容的位置。此流程类似于其他操作系统使用的“另存为”对话框中使用的流程。ACTION_CREATE_DOCUMENT 无法覆盖现有文件。如果您的应用尝试保存同名文件,系统会在文件名的末尾附加一个数字并将其包含在一对括号中。

    private lateinit var activityResultLauncher: ActivityResultLauncher<Intent>

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        activityResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {

        }
    }

    private fun createFile(pickerInitialUri: Uri) {
        val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
            addCategory(Intent.CATEGORY_OPENABLE)
            type = "application/pdf"
            putExtra(Intent.EXTRA_TITLE, "invoice.pdf")
            //指定文件选择器在首次加载时应显示的目录的 URI(可选)
            putExtra(DocumentsContract.EXTRA_INITIAL_URI, pickerInitialUri)
        }
        activityResultLauncher.launch(intent)
    }

4.2.3 授予对目录内容的访问权限

Android 10分区存储权限变更及适配
使用 ACTION_OPEN_DOCUMENT_TREE intent 操作,它支持用户授予应用对整个目录树的访问权限。然后,您的应用便可以访问所选目录及其任何子目录中的任何文件。

    fun openDirectory(pickerInitialUri: Uri) {
        val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
            //授予读操作
            flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
            //授予写操作
            flags = Intent.FLAG_GRANT_WRITE_URI_PERMISSION
            //指定文件选择器在首次加载时应显示的目录的 URI(可选)
            putExtra(DocumentsContract.EXTRA_INITIAL_URI, pickerInitialUri)
        }
        activityResultLauncher.launch(intent)
    }

4.2.4 保留权限

当您的应用打开文件进行读取或写入时,系统会向应用授予对该文件的 URI 的访问权限,该授权在用户重启设备之前一直有效。但是,如果需要在应用中保存最近访问的文件,例如历史记录。可以获取系统提供的永久性 URI 访问权限,如以下代码段所示:

        val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or
                Intent.FLAG_GRANT_WRITE_URI_PERMISSION 
        // Check for the freshest data.
        applicationContext.contentResolver.takePersistableUriPermission(uri, takeFlags)

4.2.5 修改文档

4.1.3 打开媒体文件类似

    private fun alterDocument(uri: Uri) {
        try {
            applicationContext.contentResolver.openFileDescriptor(uri, "w")?.use {
                FileOutputStream(it.fileDescriptor).use {
                    it.write(("Overwritten at ${System.currentTimeMillis()}\n").toByteArray())
                }
            }
        } catch (e: FileNotFoundException) {
            e.printStackTrace()
        } catch (e: IOException) {
            e.printStackTrace()
        }
    }

4.2.6 删除文档

如果您获得了文档的 URI,并且该文档的 Document.COLUMN_FLAGS 包含 SUPPORTS_DELETE,您便可以删除该文档。例如:

DocumentsContract.deleteDocument(applicationContext.contentResolver, uri)
上一篇:JavaScript基础学习——HTML5 API


下一篇:主流Webrtc流媒体服务器之Kurento Media Server