Android Q 存储新特性适配脑壳疼?指南来了

码个蛋(codeegg)第 692 次推文

原文:

https://mp.weixin.qq.com/s/aiDMyAfAZvaYIHuIMLAlcg

简单回顾下:Android Q 适配 之 存储新特性

接下来看看存储新特性的适配啦~

继续第二章,且看第二回~

2. 存储空间限制

2.3 适配指导

Android Q Scoped Storage 新特性谷歌官方适配文档:

https://developer.android.google.cn/preview/privacy/scoped-storage

OPPO 适配指导如下,分为:访问 APP 自身 App-specific 目录文件、使用 MediaStore 访问公共目录、使用 SAF 访问指定文件和目录、分享 App-specific 目录下文件和其他细节适配。

2.3.1 访问 APP 自身 App-specific 目录文件

无需任何权限,APP 即可直接使用文件路径来读写自身 App-specific 目录下的文件。获取 App-specific 目录路径的接口如下表所示。

如下,以新建并写入文件为例。

// set "Documents" as subDir
final File dirs = getExternalFilesDirs("Documents");
File primaryDir = ;
if (dirs != && dirs.length > 0) {
primaryDir = dirs[0];
}
if (primaryDir == ) {
return;
}
File newFile = new File(primaryDir.getAbsolutePath, "MyTestDocument");
OutputStream fileOS = ;
try {
fileOS = new FileOutputStream(newFile);
if (fileOS != ) {
fileOS.write("file is created".getBytes(StandardCharsets.UTF_8));
fileOS.flush;
}
} catch (IOException e) {
LogUtil.log("create file fail");
} finally {
try {
if (fileOS != ) {
fileOS.close;
}
} catch (IOException e1) {
LogUtil.log("close stream fail");
}
}

2.3.2 使用 MediaStore 访问公共目录

APP 无法直接访问公共目录下的文件。MediaStore 为 APP 提供了访问公共目录下媒体文件的接口。APP 在有适当权限时,可以通过 MediaStore 查询到公共目录文件的 Uri,然后通过 Uri 读写文件。

MediaStore 相关的 Google 官方文档:

https://developer.android.google.cn/reference/android/provider/MediaStore

2.3.2.1 MediaStore 的 Uri 和路径对照表

MediaStore 提供了下列几种类型的访问 Uri,通过查询对应 Uri 数据(在 MediaProvider 中),达到访问的目的。

下列每种类型又分为三种 Uri:Internal、External、可移动存储。

在 Android Q 上,所有的外部存储设备,包括内置卡、SD 卡等,都会被命名,即设备的 Volume Name。MediaStore 可以通过 Volume Name 获取对应存储设备的 Uri。

for (String volumeName : MediaStore.getExternalVolumeNames(this)){
MediaStore.Images.Media.getContentUri(volumeName);
}

MediaProvider 对于 APP 新建到公共目录的文件,通过 ContentResolver.insert 方法中的 Uri 来确定具体存放目录。其中下表中

content://media//>

2.3.2.2 APP 通过 MediaStore 访问文件所需要的权限

通过 MediaStore 提供的 Uri,使用 ContentResolver 的 insert 接口,将文件保存到公共目录下。不同的 Uri,可以保存到不同的公共目录中,详见 2.3.2.1。

ContentValues values = new ContentValues;
values.put(MediaStore.Images.Media.DESCRIPTION, "This is an image");
values.put(MediaStore.Images.Media.DISPLAY_NAME, "Image.png");
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/test");

Uri external = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
ContentResolver resolver = context.getContentResolver;

Uri insertUri = resolver.insert(external, values);
LogUtil.log("insertUri: " + insertUri);

OutputStream os = ;
try {
if (insertUri != ) {
os = resolver.openOutputStream(insertUri);
}
if (os != ) {
final Bitmap bitmap = Bitmap.createBitmap(32, 32, Bitmap.Config.ARGB_8888);
bitmap.compress(Bitmap.CompressFormat.PNG, 90, os);
// write what you want
}
} catch (IOException e) {
LogUtil.log("fail: " + e.getCause);
} finally {
try {
if (os != ) {
os.close;
}
} catch (IOException e) {
LogUtil.log("fail in close: " + e.getCause);
}
}

