Android 權(quán)限管理 — 只防君子不防小人

存儲優(yōu)化系列專題,來聊一聊開發(fā)過程中常見存儲方法的優(yōu)缺點(diǎn),希望可以幫你在日常工作中如何做出更好的選擇。


Android 存儲優(yōu)化系列專題
  • SharedPreferences 系列

Android 之不要濫用 SharedPreferences(上)
Android 之不要濫用 SharedPreferences(下)

  • ContentProvider 系列(待更)

Android 存儲選項之 ContentProvider 的啟動性能
《Android 存儲選項之 ContentProvider 深入分析》

  • 對象序列化系列

Android 對象序列化之你不知道的 Serializable
Android 對象序列化之 Parcelable 取代 Serializable ?
Android 對象序列化之追求性能完美的 Serial

  • 數(shù)據(jù)序列化系列(待更)

《Android 數(shù)據(jù)序列化之 JSON》
《Android 數(shù)據(jù)序列化之 Protocol Buffer》
《基于 MMAP 的高性能 K-V 組件之 MMKV》

  • SQLite 存儲系列

Android 存儲選項之 SQLiteDatabase 創(chuàng)建過程源碼分析
Android 存儲選項之 SQLiteDatabase 源碼分析
數(shù)據(jù)庫連接池 SQLiteConnectionPool 源碼分析
SQLiteDatabase 啟用事務(wù)源碼分析
SQLite 數(shù)據(jù)庫 WAL 模式工作原理簡介
SQLite 數(shù)據(jù)庫鎖機(jī)制與事務(wù)簡介
SQLite 數(shù)據(jù)庫優(yōu)化那些事兒


大多數(shù)開發(fā)者都曾經(jīng)遇到過在存儲設(shè)計上的問題,該問題也是被廣泛討論的老話題了,時至現(xiàn)在的 Android 10 仍然有很多問題在系統(tǒng)層面沒有被很好的解決。

Android 存儲優(yōu)化系列專題先后為大家介紹了系統(tǒng)為我們提供的多種持久化存儲方案,存儲就是把特定的數(shù)據(jù)結(jié)構(gòu)轉(zhuǎn)換成可以被記錄和還原的格式。如果文件涉及到敏感或不想被其他應(yīng)用訪問時該如何選擇呢?今天我們就來聊一聊 Android 存儲的權(quán)限管理。

所有 Android 設(shè)備都有兩個文件存儲區(qū)域:內(nèi)部存儲區(qū)和外部存儲區(qū)。這主要是由于 Android 早期,當(dāng)時大多數(shù)設(shè)備都提供了內(nèi)置的非意易失性內(nèi)存(內(nèi)部存儲),以及可移動的外置存儲介質(zhì),例如 micro SD 卡(外部存儲)。

1. 有限的內(nèi)部存儲

由于早期 Android 手機(jī)自帶存儲空間只有內(nèi)部存儲,而且空間很有限。也是因為這樣的原因,應(yīng)用一般要將語音、圖片、視頻等都放在外置 SD 卡上,否則放入內(nèi)部存儲會很快被用盡存儲空間,導(dǎo)致用戶看到手機(jī)還有空間,而 App 卻不能正常使用。

現(xiàn)在,許多設(shè)備將永久存儲空間劃分為單獨(dú)的內(nèi)部和外部分區(qū)。因此,即使沒有可移動的存儲介質(zhì),這兩個存儲空間也始終存在,并且無論外部存儲是否可移動,API 行為都是相同的。

2. 目前還不安全的外部(私有)儲存

雖然 Android 也提供了不獲取權(quán)限直接可用的外部私有存儲目錄如 Context.getExternalFilesDir()。但目前這樣的設(shè)計對應(yīng)用數(shù)據(jù)的安全來說沒有幫助,因為外置(私有)存儲仍然可以被有權(quán)限的應(yīng)用讀取。

由于外部存儲的不確定性,因此兩種選項之間會存在一些差異,如下圖所示:

