Android Q 独立存储

Android Q 系统, 对应用存储空间访问进行了限制。


目的

为了让用户更好地控制文件并限制文件混乱,Android Q 改变了应用程序访问设备外部存储上文件的方式

变化

  • 使用更精细粒度、特殊媒体权限取代READ_EXTERNAL_STORAGEWRITE_EXTERNAL_STORAGE 权限
  • 应用程序从外部存储设备访问自己的文件不需要特殊权限

影响

这些更改会影响您的应用保存和访问外部存储上的文件的方式。

android_q_file_access.png

沙箱独立存储应用私有文件

Android Q 为每一个应用在外部存储设备内采用了沙箱独立存储。比如/sdcard, 没有其他应用程序能够直接访问您应用程序沙箱内的文件,因为对于您的应用程序来说这些文件是私有的,您不再需要任何权限去访问和保存您自己外部存储中的文件,这一变化使维护用户文件的隐私变得更容易,并有助于减少应用程序所需的权限数量。

【注意】如果用户手动卸载了应用程序,沙箱内的私有文件也会被清理

通过Context.getExternalFilesDir()返回的位置是在外部存储上存储文件的最佳位置。因为这个位置在所有Android版本上的行为都是一致的。使用此方法时,请传入与要创建或打开的文件类型对应的媒体环境,比如说,访问或保存应用程序私有图片,调用Context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)

分享媒体文件集

如果您的应用程序创建的文件属于用户,用户希望在当应用程序被卸载之后这些文件能够被保留,然后保存到一个公共的媒体集,也称为共享集合。共享集合包括:图片&视频、音乐以及下载

查看其他应用程序文件的权限

在这些共享集合中您的应用程序为了创建和变更它自己的文件不需要请求任何的权限。如果您的应用需要去创建和变更其他应用程序创建的文件,它必须首先要请求适当的权限

  • 图片&视频共享集合中去访问其他应用的文件需要请求READ_MEDIA_IMAGES或者 READ_MEDIA_VIDEO权限,依赖您的应用程序需要去访问的文件类型
  • 音乐共享集合中去访问其他应用的文件需要请求READ_MEDIA_AUDIO权限

【注意】这里没有访问下载共享集合的权限。您的应用程序能够在这个集合里访问它自己的文件,对于访问这个集合里其他应用程序的文件,您必须使用系统文件选择器应用程序允许用户去选择一个文件

【注意】如果您的应用使用了Storage Access Framework(存储访问框架),它不需要去请求这些独立的媒体权限

访问共享集合

请求必要的权限之后,您的应用程序使用MediaStoreAPI 访问这些集合

  • 图片 & 视频 共享集合 —— 使用MediaStore.ImagesMediaStore.Video
  • 音乐 共享集合 —— 使用MediaStore.Audio
  • 下载 共享集合 —— 使用MediaStore.Downloads
    【警告】对于新安装在Android Q上的应用程序,调用getExternalStoragePublicDirectory,只提供对应用程序存储在其隔离存储沙箱中的文件的访问。保持对其他应用程序文件的访问权限,更新应用程序的逻辑,使用MediaStore代替

保留应用程序在共享集合中的文件

默认情况下,当用户卸载了您的应用程序,Android Q 会清理您保存在沙箱中的文件。当您应用程序被卸载时对于保留这些文件,使用Storage Access Framework(存储访问框架)或者保存到一个共享集合。

要保留文件到共享集合,请在相关的MediaStore集合中插入新行,并按以下方式填充其列

  • 最低限度,为DISPLAY_NAMEMIME_TYPE列提供值
  • 可选性,您能够使用PRIMARY_DIRECTORYSECONDARY_DIRECTORY列影响文件放置在磁盘上的位置。
  • 不定义DATA列,这样,平台可以灵活地将文件保存在您的沙箱之外。

插入之后,您能够使用像ContentResolver.openFileDescriptor()读写数据到新创建的文件

