Android 4.4(api level 19)中引入了SAF。SAF使用户能够轻松地浏览和打开所有首选的文档存储提供程序中的文档、图像和其他文件。用户可以使用简单易用的UI浏览文件,及在不同的APP及提供程序之间使用统一的方式查看最近历史。
云存储服务或本地存储服务可以通过实现封装了存储服务功能的 DocumentsProvider 加入到这样的生态系统中。客户端APP只需要几行代码集成SAF就能够访问提供程序的文档。
SAF包含了一下3部分:
SAF提供的特性包含以下几点:
SAF围绕着 DocumentsProvider 子类的一个文档提供程序为中心。在一个文档提供程序中,数据作为普通文件结构被组织保存。
注意几点:
如上所述,文档提供程序数据模型是基于传统文件结构类型。然而,你可以在物理介质中随意存储数据,只要你可以通过 DocumentsProvider 访问。
上图显示了Photo APP使用SAF来访问存储的数据
注意如下几点:
在Android 4.3及以下版本,如果想从其他APP中检索一个文件,必须调用 ACTION_PICK 或 ACTION_GET_CONTENT 的Intent实现。用户必须在选择一个APP并且在APP提供的文档提供程序的UI中浏览并且选择可获取的文件。
在Android 4.4以上,用户有了可选的 ACTION_OPEN_DOCUMENT 项来显示系统的picker UI让用户进行浏览其他App中可获取的所有文件。从单一的UI,用户可以从支持的App中任一一个选择文件。
ACTION_OPEN_DOCUMENT 并非取代 ACTION_GET_CONTENT。使用哪个取决于App的需要:
这部分来说下基于 ACTION_OPEN_DOCUMENT 和 ACTION_GET_CONTENT 的客户端。
调用Intent ACTION_GET_CONTENT 启动的picker ui。 在修改为 ACTION_OPEN_DOCUMENT* 效果相同。
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.setType("image/*"); // 过滤出需要搜索到的文档类型 如apk:“application/vnd.android.package-archive”
intent.addCategory(Intent.CATEGORY_OPENABLE); // 过滤出系统认为可打开的文件
((Activity) mContext).startActivityForResult(intent, 500);
点击选取某张图片,返回的发起页面后,得到的路径:
content://com.android.providers.media.documents/document/image%3A1586
这个路径的schema部分是“content”,authorities值是“com.android.providers.media.documents”,后边是路径部分,路径的最后一段是图片名。
需要关注几点:
在上述代码中,启动picker时调用了startActivityForResult()方法,因此在最终选择了一个文档后,发起页面的onActivityResult()方法会被调用,其中包含返回选中文档的Uri信息,在Intent类型的参数data中,可以通过调用getData()来获取Uri。
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (resultCode != RESULT_OK) {
return;
}
if (requestCode == 500) {
Uri uri = data != null ? data.getData() : null;
Log.d("SAF", "uri====>" + uri);
}
}
获取到Uri后,可以通过它查询文档的元数据。以上图为例,获取图片的名称,大小。
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (resultCode != RESULT_OK) {
return;
}
if (requestCode == 500) {
Uri uri = data != null ? data.getData() : null;
if (uri == null) {
return;
}
Log.d("SAF", "uri====>" + uri);
Cursor cursor = getContentResolver().query(uri, null, null, null, null, null);
if (cursor == null || !cursor.moveToFirst()) {
return;
}
final String displayName = cursor.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME));
final int indexOfSize = cursor.getColumnIndex(OpenableColumns.SIZE);
final String size = !cursor.isNull(indexOfSize) ? cursor.getString(indexOfSize) : "Unknown";
Log.d("SAF", "displayName=" + displayName + ", size=" + size);
cursor.close();
}
}
其他的数据按需要可以进行查询,此处不再一一列出。
在有了文档的URI数据后,就可以使用想要的方式打开它。
看看Bitmap的开发方式。
public void showImage(Uri uri) {
try {
final ParcelFileDescriptor parcelFileDescriptor = getActivity().getContentResolver().openFileDescriptor(uri, "r");
final FileDescriptor fileDescriptor = parcelFileDescriptor.getFileDescriptor();
final Bitmap bitmap = BitmapFactory.decodeFileDescriptor(fileDescriptor);
parcelFileDescriptor.close();
imageView.setImageBitmap(bitmap);
} catch (IOException e) {
e.printStackTrace();
}
}
接着上述功能例子,得到运行结果后,设置到ImageView组件中。
注意:读取文件是个耗时操作,因此建议将此操作放入到工作线程中。
再看下从Uri获取InputStream。代码片段中,最终将读取的文档数据输入到String。
public void showContentFromUri(Uri uri) {
try {
final InputStream is = getActivity().getContentResolver().openInputStream(uri);
final BufferedReader reader = new BufferedReader(new InputStreamReader(is));
final StringBuilder sb = new StringBuilder();
String line;
while ((line = reader.readLine()) != null) {
sb.append(line);
}
reader.close();
textView.setText(sb.toString());
} catch (IOException e) {
e.printStackTrace();
}
}
在读取内容后,写入到TextView。
App可以调用 ACTION_CREATE_DOCUMENT Intent在一个文档提供程序中创建文档。要创建文件,需要这是Intent的MIME类型及文件名。
Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
intent.setType("text/plain");
intent.putExtra(Intent.EXTRA_TITLE, "create_file.txt");
startActivityForResult(intent, 450);
可以看到创建的UI效果。
在创建之后,可以在onActivityResult()方法中获取到URI数据,继而针对文件可以进行持续操作。
得到的Uri与选择后组成方式一致:
content://com.android.externalstorage.documents/document/primary%3Acreate_file.txt
如果你有了一个文档的Uri并且在 Document.COLUMN_FLAGS 中包含 SUPPORTS_DELETE ,那么就可以删除该文档了。
同样,使用SAF可以用来编辑文档。先使用 ACTION_OPEN_DOCUMENT 获取到目标文档Uri。
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("text/plain");
startActivityForResult(intent, 500);
再通过FileOutputStream对象,使用write()方法将需要的内容写入到文档中。
public void modifyDocument(Uri uri) throws IOException {
final ParcelFileDescriptor parcelFileDescriptor = getActivity().getContentResolver().openFileDescriptor(uri, "w");
final FileDescriptor fileDescriptor = parcelFileDescriptor.getFileDescriptor();
final FileOutputStream fos = new FileOutputStream(fileDescriptor);
fos.write(("Overwritten by snowman at " + System.currentTimeMillis() + "\n").getBytes());
fos.close();
parcelFileDescriptor.close();
}
最后内容成功写入到文档中。
在App打开文档进行读取操作时,系统给予了App对该文档进行操作的URI权限授权——这个权限会持续到设备重启。但是假设App是一个图片编辑App,并且只想用户可以访问最近编辑过的5张图片。如果用户设备重启了,则需要再次使用户调用picker来找到文件,但很显然这是不理想的方式。
要防止这种情况发生,最好的方式当然是App能记住这个系统提供的URI授权。这样用户就可以通过你的App持续访问文件,即使设备重启了也可以持续访问。
final int persistableFlags = data.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
getContentResolver().takePersistableUriPermission(uri, persistableFlags);
还有最后一步。App访问过得最近的URI很可能不再有效——其他App可能删除或者修改了文档。因此需要调用 getContentResolver().takePersistableUriPermission() 来检查最新数据。
此部分不再此列举。有兴趣的查看视频链接
在一些场景下,选择本地文件后,不仅仅像图片一样直接打开,或者普通文档一样直接读取text内容。像apk文件,需要安装。或者获取绝对路径进行其他操作,此时就需要通过picker获取的Uri来得到对应的绝对路径。
object ApkPickHelper {
fun getFilePath(context: Context, srcUri: Uri): String? {
var uri = srcUri
var selection: String? = null
var selectionArgs: Array<String>? = null
if (DocumentsContract.isDocumentUri(context.applicationContext, uri)) {
when {
isExternalStorageDocument(uri) -> {
val docId = DocumentsContract.getDocumentId(uri)
val split = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
return Environment.getExternalStorageDirectory().absolutePath + "/" + split[1]
}
isDownloadsDocument(uri) -> {
val id = DocumentsContract.getDocumentId(uri)
uri = ContentUris.withAppendedId(
Uri.parse("content://downloads/public_downloads"), java.lang.Long.valueOf(id))
}
isMediaDocument(uri) -> {
val docId = DocumentsContract.getDocumentId(uri)
val split = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
when (split[0]) {
"image" -> uri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
"video" -> uri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI
"audio" -> uri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
}
selection = "_id=?"
selectionArgs = arrayOf(split[1])
}
}
}
if ("content".equals(uri.scheme!!, ignoreCase = true)) {
if (isGooglePhotosUri(uri)) {
return uri.lastPathSegment
}
val projection = arrayOf(MediaStore.Images.Media.DATA)
var cursor: Cursor? = null
try {
cursor = context.contentResolver
.query(uri, projection, selection, selectionArgs, null)
val columnIndex = cursor!!.getColumnIndexOrThrow(MediaStore.Images.Media.DATA)
if (cursor.moveToFirst()) {
return cursor.getString(columnIndex)
}
} catch (e: Exception) {
e.printStackTrace()
} finally {
cursor?.close()
}
} else if ("file".equals(uri.scheme!!, ignoreCase = true)) {
return uri.path
}
return null
}
private fun isExternalStorageDocument(uri: Uri): Boolean {
return "com.android.externalstorage.documents" == uri.authority
}
private fun isDownloadsDocument(uri: Uri): Boolean {
return "com.android.providers.downloads.documents" == uri.authority
}
private fun isMediaDocument(uri: Uri): Boolean {
return "com.android.providers.media.documents" == uri.authority
}
private fun isGooglePhotosUri(uri: Uri): Boolean {
return "com.google.android.apps.photos.content" == uri.authority
}
}