2.3.2.4 使用 MediaStore 查询文件

用 MediaStore 提供的 Uri 指定设备,selection 参数指定过滤条件,通过 ContentResolver.query 接口查询文件 Uri。

Uri external = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;

ContentResolver resolver = context.getContentResolver;

String selection = MediaStore.Images.Media.TITLE + "=?";
String args = new String {"Image"};
String projection = new String {MediaStore.Images.Media._ID};
Cursor cursor = resolver.query(external, projection, selection, args, );
Uri imageUri = ;

if (cursor != && cursor.moveToFirst) {
imageUri = ContentUris.withAppendedId(external, cursor.getLong(0));
cursor.close;
}

2.3.2.5 使用 MediaStore 读取文件

通过以上查询方式得到 Uri 之后,通过以下方式读取文件:

1)通过 ContentResolver openFileDescriptor 接口,选择对应的打开方式。例如”r” 表示读,”w” 表示写,返回 ParcelFileDescriptor 类型的文件描述符。

ParcelFileDescriptor pfd = ;
if (imageUri != ) {
try {
pfd = context.getContentResolver.openFileDescriptor(imageUri, "r");
if (pfd != ) {
Bitmap bitmap = BitmapFactory.decodeFileDescriptor(pfd.getFileDescriptor);
// show the bitmap, or do something else.
}
} catch (IOException e) {
LogUtil.log("fail: " + e.getCause);
} finally {
try {
if (pfd != ) {
pfd.close;
}
} catch (IOException e) {
LogUtil.log("fail in close: " + e.getCause);
}
}
}

2)访问 Thumbnail,使用 ContentResolver.loadThumbnail 接口。通过传入 size 参数,MediaProvider 返回指定大小的 Thumbnail。

3)Native 代码访问文件

如果 Native 代码需要访问文件,可以参考下面方式:

  • 通过 openFileDescriptor 返回 ParcelFileDescriptor

  • 通过 ParcelFileDescriptor.detachFd 读取 FD

  • 将 FD 传递给 Native 层代码

  • 通过 close 接口关闭 FD


String fileOpenMode = "r";
ParcelFileDescriptor parcelFd = resolver.openFileDescriptor(uri, fileOpenMode);
if (parcelFd != ) {
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.
}

2.3.2.6 使用 MediaStore 修改文件

根据查询得到的文件 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 = ;
try {
if (imageUri != ) {
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, , 0, 0, 0);
} catch (IntentSender.SendIntentException e2) {
LogUtil.log("startIntentSender fail");
}
}

2.3.2.7 使用 MediaStore 删除文件

删除其他 APP 新建的媒体文件,与修改类似,需要用户授权。删除文件使用 ContentResolver.delete 接口。

getContentResolver.delete(imageUri, , );

2.3.3 使用 SAF 访问指定文件和目录

SAF,即 Storage Access Framework。根据当前系统中存在的 DocumentsProvider,让用户选择特定的文件或文件夹,使调用 SAF 的 APP 获取它们的读写权限。APP 通过 SAF 获得文件或目录的读写权限,无需申请任何存储相关的运行时权限。

SAF 相关的 Google 官方文档:

https://developer.android.com/guide/topics/providers/document-provider

使用 SAF 获取文件或目录权限的过程:

APP 通过特定 Intent 调起 DocumentUI -> 用户在 DocumentUI 界面上选择要授权的文件或目录 -> APP 在回调中解析文件或目录的 Uri,最后根据这一 Uri 可进行读写删操作。

2.3.3.1 使用 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);

2.3.3.2 使用 SAF 修改文件

通过 2.3.3.1 的方式,用户选择文件授权给 APP 后,在 APP 的 onActivityResult 回调中收到返回结果,解析出对应文件的 Uri。然后使用该 Uri,用户可以获取可写的 ParcelFileDescriptor 或者打开 OutputStream 进行修改。