如果用户在之后重新安装了您的应用,您的应用程序不会访问这些文件,除非它执行以下操作之一:

  • 请求相应的集合权限
  • 从存储访问框架向用户发送请求。
    这种情况类似于应用程序尝试访问其他应用程序文件的情况。

照片的特殊注意事项

Android Q 添加了几个增强功能,让用户更好的控制在外部存储上他们照片的访问

访问照片的位置

有一些照片在它们Exif元数据中包含了位置信息,允许用户查看照片的拍摄地。因为这些位置信息是敏感的,Android Q 默认情况下重新编辑了这些信息。这个位置信息的限制不同于适用于相机特性的限制。

【注意】如果您的应用程序是用户默认的照片管理应用程序,平台将会自动给予您应用程序访问照片中的位置信息。

如果您的应用程序需要访问一个照片的位置,需要完成如下步骤

1.添加新的权限ACCESS_MEDIA_LOCATION到您应用程序的清单中
2.从您的MediaStore对象,调用setRequireOriginal(),传入照片的URI

展示用户图库照片

如果您的应用程序是一个相机应用,它无法直接访问保存在图片&视频共享集合中的照片,除非它是默认的照片管理应用,要将用户引导到图库应用程序,请使用ACTION_REVIEW intent意图。

处理其他应用程序的文件

这个环节解释应用程序如何与存储在共享集合中的其他应用程序的文件进行交互。

访问其他应用创建的文件

在一个外部存储设备上,访问和读取其他应用程序的已经保存的媒体文件,分为以下几步:

1.请求必要的权限,基于包含您要访问的文件的共享集合。
2.使用ContentResolver对象去查找并打开文件

【注意】ContentResolver类包含了一个新的方法,loadThumbnail(),提供给您应用预览文件。最好先调用loadThumbnail(),以便用户能够查看媒体文件快照无需您的应用程序加载所有文件本身。这个方法还允许更灵活的请求,比如请求特殊维度和取消请求的能力。

写入其他应用创建的文件

通过保存文件到共享集合,您的应用程序将成为该文件的所有者。通常情况下,您的应用程序能够写入到共享集合的文件,除非您是这个文件的所有者。如果您的应用程序有正确的角色分配给它,您也能够写入到其他应用程序自己的文件:

  • 如果您的应用程序是用户默认的照片管理应用,你能够变更其他应用保存在图片&视频共享集合里的图片文件
  • 如果您的应用程序是用户默认的音乐应用,您能够变更其他应用程序保存在音乐共享集合里的音频文件

【注意】您的应用程序应该保持功能,无论它是默认的照片管理器或音乐应用程序。

要变更其他应用保存在外部存储设备中原始的媒体文件,完成如下步骤之一:

  • 通过角色指南检查您的应用是否是默认的图片管理应用或者默认的音乐管理应用
  • 使用ContentResolver对象查找文件并变更它,当执行编辑/变更操作,捕获RecoverableSecurityException以便您能够请求用户授予您写入的特殊项

访问特殊文件

在一些使用情况中,您的应用或许需要打开或者创建权限,它无需要有访问权限:

  • 在照片编辑应用程序中,打开一张图片。
  • 在商业产品应用中,保存文本文档到本地让用户选择

允许用户去选择特殊文件打开,或者选择特殊位置保存文件,对于这些解决方案,使用存储访问框架

配套的app文件共享

如果您管理一套需要互相访问彼此文件的应用程序,使用content://URIs,我们已经建议作为安全性最佳实践。

更多信息,请查阅文档如何设置文件共享

升级设备上以前安装的应用程序的兼容性模式

限制外部存储中的文件访问应用于应用程序target目标版本是Android Q,或者运行在Android Q设备上最新安装的应用

当满足如下条件时,系统会将您的应用文件访问设置为兼容模式:

  • target version < = Android 9 (API 28)
  • 您安装在设备上的应用从[ Android 9 ] –> 升级到—> [ Android Q ]

