资料
Android SAF(Storage Access Framework)是Android操作系统提供的一种文件访问机制,它允许应用程序在设备上访问和共享存储媒体(如照片、视频、音频等)的文件。
SAF提供了一种安全的方式来访问文件,它使用运行时权限模型,允许用户控制应用程序可以访问哪些文件。应用程序可以通过SAF的API请求访问文件的权限,并向用户显示一个文件选择器,以便用户可以选择要访问的文件。
SAF包括两部分:
- 媒体存储(MediaStore):是一个系统级别的媒体数据库,提供了对设备上的媒体文件的访问。应用程序可以使用MediaStore API查询和检索设备上的媒体文件,如照片、视频和音频。
- 文件和文件提供者(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简介
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:
- Intent.ACTION_VIEW:用于打开一个文件或URI,类似于文件的查看操作。
- Intent.ACTION_EDIT:用于编辑一个文件,类似于文件的编辑操作。
- Intent.ACTION_INSERT:用于在一个应用程序中插入一个文件或URI,类似于文件的插入操作。
- Intent.ACTION_DELETE:用于删除一个文件或URI,类似于文件的删除操作。
- Intent.ACTION_CREATE_DOCUMENT:用于在一个应用程序中创建一个新文件或URI,类似于文件的创建操作。
- Intent.ACTION_RENAMING:用于重命名一个文件或URI,类似于文件的重命名操作。
通过这些Intent action,应用程序可以方便地实现文件的打开、编辑、插入、删除、创建和重命名等操作,使得不同应用程序之间的文件交互变得更加便捷。
category
类似于CATEGORY_OPENABLE,Android系统中还有其他的Intent category,用于指示不同类型的操作或意图。以下是一些常见的Intent category:
- Intent.CATEGORY_DEFAULT:用于指示一个默认的Intent,可以用于启动一个应用程序或执行其他操作。
- Intent.CATEGORY_APP_BROWSER:用于指示启动一个浏览器应用程序,用于浏览互联网或本地文件系统。
- Intent.CATEGORY_LAUNCHER:用于指示启动一个主屏幕应用程序,用于启动应用程序并显示应用程序的启动页面。
- Intent.CATEGORY_INFO:用于指示一个信息类的Intent,可以用于获取关于某个应用程序或数据的详细信息。
- Intent.CATEGORY_PROVIDE:用于指示一个提供服务的Intent,可以用于启动一个服务并提供数据或资源给其他应用程序。
- Intent.CATEGORY_OPENABLE:用于指示一个可打开的Intent,可以用于打开一个文件或URI并供应用程序读取或处理。
- Intent.CATEGORY_SELECTED_ORGANIZER:用于指示一个被选中的组织者Intent,可以用于启动一个日历应用程序并显示特定的日程安排。
这些Intent category可以与Intent action结合使用,以实现不同类型的文件操作或启动不同的应用程序。请注意,使用合适的category来指示您的Intent可以提高用户体验并确保您的应用程序与系统的无缝集成。
MIME type
MIME类型是一个互联网标准,它扩展了电子邮件标准,使其能够支持非ASCII字符、二进制格式附件等多种格式的邮件消息。而且,新的MIME类型也在不断添加和更新,无法穷举。
但是,以下是一些常见的MIME类型:
- 文本(Text):用于标准化地表示的文本信息,文本消息可以是多种字符集和或者多种格式的。常见的文本MIME类型包括"text/plain","text/html","text/xml"等。
- 多部分(Multipart):用于连接消息体的多个部分构成一个消息,这些部分可以是不同类型的数据。常见的多部分MIME类型包括"multipart/mixed","multipart/form-data"等。
- 应用程序(Application):用于传输应用程序数据或者二进制数据。常见的应用程序MIME类型包括"application/octet-stream","application/pdf","application/excel"等。
- 消息(Message):用于包装一个E-mail消息。常见的消息MIME类型包括"message/rfc822"等。
- 图像(Image):用于传输静态图片数据。常见的图像MIME类型包括"image/jpeg","image/gif","image/png"等。
- 音频(Audio):用于传输音频或者音声数据。常见的音频MIME类型包括"audio/mpeg","audio/wav"等。
- 视频(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