if (requestCode == REQUEST_CODE_FOR_SINGLE_FILE && resultCode == Activity.RESULT_OK) {
Uri fileUri = ;
if (data != ) {
fileUri = data.getData;
}
if (fileUri != ) {
OutputStream os = ;
try {
os = getContentResolver.openOutputStream(fileUri);
os.write("something".getBytes(StandardCharsets.UTF_8));
} catch (IOException e) {
LogUtil.log("modify document fail");
} finally {
if (os != ) {
try {
os.close;
} catch (IOException e1) {
LogUtil.log("close fail");
}
}
}
}
}

2.3.3.3 使用 SAF 删除文件

类似修改文件,在回调中解析出文件 Uri,然后使用 DocumentsContract.deleteDocument 接口进行删除操作。

if (requestCode == REQUEST_CODE_FOR_SINGLE_FILE && resultCode == Activity.RESULT_OK) {
Uri fileUri = ;
if (data != ) {
fileUri = data.getData;
}
if (fileUri != ) {
try {
DocumentsContract.deleteDocument(getContentResolver, fileUri);
} catch (FileNotFoundException e) {
LogUtil.log("delete document fail");
}
}
}

2.3.3.4 使用 SAF 新建文件

APP 通过 Intent.ACTION_CREATE_DOCUMENT 调起 DocumentUI 界面,由用户决定文件命名,以及存放位置。

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 = ;
if (data != ) {
fileUri = data.getData;
}
// read/update/delete by the uri got here.
LogUtil.log("uri: " + fileUri);
}

2.3.3.5 使用 SAF 选择目录

通过 Intent.ACTION_OPEN_DOCUMENT_TREE 调起 DocumentUI 界面,用户可以选择任意文件夹,将它及其子文件夹的读写权限授予 APP。

Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
startActivityForResult(intent, REQUEST_CODE_FOR_DIR);

在右上角的菜单中选择 show internal storage,可以在左侧菜单中选择内置存储设备,接着用户可以选择内置存储设备中的任意文件夹。

在用户确定后,APP 的 onActivityResult 回调收到操作结果,解析出被选文件夹的 uriTree。根据这一 uriTree ,进一步可以生成表示被选文件夹的 DocumentFile,利用 DocumentFile 提供的 API 可以对目录下的文件进行各种操作。

if (requestCode == REQUEST_CODE_FOR_DIR && resultCode == Activity.RESULT_OK) {
Uri uriTree = ;
if (data != ) {
uriTree = data.getData;
}
if (uriTree != ) {
// create DocumentFile which represents the selected directory
DocumentFile root = DocumentFile.fromTreeUri(this, uriTree);
// list all sub dirs of root
DocumentFile files = root.listFiles;
// do anything you want with APIs provided by DocumentFile
// ...
}
}

2.3.3.6 永久保存获取的目录权限

在 2.3.3.5 中,通过 SAF 获取了用户指定目录的读写权限,直至设备下一次重启。APP 可以通过 takePersistableUriPermission 接口获取该 uriTree 的永久权限,并将 uriTree 以 SharedPreferences 等形式持久化保存,以备之后随时使用。