乍一看,可能還是不太好理解,到底該怎么理解內(nèi)部存儲和外部存儲呢?當(dāng)要確保其他應(yīng)用或用戶都無法訪問的存儲時,此時內(nèi)部存儲是最佳選擇;而外部存儲是存放那些不需要訪問限制,允許其他應(yīng)用或用戶通過手機(jī)可以直接訪問的文件。

  • 應(yīng)用程序默認(rèn)被安裝到內(nèi)部存儲區(qū)域,但是也可以通過清單文件配置來指定應(yīng)用允許被安裝在外部存儲。這種情況適用于 APK 文件較大且外部空間大于內(nèi)部存儲空間時。具體可以參閱 App 安裝位置。
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    android:installLocation="preferExternal"
    ... >

內(nèi)部存儲

默認(rèn)情況下,保存至內(nèi)部存儲的文件屬于應(yīng)用私有文件,其他應(yīng)用(或用戶)不能訪問這些文件(除非擁有 Root 訪問權(quán)限)。如此一來,內(nèi)部存儲非常適合保存不需要讓用戶直接訪問的應(yīng)用內(nèi)部數(shù)據(jù)。在文件系統(tǒng)中,系統(tǒng)會為每個應(yīng)用提供私有存儲目錄,在該目錄下我們可以進(jìn)一步創(chuàng)建應(yīng)用所需的任何文件。

當(dāng)應(yīng)用被卸載時,保存在內(nèi)部存儲中的文件也隨之被移除。所以,不能依賴內(nèi)部存儲來保存用戶希望獨(dú)立于應(yīng)用而保留的任何數(shù)據(jù)。

按照訪問存儲目錄 API,內(nèi)部存儲可以簡單劃分為:內(nèi)部文件存儲和內(nèi)部緩存存儲。它們都屬于應(yīng)用私有存儲目錄,訪問應(yīng)用內(nèi)部存儲目錄 API 如下:

// 內(nèi)部文件存儲
File filesDir = getFilesDir();
// 內(nèi)部緩存存儲
File cacheDir = getCacheDir();
// 在應(yīng)用唯一目錄內(nèi)創(chuàng)建/打開一個新的目錄
getDir(String name, int mode)
// 應(yīng)用內(nèi)部存儲的核心 API,以上目錄全都依賴于它
getDataDir()
內(nèi)部緩存文件

如果想要暫時保存某些數(shù)據(jù),系統(tǒng)提供了特殊的私有緩存目錄來保存這些數(shù)據(jù)。當(dāng)設(shè)備存儲空間不足時,Android 可能會刪除這些緩存文件以回收空間。但是不要過度依賴于系統(tǒng)提供的清理工作,而始終應(yīng)該自行維護(hù)這些緩存文件的大小。以便使占用空間保持在合理的限制范圍內(nèi)。當(dāng)應(yīng)用被卸載時,這些文件也會隨之被刪除。

內(nèi)部緩存存儲在設(shè)備存儲空間不足時,可能會被系統(tǒng)清理掉,這是區(qū)別于內(nèi)部文件存儲的最重要特征。


將數(shù)據(jù)保存在內(nèi)部存儲中

內(nèi)部存儲是根據(jù)程序的包名在 Android 文件系統(tǒng)的特殊位置被創(chuàng)建。注意與外部存儲目錄不同,應(yīng)用不需要任何系統(tǒng)權(quán)限即可讀寫內(nèi)部存儲。

1. 查詢可用空間

如果我們事先知道要保存數(shù)據(jù)的大小,可以通過如下兩個 API 查找是否有足夠的可用空間。這樣可以有效避免將存儲量填滿到某個閾值以上。

  • getFreeSpace():獲取當(dāng)前存儲空間的剩余空間大小。
  • getTotalSpace():獲取當(dāng)前存儲空間總大小。
2. 寫入文件

將數(shù)據(jù)保存到內(nèi)部存儲時,可以通過以下兩種方式獲取適當(dāng)?shù)拇鎯δ夸洠?/p>

注意:如果系統(tǒng)存儲空間不足,則可能在沒有警告的情況下刪除緩存目錄下文件。

如果需要創(chuàng)建新的目錄,可以使用 File 重新指定,將上述指定的內(nèi)部存儲目錄作為參數(shù):

File newFile = new File(getFilesDir(), fileName)

Google 推薦使用 Jetpack 安全性庫 方式寫入文件:

