Android文件系统(02)存储访问框架SAF的增删改查

资料

SAFGoogle官网

Android SAF(Storage Access Framework)是Android操作系统提供的一种文件访问机制,它允许应用程序在设备上访问和共享存储媒体(如照片、视频、音频等)的文件。

SAF提供了一种安全的方式来访问文件,它使用运行时权限模型,允许用户控制应用程序可以访问哪些文件。应用程序可以通过SAF的API请求访问文件的权限,并向用户显示一个文件选择器,以便用户可以选择要访问的文件。

SAF包括两部分:

  1. 媒体存储(MediaStore):是一个系统级别的媒体数据库,提供了对设备上的媒体文件的访问。应用程序可以使用MediaStore API查询和检索设备上的媒体文件,如照片、视频和音频。
  2. 文件和文件提供者(File and File Provider):文件和文件提供者API允许应用程序访问设备上的其他类型的文件,如文档、PDF、电子书等。应用程序可以使用文件和文件提供者API来查询、检索和操作这些文件。

SAF的好处是它可以简化应用程序的文件访问,提高用户体验,同时提供更强的隐私保护和安全性。它让用户可以更好地控制哪些应用程序可以访问他们的文件,并且可以减少应用程序对设备的负面影响,如文件冗余、权限滥用等问题。

正文

通过上面的描述大概可以知道,SAF机制可以提供返回参数的,而且这个这个是跨进程的,而Android中什么可以提供这种逻辑了?那就是通过intent去。持有bundle对象,所以这个玩意的回调,一定是走的是 activityResult。

activityResult

既然都23年了,写onActivityResult会提示过期了。所以我们换一种思路。先写一个简单的,先定义不可变量:

aidl 复制代码
    private  val  activityResultLauncher: ActivityResultLauncher<Intent> =registerForActivityResult(
        ActivityResultContracts.StartActivityForResult()
    ) { result ->
        if (result.resultCode === RESULT_OK) {
            // 处理Activity返回的结果
            LogUtils.e("成功")
            result.data?.let {
                LogUtils.e(it.data)
            }

        } else if (result.resultCode === RESULT_CANCELED) {
            // 处理Activity被取消的情况
            LogUtils.e("取消")
        }
    }

发送intent:

aidl 复制代码
activityResultLauncher.launch(intent)

SAF简介

SAF使用教程官方文档

ini 复制代码
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
intent.addCategory(Intent.CATEGORY_OPENABLE)
intent.type = "image/*"
activityResultLauncher.launch(intent)

当我们选择一张图片成功后,result返回的是一个uri:

content://com.android.providers.media.documents/document/image%3A306

通过上面的简单例子,我可以大概的知道,SAF的一些基本信息。

  • 需要一个action
  • 需要一个category
  • 可以设置mime type
  • 返回的是Uri

action

在Android系统中,类似于ACTION_OPEN_DOCUMENT的Intent action还有很多,它们都是Android系统为了方便应用程序之间的文件交互而设计的一些标准操作。以下是一些常见的Intent action:

  1. Intent.ACTION_VIEW:用于打开一个文件或URI,类似于文件的查看操作。
  2. Intent.ACTION_EDIT:用于编辑一个文件,类似于文件的编辑操作。
  3. Intent.ACTION_INSERT:用于在一个应用程序中插入一个文件或URI,类似于文件的插入操作。
  4. Intent.ACTION_DELETE:用于删除一个文件或URI,类似于文件的删除操作。
  5. Intent.ACTION_CREATE_DOCUMENT:用于在一个应用程序中创建一个新文件或URI,类似于文件的创建操作。
  6. Intent.ACTION_RENAMING:用于重命名一个文件或URI,类似于文件的重命名操作。

通过这些Intent action,应用程序可以方便地实现文件的打开、编辑、插入、删除、创建和重命名等操作,使得不同应用程序之间的文件交互变得更加便捷。

category