if (uriTree != ) {
// get persistable uri permission
final int takeFlags = data.getFlags
& (Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
getContentResolver.takePersistableUriPermission(uriTree, takeFlags);

// save uriTree to sharedPreference
SharedPreferences sp = getSharedPreferences("DirPermission", Context.MODE_PRIVATE);
SharedPreferences.Editor editor = sp.edit;
editor.putString("uriTree", uriTree.toString);
editor.commit;
}

在使用保存的 uriTree 时,首先检查是否顺利从 SharedPreferences 中获取到 uriTree,然后通过 takePersistableUriPermission 接口是否抛异常来判断权限是否仍存在。如果权限不存在,则重新通过 SAF 申请权限。

SharedPreferences sp = getSharedPreferences("DirPermission", Context.MODE_PRIVATE);
String uriTree = sp.getString("uriTree", "");
if (TextUtils.isEmpty(uriTree)) {
startSafForDirPermission;
} else {
try {
Uri uri = Uri.parse(uriTree);
final int takeFlags = getIntent.getFlags
& (Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
getContentResolver.takePersistableUriPermission(uri, takeFlags);
// uri tree permission is granted, do what you want with this uri
LogUtil.log("uri is granted");
DocumentFile root = DocumentFile.fromTreeUri(this, uri);
} catch (SecurityException e) {
LogUtil.log("uri is not granted");
startSafForDirPermission;
}
}

APP 申请到目录的永久权限后,用户可以在该 APP 的设置页面取消目录的访问权限,即点击如下图的 “Clear access” 按钮。

2.3.4 分享 App-specific 目录下文件

APP 可以选择以下的方式,将自身 App-specific 目录下的文件分享给其他 APP 读写。

2.3.4.1 使用 FileProvider

APP 可以使用 FileProvider 将私有文件的读写权限赋给其他 APP。这种方式十分适用于 APP 主动发起事件的情况,例如从 APP 将某个私有文件分享给其他 APP。

FileProvider 相关的 Google 官方文档:

  • https://developer.android.google.cn/reference/androidx/core/content/FileProvider

  • https://developer.android.com/training/secure-file-sharing/setup-sharing

自定义 FileProvider 及使用的基本步骤:

1)在 AndroidManifest.xml 中声明 App 的 FileProvider

 android:authorities="com.oppo.whoops.fileprovider"
android:
android:grantUriPermissions="true"
android:exported="false">
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/filepaths"/>

2)根据 FileProvider 声明中的 meta data,在 res/xml 中新建 filepaths.xml ,用于定义分享的路径。

name represents what other apps see in the shared uri as subdir. -->

3)在 APP 逻辑代码中生成要分享的 uri,设置权限,然后发送 uri。

String filePath = getExternalFilesDir("Documents") + "/MyTestImage.PNG";
Uri uri = FileProvider.getUriForFile(this, "com.oppo.whoops.fileprovider", new File(filePath));
Intent intent = new Intent(Intent.ACTION_SEND);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
intent.setDataAndType(uri, getContentResolver.getType(uri));
startActivity(Intent.createChooser(intent, "File Provider share"));

4)接收方 APP 的组件设置对应的 intent-filter。

5)接收方 APP 的组件收到 intent,解析获得 uri,通过 uri 获取文件的 FD。

Uri uri = getIntent.getData;
ParcelFileDescriptor pdf = ;
try {
if (uri != ) {
LogUtil.log("Uri: " + uri);
pdf = getContentResolver.openFileDescriptor(uri, "r ");
LogUtil.log("Pdf: " + pdf);
}
} catch (FileNotFoundException e) {
LogUtil.log("open file fail
");
} finally {
try {
if (pdf != ) {
pdf.close;
}
} catch (IOException e1) {
LogUtil.log("close fd fail ");
}
}

2.3.4.2 使用 ContentProvider

APP 可以实现自定义 ContentProvider 来向外提供 APP 私有文件。这种方式十分适用于内部文件分享,不希望有 UI 交互的情况。

ContentProvider 相关的 Google 官方文档:

https://developer.android.google.cn/guide/topics/providers/content-providers

2.3.4.3 使用 DocumentsProvider

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

2.3.5 细节适配

2.3.5.1 图片的地理位置信息

Android Q 上,默认情况下 APP 不能获取图片的地理位置信息。如果 APP 需要访问图片上的 Exif Metadata,需要完成以下步骤:

1)申请 ACCESS_MEDIA_LOCATION 权限。

2)通过 MediaStore.setRequireOriginal 返回新 Uri。