// Although you can define your own key generation parameter specification, it's
// recommended that you use the value specified here.
KeyGenParameterSpec keyGenParameterSpec = MasterKeys.AES256_GCM_SPEC;
String masterKeyAlias = MasterKeys.getOrCreate(keyGenParameterSpec);

// Creates a file with this name, or replaces an existing file
// that has the same name. Note that the file name cannot contain
// path separators.
String fileToWrite = "my_sensitive_data.txt";
try {
    EncryptedFile encryptedFile = new EncryptedFile.Builder(
            new File(directory, fileToWrite),
            context,
            masterKeyAlias,
            EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB
    ).build();

    // Write to a file.
    BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(
            encryptedFile.openFileOutput()));
    writer.write("MY SUPER-SECRET INFORMATION");
} catch (GeneralSecurityException gse) {
    // Error occurred getting or creating keyset.
} catch (IOException ex) {
    // Error occurred opening file for writing.
}

或者,可以直接使用系統(tǒng)提供的 openFileOutput() 獲取一個 FileOutputStream 對象如下:

final String fileName = "testInternalFile";
final String fileContent = "Hello World!";
FileOutputStream fos;
try{
    fos = context.openFileOutput(fileName, Context.MODE_PRIVATE);
    fos.write(fileContent.toByteArray());
    fos.close();
}catch(Exception e){
    e.printStackTrace();
}

注意:openFileOutput() 方法需要指定文件模式,通過 MODE_PRIVATE 使它對您的應(yīng)用變?yōu)樗接?。?API Level 17 開始,其他模式選項 MODE_WROLD_READABLEMODE_WORLD_WRITEABLE 已被棄用。從 Android 7.0(API Level 24)開始,如果再使用它們將會拋出 SecurityException。如果需要與其他應(yīng)用程序共享私有文件,則應(yīng)該使用帶有 FLAG_GRANT_READ_URI_PERMISSION 屬性的 FileProvider。

3. 寫入緩存文件

如果需要暫時緩存某些數(shù)據(jù),可以使用 createTempFile()。例如從 URL 對象中提取文件名,并為其創(chuàng)建緩存目錄:

File file;
try{
    final String fileName = Uri.parse(url).getLastPathSegmetn();
    file = File.createTempFile(fileName, null, context.getCacheDir());
}catch(Exception e){
    // Error while creating file
}
return file;

正如前面所述,我們應(yīng)該定期清除不再需要的緩存文件,而不是依賴系統(tǒng)的清理工作。

4. 打開已有文件

Google 推薦使用 Jetpack 庫 以更安全的方式讀取文件,如下:

// Although you can define your own key generation parameter specification, it's
// recommended that you use the value specified here.
KeyGenParameterSpec keyGenParameterSpec = MasterKeys.AES256_GCM_SPEC;
String masterKeyAlias = MasterKeys.getOrCreate(keyGenParameterSpec);

String fileToRead = "my_sensitive_data.txt";
EncryptedFile encryptedFile = new EncryptedFile.Builder(
        File(directory, fileToRead),
        context,
        masterKeyAlias,
        EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB
).build();

StringBuffer stringBuffer = new StringBuffer();
try (BufferedReader reader =
             new BufferedReader(new FileReader(encryptedFile))) {

    String line = reader.readLine();
    while (line != null) {
        stringBuffer.append(line).append('\n');
        line = reader.readLine();
    }
} catch (IOException e) {
    // Error occurred opening raw file for reading.
} finally {
    String contents = stringBuffer.toString();
}

同樣,也可以直接使用 openFileInput(name) 傳遞文件名方式獲取到 FileInputStream。

注意:如果需要在安裝時將文件打包為可在應(yīng)用程序中訪問的文件,可以將文件保存在項目 /res/raw 目錄中。傳入帶有前綴的文件名 R.raw 作為資源 ID 通過 openRawResource() 打開對應(yīng)文件。此方法返回 InputStream,但是無法寫回數(shù)據(jù)。


外部存儲

外部存儲在概念上非常容易被誤解,它屬于 Android 發(fā)展的歷史產(chǎn)物,早期的手機(jī)設(shè)備支持插入新的存儲介質(zhì),例如 micro SD 卡(外部存儲)。現(xiàn)在已經(jīng)很難看到了。外部存儲是相對于內(nèi)部存儲而言的,如果數(shù)據(jù)允許被其他應(yīng)用訪問或者允許用戶在手機(jī)中進(jìn)行訪問,此時外部存儲便非常適合。