类似于CATEGORY_OPENABLE,Android系统中还有其他的Intent category,用于指示不同类型的操作或意图。以下是一些常见的Intent category:

  1. Intent.CATEGORY_DEFAULT:用于指示一个默认的Intent,可以用于启动一个应用程序或执行其他操作。
  2. Intent.CATEGORY_APP_BROWSER:用于指示启动一个浏览器应用程序,用于浏览互联网或本地文件系统。
  3. Intent.CATEGORY_LAUNCHER:用于指示启动一个主屏幕应用程序,用于启动应用程序并显示应用程序的启动页面。
  4. Intent.CATEGORY_INFO:用于指示一个信息类的Intent,可以用于获取关于某个应用程序或数据的详细信息。
  5. Intent.CATEGORY_PROVIDE:用于指示一个提供服务的Intent,可以用于启动一个服务并提供数据或资源给其他应用程序。
  6. Intent.CATEGORY_OPENABLE:用于指示一个可打开的Intent,可以用于打开一个文件或URI并供应用程序读取或处理。
  7. Intent.CATEGORY_SELECTED_ORGANIZER:用于指示一个被选中的组织者Intent,可以用于启动一个日历应用程序并显示特定的日程安排。

这些Intent category可以与Intent action结合使用,以实现不同类型的文件操作或启动不同的应用程序。请注意,使用合适的category来指示您的Intent可以提高用户体验并确保您的应用程序与系统的无缝集成。

MIME type

MIME类型是一个互联网标准,它扩展了电子邮件标准,使其能够支持非ASCII字符、二进制格式附件等多种格式的邮件消息。而且,新的MIME类型也在不断添加和更新,无法穷举。

但是,以下是一些常见的MIME类型:

  1. 文本(Text):用于标准化地表示的文本信息,文本消息可以是多种字符集和或者多种格式的。常见的文本MIME类型包括"text/plain","text/html","text/xml"等。
  2. 多部分(Multipart):用于连接消息体的多个部分构成一个消息,这些部分可以是不同类型的数据。常见的多部分MIME类型包括"multipart/mixed","multipart/form-data"等。
  3. 应用程序(Application):用于传输应用程序数据或者二进制数据。常见的应用程序MIME类型包括"application/octet-stream","application/pdf","application/excel"等。
  4. 消息(Message):用于包装一个E-mail消息。常见的消息MIME类型包括"message/rfc822"等。
  5. 图像(Image):用于传输静态图片数据。常见的图像MIME类型包括"image/jpeg","image/gif","image/png"等。
  6. 音频(Audio):用于传输音频或者音声数据。常见的音频MIME类型包括"audio/mpeg","audio/wav"等。
  7. 视频(Video):用于传输动态影像数据,可以是与音频编辑在一起的视频数据格式。常见的视频MIME类型包括"video/mp4","video/mpeg"等。

基于教程敲一遍

SAF使用教程官方文档 ,其实官网对于增删改查等一系列的操作都写得贼详细了,但是笔记嘛,为了避免看了就会了,所以说,还是自己敲了一遍。

创建新文件

使用 ACTION_CREATE_DOCUMENT intent 操作加载系统文件选择器,支持用户选择要写入文件内容的位置。此流程类似于其他操作系统使用的"另存为"对话框中使用的流程。

ACTION_CREATE_DOCUMENT 无法覆盖现有文件。如果您的应用尝试保存同名文件,系统会在文件名的末尾附加一个数字并将其包含在一对括号中 。例如,如果您的应用尝试将名为 confirmation.pdf 的文件保存到已有同名文件的目录中,系统会以 confirmation(1).pdf 作为名称来保存新文件。

在配置 intent 时,应指定文件的名称和 MIME 类型,并且还可以根据需要使用 EXTRA_INITIAL_URI intent extra 指定文件选择器在首次加载时应显示的文件或目录的 URI。

scss 复制代码
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)

上面的代码在我模拟器上是跳转到下载目录下,文件名是invoice.pdf 当用户点击保存按钮的时候,会返回一个Uri,我们拿到这个Uri 就可以将文件流写入进去了,所以这个玩意是占坑的,文件流都没有丢进去。这个支持设置一个uri。但是我传递MediaStore.Images.Media.EXTERNAL_CONTENT_URI 跳转进去还是下载目录。还没有尝试自己去查询一个目录的URI的方式。

