背景介绍
android 10已经推出来一段时间了,因为用户反馈,公司的demo在android10手机上有问题,适配的问题便被提上了日程。首先先给出官方文档的地址:
外部存储访问权限范围限定为应用文件和媒体
。
本文章主要参考OPPO对androidQ的适配指南,并结合华为给出的适配指南及网络上的优秀文章整理而来。
哪些应用需要适配
对于以 Android 10 及更高版本为目标平台的新安装应用,需要进行作用域存储适配。
这里需要介绍一下android 10的两种运行模式:Filtered View和Legacy View(兼容模式)。
1、Filtered View:App可以直接访问App-specific目录,但不能直接访问App-specific外的文件。访问公共目录或其他APP的App-specific目录,只能通过MediaStore、SAF、或者其他APP 提供的ContentProvider、FileProvider等访问。
2、Legacy View:兼容模式。与Android Q以前一样,申请权限后App可访问外部存储,拥有完整的访问权限。
默认情况下在Android Q上,target SDK大于或等于29的APP默认被赋予Filtered View,反之则默认被赋予Legacy View。
作用域存储只对Android Q上新安装的APP生效。
设备从Android Q之前的版本升级到Android Q,已安装的APP获得Legacy View视图。这些APP如果直接通过路径的方式将文件保存到了外部存储上,例如外部存储的根目录,那么APP被卸载后重新安装,新的APP获得Filtered View视图,无法直接通过路径访问到旧数据,导致数据丢失。
APP可以在AndroidManifest.xml中设置新属性requestLegacyExternalStorage来修改外部存储空间视图模式,true为Legacy View,false为Filtered View。可以使用Environment.isExternalStorageLegacy()这个API来检查APP的运行模式。
设置如下:
<application
...
android:requestLegacyExternalStorage="false"
android:label="@string/app_name">
...
</application>
权限适配
Android Q仍然使用
READ_EXTERNAL_STORAGE
和
WRITE_EXTERNAL_STORAGE
作为面向用户的存储相关运行时权限。
在作用域存储新特性中,外部存储空间被分为两部分:
1、公共目录
包括:Downloads、Documents、Pictures 、DCIM、Movies、Music、Ringtones等。
公共目录下的文件在APP卸载后,不会删除。
APP可以通过SAF(System Access Framework)、MediaStore接口访问其中的文件。
2、App-specific目录
APP卸载后,数据会清除。
APP的私密目录,APP访问自己的App-specific目录时无需任何权限。
我们的应用访问私用目录(App-specific目录)及向媒体库贡献的图片、音频或视频,将会自动拥有其读写权限,不需要额外申请READ_EXTERNAL_STORAGE和WRITE_EXTERNAL_STORAGE权限。
读取其他应用程序向媒体库贡献的图片、音频或视频,则必须要申请
READ_EXTERNAL_STORAGE
权限才行。
WRITE_EXTERNAL_STORAGE
权限将会在未来的Android版本中废弃。(此段引自:
Android 10适配要点,作用域存储
)
注:之前文档上的READ_MEDIA_AUDIO、READ_MEDIA_IMAGES、READ_MEDIA_VIDEO已经被删除了。
读取及写入私有目录下的文件
无需任何权限。
APP即可直接使用
文件路径
来读写自身App-specific目录下的文件。
获取App-specific目录路径的接口如下表所示:
App-specific目录 | 接口(所有存储设备) | 接口(Primary External Storage) |
---|---|---|
Media | getExternalMediaDirs() | NA |
Obb | getObbDirs() | getObbDir() |
Cache | getExternalCacheDirs() | getExternalCacheDir() |
Data | getExternalFilesDirs(String type) | getExternalFilesDir(String type) |
需要说明的一点就是,getExternalFilesDirs(String type)中的type,可以自己指定,比如String type = “chat”, 当然也可以用Environment.DIRECTORY_PICTURES等作为type。
新建并写入文件为例:
// set "chat" as subDir
final File[] dirs = getExternalFilesDirs("chat");
File primaryDir = null;
if (dirs != null && dirs.length > 0) {
primaryDir = dirs[0];
}
if (primaryDir == null) {
return;
}
File newFile = new File(primaryDir.getAbsolutePath(), "MyTestChat");
OutputStream fileOS = null;
try {
fileOS = new FileOutputStream(newFile);
if (fileOS != null) {
fileOS.write("file is created".getBytes(StandardCharsets.UTF_8));
fileOS.flush();
}
} catch (IOException e) {
LogUtil.log("create file fail");
} finally {
try {
if (fileOS != null) {
fileOS.close();
}
} catch (IOException e1) {
LogUtil.log("close stream fail");
}
}
读取公共区域的文件,并写入App-specific:
File imgFile = this.getExternalFilesDir("image");
if (!imgFile.exists()){
imgFile.mkdir();
}
try {
File file = new File(imgFile.getAbsolutePath() + File.separator +
System.currentTimeMillis() + ".jpg");
// 使用openInputStream(uri)方法获取字节输入流
InputStream fileInputStream = getContentResolver().openInputStream(uri);
FileOutputStream fileOutputStream = new FileOutputStream(file);
byte[] buffer = new byte[1024];
int byteRead;
while (-1 != (byteRead = fileInputStream.read(buffer))) {
fileOutputStream.write(buffer, 0, byteRead);
}
fileInputStream.close();
fileOutputStream.flush();
fileOutputStream.close();
// 文件可用新路径 file.getAbsolutePath()
} catch (Exception e) {
e.printStackTrace();
}
访问及写入公共目录下的多媒体文件
APP通过MediaStore访问文件所需要的权限:
无权限 | READ_EXTERNAL | |
---|---|---|
Audio | 可读写APP自己创建的文件,但不可直接使用路径访问 | 可以读其他APP创建的媒体类文件,删改操作需要用户授权 |
Image | ||
Video | ||
File | 不可读写其他APP创建的非媒体类文件 | |
Downloads |
APP无法直接访问公共目录下的文件。MediaStore为APP提供了访问公共目录下媒体文件的接口。APP在有适当权限时,可以通过MediaStore 查询到公共目录文件的Uri,然后通过Uri读写文件。
多媒体文件读取
通过ContentProvider查询文件,获得需要读取的文件Uri:
public static List < Uri > loadPhotoFiles(Context context) {
Log.e(TAG, "loadPhotoFiles");
List < Uri > photoUris = new ArrayList < Uri > ();
Cursor cursor = context.getContentResolver().query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, new String[] {
MediaStore.Images.Media._ID
}, null, null, null);
Log.e(TAG, "cursor size:" + cursor.getCount());
while (cursor.moveToNext()) {
int id = cursor.getInt(cursor
.getColumnIndex(MediaStore.Images.Media._ID));
Uri photoUri = Uri.parse(MediaStore.Images.Media.EXTERNAL_CONTENT_URI.toString() + File.separator + id);
Log.e(TAG, "photoUri:" + photoUri);
photoUris.add(photoUri);
}
return photoUris;
}
通过Uri读取文件:
public static Bitmap getBitmapFromUri(Context context, Uri uri) throws IOException {
ParcelFileDescriptor parcelFileDescriptor =
context.getContentResolver().openFileDescriptor(uri, "r");
FileDescriptor fileDescriptor = parcelFileDescriptor.getFileDescriptor();
Bitmap image = BitmapFactory.decodeFileDescriptor(fileDescriptor);
parcelFileDescriptor.close();
return image;
}
多媒体文件保存
应用只能在私用目录下通过文件路径的方式保存文件,如果需要保存文件到公共目录,需要使用特定的接口实现。
1、通过MediaStore插入多媒体文件
通过MediaStore.Images.Media.insertImage接口可以将图片文件保到/sdcard/Pictures/,但是只有图片文件保存可以通过MediaStore的接口保存,其他类型文件无法通过该接口保存;
public static void saveBitmapToFile(Context context, Bitmap bitmap, String title, String discription) {
MediaStore.Images.Media.insertImage(context.getContentResolver(), bitmap, title, discription);
}
如果有图片的路径,可以使用如下方法
public static void saveBitmapToFile(Context context, String imagePath, String title, String discription) {
MediaStore.Images.Media.insertImage(context.getContentResolver(), imagePath, title, discription);
}
2、通过ContentResolver的insert方法将多媒体文件保存到多媒体的公共集合目录
/**
* 保存多媒体文件到公共集合目录
* @param uri:多媒体数据库的Uri
* @param context
* @param mimeType:需要保存文件的mimeType
* @param displayName:显示的文件名字
* @param description:文件描述信息
* @param saveFileName:需要保存的文件名字
* @param saveSecondaryDir:保存的二级目录
* @param savePrimaryDir:保存的一级目录
* @return 返回插入数据对应的uri
*/
public static String insertMediaFile(Uri uri, Context context, String mimeType,
String displayName, String description, String saveFileName, String saveSecondaryDir, String savePrimaryDir) {
ContentValues values = new ContentValues();
values.put(MediaStore.Images.Media.DISPLAY_NAME, displayName);
values.put(MediaStore.Images.Media.DESCRIPTION, description);
values.put(MediaStore.Images.Media.MIME_TYPE, mimeType);
values.put(MediaStore.Images.Media.PRIMARY_DIRECTORY, savePrimaryDir);
values.put(MediaStore.Images.Media.SECONDARY_DIRECTORY, saveSecondaryDir);
Uri url = null;
String stringUrl = null; /* value to be returned */
ContentResolver cr = context.getContentResolver();
try {
url = cr.insert(uri, values);
if (url == null) {
return null;
}
byte[] buffer = new byte[BUFFER_SIZE];
ParcelFileDescriptor parcelFileDescriptor = cr.openFileDescriptor(url, "w");
FileOutputStream fileOutputStream =
new FileOutputStream(parcelFileDescriptor.getFileDescriptor());
InputStream inputStream = context.getResources().getAssets().open(saveFileName);
while (true) {
int numRead = inputStream.read(buffer);
if (numRead == -1) {
break;
}
fileOutputStream.write(buffer, 0, numRead);
}
fileOutputStream.flush();
} catch (Exception e) {
Log.e(TAG, "Failed to insert media file", e);
if (url != null) {
cr.delete(url, null, null);
url = null;
}
}
if (url != null) {
stringUrl = url.toString();
}
return stringUrl;
}
比如你需要把一个图片文件保存到/sdcard/dcim/test/下面,可以这样调用:
SandboxTestUtils.insertMediaFile(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, this, "image/jpeg",
"insert_test_img", "test img save use insert", "if_apple_2003193.png", "test", Environment.DIRECTORY_DCIM);
音频、视频文件和下载目录的文件也是可以通过这个方式进行保存,比如音频文件保存到/sdcard/Music/test/:
SandboxTestUtils.insertMediaFile(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, this, "audio/mpeg",
"insert_test_music", "test audio save use insert", "Never Forget You.mp3", "test", Environment.DIRECTORY_MUSIC);
可以通过PRIMARY_DIRECTORY和SECONDARY_DIRECTORY字段来设置一级目录和二级目录。
多媒体文件的编辑和修改
应用只有自己插入的多媒体文件的写权限,没有别的应用插入的多媒体文件的写权限,比如使用下面的代码删除别的应用的多媒体文件会因为权限问题导致删除失败:
context.getContentResolver().delete(uri, null, null))
根据查询得到的文件Uri,使用MediaStore修改其他APP新建的多媒体文件,需要catch RecoverableSecurityException ,由MediaProvider弹出弹框给用户选择是否允许APP修改或删除图片/视频/ 音频文件。用户操作的结果,将通过onActivityResult回调返回到APP。如果用户允许,APP将获得该Uri 的修改权限,直到设备下一次重启。
根据文件Uri,通过下列接口,获取需要修改文件的FD或者OutputStream:
1、getContentResolver().openOutputStream(contentUri)获取对应文件的OutputStream。
2、getContentResolver().openFile或者getContentResolver().openFileDescriptor。
通过openFile或者openFileDescriptor打开文件,需要选择Mode为”w”,表示写权限。 这些接口返回一个ParcelFileDescriptor。
OutputStream os = null;
try {
if (imageUri != null) {
os = resolver.openOutputStream(imageUri);
}
} catch (IOException e) {
LogUtil.log("open image fail");
} catch (RecoverableSecurityException e1) {
LogUtil.log("get RecoverableSecurityException");
try {
((Activity) context).startIntentSenderForResult(
e1.getUserAction().getActionIntent().getIntentSender(),
100, null, 0, 0, 0);
} catch (IntentSender.SendIntentException e2) {
LogUtil.log("startIntentSender fail");
}
}
读取及写入公共目录下的非多媒体文件
当我们想要读取公共目录下的非多媒体文件,比如PDF文件,这个时候就不能再使用MediaStore API了,需要用到SAF(即Storage Access Framework)。
根据当前系统中存在的DocumentsProvider,让用户选择特定的文件或文件夹,使调用SAF的APP获取它们的读写权限。APP通过SAF获得文件或目录的读写权限,无需申请任何存储相关的运行时权限。
使用SAF获取文件或目录权限的过程:
APP通过特定Intent调起DocumentUI -> 用户在DocumentUI界面上选择要授权的文件或目录 -> APP在回调中解析文件或目录的Uri,最后根据这一Uri可进行读写删操作。
使用SAF选择单个文件
使用Intent.ACTION_OPEN_DOCUMENT调起DocumentUI的文件选择页面,用户可以选择一个文件,将它的读写权限授予APP。
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
// you can set type to filter files to show
intent.setType("*/*");
startActivityForResult(intent, REQUEST_CODE_FOR_SINGLE_FILE);
使用SAF修改文件
用户选择文件授权给APP后,在APP的onActivityResult 回调中收到返回结果,解析出对应文件的Uri。然后使用该Uri,用户可以获取可写的ParcelFileDescriptor或者打开OutputStream 进行修改。
if (requestCode == REQUEST_CODE_FOR_SINGLE_FILE && resultCode == Activity.RESULT_OK) {
Uri fileUri = null;
if (data != null) {
fileUri = data.getData();
}
if (fileUri != null) {
OutputStream os = null;
try {
os = getContentResolver().openOutputStream(fileUri);
os.write("something".getBytes(StandardCharsets.UTF_8));
} catch (IOException e) {
LogUtil.log("modify document fail");
} finally {
if (os != null) {
try {
os.close();
} catch (IOException e1) {
LogUtil.log("close fail");
}
}
}
}
}
使用SAF删除文件
类似修改文件,在回调中解析出文件Uri,然后使用DocumentsContract.deleteDocument接口进行删除操作。
if (requestCode == REQUEST_CODE_FOR_SINGLE_FILE && resultCode == Activity.RESULT_OK) {
Uri fileUri = null;
if (data != null) {
fileUri = data.getData();
}
if (fileUri != null) {
try {
DocumentsContract.deleteDocument(getContentResolver(), fileUri);
} catch (FileNotFoundException e) {
LogUtil.log("delete document fail");
}
}
}
使用SAF新建文件
Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
// you can set file mimetype
intent.setType("*/*");
// default file name
intent.putExtra(Intent.EXTRA_TITLE, "myFileName");
startActivityForResult(intent, REQUEST_CODE_FOR_CREATE_FILE);
在用户确定后,操作结果将返回到APP的onActivityResult回调中,APP解析出文件Uri,之后就可以利用这一 Uri对文件进行读写删操作。
if (requestCode == REQUEST_CODE_FOR_CREATE_FILE && resultCode == Activity.RESULT_OK) {
Uri fileUri = null;
if (data != null) {
fileUri = data.getData();
}
// read/update/delete by the uri got here.
LogUtil.log("uri: " + fileUri);
}
其他需要注意的
(1)卸载应用
如果APP在AndroidManifest.xml中声明:android:hasFragileUserData=“true”,卸载应用会有提示是否保留 APP数据。默认应用卸载时App-specific目录下的数据被删除,但用户可以选择保留。
(2)DATA字段数据不再可靠
MediaStore中,DATA(即_data)字段,在Android Q中开始废弃,不再表示文件的真实路径。读写文件或判断文件是否存在,不应该使用 DATA字段,而要使用openFileDescriptor。
同时也无法直接使用路径访问公共目录的文件。
(3)MediaStore.Files接口自过滤
通过MediaStore.Files接口访问文件时,只展示多媒体文件(图片、视频、音频)。其他文件,例如PDF文件,无法访问到。
(4)Native代码访问文件
如果Native代码需要访问文件,可以参考下面方式:
- 通过openFileDescriptor返回ParcelFileDescriptor
- 通过ParcelFileDescriptor.detachFd()读取FD
- 将FD传递给Native层代码
- 通过close接口关闭FD
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.
}
关于close(2)的用法,见:
CLOSE(2)
参考文档:
1、
OPPO:Android Q版本应用兼容性适配指导
2、
华为AndroidQ适配(这个链接华为已经删除了)
3、
官方文档
4、
SAF相关的Google官方文档
5、
AndroidQ(10)分区存储完美适配
6、
Android 10适配要点,作用域存储