Android10適配-作用域存儲

背景介紹

android 10已經推出來一段時間了,因爲用戶反饋,公司的demo在android10手機上有問題,適配的問題便被提上了日程。首先先給出官方文檔的地址:外部存儲訪問權限範圍限定爲應用文件和媒體
本文章主要參考OPPO對androidQ的適配指南,並結合華爲給出的適配指南及網絡上的優秀文章整理而來。

哪些應用需要適配

對於以 Android 10 及更高版本爲目標平臺的新安裝應用,需要進行作用域存儲適配。
這裏需要介紹一下android 10的兩種運行模式:Filtered View和Legacy View(兼容模式)。

1、Filtered View:App可以直接訪問App-specific目錄,但不能直接訪問App-specific外的文件。訪問公共目錄或其他APP的App-specific目錄,只能通過MediaStore、SAF、或者其他APP 提供的ContentProvider、FileProvider等訪問。
2、Legacy View:兼容模式。與Android Q以前一樣,申請權限後App可訪問外部存儲,擁有完整的訪問權限。

默認情況下在Android Q上,target SDK大於或等於29的APP默認被賦予Filtered View,反之則默認被賦予Legacy View。
作用域存儲只對Android Q上新安裝的APP生效。
設備從Android Q之前的版本升級到Android Q,已安裝的APP獲得Legacy View視圖。這些APP如果直接通過路徑的方式將文件保存到了外部存儲上,例如外部存儲的根目錄,那麼APP被卸載後重新安裝,新的APP獲得Filtered View視圖,無法直接通過路徑訪問到舊數據,導致數據丟失。
APP可以在AndroidManifest.xml中設置新屬性requestLegacyExternalStorage來修改外部存儲空間視圖模式,true爲Legacy View,false爲Filtered View。可以使用Environment.isExternalStorageLegacy()這個API來檢查APP的運行模式。
設置如下:

<application
    ...
    android:requestLegacyExternalStorage="false"
    android:label="@string/app_name">
    
    ...

</application>

權限適配

Android Q仍然使用READ_EXTERNAL_STORAGEWRITE_EXTERNAL_STORAGE作爲面向用戶的存儲相關運行時權限。
在作用域存儲新特性中,外部存儲空間被分爲兩部分:

1、公共目錄
包括:Downloads、Documents、Pictures 、DCIM、Movies、Music、Ringtones等。
公共目錄下的文件在APP卸載後,不會刪除。
APP可以通過SAF(System Access Framework)、MediaStore接口訪問其中的文件。
2、App-specific目錄
APP卸載後,數據會清除。
APP的私密目錄,APP訪問自己的App-specific目錄時無需任何權限。

我們的應用訪問私用目錄(App-specific目錄)及向媒體庫貢獻的圖片、音頻或視頻,將會自動擁有其讀寫權限,不需要額外申請READ_EXTERNAL_STORAGE和WRITE_EXTERNAL_STORAGE權限。
讀取其他應用程序向媒體庫貢獻的圖片、音頻或視頻,則必須要申請READ_EXTERNAL_STORAGE權限纔行。WRITE_EXTERNAL_STORAGE權限將會在未來的Android版本中廢棄。(此段引自:Android 10適配要點,作用域存儲)
注:之前文檔上的READ_MEDIA_AUDIO、READ_MEDIA_IMAGES、READ_MEDIA_VIDEO已經被刪除了。

讀取及寫入私有目錄下的文件

無需任何權限。
APP即可直接使用文件路徑來讀寫自身App-specific目錄下的文件。
獲取App-specific目錄路徑的接口如下表所示:

App-specific目錄 接口(所有存儲設備) 接口(Primary External Storage)
Media getExternalMediaDirs() NA
Obb getObbDirs() getObbDir()
Cache getExternalCacheDirs() getExternalCacheDir()
Data getExternalFilesDirs(String type) getExternalFilesDir(String type)

需要說明的一點就是,getExternalFilesDirs(String type)中的type,可以自己指定,比如String type = “chat”, 當然也可以用Environment.DIRECTORY_PICTURES等作爲type。

新建並寫入文件爲例:

// set "chat" as subDir
final File[] dirs = getExternalFilesDirs("chat");
File primaryDir = null;
if (dirs != null && dirs.length > 0) {
    primaryDir = dirs[0];
}
if (primaryDir == null) {
    return;
}
File newFile = new File(primaryDir.getAbsolutePath(), "MyTestChat");
OutputStream fileOS = null;
try {
    fileOS = new FileOutputStream(newFile);
    if (fileOS != null) {
        fileOS.write("file is created".getBytes(StandardCharsets.UTF_8));
        fileOS.flush();
    }
} catch (IOException e) {
    LogUtil.log("create file fail");
} finally {
    try {
        if (fileOS != null) {
            fileOS.close();
        }
    } catch (IOException e1) {
        LogUtil.log("close stream fail");
    }
}

