1、問題背景
我們的雙開方案是基于Google的多使用者空間方案;Google多使用者方案的設計思想,是為了解決工作使用者空間(work profile)和私人使用者空間(personal profile)的資料安全的,是以兩個使用者空間的資料是并行的,互相之間是無法直接通路和分享的。
在開發應用雙開的過程中,需要支援在相冊等app裡面分享圖檔到分身微信空間。
2、問題描述
在相冊分享圖檔到微信分身,Toast提示“擷取資源失敗”,分享失敗。
這個就是Google原生邏輯中,兩個profile之間不讓直接分享圖檔導緻的;是以接下來要做的是打破兩個使用者空間的資料保護壁壘。
3、初步解決方案
為了盡快解決問題,前期我們發現原生的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>
- 配置provider_paths
和res并列,添加xml檔案夾,并添加xml檔案provider_paths.xml:
<?xml version="1.0" encoding="utf-8"?>
<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>
- 配置URI權限:
\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;
}
}
- app系統簽名
在AndroidMenifest配置如下,另外還需要對apk進行platform簽名;
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.coolos.myapp"
coreApp="true"
android:sharedUserId="android.uid.system"
>
- Demo驗證
以 "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層進行一些修改,比老的方案完美。
作者:David Yang
來源-微信公衆号:酷派技術團隊
出處:https://mp.weixin.qq.com/s/kWYOaB3eZPLgVHwJYrCj1A