我们的双开方案是基于Google的多用户空间方案;Google多用户方案的设计思想,是为了解决工作用户空间(work profile)和私人用户空间(personal profile)的数据安全的,所以两个用户空间的数据是并行的,相互之间是无法直接访问和分享的。
在开发应用双开的过程中,需要支持在相册等app里面分享图片到分身微信空间。
为了尽快解决问题,前期我们发现原生的DocumentUI可以实现跨用户分享图片;经过分析DocumentUI使用的是fileprovider,但是我们没法直接用,还是没有解决问题;这个过程不在此赘述了。
我们使用fileprovider,前两步配置fileprovider和path,都可以在网上查询到;但是仅仅这两步无法解决问题;除此之外,还需配置URI权限和APP签名配置。
前期的代码分析和日志分析不在此赘述,直接给出解决方案及demo验证,详细配置过程如下:
配置fileprovider:
在AndroidMenifest配置,在application的tag内添加provider:
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="com.coolos.myapp.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>
和res并列,添加xml文件夹,并添加xml文件provider_paths.xml:
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<root-path name="root" path="." />
<files-path name="image_files" path="images/" />
<cache-path name="cache" path="." />
<external-path name="external" path="." />
<external-files-path name="external_files" path="." />
<external-cache-path name="external_cache" path="." />
<external-media-path name="name" path="." />
</paths>
\frameworks\base\services\core\java\com\android\server\uri\UriGrantsManagerService.java:
// Bail early if system is trying to hand out permissions directly; it
// must always grant permissions on behalf of someone explicit.
final int callingAppId = UserHandle.getAppId(callingUid);
if ((callingAppId == SYSTEM_UID) || (callingAppId == ROOT_UID)) {
if ("com.android.settings.files".equals(grantUri.uri.getAuthority())
|| "com.android.settings.module_licenses".equals(grantUri.uri.getAuthority())
|| "com.journeyui.cloneit.fileprovider".equals(grantUri.uri.getAuthority())
|| "org.lineageos.setupwizard.files".equals(grantUri.uri.getAuthority())
|| "com.journeyui.filebrowser.provider".equals(grantUri.uri.getAuthority())
|| "com.coolos.myapp.fileprovider".equals(grantUri.uri.getAuthority())
|| "com.journeyui.gallery3d.fileprovider".equals(grantUri.uri.getAuthority())) {
// Exempted authority for
// 1. cropping user photos and sharing a generated license html
// file in Settings app
// 2. sharing a generated license html file in TvSettings app
// 3. Sharing module license files from Settings app
} else {
Slog.w(TAG, "For security reasons, the system cannot issue a Uri permission"
+ " grant to " + grantUri + "; use startActivityAsCaller() instead");
return -1;
}
}
在AndroidMenifest配置如下,另外还需要对apk进行platform签名;
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.coolos.myapp"
coreApp="true"
android:sharedUserId="android.uid.system"
>
以 "external/DCIM/Camera/IMG_20210709_195700.jpg" 为例进行测试:
a.初始化URI:
File imagePath = new File(getExternalStoragePublicDirectory(DIRECTORY_DCIM).getPath(), "/Camera");
File newFile = new File(imagePath, "IMG_20210709_195700.jpg");
Uri contentUri = getUriForFile(getApplicationContext(), "com.coolos.myapp.fileprovider", newFile);
if (newFile.exists()) {
Log.d(TAG, "START newFile2:" + newFile.getPath());
}
b.初始化Intent并startActivity:
Intent intent = new Intent(Intent.ACTION_SEND);
intent.setDataAndType(contentUri,"image/*");
intent.putExtra(Intent.EXTRA_STREAM, contentUri);
//intent.setClipData(ClipData.newUri(getContentResolver(), "image", contentUri));
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
startActivity(Intent.createChooser(intent, "Select share target"));
c.build.gradle配置:
implementation 'androidx.core:core:1.3.1'
虽然可以实现跨用户分享图片,但是这个方案不完美;需要推动所有相关的app都要做相应的修改,比较麻烦。长期还需有持续优化。
市面上比较主流的媒体分享方式是MediaProvider,所以大多数app都采用这一方式进行媒体文件的分享,这就导致FileProvider的分享方式并不完善,需要app同步进行适配。因此我们考虑能不能在不改变分享方式的前提下,在系统侧进行适配。经过多种方案的对比和尝试,我们最终决定在Chooser中进行Uri的转换和适配,这样就解决了大部分app媒体资源分享失败的问题。
对Chooser比较熟悉的同学可能知道,最终Chooser会通过startActivityAsCaller的方式去启动Activity,而我们就在调用这个接口之前对Intent中的Uri进行适当的转换,以满足双开的需求。
代码片段:
if(isDoubleOpen()){ //判断双开是否打开
Uri shareUri = (Uri)mResolvedIntent.getParcelableExtra(Intent.EXTRA_STREAM); //从Intent中获取Uri
if(shareUri != null){
String uriScheme = shareUri.getScheme();
String uriAuthority = shareUri.getAuthority();
if (getApplicationContext().getContentResolver().SCHEME_CONTENT.equals(uriScheme)
&& uriAuthority != null && uriAuthority.contains(MediaStore.AUTHORITY)) {
Uri uriWithoutUserid = ContentProvider.getUriWithoutUserId(shareUri); //去掉Uri中的UserId
if (userId == UserHandle.USER_SYSTEM) {
mResolvedIntent.putExtra(Intent.EXTRA_STREAM, ContentProvider.maybeAddUserId(uriWithoutUserid, userId));
} else { //针对非主用户进行Uri的转换
String oldPath = getDataColumn(getApplicationContext(), uriWithoutUserid); //从主用户Mediaprovider数据库中查询资源Path
String newPath = pathConvert(oldPath); //将主空间的资源Path根据一定的规则转换成其它空间的资源Path
Uri uriWithoutid = ContentUris.removeId(uriWithoutUserid);
Uri mediaCSpaceUri = ContentProvider.maybeAddUserId(uriWithoutid, userId);
mResolvedIntent.putExtra(Intent.EXTRA_STREAM, getUriFromPath(getApplicationContext(), mediaCSpaceUri, newPath)); //从其他用户中查询资源Path对应的Uri并更新Intent
}
}
}
}
其中,由于用到了卷交叉挂载的技术,导致多用户之间的资源Path挂载路径会有所不同,所以我们需要通过pathConvert()接口,并根据系统中挂载卷的名称和规则进行Path的转换,这样才能在数据库中query到正确的资源Path,并获取到对应的资源Uri。
当然,除了根据资源的Path去映射多用户Mediaprovider数据库的对应关系,也可以根据数据库中的其它字段来进行同样的转换操作,读者可以自己去尝试一下。
还有一点需要注意,为了让其它用户的app能去对应的Mediaprovider数据库查询数据,我们务必要在Uri中加上对应用户的UserId,格式如content://UserId@media/external/images/media/328,这样才能在ContentResolver中获取到正确的Mediaprovider。
这个方案,经过验证,不但可以解决上述问题,还不用推动app层进行一些修改,比老的方案完美。