Uri photoUri = Uri.withAppendedPath(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
cursor.getString(idColumnIndex));
final double latLong;
// Get location data from the ExifInterface class.
photoUri = MediaStore.setRequireOriginal(photoUri);
InputStream stream = getContentResolver.openInputStream(photoUri);
if (stream != ) {
ExifInterface exifInterface = new ExifInterface(stream);
double returnedLatLong = exifInterface.getLatLong;

// If lat/long is , fall back to the coordinates (0, 0).
latLong = returnedLatLong != ? returnedLatLong : new double[2];

// Don't reuse the stream associated with the instance of "ExifInterface".
stream.close;

} else {

// Failed to load the stream, so return the coordinates (0, 0).

latLong = new double[2];

}

2.3.5.2 DATA 字段数据不再可靠

MediaStore 中,DATA(即_data)字段,在 Android Q 中开始废弃,不再表示文件的真实路径。读写文件或判断文件是否存在,不应该使用 DATA 字段,而要使用 openFileDescriptor。

同时也无法直接使用路径访问公共目录的文件。

2.3.5.3 MediaStore.Files 接口自过滤

通过 MediaStore.Files 接口访问文件时,只展示多媒体文件(图片、视频、音频)。其他文件,例如 PDF 文件,无法访问到。

2.3.5.4 文件的 Pending 状态

Android Q 上,MediaStore 中添加了一个 IS_PENDING Flag,用于标记当前文件是 Pending 状态。

其他 APP 通过 MediaStore 查询文件,如果没有设置 setIncludePending 接口,就查询不到设置为 Pending 状态的文件,这就能使 APP 专享此文件。

这个 flag 在一些应用场景下可以使用,例如在下载文件的时候:下载中,文件设置为 Pending 状态;下载完成,把文件 Pending 状态置为 0。

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", );
// 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, , );

2.3.5.5 使用 MediaStore 接口定义好的 Columns

在使用 MediaStore 接口时,如果用到 Projection,Column Name 要使用在 MediaStore 中定义好的。如果 APP 引用的库使用了其他 Column Name,需要由 APP 做好 Column Name 映射。

2.3.5.6 设置相对路径

Android Q 上,通过 MediaStore 存储到公共目录的文件,除了 Uri 跟公共目录关系中规定的每一个存储空间的一级目录外,可以通过 MediaColumns.RELATIVE_PATH 来指定存储的次级目录,这个目录可以使多级,具体方法如下:

1)ContentResolver insert 方法

通过 values.put(Media.RELATIVE_PATH, "Pictures/album/family") 指定存储目录。其中,Pictures 是一级目录,album/family 是子目录。

2)ContentResolver update 方法

通过 values.put(Media.RELATIVE_PATH, "Pictures/album/family") 指定存储目录。通过 update 方法,可以移动文件的存储地方。

2.3.5.7 卸载应用

如果 APP 在 AndroidManifest.xml 中声明:android:hasFragileUserData="true",卸载应用会有提示是否保留 APP 数据。默认应用卸载时 App-specific 目录下的数据被删除,但用户可以选择保留。

2.3.5.8 新建虚拟可移动存储

APP 适配时,如果一个设备没有可移动存储,可以使用下面的方法新建虚拟存储设备:

1)命令行

adb shell sm set-virtual-disk true

2)在设置 -> 存储 -> Virtual SD,进行初始化

另外,关于存储权限的(如何启用)影响范围

  • 模拟器

    在Android Q Beat1中,谷歌暂未开放存储权限的改动。我们需要使用adb命令


    adb shell sm set-isolated-storage on

来开启模拟器对于存储权限的变更来进行适配。

  • 真机

    当满足以下每个条件时,将开启兼容模式,即不开启Q设备中的存储权限改动:

 应用targetSDK<=P。
应用安装在从 Android P 升级到 Android Q 的设备上。

但是当应用重新安装(更新)时,不会重新开启兼容模式,存储权限改动将生效。

所以按官方文档所说,无论targetSDK是否为Q,必须对应用进行存储权限改动的适配。

日问题:

除了适配,还有什么问题脑壳疼?

专属升级社区:《这件事情,我终于想明白了》

举报
评论 0