同时官网文档上有一个访问权限限制

在 Android 11(API 级别 30)及更高版本中,您不能使用 ACTION_OPEN_DOCUMENT intent 操作来请求用户从以下目录中选择单独的文件:

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

总结

  • 创建文件逻辑上可以指定文件的目录,前提是你得有这个目录或者这个目录下文件的URI。
  • 使用了ACTION_CREATE_DOCUMENT和CATEGORY_OPENABLE,MIME type

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

在Android 5.0(API 21)之后才提供。

文件管理和媒体创建应用通常在目录层次结构中管理文件组。如需在您的应用中提供此功能,请使用 ACTION_OPEN_DOCUMENT_TREE intent 操作,它支持用户授予应用对整个目录树的访问权限,但在 Android 11(API 级别 30)及以上版本中会有一些例外情况。然后,您的应用便可以访问所选目录及其任何子目录中的任何文件。

使用 ACTION_OPEN_DOCUMENT_TREE 时,您的应用只能访问用户所选目录中的文件。您无权访问位于用户所选目录之外的其他应用的文件。借助这种由用户控制的访问权限,用户可以确切选择自己想要与您的应用共享的具体内容。

您可以根据需要使用 EXTRA_INITIAL_URI intent extra 指定文件选择器在首次加载时应显示的目录的 URI。

如果您在使用 ACTION_OPEN_DOCUMENT_TREE 访问的目录中遍历大量文件,应用的性能可能会降低。

在 Android 11(API 级别 30)及更高版本中,您不能使用 ACTION_OPEN_DOCUMENT_TREE intent 操作来请求访问以下目录:

  • 内部存储卷的根目录。
  • 设备制造商认为可靠的各个 SD 卡卷的根目录,无论该卡是模拟卡还是可移除的卡。可靠的卷是指应用在大多数情况下可以成功访问的卷。
  • Download 目录。

此外,在 Android 11(API 级别 30)及更高版本中,您不能使用 ACTION_OPEN_DOCUMENT_TREE intent 操作来请求用户从以下目录中选择单独的文件:

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

示例代码:

scss 复制代码
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
    // 可选参数为要打开的目录指定一个URI 应用程序创建文档之前的系统文件选择器。
    //putExtra(DocumentsContract.EXTRA_INITIAL_URI, pickerInitialUri)
}
activityResultLauncher.launch(intent)

在所选位置执行操作

上面主要是对于文件进行创建,目录权限获取等。不同的内容提供器支持对文档执行不同的操作,例如复制文档或查看文档的缩略图。如需确定指定提供器支持哪些操作,请查看 Document.COLUMN_FLAGS 的值。应用的界面只会显示提供器支持的选项。

保留权限

当您的应用打开文件进行读取或写入时,系统会向应用授予对该文件的 URI 的访问权限,该授权在用户重启设备之前一直有效。但是,假设您的应用是图片编辑应用,而且您希望用户能够直接从应用中访问他们最近修改的 5 张图片,那么在用户重启设备后,您就必须让用户返回到系统选择器来查找这些文件。

如需在设备重启后保留对文件的访问权限并提供更出色的用户体验,您的应用可以"获取"系统提供的永久性 URI 访问权限,如以下代码段所示:

kotlin 复制代码
val contentResolver = applicationContext.contentResolver
val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or
        Intent.FLAG_GRANT_WRITE_URI_PERMISSION
contentResolver.takePersistableUriPermission(uri, takeFlags)

如果关联的文档被移动或删除,即使调用了 takePersistableUriPermission(),您的应用也无法保留对该 URI 的访问权限。在此类情况下,您需要再次请求权限才能重新获得对该 URI 的访问权限。

通过URI 获取文件