外部存儲可以劃分為公共目錄應(yīng)用私有目錄。

  • 公共目錄:可以被其他應(yīng)用或用戶自由使用的文件。該目錄下文件不會受到應(yīng)用的卸載而被移除。

  • 應(yīng)用私有目錄:存儲在特定于應(yīng)用程序目錄的文件,可以使用 Context.getExternalFilesDir() 或 Context.getExternalCacheDir() 獲取。當(dāng)應(yīng)用被卸載時該目錄下文件也會被移除。注意,雖然它們也屬于應(yīng)用私有目錄,但它們位于外部存儲上,從技術(shù)上來講用戶和其他應(yīng)用程序都可以訪問這些文件,一般用于存放一些不是特別敏感的應(yīng)用數(shù)據(jù),但由不想與其他應(yīng)用程序共享的文件。

注意:如果用戶卸下或斷開外部存儲設(shè)備(例如 SD 卡)的連接,則存儲在外部存儲中的文件可能變得不可用。如果應(yīng)用程序的功能取決于這些文件,則應(yīng)修改為將數(shù)據(jù)寫入內(nèi)部存儲。

1. 請求外部存儲權(quán)限

使用外部存儲之前必須申請以下權(quán)限:

允許應(yīng)用訪問外部存儲設(shè)備中的文件。

允許應(yīng)用寫入和修改外部設(shè)備中的文件。擁有此權(quán)限的應(yīng)用程序也會自動獲取到 READ_EXTARNAL_STORAGE 權(quán)限。

從 Android 4.4 (API 級別 19)開始,在特定于應(yīng)用的目錄中讀寫文件不需要任何與存儲相關(guān)的權(quán)限。因此,如果您的應(yīng)用程序支持 Android 4.3(API Level 18)及更低版本,并且您只想訪問特定于應(yīng)用程序的目錄,則通過添加以下 maxSdkVersion 屬性來聲明僅在較低版本的 Android 上請求權(quán)限。

<manifest ...>
    <!-- If you need to modify files in external storage, request
         WRITE_EXTERNAL_STORAGE instead. -->
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
                     android:maxSdkVersion="18" />
</manifest>

注意:如果您的應(yīng)用程序僅使用內(nèi)部存儲,則不需要聲明任何與存儲相關(guān)的權(quán)限,除非需要訪問其他應(yīng)用程序文件。

2. 驗證外部存儲是否可用

由于外部存儲可能被移除,所以外部存儲并不總是可用,因此在訪問之前,始終應(yīng)該驗證是否可用??梢酝ㄟ^查詢外部存儲狀態(tài) getExternalStorageState()。如果返回狀態(tài)為 MEDIA_MOUNTED,則可以讀取和寫入文件。如果是 MEDIA_MOUNTED_READ_ONLY,則只能讀取文件。

/* Checks if external storage is available for read and write */
public boolean isExternalStorageWritable() {
    String state = Environment.getExternalStorageState();
    if (Environment.MEDIA_MOUNTED.equals(state)) {
        return true;
    }
    return false;
}

/* Checks if external storage is available to at least read */
public boolean isExternalStorageReadable() {
    String state = Environment.getExternalStorageState();
    if (Environment.MEDIA_MOUNTED.equals(state) ||
        Environment.MEDIA_MOUNTED_READ_ONLY.equals(state)) {
        return true;
    }
    return false;
}
保存到公共目錄

如果要將文件保存在其他應(yīng)用程序可以訪問的外部存儲上,可以使用以下 API:

  • 保存照片、音頻或視頻剪輯,請使用 MediaStore API。

  • 要保存其他文件(如 PDF),請使用 ACTION_CREATE_DOCUMENT Intent,它是 Storage Access Framework 的一部分。

注意,如果想在“媒體掃描器”中隱藏文件,可以在特定應(yīng)用程序的目錄中包含一個名為 .nomedia 的空文件。這樣便可以防止媒體掃描器讀取您的媒體文件并通過 MediaStore API 將它們提供給其他應(yīng)用程序。

