码个蛋(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 = null;
if (dirs != null && dirs.length > 0) {
primaryDir = dirs[0];
}
if (primaryDir == null) {
return;
}
File newFile = new File(primaryDir.getAbsolutePath(), "MyTestDocument");
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");
}
}
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 = null;
try {
if (insertUri != null) {
os = resolver.openOutputStream(insertUri);
}
if (os != null) {
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 != null) {
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, null);
Uri imageUri = null;
if (cursor != null && 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 = null;
if (imageUri != null) {
try {
pfd = context.getContentResolver().openFileDescriptor(imageUri, "r");
if (pfd != null) {
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 != null) {
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 != 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.
}
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 = 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");
}
}
2.3.2.7 使用 MediaStore 删除文件
删除其他 APP 新建的媒体文件,与修改类似,需要用户授权。删除文件使用 ContentResolver.delete 接口。
getContentResolver().delete(imageUri, null, null);
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 = 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");
}
}
}
}
}
2.3.3.3 使用 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");
}
}
}
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 = null;
if (data != null) {
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 = null;
if (data != null) {
uriTree = data.getData();
}
if (uriTree != null) {
// 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 != null) {
// 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 = null;
try {
if (uri != null) {
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 != null) {
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 != null) {
ExifInterface exifInterface = new ExifInterface(stream);
double[] returnedLatLong = exifInterface.getLatLong();
// If lat/long is null, fall back to the coordinates (0, 0).
latLong = returnedLatLong != null ? 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", 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);
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,进行初始化
另外,关于存储权限的(如何启用)影响范围
adb shell sm set-isolated-storage on
来开启模拟器对于存储权限的变更来进行适配。
应用targetSDK<=P。
应用安装在从 Android P 升级到 Android Q 的设备上。
但是当应用重新安装(更新)时,不会重新开启兼容模式,存储权限改动将生效。
所以按官方文档所说,无论targetSDK是否为Q,必须对应用进行存储权限改动的适配。
近期文章:
产品要页面72变,x满足她
用Kotlin实现抖音爆红的文字时钟,征服产品小姐姐
Android Q 适配 之 存储新特性
今日问题:
除了适配,还有什么问题脑壳疼?
专属升级社区:《这件事情,我终于想明白了》