MediaStore使用实战:实现“0权限”操作下载目录等

前言

  • 在以前,操作外部存储通常的做法是申请存储权限,然后通过文件路径取得File(path),进行相关操作。随着谷歌对相关权限的收紧,Android 10引入分区存储Android 11强制执行,加上国内要求targetAPI必须提升到30+,存储操作需要做新的适配。

  • 由于谷歌提供的文档较为简略,所以以操作Download目录为例 撰写这篇文章。谷歌文档中提及的不止这一种,包括如下:

    图片 (包括照片和屏幕截图),存储在 DCIM/ 和 Pictures/ 目录中。系统将这些文件添加到 MediaStore.Images 表格中。

    视频 ,存储在 DCIM/、Movies/ 和 Pictures/ 目录中。系统将这些文件添加到 MediaStore.Video 表格中。

    音频 文件,存储在 Alarms/、Audiobooks/、Music/、Notifications/、Podcasts/ 和 Ringtones/ 目录中。此外,系统还可以识别 Music/ 或 Movies/ 目录中的音频播放列表,以及 Recordings/ 目录中的录音。系统将这些文件添加到 MediaStore.Audio 表格中。Recordings/ 目录在 Android 11(API 级别 30)及更低版本中不可用。

    下载的文件 ,存储在 Download/ 目录中。在搭载 Android 10(API 级别 29)及更高版本的设备上,这些文件存储在 MediaStore.Downloads 表格中。此表格在 Android 9(API 级别 28)及更低版本中不可用。

先说结论

  • 可以通过设置requestLegacyExternalStorage标记并将targetSdkVersion保持在29,申请MANAGE_EXTERNAL_STORAGE权限等办法,延续以前的代码,但这是非常不文雅的做法,强烈不推荐。
  • MediaStore是谷歌推荐解决方案之一,还有保存到专属文件目录、使用SAF等方案,具体区别看这篇文档
  • MediaStore API只在Android 10及以上提供,低版本系统还需要保留兼容代码。本文主要讲Android 10+的做法,低版本代码略去。
  • 使用MediaStore的本质是通过指定参数用ContentResolver查询数据库,获得所需文件的Uri,然后进行操作。

开始使用

权限

  • 可能用到的几条权限:
xml 复制代码
  <!-- 需要访问其他应用创建的图片时申请 -->
  <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />

  <!-- 需要访问其他应用创建的视频时申请 -->
  <uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />

  <!-- 需要访问其他应用创建的音频时申请 -->
  <uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />

  <!-- 旧版安卓兼容 -->
  <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>

  <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
                   android:maxSdkVersion="29" />
  • 以我的项目为例,只需要访问本应用存放到下载目录的文件,因此对于Android 10+,不需要申请任何权限,对于旧版本,保留读写权限。最终配置如下:
xml 复制代码
  <uses-permission
    android:name="android.permission.WRITE_EXTERNAL_STORAGE"
    android:maxSdkVersion="28" />
  <uses-permission
    android:name="android.permission.READ_EXTERNAL_STORAGE"
    android:maxSdkVersion="28" />

通过文件路径取得文件Uri

  • 这里的文件路径均以Environment.DIRECTORY_DOWNLOADS开头,也就是"Download",且包含文件名
kotlin 复制代码
      /**
       * 通过MediaStore获取文件uri
       * @return 获取失败返回null
       */
      @RequiresApi(Build.VERSION_CODES.Q)
      fun getFileUri(context: Context, path: String): Uri? {
      	// projection代表数据库中需要检索出来的列,也可以不写,query的第二个参数传null,写了性能更好
          val projection = arrayOf(
              MediaStore.Downloads.DISPLAY_NAME,
              MediaStore.Downloads._ID,
              MediaStore.Downloads.RELATIVE_PATH
          )
          // 从path解析出路径和文件名
          val directoryPath = path.substringBeforeLast("/")
          val fileName = path.substringAfterLast("/")
  
  		// SQL语句,路径匹配和文件名匹配
          val selection =
              "${MediaStore.Downloads.RELATIVE_PATH} LIKE ? AND ${MediaStore.Downloads.DISPLAY_NAME} = ?"
          // SQL语句参数
          val selectionArgs = arrayOf("%$directoryPath%", fileName)
  
          val contentResolver: ContentResolver = context.contentResolver
          val uri = MediaStore.Downloads.EXTERNAL_CONTENT_URI
  		// 使用ContentResolver查找,获得数据库指针
          val cursor = contentResolver.query(uri, projection, selection, selectionArgs, null)
  
          var fileUri: Uri? = null
          if (cursor?.moveToFirst() == true) {
              val columnIndex = cursor.getColumnIndex(MediaStore.Downloads._ID)
              val fileId = cursor.getLong(columnIndex)
              fileUri = Uri.withAppendedPath(uri, fileId.toString())
              cursor.close()
          }
          return fileUri
      }
  