官方还是建议通过contenResolver 去查询到文件真实的path,或者转化为bitmp,但是Android 很多图片加载框架支持直接丢URI,非图片还是需要自己查询,获得文件的绝对路径。建议先看Android文件系统(01)文件及其目录的基础知识 里面对于转化有一些常见的汇总。

删除文件

如果您获得了文档的 URI,并且该文档的 Document.COLUMN_FLAGS 包含 SUPPORTS_DELETE,您便可以删除该文档。所以,这个玩意还得有权限,其他APP创建的估计直接删除不了。

bash 复制代码
myUri?.let { DocumentsContract.deleteDocument(requireActivity().contentResolver, it) }

检测等效的媒体URL

我们随便打印一个SAF返回的URI。

content://com.android.providers.downloads.documents/document/4

可以看到,这个是fileProvider 提供的,所以我们需要获取到他的真实的地址。官方也提供了方案。

`getMediaUri()`\]([developer.android.com/reference/a...](https://link.juejin.cn?target=https%3A%2F%2Fdeveloper.android.com%2Freference%2Fandroid%2Fprovider%2FMediaStore%3Fhl%3Dzh-cn%23getMediaUri(android.content.Context "https://developer.android.com/reference/android/provider/MediaStore?hl=zh-cn#getMediaUri(android.content.Context"), android.net.Uri)) 方法可提供与给定文档提供程序 URI 等效的媒体 URI。这两个 URI 指的是同一个基础项。使用媒体库 URI,您可以更轻松地[访问共享存储空间中的媒体文件](https://link.juejin.cn?target=https%3A%2F%2Fdeveloper.android.com%2Ftraining%2Fdata-storage%2Fshared%2Fmedia%3Fhl%3Dzh-cn "https://developer.android.com/training/data-storage/shared/media?hl=zh-cn")。 **注意** :此方法不授予任何新权限。您的应用必须具有必要的权限才能访问给定的文档提供程序 URI,例如[打开文档](https://link.juejin.cn?target=https%3A%2F%2Fdeveloper.android.com%2Ftraining%2Fdata-storage%2Fshared%2Fdocuments-files%3Fhl%3Dzh-cn%23open "https://developer.android.com/training/data-storage/shared/documents-files?hl=zh-cn#open")。 `getMediaUri()` 方法支持 `ExternalStorageProvider` URI。在 Android 12(API 级别 31)及更高版本中,此方法还支持 `MediaDocumentsProvider` URI。 #### 打开虚拟文件(这个没有懂,原封不动的复制官方文档的过来了) 在 Android 7.0(API 级别 25)及更高版本中,您的应用可以使用存储访问框架提供的虚拟文件。即使虚拟文件没有二进制文件表示形式,您的应用也可以通过以下方法打开文件中的内容:将虚拟文件强制转换为其他文件类型,或使用 [`ACTION_VIEW`](https://link.juejin.cn?target=https%3A%2F%2Fdeveloper.android.com%2Freference%2Fandroid%2Fcontent%2FIntent%3Fhl%3Dzh-cn%23ACTION_VIEW "https://developer.android.com/reference/android/content/Intent?hl=zh-cn#ACTION_VIEW") intent 操作查看这些文件。 为了打开虚拟文件,您的客户端应用需要包含用于处理此类文件的特殊逻辑。如果您想要获取文件的字节表示形式(例如,为了预览文件),您需要从文档提供器请求其他 MIME 类型。 **注意** :由于应用无法使用 [`openInputStream()`](https://link.juejin.cn?target=https%3A%2F%2Fdeveloper.android.com%2Freference%2Fandroid%2Fcontent%2FContentResolver%3Fhl%3Dzh-cn%23openInputStream(android.net.Uri) "https://developer.android.com/reference/android/content/ContentResolver?hl=zh-cn#openInputStream(android.net.Uri)") 方法直接打开虚拟文件,因此在创建包含 `ACTION_OPEN_DOCUMENT` 或 `ACTION_OPEN_DOCUMENT_TREE` 操作的 intent 时,请勿使用 [`CATEGORY_OPENABLE`](https://link.juejin.cn?target=https%3A%2F%2Fdeveloper.android.com%2Freference%2Fandroid%2Fcontent%2FIntent%3Fhl%3Dzh-cn%23CATEGORY_OPENABLE "https://developer.android.com/reference/android/content/Intent?hl=zh-cn#CATEGORY_OPENABLE") 类别。 在用户做出选择后,请使用结果数据中的 URI 来确定文件是否为虚拟文件,如以下代码段所示: ```kotlin private fun isVirtualFile(uri: Uri): Boolean { if (!DocumentsContract.isDocumentUri(this, uri)) { return false } val cursor: Cursor? = contentResolver.query( uri, arrayOf(DocumentsContract.Document.COLUMN_FLAGS), null, null, null ) val flags: Int = cursor?.use { if (cursor.moveToFirst()) { cursor.getInt(0) } else { 0 } } ?: 0 return flags and DocumentsContract.Document.FLAG_VIRTUAL_DOCUMENT != 0 } ``` 在验证文档为虚拟文件后,您可以将其强制转换为另一种 MIME 类型,例如 `"image/png"`。以下代码段展示了如何查看某个虚拟文件是否可以表示为图片,如果可以,则从该虚拟文件获取输入流: ```kotlin @Throws(IOException::class) private fun getInputStreamForVirtualFile( uri: Uri, mimeTypeFilter: String): InputStream { val openableMimeTypes: Array? = contentResolver.getStreamTypes(uri, mimeTypeFilter) return if (openableMimeTypes?.isNotEmpty() == true) { contentResolver .openTypedAssetFileDescriptor(uri, openableMimeTypes[0], null) .createInputStream() } else { throw FileNotFoundException() } } ``` ## 自定义DocumentsProvider [Google官方文档](https://link.juejin.cn?target=https%3A%2F%2Fdeveloper.android.com%2Fguide%2Ftopics%2Fproviders%2Fcreate-document-provider%3Fhl%3Dzh-cn "https://developer.android.com/guide/topics/providers/create-document-provider?hl=zh-cn") ,[官网提供的自定义DocumentsProvider Demo gitHub地址](https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Fandroid%2Fstorage-samples%2Ftree%2Fmaster%2FStorageProvider "https://github.com/android/storage-samples/tree/master/StorageProvider")。从逻辑上讲,如果一个APP 实现热更新或者可以拿到按照APP的权限,自己写一个docmentsProvider 下载或者按照到手机里面,把启动图标屏蔽掉。感觉也可以自己用,这么更新到也是两个APP,但是通常还是自己基于MediaStore自己查询,做不出来一个生态圈或者系统,还是自己查询自己整相对安全些。 ## 总结 在国内,我们使用这个得概率是不大的,但是架不住可能有其他不是图片、音频、视频等文件的需求,可能需要通过这种方式获取,因为这个要打开一个系统的Document APP,打开别人的APP,还是容易被搞的。

相关推荐
程序员卷卷狗22 分钟前
MySQL 慢查询优化:从定位、分析到索引调优的完整流程
android·mysql·adb
写点啥呢36 分钟前
Android Studio 多语言助手插件:让多语言管理变得简单高效
android·ai·ai编程·多语言
泥嚎泥嚎3 小时前
【Android】给App添加启动画面——SplashScreen
android·java
全栈派森3 小时前
初见 Dart:这门新语言如何让你的 App「动」起来?
android·flutter·ios
q***98523 小时前
图文详述:MySQL的下载、安装、配置、使用
android·mysql·adb
恋猫de小郭3 小时前
Dart 3.10 发布,快来看有什么更新吧
android·前端·flutter
恋猫de小郭5 小时前
Flutter 3.38 发布,快来看看有什么更新吧
android·前端·flutter
百锦再10 小时前
第11章 泛型、trait与生命周期
android·网络·人工智能·python·golang·rust·go
会跑的兔子11 小时前
Android 16 Kotlin协程 第二部分
android·windows·kotlin
键来大师11 小时前
Android15 RK3588 修改默认不锁屏不休眠
android·java·framework·rk3588