讀取公共區域的文件,並寫入App-specific:

	File imgFile = this.getExternalFilesDir("image");
    if (!imgFile.exists()){
        imgFile.mkdir();
    }
    try {
        File file = new File(imgFile.getAbsolutePath() + File.separator +
            System.currentTimeMillis() + ".jpg");
        // 使用openInputStream(uri)方法獲取字節輸入流
        InputStream fileInputStream = getContentResolver().openInputStream(uri);
        FileOutputStream fileOutputStream = new FileOutputStream(file);
        byte[] buffer = new byte[1024];
        int byteRead;
        while (-1 != (byteRead = fileInputStream.read(buffer))) {
            fileOutputStream.write(buffer, 0, byteRead);
        }
        fileInputStream.close();
        fileOutputStream.flush();
        fileOutputStream.close();
        // 文件可用新路徑 file.getAbsolutePath()
    } catch (Exception e) {
        e.printStackTrace();        
    }

訪問及寫入公共目錄下的多媒體文件

APP通過MediaStore訪問文件所需要的權限:

無權限 READ_EXTERNAL
Audio 可讀寫APP自己創建的文件,但不可直接使用路徑訪問 可以讀其他APP創建的媒體類文件,刪改操作需要用戶授權
Image
Video
File 不可讀寫其他APP創建的非媒體類文件
Downloads
APP無法直接訪問公共目錄下的文件。MediaStore爲APP提供了訪問公共目錄下媒體文件的接口。APP在有適當權限時,可以通過MediaStore 查詢到公共目錄文件的Uri,然後通過Uri讀寫文件。

多媒體文件讀取
通過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;
}

多媒體文件保存
應用只能在私用目錄下通過文件路徑的方式保存文件,如果需要保存文件到公共目錄,需要使用特定的接口實現。
1、通過MediaStore插入多媒體文件
通過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);
}

如果有圖片的路徑,可以使用如下方法

public static void saveBitmapToFile(Context context, String imagePath, String title, String discription) {
    MediaStore.Images.Media.insertImage(context.getContentResolver(), imagePath, title, discription);
}

2、通過ContentResolver的insert方法將多媒體文件保存到多媒體的公共集合目錄

/**
* 保存多媒體文件到公共集合目錄
* @param uri:多媒體數據庫的Uri
* @param context
* @param mimeType:需要保存文件的mimeType
* @param 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字段來設置一級目錄和二級目錄。
多媒體文件的編輯和修改
應用只有自己插入的多媒體文件的寫權限,沒有別的應用插入的多媒體文件的寫權限,比如使用下面的代碼刪除別的應用的多媒體文件會因爲權限問題導致刪除失敗:

context.getContentResolver().delete(uri, null, null))

根據查詢得到的文件Uri,使用MediaStore修改其他APP新建的多媒體文件,需要catch RecoverableSecurityException ,由MediaProvider彈出彈框給用戶選擇是否允許APP修改或刪除圖片/視頻/ 音頻文件。用戶操作的結果,將通過onActivityResult回調返回到APP。如果用戶允許,APP將獲得該Uri 的修改權限,直到設備下一次重啓。
根據文件Uri,通過下列接口,獲取需要修改文件的FD或者OutputStream:

1、getContentResolver().openOutputStream(contentUri)獲取對應文件的OutputStream。
2、getContentResolver().openFile或者getContentResolver().openFileDescriptor。

通過openFile或者openFileDescriptor打開文件,需要選擇Mode爲”w”,表示寫權限。 這些接口返回一個ParcelFileDescriptor。

OutputStream os = null;
try {
	if (imageUri != null) {
		os = resolver.openOutputStream(imageUri);
	}
} catch (IOException e) {
	LogUtil.log("open image fail");
} catch (RecoverableSecurityException e1) {
	LogUtil.log("get RecoverableSecurityException");
	try {
		((Activity) context).startIntentSenderForResult(
e1.getUserAction().getActionIntent().getIntentSender(),
100, null, 0, 0, 0);
	} catch (IntentSender.SendIntentException e2) {
		LogUtil.log("startIntentSender fail");
	}
}

讀取及寫入公共目錄下的非多媒體文件

當我們想要讀取公共目錄下的非多媒體文件,比如PDF文件,這個時候就不能再使用MediaStore API了,需要用到SAF(即Storage Access Framework)。
根據當前系統中存在的DocumentsProvider,讓用戶選擇特定的文件或文件夾,使調用SAF的APP獲取它們的讀寫權限。APP通過SAF獲得文件或目錄的讀寫權限,無需申請任何存儲相關的運行時權限。
使用SAF獲取文件或目錄權限的過程:

APP通過特定Intent調起DocumentUI -> 用戶在DocumentUI界面上選擇要授權的文件或目錄 -> APP在回調中解析文件或目錄的Uri,最後根據這一Uri可進行讀寫刪操作。

使用SAF選擇單個文件
使用Intent.ACTION_OPEN_DOCUMENT調起DocumentUI的文件選擇頁面,用戶可以選擇一個文件,將它的讀寫權限授予APP。

Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
// you can set type to filter files to show
intent.setType("*/*");
startActivityForResult(intent, REQUEST_CODE_FOR_SINGLE_FILE);