向Download写文件

  • 这里的inputStream可以通过File#inputStream()获得,也可以是其他形式的inputStream,如okhttp下载文件。提供灵活性。
  • path支持直接嵌套目录。
kotlin 复制代码
    /**
     * 写inputStream到公共目录Download
     * @param path 文件路径,必须以Download/开头,且不包含文件名
     */
    @RequiresApi(Build.VERSION_CODES.Q)
    fun writeToDownload(
        context: Context,
        path: String,
        fileName: String,
        inputStream: InputStream
    ) {
        val contentValues = ContentValues()
        // 设置文件路径
        contentValues.put(MediaStore.Downloads.RELATIVE_PATH, path)
        // 设置文件名称
        contentValues.put(MediaStore.Downloads.DISPLAY_NAME, fileName)
        // ContentUri 表示操作哪个数据库, contentValues 表示要插入的数据内容
        val uri = context.contentResolver.insert(
            MediaStore.Downloads.EXTERNAL_CONTENT_URI,
            contentValues
        )!!
        // 向 path/filename 文件中插入数据
        val os: OutputStream = context.contentResolver.openOutputStream(uri)!!
        val bos = BufferedOutputStream(os)
        inputStream.use { istream ->
            bos.use { bos ->
                val buff = ByteArray(1024)
                var count: Int
                while (istream.read(buff).apply { count = this } != -1) {
                    bos.write(buff, 0, count)
                }
            }
        }
    }

通过path检查文件存在性

kotlin 复制代码
    fun checkFileExistence(context: Context, path: String): Boolean {
        return getFileUri(context, path) != null
    }

通过Uri获取文件名

kotlin 复制代码
    fun getFileName(context: Context, uri: Uri): String {
        var fileName = ""
        val contentResolver = context.contentResolver
        // 此处只需要取出文件名这一项
        val projection = arrayOf(OpenableColumns.DISPLAY_NAME)
        contentResolver.query(uri, projection, null, null, null)?.use { cursor ->
            val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
            cursor.moveToFirst()
            fileName = cursor.getString(nameIndex)
            cursor.close()
        }

        return fileName
    }

删除指定path文件

kotlin 复制代码
    fun deleteFile(context: Context, path: String) {
        val uri = getFileUri(context, path)
        if (uri == null) {
            return
        }
        context.contentResolver.delete(uri, null, null)
    }

跳转其他程序打开文件

kotlin 复制代码
    fun openFile(context: Context, path: String) {
        val intent = Intent()
        intent.action = Intent.ACTION_VIEW
        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
        intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
        val uri = getFileUri(context, path)
        if (uri == null) {
            toastError("打开文件失败")
            return
        }
        intent.setDataAndType(uri, context.contentResolver.getType(uri) ?: "*/*")
        try {
            context.startActivity(intent)
        } catch (exception: ActivityNotFoundException) {
            // 对于设定的MIME没有对应程序可打开的情况
            intent.setDataAndType(uri, "*/*")
            context.startActivity(intent)
        }
    }

附:判断是否可以使用MediaStore的一个小函数

kotlin 复制代码
    private fun useMediaStore(path: String): Boolean {
        return path.startsWith(Environment.DIRECTORY_DOWNLOADS) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
    }

后记

  • 这些操作都是针对文件的,MediaStore似乎没有提供针对目录的操作。
  • 本文根据个人理解撰写,如有不同意见,欢迎讨论。
相关推荐
笑一下蒜了.16 分钟前
Linux 存储多路径访问手册|multipath.conf 各配置段详解
android·linux·运维
凡泰AI19 分钟前
如何通过小程序多端框架,让一个小程序同时运行在iOS、安卓、鸿蒙以及PC客户端,实现一次开发多端运行的效果
android·ios·小程序
一笑的小酒馆1 小时前
Androidiot扫地机器人设备详情页地图加载慢卡顿优化方案
android
Zhu7587 小时前
在k8s集群部署MySQL单实例,支持多个主流发行版
android·mysql·kubernetes
AFinalStone10 小时前
Android 7系统异常问题排查(四)Framework层(上)—System Server Watchdog
android·系统异常
宝杰X712 小时前
Android Room3 多平台数据库
android·数据库
huibin14785236914 小时前
热缓解学习记录
android·学习
小田的博客17 小时前
SAP MM 供应商银行主数据更新报错!message R1228!
android·java·服务器
一笑的小酒馆18 小时前
Android智能猫砂盆视频加载慢卡顿问题分析
android
只会cv的小前端20 小时前
七巧低代码服务端脚本使用方法
android·低代码·rxjava