安卓Q | 諸多本地文件找不到?應用文件存儲空間沙箱化適配指導
上期我們針對Android Q 版本中對設備存儲空間進行的限制、新特性變更引發的兼容性問題及原因分析推出了《安卓 Q | 8大場景全面解析應用存儲沙箱化》文章,本期文章我們將手把手指導各位應用開發者如何針對以上特性進行適配。
文件共享適配指導 1、使用FileProvider的Content Uri替換File Uri
2、參考谷歌提供的適配指導鏈接:
https://developer.android.com/training/secure-file-sharing
3、大致的適配流程總結:
■ 指定應用的FileProvider
<provider
android:name="android.support.v4.content.FileProvider"
android:authorities="com.huawei.qappcompatissues.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"/>
</provider>
■ 指定應用分享的文件路徑,在res/xml/目錄增加文件file_paths.xml文件
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="external" path="" />
</paths>
■ 獲得分享文件的Content Uri fileUri = FileProvider.getUriForFile( this, "com.huawei.qappcompatissues.fileprovider", picFile); ■ 臨時授予文件接收方的文件讀寫權限
// Grant temporary read permission to the content URI
intent.addFlags(
Intent.FLAG_GRANT_READ_URI_PERMISSION);
■ 分享文件完整代碼
private void sharePicFile(File picFile) {
try {
// Use the FileProvider to get a content URI
fileUri = FileProvider.getUriForFile(
this,
"com.huawei.qappcompatissues.fileprovider",
picFile);
Log.e("test", "fileUri:" + fileUri);
if (fileUri != null) {
Intent intent = new Intent(Intent.ACTION_SEND);
// Grant temporary read permission to the content URI
intent.addFlags(
Intent.FLAG_GRANT_READ_URI_PERMISSION);
// Put the Uri and MIME type in the result Intent
intent.setDataAndType(
fileUri,
getContentResolver().getType(fileUri));
startActivity(Intent.createChooser(intent, "test file share"));
} else {
Toast.makeText(this, "share file error", Toast.LENGTH_SHORT).show();
}
} catch (IllegalArgumentException e) {
Log.e("File Selector",
"The selected file can‘t be shared: " + picFile.toString());
}
■ 接收方讀取文件,比如接收圖片文件:
AndroidManifest.xml文件中添加intent過濾器:
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
通過Intent讀取圖片,content uri:content://com.huawei.qappcompatissues.fileprovider/external/test.jpg
ImageView imageView = findViewById(R.id.imageView);
Intent intent = getIntent();
String action = intent.getAction();
String type = intent.getType();
if (Intent.ACTION_SEND.equals(action) && type != null) {
// Get the file‘s content URI from the incoming Intent
Uri returnUri = intent.getData();
if (type.startsWith("image/")) {
Log.e("test", "open image file:" + returnUri);
try {
Bitmap bmp = getBitmapFromUri(returnUri);
imageView.setImageBitmap(bmp);
} catch (IOException e) {
e.printStackTrace();
}
}
} 通過Content Uri讀取圖片:
public static Bitmap getBitmapFromUri(Context context, Uri uri) throws IOException {
ParcelFileDescriptor parcelFileDescriptor =
context.getContentResolver().openFileDescriptor(uri, "r");
FileDescriptor fileDescriptor = parcelFileDescriptor.getFileDescriptor();
Bitmap image = BitmapFactory.decodeFileDescriptor(fileDescriptor);
parcelFileDescriptor.close();
return image;
}
權限適配指導
1、應用讀寫自己生成的文件不需要申請任何權限
2、應用如果需要讀取其他應用保存的多媒體公共集合文件,就需要申請對應的權限:
■ 音樂文件:
android.permission.READ_MEDIA_AUDIO
■ 照片文件:
android.permission.READ_MEDIA_IMAGES
■ 視頻文件:
android.permission.READ_MEDIA_VIDEO
3、谷歌提供的兼容性方案:
■ 應用的targetSdkVersion<Q,只要應用動態申請了READ_EXTERNAL_STORAGE/WRITE_EXTERNAL_STORAGE權限,系統會自動將該權限轉成新增的:android.permission.READ_MEDIA_AUDIO、android.permission.READ_MEDIA_IMAGES和android.permission.READ_MEDIA_VIDEO權限
4、適配指導:
TargetSdkVersion<Q的應用不適配也不會有問題,只有TargetSdkVersion>=Q的應用需要適配,否則會導致沒有權限訪問多媒體文件:
■ 需要在 AndroidManifest.xml 中新增 uses-permissions 聲明 (不一定全要,請根據實際業務需要訪問音頻,圖片還是視頻選擇必須的; 如果完全不需要訪問媒體類的文件,只是訪問普通下載文件,下列權限都是不需要申請的)
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
■ 在代碼中使用權限前調用checkSelfPermission檢查權限是否授權,未授權情況下調用requestPermission動態申請上述權限,讓用戶通過彈框確認。
■ 同時兼容Q和Q之前的安卓版本:
◆ 在AndroidManifest.xml 中同時uses-permission聲明新老權限;
◆ 在代碼中通過API level來區分,當API level低於Q時,運行P版本舊的權限的動態授權代碼;大於等於Q時運行新的權限的動態授權代碼;
private void requestPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
if (ContextCompat.checkSelfPermission(this,
Manifest.permission.READ_MEDIA_IMAGES)
!= PackageManager.PERMISSION_GRANTED || ContextCompat.checkSelfPermission(this,
Manifest.permission.READ_MEDIA_AUDIO)
!= PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this,
new String[]{Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_AUDIO},
MY_PERMISSIONS_REQUEST_READ_MEDIA_IMAGES);
}
} else {
// request old storage permission
}
}
本地多媒體文件讀寫適配指導
1、多媒體文件讀取
■ 多媒體文件和下載文件讀取接口
■ 通過ContentProvider查詢文件,獲得需要讀取的文件Uri:
public static List<Uri> loadPhotoFiles(Context context) {
Log.e(TAG, "loadPhotoFiles");
List<Uri> photoUris = new ArrayList<Uri>();
Cursor cursor = context.getContentResolver().query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, new String[]{MediaStore.Images.Media._ID}, null, null, null);
Log.e(TAG, "cursor size:" + cursor.getCount());
while (cursor.moveToNext()) {
int id = cursor.getInt(cursor
.getColumnIndex(MediaStore.Images.Media._ID));
Uri photoUri = Uri.parse(MediaStore.Images.Media.EXTERNAL_CONTENT_URI.toString() + File.separator + id);
Log.e(TAG, "photoUri:" + photoUri);
photoUris.add(photoUri);
}
return photoUris;
}
■ 通過Uri讀取文件:
public static Bitmap getBitmapFromUri(Context context, Uri uri) throws IOException {
ParcelFileDescriptor parcelFileDescriptor =
context.getContentResolver().openFileDescriptor(uri, "r");
FileDescriptor fileDescriptor = parcelFileDescriptor.getFileDescriptor();
Bitmap image = BitmapFactory.decodeFileDescriptor(fileDescriptor);
parcelFileDescriptor.close();
return image;
}
■ MediaProvider變更適配指導
MediaProvider中的“_data”字段已經廢棄掉了,開發者不能再認為該字段保存的是文件的真實路徑,Q版本因為存儲空間限制的變更,應用已經無法直接通過文件路徑讀取文件,需要使用文件的Content URI讀取文件,目前發現有很多應用通過“_data”值作為文件的真實路徑在加載顯示圖片之前判斷文件是否存在,這樣的做法在Q版本是有問題的,應用需要整改。
2、多媒體文件保存
應用只能在沙箱內通過文件路徑的方式保存文件,如果需要保存文件到沙箱目錄外,需要使用特定的接口實現,具體可參考:
■ 方式1:
通過MediaStore.Images.Media.insertImage接口可以將圖片文件保存到/sdcard/Pictures/,但是只有圖片文件保存可以通過MediaStore的接口保存,其他類型文件無法通過該接口保存;
public static void saveBitmapToFile(Context context, Bitmap bitmap, String title, String discription) {
MediaStore.Images.Media.insertImage(context.getContentResolver(), bitmap, title, discription);
}
■ 方式2:
通過ContentResolver的insert方法將多媒體文件保存到多媒體的公共集合目錄;
/**
* 保存多媒體文件到公共集合目錄
* [@param](https://my.oschina.net/u/2303379) uri:多媒體數據庫的Uri
* [@param](https://my.oschina.net/u/2303379) context
* [@param](https://my.oschina.net/u/2303379) mimeType:需要保存文件的mimeType
* [@param](https://my.oschina.net/u/2303379) displayName:顯示的文件名字
* @param description:文件描述信息
* @param saveFileName:需要保存的文件名字
* @param saveSecondaryDir:保存的二級目錄
* @param savePrimaryDir:保存的一級目錄
* @return 返回插入數據對應的uri
*/
public static String insertMediaFile(Uri uri, Context context, String mimeType,
String displayName, String description, String saveFileName, String saveSecondaryDir, String savePrimaryDir) {
ContentValues values = new ContentValues();
values.put(MediaStore.Images.Media.DISPLAY_NAME, displayName);
values.put(MediaStore.Images.Media.DESCRIPTION, description);
values.put(MediaStore.Images.Media.MIME_TYPE, mimeType);
values.put(MediaStore.Images.Media.PRIMARY_DIRECTORY, savePrimaryDir);
values.put(MediaStore.Images.Media.SECONDARY_DIRECTORY, saveSecondaryDir);
Uri url = null;
String stringUrl = null; /* value to be returned */
ContentResolver cr = context.getContentResolver();
try {
url = cr.insert(uri, values);
if (url == null) {
return null;
}
byte[] buffer = new byte[BUFFER_SIZE];
ParcelFileDescriptor parcelFileDescriptor = cr.openFileDescriptor(url, "w");
FileOutputStream fileOutputStream =
new FileOutputStream(parcelFileDescriptor.getFileDescriptor());
InputStream inputStream = context.getResources().getAssets().open(saveFileName);
while (true) {
int numRead = inputStream.read(buffer);
if (numRead == -1) {
break;
}
fileOutputStream.write(buffer, 0, numRead);
}
fileOutputStream.flush();
} catch (Exception e) {
Log.e(TAG, "Failed to insert media file", e);
if (url != null) {
cr.delete(url, null, null);
url = null;
}
}
if (url != null) {
stringUrl = url.toString();
}
return stringUrl;
}
比如你需要把一個圖片文件保存到/sdcard/dcim/test/下面,可以這樣調用:
SandboxTestUtils.insertMediaFile(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, this, "image/jpeg", "insert_test_img", "test img save use insert", "if_apple_2003193.png", "test", Environment.DIRECTORY_DCIM);
音頻和視頻文件也是可以通過這個方式進行保存,比如音頻文件保存到/sdcard/Music/test/:
SandboxTestUtils.insertMediaFile(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, this, "audio/mpeg", "insert_test_music", "test audio save use insert", "Never Forget You.mp3", "test", Environment.DIRECTORY_MUSIC);
可以通過PRIMARY_DIRECTORY和SECONDARY_DIRECTORY字段來設置一級目錄和二級目錄:
■ 一級目錄必須是和MIME type的匹配的根目錄下的Public目錄,一級目錄可以不設置,不設置時會放到默認的路徑;
■ 二級目錄可以不設置,不設置時直接保存在一級目錄下
■ 應用生成的文檔類文件,代碼裏面默認不設置時,一級是Downloads目錄,也可以設置為Documents目錄,建議推薦三方應用把文檔類的文件一級目錄設置為Documents目錄。
■ 一級目錄MIME type,默認目錄、允許的目錄映射以及對應的讀取權限如下表所示:
3、多媒體文件的編輯和修改
應用只有自己插入的多媒體文件的寫權限,沒有別的應用插入的多媒體文件的寫權限,比如使用下面的代碼刪除別的應用的多媒體文件會因為權限問題導致刪除失敗:
context.getContentResolver().delete(uri, null, null))
對於需要修改和刪除別的應用保存的多媒體文件的適配建議:
■ 方式1:
如果應用需要修改其他應用插入的多媒體文件,需要作為系統默認應用,比如作為系統默認圖庫,可以刪除和修改其他應用的圖片和視頻文件;作為系統的默認音樂播放軟件,可以刪除和修改其他應用的音樂文件。
參考谷歌提供的適配指導:https://developer.android.google.cn/preview/features/roles#check-default-app
在AndroidManifest文件增加對應的權限和默認應用intent過濾器的申明
<activity
android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
<category android:name="android.intent.category.APP_GALLERY" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
在啟動應用頁面裏面增加是不是默認應用的判斷:
//設置默認應用
RoleManager roleManager = getSystemService(RoleManager.class);
if (roleManager.isRoleAvailable(RoleManager.ROLE_GALLERY)) {
if (roleManager.isRoleHeld(RoleManager.ROLE_GALLERY)) {
// This app is the default gallery app.
Log.e(TAG, "This app is the default gallery app");
} else {
// This app isn‘t the default gallery app, but the role is available,
// so request it.
Log.e(TAG, "This app isn‘t the default gallery app");
Intent roleRequestIntent = roleManager.createRequestRoleIntent(
RoleManager.ROLE_GALLERY);
startActivityForResult(roleRequestIntent, ROLE_REQUEST_CODE);
}
}
用戶設置您的App為默認音樂應用之後,就有權限寫其他應用保存的音樂文件了。另外其他的類型多媒體文件也可以按照同樣的方式處理:
■ 方式2:
使用ContentResolver對象查找文件並進行修改或者刪除。執行修改或刪除操作時,捕獲RecoverableSecurityException,以便您可以請求用戶授予您對多媒體文件的寫入權限。(備註:目前這一塊代碼還未完全ready,當前版本無法通過這個方式完成多媒體文件刪除。)
在任意的指定目錄讀寫文件適配指導
1、使用SAF方式適配
2、參考谷歌提供的適配指導:
https://developer.android.com/guide/topics/providers/document-provider
3、參考實現代碼:
■ 讀取和修改文件
通過Intent傳入ACTION_OPEN_DOCUMENT拉起DocumentUI:
public void performFileSearch() {
// ACTION_OPEN_DOCUMENT is the intent to choose a file via the system‘s file
// browser.
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
// Filter to only show results that can be "opened", such as a
// file (as opposed to a list of contacts or timezones)
intent.addCategory(Intent.CATEGORY_OPENABLE);
// Filter to show only images, using the image MIME data type.
// If one wanted to search for ogg vorbis files, the type would be "audio/ogg".
// To search for all documents available via installed storage providers,
// it would be "*/*".
intent.setType("image/*");
startActivityForResult(intent, READ_REQUEST_CODE);
}
在拉起的DocumentUI中用戶可以選擇需要打開的圖片文件:
DocumentUI會把用戶選擇的圖片文件的Content Uri通過intent傳回給應用,應用在onActivityResult回調中就可以拿到這個Uri,通過Uri讀取或者修改文件,比如打開文件:
if (requestCode == READ_REQUEST_CODE && resultCode == Activity.RESULT_OK) {
// The document selected by the user won‘t be returned in the intent.
// Instead, a URI to that document will be contained in the return intent
// provided to this method as a parameter.
// Pull that URI using resultData.getData().
Uri uri = null;
if (resultData != null) {
uri = resultData.getData();
Log.i(TAG, "Uri: " + uri.toString());
showImage(uri);
}
}
private void showImage(Uri uri) {
try {
((ImageView) findViewById(R.id.imageView)).setImageBitmap(getBitmapFromUri(uri));
} catch (IOException e) {
e.printStackTrace();
}
}
private Bitmap getBitmapFromUri(Uri uri) throws IOException {
ParcelFileDescriptor parcelFileDescriptor =
getContentResolver().openFileDescriptor(uri, "r"www.qwert888.com/);
FileDescriptor fileDescriptor = parcelFileDescriptor.getFileDescriptor();
Bitmap image = BitmapFactory.decodeFileDescriptor(fileDescriptor);
parcelFileDescriptor.close();
return image;
}
修改文件:
try {
ParcelFileDescriptor pfd = getContentResolver().
openFileDescriptor(uri, www.jrgjze.com"w");
FileOutputStream fileOutputStream =
new FileOutputStream(pfd.getFileDescriptor());
fileOutputStream.write((
System.currentTimeMillis() + " edit file by saf\n").getBytes());
// Let the document provider know you‘re done by closing the stream.
fileOutputStream.close();
pfd.close();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
刪除文件:
Uri uri = null;
if (resultData != null) {
uri = resultData.getData();
Log.i(TAG, "delete Uri: " + uri.toString());
// showImage(uri);
final int takeFlags =www.njdxtm.com getIntent().getFlags()
& (Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
// Check for the freshest data.
getContentResolver().takePersistableUriPermission(uri, takeFlags);
try {
DocumentsContract.deleteDocument(getContentResolver(), uri);
} catch (FileNotFoundException e) {
e.printStackTrace();
}
}
■ 新建文件
private void createFile(String mimeType, String fileName) {
Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
// Filter to only show results that can be "opened", such as
// a file (as opposed to a list of contacts or timezones).
intent.addCategory(Intent.CATEGORY_OPENABLE);
// Create a file with the requested MIME type.
intent.setType(mimeType);
intent.putExtra(Intent.EXTRA_TITLE, fileName);
startActivityForResult(intent, WRITE_REQUEST_CODE);
}
用戶通過拉起的DocumentUI選擇文件保存的目錄,點擊保存:
用戶點擊保存之後,DocumentUI就會把需要保存的文件的Content Uri通過intent傳回給應用:
if (requestCode == WRITE_REQUEST_CODE && resultCode == Activity.RESULT_OK) {
// The document selected by the user won‘t be returned in the intent.
// Instead, a URI to that document will be contained in the return intent
// provided to this method as a parameter.
// Pull that URI using resultData.getData().
Uri uri = null;
if (resultData != null) {
uri = resultData.getData();
Log.i(TAG, "Uri: " + uri.toString());
// showImage(uri);
final int takeFlags = getIntent(www.zhongyiyul.cn).getFlags()
& (Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
// Check for the freshest data.
getContentResolver(www.tiaotiaoylzc.com).takePersistableUriPermission(uri, takeFlags);
writeFile(uri);
}
}
寫文件:
private void writeFile(Uri uri) {
try {
ParcelFileDescriptor pfd = getContentResolver().
openFileDescriptor(uri, "w");
FileOutputStream fileOutputStream =
new FileOutputStream(pfd.getFileDescriptor());
fileOutputStream.write(("Overwritten by MyCloud at " +
System.currentTimeMillis(www.yunshengyule178.com) + "\n").getBytes());
// Let the document provider know you‘re done by closing the stream.
fileOutputStream.close(www.yongshiyule178.com);
pfd.close();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
應用卸載之後應用文件刪除適配指導
應用通過路徑生成的文件都是存放在應用的沙箱目錄下面,應用卸載的時候,應用不做適配,應用的整個沙箱目錄都會被直接刪除,如果應用有一些用戶主動保存的文件不希望在應用被卸載的時候刪除需要如何做呢?有兩個方法:
■ 推薦方法:
把不希望刪除的文件通過MediaProvider或者SAF的接口保存在公共集合目錄下面,具體可以參考前面的適配章節內容,保存在公共集合目錄的文件在應用卸載的時候默認會彈框提示用戶是否刪除這些文件,對應下面彈框的第一個勾選,默認保留,勾選刪除,谷歌後續的版本計劃把這個勾選去掉,意味著應用保存到公共集合目錄的文件卸載的時候不會提示用戶刪除。
■ 方法2:
在應用的AndroidManifest.xml文件增加:<application android:fragileUserData=”true” />,應用不增加該屬性的話,應用能卸載的時候應用保存在沙箱目錄的文件會直接被刪除,不會彈框提示。只有應用增加了這個屬性,並且設置的值是為true,在應用被卸載的時候,才會彈框提示,對應的是上面圖中的第2個勾選,默認刪除,勾選保留。
以上就是我們關於Android Q 版本對設備存儲空間進行的限制、新特性變更引發的兼容性問題及原因分析以及各應用廠商該如何適配這些變動點進行的重點分享與講解。目前Android Q beta 2測試版本已經默認開啟沙箱化特性,請廣大開發者盡快適配!
安卓Q | 諸多本地文件找不到?應用文件存儲空間沙箱化適配指導