为了使用户能够更好地控制自己的文件,并限制文件混乱,AndroidQ修改了外部存储权限。这种外部存储的新特性被称为分区存储(Scoped Storage)。官方翻译称为分区储存,也有称为沙盒模式。
外部存储空间被分为两部分
1.App-specific directory 沙盒目录
2.Public Directory 公共目录
公共目录包括:多媒体公共目录(photos, images, videos, audio)和下载文件目录(Downloads)
Android Q规定了APP有两种外部存储空间视图模式:Legacy View、Filtered View。
在AndroidQ上,target SDK大于或等于29的APP默认被赋予Filtered View。APP可以在AndroidManifest.xml中设置requestLegacyExternalStorage来修改外部存储空间视图模式,true为Legacy View,false为Filtered View。
//默认是false,也就是Filtered View
android:requestLegacyExternalStorage="true"
可以通过Environment.isExternalStorageLegacy()方法判断运行模式。
AndroidQ除了划分外部储存空间访问权限外,还增加了媒体数据限制,默认删除图片中位置信息,如需获取需要在清单文件中注册 ACCESS_MEDIA_LOCATION
// Get location data from the ExifInterface class.
val photoUri = MediaStore.setRequireOriginal(photoUri)
contentResolver.openInputStream(photoUri).use { stream ->
ExifInterface(stream).run {
// If lat/long is null, fall back to the coordinates (0, 0).
val latLong = ?: doubleArrayOf(0.0, 0.0)
}
}
同时通过MediaProvide获得的data字段将不再可靠,增加了文件的Pending状态,增加了Media.RELATIVE_PATH相对路径字段等等,稍后将详细介绍。
Scoped Storage对于通过文件路径操作App-specific(以下简称沙盒)之外的目录以及APP之间的数据数据共享都产生很大的影响。请参考以下事项
2.1 无法新建文件
问题原因:直接使用沙盒目录以外的路径新建文件。
原因分析:Q之前的应用,可以通过Environment.getExternalStorageDirectory()等路径操作外部文件,而在Android Q上,APP只允许在沙盒目录下通过路径创建文件,也就是Context.getExternalFilesDir()目录下,可以通过File的方式操作。
解决办法:
2.2 无法访问文件
问题原因:1.直接使用路径访问沙盒目录以外的文件 2.使用MediaStore接口访问非多媒体文件
原因分析:1.AndroidQ默认只允许访问沙盒目录下的文件,也就是说只有Context.getExternalFilesDir()目录下的文件,可以通过File的方式访问;2.在AndroidQ上MediaStore只能访问公共目录下的多媒体文件
解决办法:
注意: 通过MediaStore接口查询到的DATA将在AndroidQ上开始废弃,不应该用它来访问文件或者判断文件是否存在;从MediaStore接口或者SAF获取到文件Uri后,请利用Uri打开FD或者输入输出流,而不要再去转换成文件路径访问。
2.3 无法修改文件
问题原因1:直接使用路径访问沙盒目录以外的文件
问题分析1:同 2.2
解决办法1:同 2.2
问题原因2:使用MediaStore接口获取到多媒体文件的Uri后,要修改文件的FD或者OutputStream,失败
问题分析2:在AndroidQ上,修改和删除其他App创建的多媒体文件时,需要用户授权
解决办法2:从MediaStore接口获取到其他APP创建的多媒体文件Uri后,打开OutputStream或FD时,需要catch RecoverableSecurityException,由MediaProvider弹出弹框给用户选择是否允许APP修改或删除,授权成功后才能删除,请参考3.2.6;
问题原因3:根据SAF获取到文件或者目录的Uri,修改或者删除失败
问题分析3:使用SAF获取的Uri,需要检查Uri权限的时效性,设备重启或者用户手动删除权限,则会失败
解决办法3:使用SAF获取到文件或目录的Uri时,用户已经授权读写,可以直接使用,但要注意Uri权限的时效,请参见3.3.6
2.4 无法分享文件
问题原因:使用了file://URI分享文件
问题分析:该文件保存在APP的沙盒目录下,其他APP没有权限访问
解决办法:参考3.4,使用FileProvider适配,将file://类型的Uri转换成content://类型的
2.5 应用卸载后文件删除
问题原因:将文件保存在APP的沙盒目录下
问题分析:该文件保存在app的沙盒目录下,其他APP没有权限访问
解决办法:APP应该将想要保存的文件通过MediaStore接口保存在公共目录下。默认会将非多媒体文件保存在Downloads目录下。如果APP想要卸载的时候保存沙盒目录下的文件,可以在AndroidManifest.xml中声明android:hasFragileUserData=“true”,这样在APP卸载时就会有弹出框提示用户是否保留应用数据。
2.6 OAT升级问题
问题原因:OAT升级以后,APP被卸载,重新安装后无法访问到APP数据
问题分析:分区储存(Scoped Storage)特性只针对AndroidQ上新安装的APP生效。设备从AndroidQ之前的版本升级到AndroidQ,这时候已安装的APP将获得Legacy View视图。而卸载后安装,APP获得Filtered View视图,无法通过路径访问到旧的数据,从而导致该问题
解决办法:APP主动开启沙盒模式之前,一定要做好历史文件的迁移工资,将之前通过File路径方式保存在沙盒目录和公共目录以外的文件,迁移到沙盒目录和公共目录集合。
Scoped Storage不会强制生效,可以自己决定是否开启新特性。建议先不主动开启,安装新特性的要求,做好沙盒目录和公共文件的存储方式,将老数据迁移,确定没有问题以后再开启。
谷歌适配文档
https://developer.android.google.cn/preview/privacy/scoped-storage
无需任何权限,可以直接通过File的方式操作App-specific目录下的文件。
App-specific目录 | 接口(所有存储设备) | 接口(Primary External Storage) |
---|---|---|
Media | getExternalMediaDirs() | NA |
Obb | getObbDirs() | getObbDir() |
Cache | getExternalCacheDirs() | getExternalCacheDir() |
Data | getExternalFilesDirs(String type) | getExternalFilesDir(String type) |
创建文件
val documents = getExternalFilesDirs(Environment.DIRECTORY_DOCUMENTS)
if (documents.isNotEmpty()) {
val dir = documents[0]
var os: FileOutputStream? = null
try {
val newFile = File(dir.absolutePath, "MyDocument")
os = FileOutputStream(newFile)
os.write("create a file".toByteArray(Charsets.UTF_8))
os.flush()
Log.d(TAG, "创建成功")
dir.listFiles()?.forEach { file: File? ->
if (file != null) {
Log.d(TAG, "Documents 目录下的文件名:" + file.name)
}
}
} catch (e: IOException) {
e.printStackTrace()
Log.d(TAG, "创建失败")
} finally {
closeIO(os)
}
}
Goole官方文档https://developer.android.google.cn/reference/android/provider/MediaStore
MediaStore提供下列Uri,可以用MediaProvider查询对应的Uri数据
在AndroidQ上,所有的外部存储设备都会被命令,即Volume Name。MediaStore可以通过Volume Name 获取对应的Uri
MediaStore.getExternalVolumeNames(this).forEach { volumeName ->
Log.d(TAG, "uri:${MediaStore.Images.Media.getContentUri(volumeName)}")
}
MediaProvider
通过ContentResolver.insert(uri)方法中的uri确定存放路径。Uri路径格式:
content:// media/
,下表对应Uri路径为相对路径
通过ContentResolver的insert方法,将多媒体文件保存在公共集合目录,不同的Uri对应不同的公共目录,详见3.2.1;其中RELATIVE_PATH的一级目录必须是Uri对应的一级目录,二级目录或者二级以上的目录,可以随意的创建和指定
val values = ContentValues()
values.put(MediaStore.Images.Media.DISPLAY_NAME, "Image.png")
values.put(MediaStore.Images.Media.DESCRIPTION, "This is an image")
values.put(MediaStore.Images.Media.MIME_TYPE, "image/png")
values.put(MediaStore.Images.Media.TITLE, "Image.png")
values.put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/sl")
val external = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
val insertUri = contentResolver.insert(external, values)
var os: OutputStream? = null
try {
if (insertUri != null) {
os = contentResolver.openOutputStream(insertUri)
}
if (os != null) {
val bitmap = Bitmap.createBitmap(400, 400, Bitmap.Config.ARGB_8888)
//创建了一个红色的图片
val canvas = Canvas(bitmap)
canvas.drawColor(Color.RED)
bitmap.compress(Bitmap.CompressFormat.PNG, 90, os)
Log.d(TAG, "创建Bitmap成功")
}
} catch (e: IOException) {
Log.d(TAG, "创建失败:${e.message}")
} finally {
closeIO(os)
}
通过ContentResolver.query接口查询文件Uri
val external = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
val selection = "${MediaStore.Images.Media.DISPLAY_NAME}=?"
val args = arrayOf("Image.png")
val projection = arrayOf(MediaStore.Images.Media._ID)
val cursor = contentResolver.query(external, projection, selection, args, null)
if (cursor != null && cursor.moveToFirst()) {
queryUri = ContentUris.withAppendedId(external, cursor.getLong(0))
Log.d(TAG, "查询成功,Uri路径$queryUri")
cursor.close()
}
通过ContentResolver.query查询得到的Uri之后,可以通过contentResolver.openFileDescriptor,根据文件描述符选择对应的打开方式。"r"表示读,"w"表示写
var pfd: ParcelFileDescriptor? = null
try {
pfd = contentResolver.openFileDescriptor(queryUri!!, "r")
if (pfd != null) {
val bitmap = BitmapFactory.decodeFileDescriptor(pfd.fileDescriptor)
imageIv.setImageBitmap(bitmap)
}
} catch (e: IOException) {
e.printStackTrace()
} finally {
closeIO(pfd)
}
或者访问Thumbnail,通过ContentResolver.loadThumbnail,传入size,返回指定大小的缩略图
getContentResolver().loadThumbnail(uri,Size(640, 480), null)
Native访问文件
String fileOpenMode = "r";
ParcelFileDescriptor parcelFd = resolver.openFileDescriptor(uri, fileOpenMode); if (parcelFd != null) {
int fd = parcelFd.detachFd();
// Pass the integer value "fd" into your native code. Remember to call
// close(2) on the file descriptor when you're done using it.
使用MediaStore修改其他APP创新建的多媒体文件,需要注意一下两点
READ_EXTERNAL_STORAGE
权限catch RecoverableSecurityException
,由MediaProvider弹出弹框给用户选择是否允许APP修改或删除图片/视频/音频文件。用户操作的结果,将通过onActivityResult回调返回到APP。如果用户允许,APP将获得该Uri的修改权限,直到设备重启。 //首先判断是否有READ_EXTERNAL_STORAGE权限
if (checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED) {
//这里的img 是我相册里的,如果运行demo,可以换成你自己的
val queryUri = queryUri("IMG_20191106_223612.jpg")
var os: OutputStream? = null
try {
queryUri?.let { uri ->
os = contentResolver.openOutputStream(uri)
}
} catch (e: IOException) {
e.printStackTrace()
} catch (e1: RecoverableSecurityException) {
e1.printStackTrace()
//捕获 RecoverableSecurityException异常,发起请求
try {
startIntentSenderForResult(
e1.userAction.actionIntent.intentSender,
SENDER_REQUEST_CODE,
null,
0,
0,
0
)
} catch (e2: IntentSender.SendIntentException) {
e2.printStackTrace()
}
}
}
删除自己创建的多媒体文件不需要权限,其他APP创建的,与修改类型,需要用户授权,同3.2.5
getContentResolver().delete(imageUri, null, null);
Android 4.4(API 级别 19)引入了存储访问框架Storage Access Framework (SAF)。借助 SAF,用户可轻松在其所有首选文档存储提供程序中浏览并打开文档、图像及其他文件。用户可通过易用的标准界面,以统一方式在所有应用和提供程序中浏览文件,以及访问最近使用的文件。
SAF google官方文档 https://developer.android.google.cn/guide/topics/providers/document-provider
SAF本地存储服务的围绕 DocumentsProvider实现的,通过Intent调用DocumentUI,由用户在DocumentUI上选择要创建、授权的文件以及目录等,授权成功后再onActivityResult回调用拿到指定的Uri,根据这个Uri可进行读写等操作,这时候已经赋予文件读写权限,不需要再动态申请权限
通过Intent.ACTION_OPEN_DOCUMENT调文件选择界面,用户选择并返回一个或多个现有文档,所有选定的文档均具有持久的读写权限授予,直至设备重启。如果重启后仍然需要参考3.3.6
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
// Filter to only show results that can be "opened", such as a
// file (as opposed to a list of contacts or timezones)
addCategory(Intent.CATEGORY_OPENABLE)
// Filter to show only images, using the image MIME data type.
// If one wanted to search for ogg vorbis files, the type would be "audio/ogg".
// To search for all documents available via installed storage providers,
// it would be "*/*".
type = "image/*"
}
startActivityForResult(intent, REQUEST_CODE_FOR_SINGLE_FILE)
可通过使用 Intent.ACTION_CREATE_DOCUMENT,可以提供 MIME 类型和文件名,但最终结果由用户决定
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
// Filter to only show results that can be "opened", such as
// a file (as opposed to a list of contacts or timezones).
addCategory(Intent.CATEGORY_OPENABLE)
// Create a file with the requested MIME type.
type = mimeType
putExtra(Intent.EXTRA_TITLE, fileName)
}
startActivityForResult(intent, WRITE_REQUEST_CODE)
如果您获得了文档的 URI,并且文档的 Document.COLUMN_FLAGS 包含 FLAG_SUPPORTS_DELETE,则便可删除该文档。这个的包含我理解为获取到的Document.COLUMN_FLAGS>FLAG_SUPPORTS_DELETE,个人理解,有问题欢迎指正
val deleted = DocumentsContract.deleteDocument(contentResolver, uri)
这里的Uri,是通过用户选择授权的Uri,通过Uri获取ParcelFileDescriptor或者打开OutputStream进行修改
try {
contentResolver.openFileDescriptor(uri, "w")?.use {
// use{} lets the document provider know you're done by automatically closing the stream
FileOutputStream(it.fileDescriptor).use {
it.write(
("Overwritten by MyCloud at ${System.currentTimeMillis()}\n").toByteArray()
)
}
}
} catch (e: FileNotFoundException) {
e.printStackTrace()
} catch (e: IOException) {
e.printStackTrace()
}
使 用ACTION_OPEN_DOCUMENT_TREE的intent,拉起DocumentUI让用户主动授权的方式 获取,获得用户主动授权之后,应用就可以临时获得该目录下面的所有文件和目录的读写 权限,可以通过DocumentFile操作目录和其下的文件
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
startActivityForResult(intent, REQUEST_CODE_FOR_DOCUMENT_DIR)
...
if (requestCode == REQUEST_CODE_FOR_DOCUMENT_DIR) {
//选择目录
if (resultCode == Activity.RESULT_OK) {
val treeUri = data?.data
if (treeUri != null) {
//implementation 'androidx.documentfile:documentfile:1.0.1'
val root = DocumentFile.fromTreeUri(this, treeUri)
root?.listFiles()?.forEach { it ->
Log.d(TAG, "目录下文件名称:${it.name}")
}
}
}
通过用户授权的Uri,就默认获取了该Uri的读写权限,直到设备重启。可以通过保存权限来永久的获取该权限,不需要每次重启手机之后又要重新让用户主动授权
参考代码:
if (resultCode == Activity.RESULT_OK) {
//创建文档
val uri = data?.data
if (uri != null) {
val sp = getSharedPreferences("DirPermission", Context.MODE_PRIVATE)
sp.edit {
this.putString("uri", uri.toString())
this.commit()
}
...
}
}
val sp = getSharedPreferences("DirPermission", Context.MODE_PRIVATE)
val uriString = sp.getString("uri", "")
if (!uriString.isNullOrEmpty()) {
try {
val treeUri = Uri.parse(uriString)
val takeFlags: Int = intent.flags and
(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
// Check for the freshest data.
contentResolver.takePersistableUriPermission(treeUri, takeFlags)
Log.d(TAG, "已经获得永久访问权限")
val root = DocumentFile.fromTreeUri(this, treeUri)
root?.listFiles()?.forEach { it ->
Log.d(TAG, "目录下文件名称:${it.name}")
}
} catch (e: SecurityException) {
Log.d(TAG, "uri 权限失效,调用目录获取")
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
startActivityForResult(intent, REQUEST_CODE_FOR_DOCUMENT_DIR)
}
} else {
Log.d(TAG, "没有永久访问权限,调用目录获取")
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
startActivityForResult(intent, REQUEST_CODE_FOR_DOCUMENT_DIR)
}
用户可以通过APP设置界面主动清除储存空间权限
Android默认提供的ExternalStorageProvider、DownloadStorageProivder和MediaDocumentsProvider会显示在SAF调起的DocumentUI界面中。ExternalStorageProvider展示了所有外部存储设备的所有目录及文件,包括App-specific目录,所以App-specific目录下的文件也可以通过SAF授权给其他APP。APP也可以自定义DocumentsProvider来提供向外授权。
自定义的DocumentsProivder将作为第三方DocumentsProvider展示在SAF调起的界面中。DocumentsProvider的使用方法请参考官方文档。
DocumentsProvider相关的Google官方文档:
https://developer.android.google.cn/reference/kotlin/android/provider/DocumentsProvider
APP可以选择以下的方式,将自身App-specific目录下的文件分享给其他APP读写
FileProvider相关的Google官方文档:
https://developer.android.google.cn/reference/androidx/core/content/FileProvider
https://developer.android.com/training/secure-file-sharing/setup-sharing
FileProvider属于在Android7.0的行为变更,各种帖子很多,这里就不详细介绍了。
为了避免和已有的三方库冲突,建议采用extends FileProvider的方式
public class TakePhotoProvider extends FileProvider {
}
...
APP可以实现自定义ContentProvider来向外提供APP私有文件。这种方式十分适用于内部文件分享,不希望有UI交互的情况。
ContentProvider相关的Google官方文档:
https://developer.android.google.cn/guide/topics/providers/content-providers
详见3.3.7
Android Q上,默认情况下APP不能获取图片的地理位置信息。如果APP需要访问图片上的Exif Metadata,可以采用以下方式:
// Get location data from the ExifInterface class.
val photoUri = MediaStore.setRequireOriginal(photoUri)
contentResolver.openInputStream(photoUri).use { stream ->
ExifInterface(stream).run {
// If lat/long is null, fall back to the coordinates (0, 0).
val latLong = ?: doubleArrayOf(0.0, 0.0)
}
}
在Android Q中DATA(即_data)字段开始废弃,不再表示文件的真实路径。读写文件或判断文件是否存在,不应该使用DATA字段,而要使用openFileDescriptor。
同时也无法直接使用路径访问公共目录的文件。
通过MediaStore.Files接口访问文件时,只返回多媒体文件(图片、视频、音频)。其他类型文件,例如PDF文件,无法访问到。
AndroidQ上,MediaStore中添加MediaStore.Images.Media.IS_PENDING flag,用来表示文件的Pending状态,0是可见,其他不可见,
如果没有设置setIncludePending接口,查询不到设置IS_PENDIN flag的文件,可以用来下载,或者生产截图等等
ContentValues values = new ContentValues();
values.put(MediaStore.Images.Media.DISPLAY_NAME, "myImage.PNG");
values.put(MediaStore.Images.Media.MIME_TYPE, "image/png");
values.put(MediaStore.Images.Media.IS_PENDING, 1);
ContentResolver resolver = context.getContentResolver();
Uri uri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
Uri item = resolver.insert(uri, values);
try {
ParcelFileDescriptor pfd = resolver.openFileDescriptor(item, "w", null);
// write data into the pending image.
} catch (IOException e) {
LogUtil.log("write image fail");
}
// clear IS_PENDING flag after writing finished.
values.clear();
values.put(MediaStore.Images.Media.IS_PENDING, 0);
resolver.update(item, values, null, null);
AndroidQ中,通过MediaSore将多媒体没见储存在公共目录下,除了默认的一级目录,还可以指定次级目录,对应的一级目录详见3.2.1表二
val values = ContentValues()
//Pictures为一级目录对应Environment.DIRECTORY_PICTURES,sl为二级目录
values.put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/sl")
val external = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
val insertUri = contentResolver.insert(external, values)
values.clear()
//DCIM为一级目录对应Environment.DIRECTORY_DCIM,sl为二级目录,sl2为三级目录
values.put(MediaStore.Images.Media.RELATIVE_PATH, "DCIM/sl/sl2")
contentResolver.update(insertUri,values,null,null)
sample Github地址
其中AndroidQActivity.kt 中有MediaStore的操作示例
StorageAccessFrameworkActivity.kt 有SAF的操作示例
uTakePhoto是Android上一行调用拍照/选择图片,裁剪,压缩,适配androidQ,里面有丰富的处理图片的适配解决方案,欢迎start
特别鸣谢
oppo AndroidQ适配指导
华为 AndroidQ适配指导
其他项适配方案可参考 oppo AndroidQ适配指导