当您的应用程序进入兼容模式,以下文件访问行为适用:

  • 您的应用程序能够访问MediaStore集合内所有的文件存储,甚至是您的应用程序没有创建的文件。
  • 面向用户的是允许或拒绝您的应用程序访问整个外部存储的权限,而不是像图片&视频或者音乐这样单独的共享集合

这个兼容模式直到您首次卸载您的应用程序仍然有效

【注意】如果您的应用程序之后在同一设备重新安装,兼容模式不会重新被激活

识别特殊的外部存储设备

SDK < = Android 9 (API 28) —— 在所有的外部存储设备上的所有文件都显示在当个"external"(外部)卷标下

Android Q, 给每一个外部存储设备一个不一样的卷标,这意味着系统帮助您有效地组织和索引内容,它可以让您控制新内容的存储位置

【注意】主外部存储设备始终使用卷名“external”。

对于外部存储内唯一地标识特殊文件,您必须使用卷标名和ID一起。比如说,一个文件在主存储设备将是content://media/external/images/media/12,但是二级存储设备上名为FA23-3E92的相应文件将是content://media/FA23-3E92/images/media/12

您能够在特殊的卷上,通过传入卷标名到特殊媒体集访问存储文件。比如说MediaStore.Images.getContentUri()

获取外部存储设备列表

要获取所有当前可用的卷标名称列表,调用call MediaStore.getAllVolumeNames()

1
val volumeNames: Set<String> = MediaStore.getAllVolumeNames(context)

设置一个虚拟的外部存储设备

在没有可移动外部存储的设备上,使用以下命令启用虚拟磁盘以进行测试:

1
adb shell sm set-virtual-disk true

测试行为变化

为了帮助您使应用程序与此新行为变化兼容,平台提供了多种方法来调整与更改相关的多个参数。

切换行为更改

要在Android Q Beta 1中启用此行为更改,请在终端窗口中执行以下命令:

1
adb shell sm set-isolated-storage on

运行此命令后,设备将重新启动。 如果没有,请等一下再尝试再次运行该命令。

要确认行为变化是否已生效,请使用以下命令:

1
adb shell getprop sys.isolated_storage_snapshot

测试兼容模式行为

测试应用程序时,可以通过在终端窗口中运行以下命令来启用外部文件存储访问的兼容性模式:

1
adb shell cmd appops set your-package-name android:legacy_storage allow

要禁用兼容模式,请在Android Q上卸载并重新安装您的应用,或在终端窗口中运行以下命令:

1
adb shell cmd appops set your-package-name android:legacy_storage default

将外部存储作为文件管理器浏览

获得对外部存储中的目录的广泛访问,就像文件管理器应用程序可能做的那样,使用ACTION_OPEN_DOCUMENT_TREE意图,比如说,在GitHub上查看android-DirectorySelection示例。

【警告】在Android Q中不推荐使用StorageVolume类中的createAccessIntent() 方法,因此不应使用此方法浏览外部存储设备。 如果您这样做,运行Android Q设备的用户将无法在您的应用中查看保存在外部存储中的文件。

测试示例

文件存储

测试 1 —— 默认方式 getExternalStorageDirectory

定义一个Activity——FileStorageActivity,然后定义如下读写方法,通过界面上两个按钮触发,进行默认getExternalStorageDirectory路径下文件的读写测试,运行在Android Q 设备也无需在清单文件定义READ_EXTERNAL_STORAGEWRITE_EXTERNAL_STORAGE权限

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private fun writeMethod() {
val file = File(Environment.getExternalStorageDirectory(), "测试Android Q文件.txt");
Log.d(tag, "file.exists():${file.exists()} , file.getAbsolutePath(): ${file.absolutePath}");
if (file.exists()) {
file.delete();
file.createNewFile();
}
Toast.makeText(this@FileStorageActivity, "SD卡目录下创建文件成功...", Toast.LENGTH_LONG).show();
val fw = FileWriter(file);
fw.write("我是测试Android Q文件写入的内容");
fw.close()
Toast.makeText(this@FileStorageActivity, "SD卡写入内容完成...", Toast.LENGTH_LONG).show()
Log.d(tag, "SD卡写入内容完成...");
}