在這裏插入圖片描述
使用SAF修改文件
用戶選擇文件授權給APP後,在APP的onActivityResult 回調中收到返回結果,解析出對應文件的Uri。然後使用該Uri,用戶可以獲取可寫的ParcelFileDescriptor或者打開OutputStream 進行修改。

if (requestCode == REQUEST_CODE_FOR_SINGLE_FILE && resultCode == Activity.RESULT_OK) {
    Uri fileUri = null;
    if (data != null) {
        fileUri = data.getData();
    }
    if (fileUri != null) {
        OutputStream os = null;
        try {
            os = getContentResolver().openOutputStream(fileUri);
            os.write("something".getBytes(StandardCharsets.UTF_8));
        } catch (IOException e) {
            LogUtil.log("modify document fail");
        } finally {
            if (os != null) {
                try {
                    os.close();
                } catch (IOException e1) {
                    LogUtil.log("close fail");
                }
            }
        }
    }
}

使用SAF刪除文件
類似修改文件,在回調中解析出文件Uri,然後使用DocumentsContract.deleteDocument接口進行刪除操作。

if (requestCode == REQUEST_CODE_FOR_SINGLE_FILE && resultCode == Activity.RESULT_OK) {
    Uri fileUri = null;
    if (data != null) {
        fileUri = data.getData();
    }
    if (fileUri != null) {
        try {
            DocumentsContract.deleteDocument(getContentResolver(), fileUri);
        } catch (FileNotFoundException e) {
            LogUtil.log("delete document fail");
        }
    }
}

使用SAF新建文件

Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
// you can set file mimetype
intent.setType("*/*");
// default file name
intent.putExtra(Intent.EXTRA_TITLE, "myFileName");
startActivityForResult(intent, REQUEST_CODE_FOR_CREATE_FILE);

在這裏插入圖片描述
在用戶確定後,操作結果將返回到APP的onActivityResult回調中,APP解析出文件Uri,之後就可以利用這一 Uri對文件進行讀寫刪操作。

if (requestCode == REQUEST_CODE_FOR_CREATE_FILE && resultCode == Activity.RESULT_OK) {
    Uri fileUri = null;
    if (data != null) {
        fileUri = data.getData();
    }
    // read/update/delete by the uri got here.
    LogUtil.log("uri: " + fileUri);
}

其他需要注意的

(1)卸載應用
如果APP在AndroidManifest.xml中聲明:android:hasFragileUserData=“true”,卸載應用會有提示是否保留 APP數據。默認應用卸載時App-specific目錄下的數據被刪除,但用戶可以選擇保留。
(2)DATA字段數據不再可靠
MediaStore中,DATA(即_data)字段,在Android Q中開始廢棄,不再表示文件的真實路徑。讀寫文件或判斷文件是否存在,不應該使用 DATA字段,而要使用openFileDescriptor。
同時也無法直接使用路徑訪問公共目錄的文件。
(3)MediaStore.Files接口自過濾
通過MediaStore.Files接口訪問文件時,只展示多媒體文件(圖片、視頻、音頻)。其他文件,例如PDF文件,無法訪問到。
(4)Native代碼訪問文件
如果Native代碼需要訪問文件,可以參考下面方式:

  • 通過openFileDescriptor返回ParcelFileDescriptor
  • 通過ParcelFileDescriptor.detachFd()讀取FD
  • 將FD傳遞給Native層代碼
  • 通過close接口關閉FD
String fileOpenMode = "r";
ParcelFileDescriptor parcelFd = resolver.openFileDescriptor(uri, fileOpenMode);
if (parcelFd != null) {
	int fd = parcelFd.detachFd();
	// Pass the integer value "fd" into your native code. Remember to call
	// close(2) on the file descriptor when you're done using it.
}

關於close(2)的用法,見:CLOSE(2)

參考文檔:

1、OPPO:Android Q版本應用兼容性適配指導
2、華爲AndroidQ適配(這個鏈接華爲已經刪除了)
3、官方文檔
4、SAF相關的Google官方文檔
5、AndroidQ(10)分區存儲完美適配
6、Android 10適配要點,作用域存儲

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章