保存到私有目錄

注意外部存儲雖然也有應(yīng)用私有目錄,但是它并不向內(nèi)部存儲那樣安全,并且也會跟隨應(yīng)用的卸載而被移除。

如果需要保存一些不是那么敏感的應(yīng)用私有數(shù)據(jù)到外部存儲上,可以使用 getExternalFilesDir() 或 getExternalCacheDir() 獲取應(yīng)用程序在外部存儲的私有目錄,它們在功能上類似于內(nèi)部存儲的 getFileDir() 和 getCacheDir()。

public File getPrivateAlbumStorageDir(Context context, String albumName) {
    // Get the directory for the app's private pictures directory.
    File file = new File(context.getExternalFilesDir(
            Environment.DIRECTORY_PICTURES), albumName);
    if (!file.mkdirs()) {
        Log.e(LOG_TAG, "Directory not created");
    }
    return file;
}

注意,如果預(yù)定義的子目錄(Environment.DIRECTORY_PICTURES)都不合適作為要存儲的目錄,那么可以使用 getExternalFilesDir(null) 返回應(yīng)用程序在外部存儲上私有目錄的根目錄。

在多個外部存儲之間進(jìn)行選擇

當(dāng)設(shè)備也插入外置 SD 卡時,此時設(shè)備便有兩個不同的外部存儲目錄,因此將“私有”文件寫入外部存儲時,需要選擇使用哪個目錄。

從 Android 4.4(API Level 19) 開始,可以通過 getExternalFilesDirs() 來訪問這些目錄,它返回一個 File 數(shù)組,數(shù)組的第一條被默認(rèn)為是主要的外部存儲,應(yīng)用應(yīng)該優(yōu)先使用該位置作為第一外部存儲(除非空間已滿)。

或者使用 ContextCompat.getExternalFilesDir() 來兼容 Android 4.3 及更低版本。需要注意,在 4.3 及更低版本該方法僅返回一個主外部存儲目錄,即在 Android 4.3 及更低的版本無法訪問第二個存儲位置。


最后

隨著 Android 手機(jī)硬件配置的提升,內(nèi)置存儲空間越來越大;出于兼容低端機(jī)的考慮,我們還是不能簡單粗暴的將數(shù)據(jù)遷入內(nèi)部存儲。綜合來看,遷移還需慢慢做,外部存儲的敏感數(shù)據(jù)加密混淆也要配合。

Android 的權(quán)限管理只防君子不防小人,SD 卡存儲讀寫權(quán)限只要應(yīng)用申請了基本都可以獲取到。所以建議對于敏感的外置存儲的文件進(jìn)行加密或混淆處理。剩下的等待明年的 Android 11 相對更完善的外部私有存儲空間進(jìn)行權(quán)限隔離控制。

Android 10 計劃將外部(私有)存儲的共享權(quán)限徹底收回,不過對 native 支持的不好,及一次性改變太大開發(fā)者對其反映強(qiáng)烈,計劃被推遲一年。具體可以參考 Android 10 功能和 API

便于大家理解,最后我將數(shù)據(jù)存儲目錄的相關(guān)路徑整理出,大家需要對照查看發(fā)現(xiàn)它們的異同之處:


我們也曾在項目中遇到存儲敏感數(shù)據(jù)設(shè)計上的問題,本文正好借此來跟大家聊一聊,文中如有不妥或有更好的分析結(jié)果歡迎您的指正。

文章如果對你有幫助,請留個贊吧!


擴(kuò)展閱讀

Android 存儲優(yōu)化系列專題
Android 之不要濫用 SharedPreferences(上)
Android 對象序列化之 Parcelable 取代 Serializable ?
Android 存儲選項之 SQLite 優(yōu)化那些事兒

其他系列專題

深入 Activity 三部曲(1)View 繪制流程之 setContentView() 到底做了什么 ?
深入 Activity 三部曲(2)View 繪制流程之 DecorView 添加至窗口的過程
深入 Activity 三部曲(3)之 View 繪制流程
關(guān)于 UI 渲染,你需要了解什么?
Android 之如何優(yōu)化 UI 渲染(上)
Android 之你真的了解 View.post() 原理嗎?
Android 之 ViewTreeObserver 全面解析

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容