private fun readMethod() {
val fr = FileReader(Environment.getExternalStorageDirectory().path+"/测试Android Q文件.txt");
val r = BufferedReader(fr);
val result = r.readLine();
Log.d(tag, "SD卡文件里面的内容:$result")
}

输出日志:

1
2
3
2019-03-17 14:06:46.117 31140-31140/com.xw.androidqtest D/FileStorageActivity: file.exists():false , file.getAbsolutePath(): /storage/emulated/0/测试Android Q文件.txt
2019-03-17 14:06:46.130 31140-31140/com.xw.androidqtest D/FileStorageActivity: SD卡写入内容完成...
2019-03-17 14:06:50.346 31140-31140/com.xw.androidqtest D/FileStorageActivity: SD卡文件里面的内容:我是测试Android Q文件写入的内容

查看/sdcard根下的文件,发现没有该文件,被系统自动存放在如下沙箱中

android-q-beta-file-test-1.png

测试 2 —— 推荐方式 Context.getExternalFilesDir()

测试将文件放入文档目录,传入Environment.DIRECTORY_DOCUMENTSgetExternalFilesDir()方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
private fun writeMethod() {
val context: Context = this@FileStorageActivity
val file = File(context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS), "测试Android Q文件.txt");
Log.d(tag, "file.exists():${file.exists()} , file.getAbsolutePath(): ${file.absolutePath}");
if (file.exists()) {
file.delete();
file.createNewFile();
}
Toast.makeText(this@FileStorageActivity, "SD卡目录下创建文件成功...", Toast.LENGTH_LONG).show();
val fw = FileWriter(file);
fw.write("我是测试Android Q文件写入的内容");
fw.close()
Toast.makeText(this@FileStorageActivity, "SD卡写入内容完成...", Toast.LENGTH_LONG).show()
Log.d(tag, "SD卡写入内容完成...");
}

private fun readMethod() {
val context: Context = this@FileStorageActivity
val fr = FileReader(context.getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS).path + "/测试Android Q文件.txt");
val r = BufferedReader(fr);
val result = r.readLine();
Log.d(tag, "SD卡文件里面的内容:$result")
}

输出日志:

1
2
3
2019-03-17 14:27:23.423 9511-9511/com.xw.androidqtest D/FileStorageActivity: file.exists():false , file.getAbsolutePath(): /storage/emulated/0/Android/data/com.xw.androidqtest/files/Documents/测试Android Q文件.txt
2019-03-17 14:27:23.437 9511-9511/com.xw.androidqtest D/FileStorageActivity: SD卡写入内容完成...
2019-03-17 14:27:27.696 9511-9511/com.xw.androidqtest D/FileStorageActivity: SD卡文件里面的内容:我是测试Android Q文件写入的内容

文件所在位置:

android-q-beta-file-test-2.png

更多示例,请参照以上内容官方文档, 自行测试


2019-03-26 更新:

得到反馈,测试 1 未能在android-10模拟器上复现成功,由于上面的测试我是在pixel 2真机上测试的,对此如下又在模拟器上进行了测试 (本次真机也同时测试了一遍)。

由于没有添加读写权限,在模拟器上运行抛出了异常 —-> (真机没有任何异常)

android-q-beta-file-test-3.png

android-q-beta-file-test-4.png

于是添加读写权限,并动态授权,模拟器上运行无异常,但是确实写入的文件未能自动放入/Android/sandbox/com.xw.androidqtest目录下,位置没变还是在/storage/emulated/0/根目录下

android-q-beta-file-test-5.png

针对以上的出现的问题,猜想应该是模拟器和真机之间的差异,大家怎么看? 欢迎大家在评论处留言讨论


本文参考官方文档进行简单翻译及示例总结,感谢您的阅读。如有不足,欢迎指正

if (本文对您有用) { Pay (请随意¥打赏) } else { Commit feedback (底部评论区提交建议、反馈) } 感谢支持!