存储访问框架SAF简析(Storage Access Framework)
1.简介
https://developer.android.google.cn/guide/topics/providers/document-provider
https://developer.android.google.cn/training/data-storage/shared/documents-files
https://developer.android.google.cn/training/data-storage/use-cases
Android的存储访问框架主要是用于访问非应用本身专属的文件(应用专属的文件包括如/data/data/下面应用报名目录中的文件和/sdcard(/storage/emulated/0/)下面Android/目录下data或media等目录下应用报名目录中的文件,如/storage/emulated/0/Android/data/包名/),具体可先看下上面三个开发者网站的资料,然后看个demo:
public class SAFDemoActivity extends AppCompatActivity {
private final static String TAG="SAFDemoActivity";
@Override
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if(requestCode==PICK_FILE){
if(resultCode==RESULT_OK){
Uri uri = null;
if (data != null) {
uri = data.getData();
// Perform operations on the document using its URI.
int result=checkCallingOrSelfUriPermission(uri,Intent.FLAG_GRANT_READ_URI_PERMISSION);
Log.i(TAG,"onActivityResult:uri="+uri+":av="+isUriAvailable(uri)+":result="+result);
}
}else{
}
}
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_safdemo);
}
public void startOpen(View view) {
File file=new File("/sdcard/My/testfile1.txt");
Log.i(TAG,"startOpen:exists="+file.exists()+":canRead="+file.canRead());
Uri uri=Uri.fromFile(file);
int result=checkCallingOrSelfUriPermission(uri,Intent.FLAG_GRANT_READ_URI_PERMISSION);
Log.i(TAG,"startOpen:uri="+uri+":av="+isUriAvailable(uri)+":result="+result);
try {
MediaScannerConnection.scanFile(getApplicationContext(), new String[]{file.getCanonicalPath()},
new String[]{"text/plain"}, new MediaScannerConnection.OnScanCompletedListener() {
@Override
public void onScanCompleted(String path, Uri uri) {
int result=checkCallingOrSelfUriPermission(uri,Intent.FLAG_GRANT_READ_URI_PERMISSION);
Log.i(TAG,"startOpen:path="+path+":uri="+uri+":av="+isUriAvailable(uri)+":result="+result);
}
});
} catch (IOException e) {
e.printStackTrace();
}
openFile();
}
private static final int PICK_FILE = 2;
private void openFile() {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("text/plain");
startActivityForResult(intent, PICK_FILE);
}
private boolean isUriAvailable(Uri uri){
try {
AssetFileDescriptor afd=getContentResolver().openAssetFileDescriptor(uri,"r");
long length=afd.getLength();
Log.i(TAG,"isUriAvailable:test:length="+length);
return true;
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (Exception e) {
e.printStackTrace();
}
return false;
}
}
这里在外部存储/sdcard目录下创建了个My目录,然后在该目录下保存了一个文件testfile1.txt,查看上述demo运行对于该文件的访问权限
3032 3032 I SAFDemoActivity: startOpen:exists=true:canRead=false
3032 3032 W System.err: java.io.FileNotFoundException: open failed: EACCES (Permission denied)
3032 3032 W System.err: at android.os.ParcelFileDescriptor.openInternal(ParcelFileDescriptor.java:315)
3032 3032 W System.err: at android.os.ParcelFileDescriptor.open(ParcelFileDescriptor.java:220)
3032 3032 W System.err: at android.content.ContentResolver.openAssetFileDescriptor(ContentResolver.java:1498)
3032 3032 W System.err: at android.content.ContentResolver.openAssetFileDescriptor(ContentResolver.java:1420)
3032 3032 W System.err: at com.example.android.mydemos.SAFDemoActivity.isUriAvailable(SAFDemoActivity.java:79)
3032 3032 W System.err: at com.example.android.mydemos.SAFDemoActivity.startOpen(SAFDemoActivity.java:50)
3032 3032 W System.err: at java.lang.reflect.Method.invoke(Native Method)
3032 3032 W System.err: at androidx.appcompat.app.AppCompatViewInflater$DeclaredOnClickListener.onClick(AppCompatViewInflater.java:397)
3032 3032 W System.err: at android.view.View.performClick(View.java:7140)
3032 3032 W System.err: at android.view.View.performClickInternal(View.java:7117)
3032 3032 W System.err: at android.view.View.access$3500(View.java:801)
3032 3032 W System.err: at android.view.View$PerformClick.run(View.java:27351)
3032 3032 W System.err: at android.os.Handler.handleCallback(Handler.java:883)
3032 3032 W System.err: at android.os.Handler.dispatchMessage(Handler.java:100)
3032 3032 W System.err: at android.os.Looper.loop(Looper.java:214)
3032 3032 W System.err: at android.app.ActivityThread.main(ActivityThread.java:7356)
3032 3032 W System.err: at java.lang.reflect.Method.invoke(Native Method)
3032 3032 W System.err: at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:492)
3032 3032 W System.err: at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:930)
3032 3032 I SAFDemoActivity: startOpen:uri=file:///sdcard/My/testfile1.txt:av=false:result=-1
0979 3092 D MediaProvider: Scanned /storage/emulated/0/My/testfile1.txt as /storage/emulated/0/My/testfile1.txt for content://media/external_primary/file/346
0979 30999 E DatabaseUtils: Writing exception to parcel
0979 30999 E DatabaseUtils: java.lang.SecurityException: com.example.android.mydemos has no access to content://media/external_primary/file/346
0979 30999 E DatabaseUtils: at com.android.providers.media.MediaProvider.enforceCallingPermissionInternal(MediaProvider.java:5707)
0979 30999 E DatabaseUtils: at com.android.providers.media.MediaProvider.enforceCallingPermission(MediaProvider.java:5630)
0979 30999 E DatabaseUtils: at com.android.providers.media.MediaProvider.checkAccess(MediaProvider.java:5727)
0979 30999 E DatabaseUtils: at com.android.providers.media.MediaProvider.openFileAndEnforcePathPermissionsHelper(MediaProvider.java:5357)
0979 30999 E DatabaseUtils: at com.android.providers.media.MediaProvider.openFileCommon(MediaProvider.java:5167)
0979 30999 E DatabaseUtils: at com.android.providers.media.MediaProvider.openFile(MediaProvider.java:5117)
0979 30999 E DatabaseUtils: at android.content.ContentProvider.openAssetFile(ContentProvider.java:1712)
0979 30999 E DatabaseUtils: at android.content.ContentProvider.openAssetFile(ContentProvider.java:1776)
0979 30999 E DatabaseUtils: at android.content.ContentProvider$Transport.openAssetFile(ContentProvider.java:459)
0979 30999 E DatabaseUtils: at android.content.ContentProviderNative.onTransact(ContentProviderNative.java:255)
0979 30999 E DatabaseUtils: at android.os.Binder.execTransactInternal(Binder.java:1021)
0979 30999 E DatabaseUtils: at android.os.Binder.execTransact(Binder.java:994)
3032 3051 W System.err: java.lang.SecurityException: com.example.android.mydemos has no access to content://media/external_primary/file/346
3032 3051 W System.err: at android.os.Parcel.createException(Parcel.java:2071)
3032 3051 W System.err: at android.os.Parcel.readException(Parcel.java:2039)
3032 3051 W System.err: at android.database.DatabaseUtils.readExceptionFromParcel(DatabaseUtils.java:188)
3032 3051 W System.err: at android.database.DatabaseUtils.readExceptionWithFileNotFoundExceptionFromParcel(DatabaseUtils.java:151)
3032 3051 W System.err: at android.content.ContentProviderProxy.openAssetFile(ContentProviderNative.java:631)
3032 3051 W System.err: at android.content.ContentResolver.openAssetFileDescriptor(ContentResolver.java:1521)
3032 3051 W System.err: at android.content.ContentResolver.openAssetFileDescriptor(ContentResolver.java:1420)
3032 3051 W System.err: at com.example.android.mydemos.SAFDemoActivity.isUriAvailable(SAFDemoActivity.java:79)
3032 3051 W System.err: at com.example.android.mydemos.SAFDemoActivity.access$000(SAFDemoActivity.java:18)
3032 3051 W System.err: at com.example.android.mydemos.SAFDemoActivity$1.onScanCompleted(SAFDemoActivity.java:58)
3032 3051 W System.err: at android.media.MediaScannerConnection$ClientProxy.onScanCompleted(MediaScannerConnection.java:204)
3032 3051 W System.err: at android.media.MediaScannerConnection$1.scanCompleted(MediaScannerConnection.java:53)
3032 3051 W System.err: at android.media.IMediaScannerListener$Stub.onTransact(IMediaScannerListener.java:97)
3032 3051 W System.err: at android.os.Binder.execTransactInternal(Binder.java:1021)
3032 3051 W System.err: at android.os.Binder.execTransact(Binder.java:994)
3032 3051 I SAFDemoActivity: startOpen:path=/storage/emulated/0/My/testfile1.txt:uri=content://media/external_primary/file/346:av=false:result=-1
3032 3032 I SAFDemoActivity: isUriAvailable:test:length=6
3032 3032 I SAFDemoActivity: onActivityResult:uri=content://com.android.externalstorage.documents/document/primary%3AMy%2Ftestfile1.txt:av=true:result=0
其中关键打印的如下
3032 3032 I SAFDemoActivity: startOpen:exists=true:canRead=false
3032 3032 I SAFDemoActivity: startOpen:uri=file:///sdcard/My/testfile1.txt:av=false:result=-1
3032 3051 I SAFDemoActivity: startOpen:path=/storage/emulated/0/My/testfile1.txt:uri=content://media/external_primary/file/346:av=false:result=-1
3032 3032 I SAFDemoActivity: isUriAvailable:test:length=6
3032 3032 I SAFDemoActivity: onActivityResult:uri=content://com.android.externalstorage.documents/document/primary%3AMy%2Ftestfile1.txt:av=true:result=0
显然在demo的几种访问方式中,简单的file或uri直接去获取文件的方式是无法得到文件的,而只有通过action为ACTION_OPEN_DOCUMENT的intent去选择的方式在选择返回后才有文件的权限,能够得到文件
2.应用分享文件
https://developer.android.google.cn/training/secure-file-sharing/setup-sharing
https://developer.android.google.cn/training/secure-file-sharing/share-file
在这里建议先看下应用分享文件的介绍,因为其和SAF访问逻辑大同小异,理解了分享文件,后面SAF逻辑就比较容易理解了
这里只简要介绍下
首先,要分享一个应用的专属文件(如),需要声明一个ContentProvider:如(一般可以直接使用androidx.core.FileProvider,也可以自己实现,这里是简单的将androidx.core.FileProvider中的代码复制挪到本地创建的FileProvider中以便调试修改)
<provider
android:name=".FileProvider"
android:authorities="com.example.android.myprovider"
android:grantUriPermissions="true"
android:exported="false">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/filepaths" />
</provider>
这里必须要有grantUriPermissions为true,或者exported为true(直接使用androidx.core.FileProvider会抛异常,可注释调抛异常的部分(attachInfo方法中)),不然其他应用无法通过uri访问文件,这里的meta-data部分主要是在androidx.core.FileProvider中会读取filepaths文件中配置用于根据文件路径生成对应的uri,这里本地使用外部存储目录下的文件,所以filepaths文件中配置了external-files-path标签,具体配置和路径对应情况可查看androidx.core.FileProvider中代码,这里的意思即对应/storage/emulated/0/Android/data/com.example.android.myprovider/files/Documents/下的文件,生成的uri的path目录为documents
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-files-path
name="documents"
path="Documents"/>
</paths>
如果在grantUriPermissions为true,exported为false的时候,另一个应用需要通过uri访问该应用的文件,则可以启动该应用的activity,然后在该应用的activity中选择了文件后setResult然后回到另一个应用,如:
public void onFileClick(View view) {
File file=new File(getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS),"testfile.txt");
Log.i(TAG,"onFileClick:file="+file+":exists="+file.exists()+":canRead="+file.canRead());
if(file.exists()&&file.canRead()){
Uri uri=FileProvider.getUriForFile(getApplicationContext(),AUTH,file);
Log.i(TAG,"onFileClick:uri="+uri);
Intent intent=new Intent();
intent.setData(uri);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION
| Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
| Intent.FLAG_GRANT_PREFIX_URI_PERMISSION);
setResult(RESULT_OK,intent);
}else{
setResult(RESULT_OK);
}
finish();
}
这里主要是通过将文件路径转为provider对应的uri,然后将uri设置到intent中,并添加对应的flag,如Intent.FLAG_GRANT_READ_URI_PERMISSION,然后将其通过setResult进行设置,这样,在另一应用的onActivityResult回调中就可以获取到该uri,并暂时能通过该uri访问对应文件
打印log如下
8510 8510 I SAFDemoActivity: startOpen2:exists=true:canRead=false
8510 8510 I SAFDemoActivity: startOpen2:uri=file:///storage/emulated/0/Android/data/com.example.android.myprovider/files/Documents/testfile.txt:av=false:result=-1
8510 8510 I SAFDemoActivity: startOpen2:uri2=content://com.example.android.myprovider/documents/testfile.txt:av=false:result2=-1
8559 8559 I MyProvider: onFileClick:file=/storage/emulated/0/Android/data/com.example.android.myprovider/files/Documents/testfile.txt:exists=true:canRead=true
8559 8559 I MyProvider: onFileClick:uri=content://com.example.android.myprovider/documents/testfile.txt
8510 8510 I SAFDemoActivity: isUriAvailable:test:length=0
8510 8510 I SAFDemoActivity: onActivityResult:uri=content://com.example.android.myprovider/documents/testfile.txt:av=true:result=0
可见其生成uri为content://com.example.android.myprovider/documents/testfile.txt
而此时,另一应用就可以通过该uri访问该应用的文件了
这里的逻辑主要涉及UriGrantsManagerService,(以android10的代码查看)在结束所写的Provider的应用的Activity时其会调到ActivityStack的finishActivityResultsLocked方法
private void finishActivityResultsLocked(ActivityRecord r, int resultCode, Intent resultData) {
// send the result
ActivityRecord resultTo = r.resultTo;
if (resultTo != null) {
if (DEBUG_RESULTS) Slog.v(TAG_RESULTS, "Adding result to " + resultTo
+ " who=" + r.resultWho + " req=" + r.requestCode
+ " res=" + resultCode + " data=" + resultData);
if (resultTo.mUserId != r.mUserId) {
if (resultData != null) {
resultData.prepareToLeaveUser(r.mUserId);
}
}
if (r.info.applicationInfo.uid > 0) {
mService.mUgmInternal.grantUriPermissionFromIntent(r.info.applicationInfo.uid,
resultTo.packageName, resultData,
resultTo.getUriPermissionsLocked(), resultTo.mUserId);
}
resultTo.addResultLocked(r, r.resultWho, r.requestCode, resultCode, resultData);
r.resultTo = null;
}
else if (DEBUG_RESULTS) Slog.v(TAG_RESULTS, "No result destination from " + r);
// Make sure this HistoryRecord is not holding on to other resources,
// because clients have remote IPC references to this object so we
// can't assume that will go away and want to avoid circular IPC refs.
r.results = null;
r.pendingResults = null;
r.newIntents = null;
r.icicle = null;
}
这里有这样的代码调用:
mService.mUgmInternal.grantUriPermissionFromIntent(r.info.applicationInfo.uid,
resultTo.packageName, resultData,
resultTo.getUriPermissionsLocked(), resultTo.mUserId);
这里最后会调用到UriGrantsManagerService的grantUriPermissionFromIntent方法
void grantUriPermissionFromIntent(int callingUid,
String targetPkg, Intent intent, UriPermissionOwner owner, int targetUserId) {
NeededUriGrants needed = checkGrantUriPermissionFromIntent(callingUid, targetPkg,
intent, intent != null ? intent.getFlags() : 0, null, targetUserId);
if (needed == null) {
return;
}
grantUriPermissionUncheckedFromIntent(needed, owner);
}
这里会将查询对应应用是否有对应uri权限,如果没有则尝试创建(这里会检查相关权限和配置,创建会保存一些uri、应用包名、uid、模式等变量信息),可使用adb shell dumpsys activity permissions来dump当前的权限信息(即UriGrantsManagerService持有的mGrantedUriPermissions信息)如:
ACTIVITY MANAGER URI PERMISSIONS (dumpsys activity permissions)
Granted Uri Permissions:
* UID 10219 holds:
UriPermission{bfeaca0 content://com.example.android.myprovider/documents/testfile.txt [user 0] [prefix]}
targetUserId=0 sourcePkg=com.example.android.myprovider targetPkg=com.example.android.mydemos
mode=0x3 owned=0x3 global=0x0 persistable=0x3 persisted=0x0
readOwners:
* ActivityRecord{ece1a65 u0 com.example.android.mydemos/.SAFDemoActivity t1452}
writeOwners:
* ActivityRecord{ece1a65 u0 com.example.android.mydemos/.SAFDemoActivity t1452}
3.启动action为ACTION_OPEN_DOCUMENT的intent的activity后的逻辑和uri返回
结合启动的activity和对应intent的action等信息,可知其就是启动com.android.documentsui/com.android.documentsui.picker.PickActivity,查看其AndroidManifest.xml文件中PickActivity信息:
<activity
android:name=".picker.PickActivity"
android:theme="@style/DocumentsTheme"
android:visibleToInstantApps="true">
<intent-filter>
<action android:name="android.intent.action.OPEN_DOCUMENT" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.OPENABLE" />
<data android:mimeType="*/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.CREATE_DOCUMENT" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.OPENABLE" />
<data android:mimeType="*/*" />
</intent-filter>
<intent-filter android:priority="100">
<action android:name="android.intent.action.GET_CONTENT" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.OPENABLE" />
<data android:mimeType="*/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.OPEN_DOCUMENT_TREE" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
其在选择文件后会setResult然后finish退出返回
private void onPickFinished(Uri... uris) {
if (DEBUG) {
Log.d(TAG, "onFinished() " + Arrays.toString(uris));
}
final Intent intent = new Intent();
if (uris.length == 1) {
intent.setData(uris[0]);
} else if (uris.length > 1) {
final ClipData clipData = new ClipData(
null, mState.acceptMimes, new ClipData.Item(uris[0]));
for (int i = 1; i < uris.length; i++) {
clipData.addItem(new ClipData.Item(uris[i]));
}
intent.setClipData(clipData);
}
updatePickResult(
intent, mSearchMgr.isSearching(), Metrics.sanitizeRoot(mState.stack.getRoot()));
// TODO: Separate this piece of logic per action.
// We don't instantiate different objects for different actions at the first place, so it's
// not a easy task to separate this logic cleanly.
// Maybe we can add an ActionPolicy class for IoC and provide various behaviors through its
// inheritance structure.
if (mState.action == ACTION_GET_CONTENT) {
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
} else if (mState.action == ACTION_OPEN_TREE) {
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION
| Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
| Intent.FLAG_GRANT_PREFIX_URI_PERMISSION);
} else if (mState.action == ACTION_PICK_COPY_DESTINATION) {
// Picking a copy destination is only used internally by us, so we
// don't need to extend permissions to the caller.
intent.putExtra(Shared.EXTRA_STACK, (Parcelable) mState.stack);
intent.putExtra(FileOperationService.EXTRA_OPERATION_TYPE, mState.copyOperationSubType);
} else {
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION
| Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION);
}
mActivity.setResult(FragmentActivity.RESULT_OK, intent, 0);
mActivity.finish();
}
这里逻辑与分享文件的逻辑基本一致,另外一个关键即是uri,这里uri是查询数据库来获取的,但基本上并不是DocumentsUI(com.android.documentsui)的数据库,其在Providers类中有定义几种authority
public static final String AUTHORITY_STORAGE = "com.android.externalstorage.documents";
public static final String ROOT_ID_DEVICE = "primary";
public static final String ROOT_ID_HOME = "home";
public static final String AUTHORITY_DOWNLOADS = "com.android.providers.downloads.documents";
public static final String ROOT_ID_DOWNLOADS = "downloads";
public static final String AUTHORITY_MEDIA = "com.android.providers.media.documents";
public static final String ROOT_ID_IMAGES = "images_root";
public static final String ROOT_ID_VIDEOS = "videos_root";
public static final String ROOT_ID_AUDIO = "audio_root";
public static final String AUTHORITY_MTP = "com.android.mtp.documents";
其中com.android.externalstorage.documents是在ExternalStorageProvider应用中定义的,查看其AndroidManifest.xml中相关定义
<provider
android:name=".ExternalStorageProvider"
android:label="@string/storage_description"
android:authorities="com.android.externalstorage.documents"
android:grantUriPermissions="true"
android:exported="true"
android:permission="android.permission.MANAGE_DOCUMENTS">
<intent-filter>
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
</intent-filter>
<!-- Stub that allows MediaProvider to make incoming calls -->
<path-permission
android:path="/media_internal"
android:permission="android.permission.WRITE_MEDIA_STORAGE" />
</provider>
其中com.android.providers.downloads.documents是在DownloadProvider应用中定义的,查看其AndroidManifest.xml中相关定义
<provider
android:name=".DownloadStorageProvider"
android:label="@string/storage_description"
android:authorities="com.android.providers.downloads.documents"
android:grantUriPermissions="true"
android:exported="true"
android:permission="android.permission.MANAGE_DOCUMENTS">
<intent-filter>
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
</intent-filter>
</provider>
其中com.android.providers.media.documents是在MediaProvider应用中定义的,查看其AndroidManifest.xml中相关定义
<provider
android:name=".MediaDocumentsProvider"
android:label="@string/storage_description"
android:authorities="com.android.providers.media.documents"
android:grantUriPermissions="true"
android:exported="true"
android:permission="android.permission.MANAGE_DOCUMENTS">
<intent-filter>
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
</intent-filter>
</provider>
其中com.android.mtp.documents是在MtpDocumentsProvider应用中定义的,查看其AndroidManifest.xml中相关定义
<provider
android:name=".MtpDocumentsProvider"
android:authorities="com.android.mtp.documents"
android:grantUriPermissions="true"
android:exported="true"
android:permission="android.permission.MANAGE_DOCUMENTS">
<intent-filter>
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
</intent-filter>
</provider>
当然可能还有其他的provider,到这里也可以看到其逻辑原理与应用分享文件的逻辑基本一致,不同的是这里使用DocumentsUI来统一管理,但实际provider并不一定在DocumentsUI,当然DocumentsUI有对应provider的权限
而且参考这种方式应用也可以实现自己的provider添加到SAF框架中,可参考创建自定义文档提供程序 | Android 开发者 | Android Developers (google.cn)
标签:存储,java,err,框架,SAF,3032,uri,android,com From: https://www.cnblogs.com/luoliang13/p/18227118