AndroidQ兼容性適配指南

AndroidQ

android10.jpeg

Android 10 中的隱私權(quán)變更

隱私權(quán)變更 受影響的應(yīng)用 緩解策略
? 分區(qū)存儲(chǔ) 針對(duì)外部存儲(chǔ)的過濾視圖,可提供對(duì)特定于應(yīng)用的文件和媒體集合的訪問權(quán)限 訪問和共享外部存儲(chǔ)中的文件的應(yīng)用 使用特定于應(yīng)用的目錄和媒體集合目錄 了解詳情
? 增強(qiáng)了用戶對(duì)位置權(quán)限的控制力 僅限前臺(tái)權(quán)限,可讓用戶更好地控制應(yīng)用對(duì)設(shè)備位置信息的訪問權(quán)限 在后臺(tái)時(shí)請(qǐng)求訪問用戶位置信息的應(yīng)用 確保在沒有后臺(tái)位置信息更新的情況下優(yōu)雅降級(jí) 使用 Android 10 中引入的權(quán)限在后臺(tái)獲取位置信息 了解詳情
? 系統(tǒng)執(zhí)行后臺(tái) Activity 針對(duì)從后臺(tái)啟動(dòng) Activity 實(shí)施了限制 不需要用戶互動(dòng)就啟動(dòng) Activity 的應(yīng)用 使用通知觸發(fā)的 Activity 了解詳情
? 不可重置的硬件標(biāo)識(shí)符 針對(duì)訪問設(shè)備序列號(hào)和 IMEI 實(shí)施了限制 訪問設(shè)備序列號(hào)或 IMEI 的應(yīng)用 使用用戶可以重置的標(biāo)識(shí)符 了解詳情
? 無線掃描權(quán)限 訪問某些 WLAN、WLAN 感知和藍(lán)牙掃描方法需要獲得精確位置權(quán)限 使用 WLAN API 和藍(lán)牙 API 的應(yīng)用 針對(duì)相關(guān)使用場景請(qǐng)求 ACCESS_FINE_LOCATION 權(quán)限 了解詳情

上面是官網(wǎng)的AndroidQ的隱私權(quán)變更鏈接,本文章只針對(duì)部分重大隱私權(quán)限變更做出解釋說明。

從后臺(tái)啟動(dòng) Activity 的限制

創(chuàng)建高優(yōu)先級(jí)通知

Android10中, 當(dāng)App無前臺(tái)顯示的Activity時(shí),其啟動(dòng)Activity會(huì)被系統(tǒng)攔截, 導(dǎo)致啟動(dòng)無效。
對(duì)此官方給予的折中方案是使用全屏Intent(full-screen intent), 既創(chuàng)建通知欄通知時(shí), 加入full-screen intent設(shè)置, 示例代碼如下(基于官方文檔修改):

        Intent fullScreenIntent = new Intent(this, CallActivity.class);
    PendingIntent fullScreenPendingIntent = PendingIntent.getActivity(this, 0,
            fullScreenIntent, PendingIntent.FLAG_UPDATE_CURRENT);

    NotificationCompat.Builder notificationBuilder =
            new NotificationCompat.Builder(this, CHANNEL_ID)
        .setSmallIcon(R.drawable.notification_icon)
        .setContentTitle("Incoming call")
        .setContentText("(919) 555-1234")
        .setPriority(NotificationCompat.PRIORITY_HIGH)
        .setCategory(NotificationCompat.CATEGORY_CALL)

        // Use a full-screen intent only for the highest-priority alerts where you
        // have an associated activity that you would like to launch after the user
        // interacts with the notification. Also, if your app targets Android 10
        // or higher, you need to request the USE_FULL_SCREEN_INTENT permission in
        // order for the platform to invoke this notification.
        .setFullScreenIntent(fullScreenPendingIntent, true);

    Notification incomingCallNotification = notificationBuilder.build();

注意:在Target SDk為29及以上時(shí),需要在AndroidManifest上增加USE_FULL_SCREEN_INTENT申明

//AndroidManifest中
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />

當(dāng)手機(jī)處于亮屏狀態(tài)時(shí), 會(huì)顯示一個(gè)通知欄, 當(dāng)手機(jī)處于鎖屏或者滅屏狀態(tài)時(shí),會(huì)亮屏并直接進(jìn)入到CallActivity中。

不可重置的設(shè)備標(biāo)識(shí)符實(shí)施了限制

從 Android 10 開始,應(yīng)用必須具有 READ_PRIVILEGED_PHONE_STATE 特許權(quán)限才能訪問設(shè)備的不可重置標(biāo)識(shí)符(包含 IMEI 和序列號(hào))。

受影響的方法包括:

ANDROID_ID 生成規(guī)則:簽名+設(shè)備信息+設(shè)備用戶
ANDROID_ID 重置規(guī)則:設(shè)備恢復(fù)出廠設(shè)置時(shí),ANDROID_ID 將被重置

當(dāng)前獲取設(shè)備唯一ID的方式為使用ANDROID_ID, 若獲取為空的話則使用UUID.randomUUID().toString()獲得一個(gè)隨機(jī)ID并存儲(chǔ)起來, 該ID保證唯一, 但App卸載重裝之后就會(huì)改變。

String id = android.provider.Settings.Secure.getString(context.getContentResolver(), android.provider.Settings.Secure.ANDROID_ID);

限制了對(duì)剪貼板數(shù)據(jù)的訪問權(quán)限

除非您的應(yīng)用是默認(rèn)輸入法 (IME) 或是目前處于焦點(diǎn)的應(yīng)用,否則它無法訪問 Android 10 或更高版本平臺(tái)上的剪貼板數(shù)據(jù)。

因?yàn)槎际菓?yīng)用處于前臺(tái)的時(shí)候進(jìn)行剪貼板數(shù)據(jù)的獲取,對(duì)于大部分業(yè)務(wù)不受影響。

定位權(quán)限

Android Q引入了新的位置權(quán)限ACCESS_BACKGROUND_LOCATION,該權(quán)限僅會(huì)影響應(yīng)用在后臺(tái)運(yùn)行時(shí)對(duì)位置信息的訪問權(quán)。如果應(yīng)用targetSDK<=P,請(qǐng)求了ACCESS_FINE_LOCATIONACCESS_COARSE_LOCATION權(quán)限,AndroidQ設(shè)備會(huì)自動(dòng)幫你申請(qǐng)ACCESS_BACKGROUND_LOCATION權(quán)限。
如果應(yīng)用以Android 10或更高版本為目標(biāo)平臺(tái),則您必須在應(yīng)用的清單文件中聲明ACCESS_BACKGROUND_LOCATION權(quán)限并接收用戶權(quán)限,才能在應(yīng)用位于后臺(tái)時(shí)接收定期位置信息更新。
以下代碼段展示了如何在應(yīng)用中請(qǐng)求在后臺(tái)訪問位置信息:

    <manifest ... >
        <!--允許獲得精確的GPS定位-->
        <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
        <!--允許獲得粗略的基站網(wǎng)絡(luò)定位-->
        <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
        <!-- 兼容10.0系統(tǒng),允許App在后臺(tái)獲得位置信息 -->
        <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/>
    </manifest>

以下代碼段中顯示了定位權(quán)限檢查邏輯的示例:

    boolean permissionAccessCoarseLocationApproved =
        ActivityCompat.checkSelfPermission(this, permission.ACCESS_COARSE_LOCATION)
            == PackageManager.PERMISSION_GRANTED;
 
    if (permissionAccessCoarseLocationApproved) {
       boolean backgroundLocationPermissionApproved =
               ActivityCompat.checkSelfPermission(this,
                   permission.ACCESS_BACKGROUND_LOCATION)
                   == PackageManager.PERMISSION_GRANTED;
 
       if (backgroundLocationPermissionApproved) {
           // App can access location both in the foreground and in the background.
           // Start your service that doesn't have a foreground service type
           // defined.
       } else {
           // App can only access location in the foreground. Display a dialog
           // warning the user that your app must have all-the-time access to
           // location in order to function properly. Then, request background
           // location.
           ActivityCompat.requestPermissions(this, new String[] {
               Manifest.permission.ACCESS_BACKGROUND_LOCATION},
               your-permission-request-code);
       }
    } else {
       // App doesn't have access to the device's location at all. Make full request
       // for permission.
       ActivityCompat.requestPermissions(this, new String[] {
            Manifest.permission.ACCESS_COARSE_LOCATION,
            Manifest.permission.ACCESS_BACKGROUND_LOCATION
            },
            your-permission-request-code);
    }

如果您的應(yīng)用通常需要在被置于后臺(tái)后(如當(dāng)用戶按設(shè)備上的主屏幕按鈕或關(guān)閉設(shè)備的顯示屏?xí)r)訪問設(shè)備的位置信息。

要在這種特定類型的用例中保留對(duì)設(shè)備位置信息的訪問權(quán),請(qǐng)啟動(dòng)您已在應(yīng)用的清單中聲明前臺(tái)服務(wù)類型為 "location" 的前臺(tái)服務(wù):

    <service
        android:name="MyNavigationService"
        android:foregroundServiceType="location" ... >
        ...
    </service>

在啟動(dòng)該前臺(tái)服務(wù)之前,請(qǐng)確保您的應(yīng)用仍可訪問設(shè)備的位置信息:

    boolean permissionAccessCoarseLocationApproved =
        ActivityCompat.checkSelfPermission(this,
            permission.ACCESS_COARSE_LOCATION) ==
            PackageManager.PERMISSION_GRANTED;
    if (permissionAccessCoarseLocationApproved) {
        // App has permission to access location in the foreground. Start your
        // foreground service that has a foreground service type of "location".
    } else {
       // Make a request for foreground-only location access.
       ActivityCompat.requestPermissions(this, new String[] {
            Manifest.permission.ACCESS_COARSE_LOCATION},
           your-permission-request-code);
    }

分區(qū)存儲(chǔ)

為了讓用戶更好地控制自己的文件,并限制文件混亂的情況,Android Q修改了APP訪問外部存儲(chǔ)中文件的方法。外部存儲(chǔ)的新特性被稱為Scoped Storage

Android Q仍然使用READ_EXTERNAL_STORAGEWRITE_EXTERNAL_STORAGE作為面向用戶的存儲(chǔ)相關(guān)運(yùn)行時(shí)權(quán)限,但現(xiàn)在即使獲取了這些權(quán)限,訪問外部存儲(chǔ)也受到了限制。

APP需要這些運(yùn)行時(shí)權(quán)限的情景發(fā)生了變化,且各種情況下外部存儲(chǔ)對(duì)APP的可見性也發(fā)生了變化。

Scoped Storage新特性中,外部存儲(chǔ)空間被分為兩部分:

● 公共目錄:Downloads、DocumentsPictures、DCIMMovies、MusicRingtones

公共目錄下的文件在APP卸載后,不會(huì)刪除。

APP可以通過SAF(System Access Framework)、MediaStore接口訪問其中的文件。

App-specific目錄:存儲(chǔ)應(yīng)用私有數(shù)據(jù),外部存儲(chǔ)應(yīng)用私有目錄對(duì)應(yīng) Android/data/packagename,內(nèi)部存儲(chǔ)應(yīng)用私有目錄對(duì)應(yīng) data/data/packagename;

APP卸載后,數(shù)據(jù)會(huì)清除。

APP的私密目錄,APP訪問自己的App-specific目錄時(shí)無需任何權(quán)限。

Scoped Storage.png

存儲(chǔ)空間視圖模式

Android Q規(guī)定了APP有兩種外部存儲(chǔ)空間視圖模式:Legacy View、Filtered View。

Filtered View:App可以直接訪問App-specific目錄,但不能直接訪問App-specific外的文件。訪問公共目錄或其他APP的App-specific目錄,只能通過MediaStoreSAF、或者其他APP 提供的ContentProviderFileProvider等訪問。

Legacy View: 兼容模式。與Android Q以前一樣,申請(qǐng)權(quán)限后App可訪問外部存儲(chǔ),擁有完整的訪問權(quán)限

requestLegacyExternalStorage和preserveLegacyExternalStorage

requestLegacyExternalStorage 是Anroid10引入的,如果你進(jìn)行適配Android 10之后,應(yīng)用通過升級(jí)安裝,那么還會(huì)使用以前的儲(chǔ)存模式Legacy View,只有通過首次安裝或是卸載重新安裝才能啟用新模式Filtered View。

android:requestLegacyExternalStorage="true"讓適配了Android10的app新安裝在Android 10系統(tǒng)上也繼續(xù)訪問舊的存儲(chǔ)模型。

Environment.isExternalStorageLegacy();//存儲(chǔ)是否為兼容模式

在適配Android11的時(shí)候requestLegacyExternalStorage 標(biāo)簽會(huì)在Android11以上的設(shè)備上被忽略,preserveLegacyExternalStorage只是讓覆蓋安裝的app能繼續(xù)使用舊的存儲(chǔ)模型,如果之前是舊的存儲(chǔ)模型的話。

  • Android10適配的時(shí)候可以通過requestLegacyExternalStoragec使用兼容模式;
  • Android11適配可以通過preserveLegacyExternalStorage讓Android10及一下的設(shè)備使用兼容模式,但Android11及以上的設(shè)備無論是覆蓋安裝還是重新安裝都無法使用兼容模式;

可以通過調(diào)用 Environment.getExternalStorageState() 查詢?cè)摼淼臓顟B(tài)。如果返回的狀態(tài)為 MEDIA_MOUNTED,那么您就可以在外部存儲(chǔ)空間中讀取和寫入應(yīng)用專屬文件。如果返回的狀態(tài)為 MEDIA_MOUNTED_READ_ONLY,您只能讀取這些文件。

分區(qū)存儲(chǔ)的影響

圖片位置信息

一些圖片會(huì)包含位置信息,因?yàn)槲恢脤?duì)于用戶屬于敏感信息, Android 10 應(yīng)用在分區(qū)存儲(chǔ)模式下圖片位置信息默認(rèn)獲取不到,應(yīng)用通過以下兩項(xiàng)設(shè)置可以獲取圖片位置信息:

  • manifest 中申請(qǐng) ACCESS_MEDIA_LOCATION;
  • 調(diào)用 MediaStore.setRequireOriginal(Uri uri)接口更新圖片 Uri;
// Get location data from the ExifInterface class.
val photoUri = MediaStore.setRequireOriginal(photoUri)
contentResolver.openInputStream(photoUri).use { stream ->
    ExifInterface(stream).run {
        // If lat/long is null, fall back to the coordinates (0, 0).
        val latLong = ?: doubleArrayOf(0.0, 0.0)
    }
}

訪問數(shù)據(jù)

私有目錄:

應(yīng)用私有目錄文件訪問方式與之前 Android 版本一致,可以通過 File path 獲取資源。

共享目錄:

共享目錄文件需要通過 MediaStore API 或者 Storage Access Framework 方式訪問。
MediaStore API 在共享目錄指定目錄下創(chuàng)建文件或者訪問應(yīng)用自己創(chuàng)建文件,不需要申請(qǐng)存儲(chǔ)權(quán)限
MediaStore API 訪問其他應(yīng)用在共享目錄創(chuàng)建的媒體文件(圖片、音頻、視頻), 需要申請(qǐng)存儲(chǔ)權(quán)限,未申請(qǐng)存儲(chǔ)權(quán)限,通過 ContentResolver 查詢不到文件 Uri,即使通過其他方式獲取到文件 Uri,讀取或創(chuàng)建文件會(huì)拋出異常;
MediaStore API 不能夠訪問其他應(yīng)用創(chuàng)建的非媒體文件(pdf、office、doc、txt 等), 只能夠通過 Storage Access Framework 方式訪問;

File路徑訪問受影響接口

FileOutputStreamFileInputStream

在分區(qū)存儲(chǔ)模型下,SD卡的公共目錄是不讓訪問的,除了共享媒體的那幾個(gè)文件夾。所以,用一個(gè)公共目錄的路徑實(shí)例化FileOutputStream或者FileInputStream會(huì)報(bào)FileNotFoundException異常。

W/System.err: java.io.FileNotFoundException: /storage/emulated/0/Log01-28-18-10.txt: open failed: EACCES (Permission denied)
W/System.err:     at libcore.io.IoBridge.open(IoBridge.java:496)
W/System.err:     at java.io.FileInputStream.<init>(FileInputStream.java:159)

File.createNewFile

W/System.err: java.io.IOException: Permission denied
W/System.err:     at java.io.UnixFileSystem.createFileExclusively0(Native Method)
W/System.err:     at java.io.UnixFileSystem.createFileExclusively(UnixFileSystem.java:317)
W/System.err:     at java.io.File.createNewFile(File.java:1008)
  • File.renameTo
  • File.delete
  • File.renameTo
  • File.mkdir
  • File.mkdirs

以上File的方法都返回false。

BitmapFactory.decodeFile生成的Bitmapnull

適配指導(dǎo)

Android Q Scoped Storage新特性谷歌官方適配文檔:https://developer.android.google.cn/preview/privacy/scoped-storage

適配指導(dǎo)如下,分為:訪問APP自身App-specific目錄文件、使用MediaStore訪問公共目錄、使用SAF 訪問指定文件和目錄、分享App-specific目錄下文件和其他細(xì)節(jié)適配。

訪問App-specific目錄文件

無需任何權(quán)限,可以直接通過File的方式操作App-specific目錄下的文件。

App-specific目錄 接口(所有存儲(chǔ)設(shè)備) 接口(Primary External Storage)
Media getExternalMediaDirs() NA
Obb getObbDirs() getObbDir()
Cache getExternalCacheDirs() getExternalCacheDir()
Data getExternalFilesDirs(String type) getExternalFilesDir(String type)
/**
 * 在App-Specific目錄下創(chuàng)建文件
 * 文件目錄:/Android/data/包名/files/Documents/
 */
private fun createAppSpecificFile() {
    binding.createAppSpecificFileBtn.setOnClickListener {
        val documents = getExternalFilesDirs(Environment.DIRECTORY_DOCUMENTS)
        if (documents.isNotEmpty()) {
            val dir = documents[0]
            var os: FileOutputStream? = null
            try {
                val newFile = File(dir.absolutePath, "MyDocument")
                os = FileOutputStream(newFile)
                os.write("create a file".toByteArray(Charsets.UTF_8))
                os.flush()
                Log.d(TAG, "創(chuàng)建成功")
                dir.listFiles()?.forEach { file: File? ->
                    if (file != null) {
                        Log.d(TAG, "Documents 目錄下的文件名:" + file.name)
                    }
                }
            } catch (e: IOException) {
                e.printStackTrace()
                Log.d(TAG, "創(chuàng)建失敗")

            } finally {
                closeIO(os)
            }

        }
    }
}
/**
 * 在App-Specific目錄下創(chuàng)建文件夾
 * 文件目錄:/Android/data/包名/files/
 */
private fun createAppSpecificFolder() {
    binding.createAppSpecificFolderBtn.setOnClickListener {
        getExternalFilesDir("apk")?.let {
            if (it.exists()) {
                Log.d(TAG, "創(chuàng)建成功")
            } else {
                Log.d(TAG, "創(chuàng)建失敗")
            }
        }
    }
}

使用MediaStore訪問公共目錄

MediaStore Uri和路徑對(duì)應(yīng)表
Uri-path.png

MediaStore提供下列Uri,可以用MediaProvider查詢對(duì)應(yīng)的Uri數(shù)據(jù)。在AndroidQ上,所有的外部存儲(chǔ)設(shè)備都會(huì)被命令,即Volume Name。MediaStore可以通過Volume Name 獲取對(duì)應(yīng)的Uri。

MediaStore.getExternalVolumeNames(this).forEach { volumeName ->
  Log.d(TAG, "uri:${MediaStore.Images.Media.getContentUri(volumeName)}")
}

Uri路徑格式: content:// media/<volumeName>/<Uri路徑>

Uri-minetype.png
使用MediaStore創(chuàng)建文件

通過ContentResolver的insert方法,將多媒體文件保存在公共集合目錄,不同的Uri對(duì)應(yīng)不同的公共目錄,詳見3.2.1;其中RELATIVE_PATH的一級(jí)目錄必須是Uri對(duì)應(yīng)的一級(jí)目錄,二級(jí)目錄或者二級(jí)以上的目錄,可以隨意的創(chuàng)建和指定。

private lateinit var createBitmapForActivityResult: ActivityResultLauncher<String>
//注冊(cè)ActivityResultLauncher
createBitmapForActivityResult =
    registerForActivityResult(ActivityResultContracts.RequestPermission()) {
        createBitmap()
    }
binding.createFileByMediaStoreBtn.setOnClickListener {
    createBitmapForActivityResult.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
}
private fun createBitmap() {
    val values = ContentValues()
    val displayName = "NewImage.png"
    values.put(MediaStore.Images.Media.DISPLAY_NAME, displayName)
    values.put(MediaStore.Images.Media.DESCRIPTION, "This is an image")
    values.put(MediaStore.Images.Media.MIME_TYPE, "image/png")
    values.put(MediaStore.Images.Media.TITLE, "Image.png")
    //適配AndroidQ及一下
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        values.put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/sl")
    } else {
        values.put(
            MediaStore.MediaColumns.DATA,
            "${Environment.getExternalStorageDirectory().path}/${Environment.DIRECTORY_DCIM}/$displayName"
        )
    }
    //requires android.permission.WRITE_EXTERNAL_STORAGE, or grantUriPermission()
    val external = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
    //java.lang.UnsupportedOperationException: Writing to internal storage is not supported.
    //val external = MediaStore.Images.Media.INTERNAL_CONTENT_URI
    val insertUri = contentResolver.insert(external, values)
    var os: OutputStream? = null
    try {
        if (insertUri != null) {
            os = contentResolver.openOutputStream(insertUri)
        }
        if (os != null) {
            val bitmap = Bitmap.createBitmap(400, 400, Bitmap.Config.ARGB_8888)
            //創(chuàng)建了一個(gè)紅色的圖片
            val canvas = Canvas(bitmap)
            canvas.drawColor(Color.RED)
            bitmap.compress(Bitmap.CompressFormat.PNG, 90, os)
            Log.d(TAG, "創(chuàng)建Bitmap成功")
            if (insertUri != null) {
                values.clear()
                //適配AndroidQ及一下
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                    values.put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/sl2")
                } else {
                    values.put(
                        MediaStore.MediaColumns.DATA,
                        "${Environment.getExternalStorageDirectory().path}/${Environment.DIRECTORY_DCIM}/$displayName"
                    )
                }
                contentResolver.update(insertUri, values, null, null)
            }
        }
    } catch (e: IOException) {
        Log.d(TAG, "創(chuàng)建失?。?{e.message}")
    } finally {
        closeIO(os)
    }
}
使用MediaStore查詢文件

通過 Cursor query(@RequiresPermission.Read @NonNull Uri uri,@Nullable String[] projection, @Nullable String selection,@Nullable String[] selectionArgs, @Nullable String sortOrder) 方法。

參數(shù)解釋:

參數(shù) 類型 釋義
uri Uri 提供檢索內(nèi)容的 Uri,其 scheme 是content://
projection String[] 返回的列,如果傳遞 null 則所有列都返回(效率低下)
selection String 過濾條件,即 SQL 中的 WHERE 語句(但不需要寫 where 本身),如果傳 null 則返回所有的數(shù)據(jù)
selectionArgs String[] 如果你在 selection 的參數(shù)加了 ? 則會(huì)被本字段中的數(shù)據(jù)按順序替換掉
sortOrder String 用來對(duì)數(shù)據(jù)進(jìn)行排序,即 SQL 語句中的 ORDER BY(單不需要寫ORDER BY 本身),如果傳 null 則按照默認(rèn)順序排序(可能是無序的)

通過ContentResolver.query接口查詢文件Uri,查詢其他App創(chuàng)建的文件是需要READ_EXTERNAL_STORAGE權(quán)限;

該查詢使用的是手機(jī)系統(tǒng)的數(shù)據(jù)庫查詢,可能會(huì)出現(xiàn)有些圖片文件存在但是依舊查詢不到~?。≒S:使用adb命令push的圖片就查詢不到)

/**
 * 通過MediaStore查詢文件
 */
private fun queryFileByMediaStore() {
  queryPictureForActivityResult = registerForActivityResult(ActivityResultContracts.RequestPermission()) {
      queryUri = queryImageUri("yellow.jpg")
  }
  binding.queryFileByMediaStoreBtn.setOnClickListener {
      queryPictureForActivityResult.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
  }
}
/**
  * @param displayName 查詢的圖片文件名稱
  * @return 第一個(gè)遍歷到的該文件名的uri
  */
private fun queryImageUri(displayName: String): Uri? {
    val external = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
    val selection = "${MediaStore.Images.Media.DISPLAY_NAME}=?"
    val args = arrayOf(displayName)
    val projection = arrayOf(MediaStore.Images.Media._ID)
    val cursor = contentResolver.query(external, projection, selection, args, null)
    var queryUri: Uri? = null
    if (cursor != null) {
        //可能查詢到多個(gè)同名圖片
        while (cursor.moveToNext()) {
            queryUri = ContentUris.withAppendedId(external, cursor.getLong(0))
            Log.d(TAG, "查詢成功,Uri路徑$queryUri")
            queryUri?.let {
                cursor.close()
                return it
            }
        }
        cursor.close()
    }
    return queryUri;
}
使用MediaStore讀取文件

首先需要文件存儲(chǔ)權(quán)限,通過ContentResolver.query查詢得到的Uri之后,可以通過contentResolver.openFileDescriptor,根據(jù)文件描述符選擇對(duì)應(yīng)的打開方式。"r"表示讀,"w"表示寫;

private lateinit var readPictureForActivityResult: ActivityResultLauncher<IntentSenderRequest>
/**
 * 根據(jù)查詢到的uri,獲取bitmap
 */
private fun readFileByMediaStore() {
    readPictureForActivityResult = registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) {
        readBitmapNotException()
    }
    binding.readFileByMediaStoreBtn.setOnClickListener {
        readBitmapNotException()
    }
}
private fun readBitmapNotException() {
    val queryUri = queryImageUri("20221018_113937.jpg")
    if (queryUri != null) {
        var pfd: ParcelFileDescriptor? = null
        try {
            pfd = contentResolver.openFileDescriptor(queryUri, "r")
            if (pfd != null) {
                // 第一次解析將inJustDecodeBounds設(shè)置為true,來獲取圖片大小
                val options = BitmapFactory.Options()
                options.inJustDecodeBounds = true
                BitmapFactory.decodeFileDescriptor(pfd.fileDescriptor, null, options)
                // 調(diào)用上面定義的方法計(jì)算inSampleSize值
                options.inSampleSize = calculateInSampleSize(options, 500, 500)
                // 使用獲取到的inSampleSize值再次解析圖片
                options.inJustDecodeBounds = false
                val bitmap = BitmapFactory.decodeFileDescriptor(pfd.fileDescriptor, null, options)
                binding.imageIv.setImageBitmap(bitmap)
            }
        } catch (e: Exception) {

            e.printStackTrace()
        } finally {
            closeIO(pfd)
        }

    } else {
        Log.d(TAG, "還未查詢到Uri")
    }
}

獲取圖片的縮略圖:

訪問Thumbnail,通過ContentResolver.loadThumbnail傳入size,返回指定大小的縮略圖。

/**
  * 根據(jù)查詢到的Uri,獲取Thumbnail
  */
private fun loadThumbnail() {
    binding.loadThumbnailBtn.setOnClickListener {
        queryUri?.let {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                val bitmap = contentResolver.loadThumbnail(it, Size(100, 200), null)
                binding.imageIv.setImageBitmap(bitmap)
            } else {
                MediaStore.Images.Thumbnails.getThumbnail(
                    contentResolver,
                    ContentUris.parseId(it),
                    MediaStore.Images.Thumbnails.MINI_KIND,
                    null)?.let { bitmap ->
                    binding.imageIv.setImageBitmap(bitmap)
                }
            }
        }
    }
}
使用MediaStore修改文件

PS:僅限AndroidQ及以上系統(tǒng)版本,低版本國產(chǎn)手機(jī)使用ContentResolver進(jìn)行數(shù)據(jù)更新存在數(shù)據(jù)和文件不同步問題以及縮略圖和原圖不同步問題;

當(dāng)應(yīng)用擁有了 WRITE_EXTERNAL_STORAGE 權(quán)限后,當(dāng)修改其他 App 的文件時(shí),會(huì) throw 另一個(gè) Exception:

android.app.RecoverableSecurityException: com.tzx.androidsystemversionadapter has no access to content://media/external/images/media/21

如果我們將這個(gè) RecoverableSecurityExceptionCatch住,并向用戶申請(qǐng)修改該圖片的權(quán)限,用戶操作后,我們就可以在 onActivityResult回調(diào)中拿到結(jié)果進(jìn)行操作了。

impower.png
/**
  * 根據(jù)查詢得到的Uri,修改文件
  */
private fun updateFileByMediaStore() {
  updatePictureForActivityResult =
      registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) {
          updateFileNameWithException()
      }
  registerForActivityResult =
      registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) {
          updateFileNameWithException()
      }
  binding.updateFileByMediaStoreBtn.setOnClickListener {
      registerForActivityResult.launch(arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE))
  }
}
private fun updateFileNameWithException() {
  val queryUri = queryImageUri("blue.jpg")
  var os: OutputStream? = null
  try {
      queryUri?.let { uri ->
          os = contentResolver.openOutputStream(uri)
          os?.let {
              val bitmap = Bitmap.createBitmap(400, 400, Bitmap.Config.ARGB_8888)
              //創(chuàng)建了一個(gè)紅色的圖片
              val canvas = Canvas(bitmap)
              canvas.drawColor(Color.YELLOW)
              //重新寫入文件內(nèi)容
              bitmap.compress(Bitmap.CompressFormat.PNG, 90, os)
              val contentValues = ContentValues()
              //給改文件重命名
              contentValues.put(MediaStore.Images.ImageColumns.DISPLAY_NAME, "yellow.jpg")
              contentResolver.update(uri, contentValues, null, null)
          }
      }
  } catch (e: Exception) {
      if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
          if (e is RecoverableSecurityException) {
              try {
                  updatePictureForActivityResult.launch(
                      IntentSenderRequest.Builder(e.userAction.actionIntent.intentSender)
                          .build()
                  )
              } catch (e2: IntentSender.SendIntentException) {
                  e2.printStackTrace()
              }
              return
          }
      }
      e.printStackTrace()
  }
}
使用MediaStore刪除文件

刪除自己創(chuàng)建的多媒體文件不需要權(quán)限,其他APP創(chuàng)建的,與修改類型,需要用戶授權(quán)。

/**
  * 刪除MediaStore文件
  */
private fun deleteFileByMediaStore() {
    deletePictureRequestPermissionActivityResult = registerForActivityResult(ActivityResultContracts.RequestPermission()) {
        if (it) {
            deleteFile()
        } else {
            Log.d(TAG, "deleteFileByMediaStore: 授權(quán)失敗")
        }
    }
    deletePictureSenderRequestActivityResult = registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) {
        if (it.resultCode == RESULT_OK) {
            deleteFile()
        } else {
            Log.d(TAG, "updateFileByMediaStore: 授權(quán)失敗")
        }
    }
    binding.deleteFileByMediaStoreBtn.setOnClickListener {
        deletePictureRequestPermissionActivityResult.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE)
    }
}

private fun deleteFile() {
    val queryUri = queryImageUri("2021-10-14_11.19.18.882.png")
    try {
        if (queryUri != null) {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                val delete = contentResolver.delete(queryUri, null, null)
                //delete=0刪除失敗,delete=1也不一定刪除成功,必須要授予文件的寫權(quán)限
                Log.d(TAG, "contentResolver.delete:$delete")
            } else {
                val filePathByUri = UriTool.getFilePathByUri(this@ScopedStorageActivity, queryUri)
                File(filePathByUri).delete()
            }
        }
    } catch (e: Exception) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            if (e is RecoverableSecurityException) {
                try {
                    deletePictureSenderRequestActivityResult.launch(
                        IntentSenderRequest.Builder(e.userAction.actionIntent.intentSender)
                            .build()
                    )
                } catch (e2: IntentSender.SendIntentException) {
                    e2.printStackTrace()
                }
                return
            }
        }
        e.printStackTrace()
    }
}

使用Storage Access Framework

Android 4.4(API 級(jí)別 19)引入了存儲(chǔ)訪問框架Storage Access Framework (SAF)。借助 SAF,用戶可輕松在其所有首選文檔存儲(chǔ)提供程序中瀏覽并打開文檔、圖像及其他文件。用戶可通過易用的標(biāo)準(zhǔn)界面,以統(tǒng)一方式在所有應(yīng)用和提供程序中瀏覽文件,以及訪問最近使用的文件。

SAF google官方文檔 https://developer.android.google.cn/guide/topics/providers/document-provider

StorageAccessFramework.png

SAF本地存儲(chǔ)服務(wù)的圍繞 DocumentsProvider 實(shí)現(xiàn)的,通過Intent調(diào)用DocumentUI,由用戶在DocumentUI上選擇要?jiǎng)?chuàng)建、授權(quán)的文件以及目錄等,授權(quán)成功后再onActivityResult回調(diào)用拿到指定的Uri,根據(jù)這個(gè)Uri可進(jìn)行讀寫等操作,這時(shí)候已經(jīng)賦予文件讀寫權(quán)限,不需要再動(dòng)態(tài)申請(qǐng)權(quán)限。

使用SAF選擇單個(gè)文件

通過Intent.ACTION_OPEN_DOCUMENT調(diào)文件選擇界面,用戶選擇并返回一個(gè)或多個(gè)現(xiàn)有文檔,所有選定的文檔均具有持久的讀寫權(quán)限授予,直至設(shè)備重啟。

private lateinit var createFileActivityResult: ActivityResultLauncher<Intent>
/**
 * 選擇一個(gè)文件,這里打開一個(gè)圖片作為演示
 */
private fun selectSingleFile() {
    selectSingleFileActivityResult = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
        if (it.resultCode == RESULT_OK) {
            //獲取文檔
            val uri = it?.data?.data
            if (uri != null) {
                dumpImageMetaData(uri)//dump圖片的信息進(jìn)行打印
                getBitmapFromUri(uri)?.let {
                    binding.showIv.setImageBitmap(it)
                }
                Log.d(TAG, "圖片的line :${readTextFromUri(uri)}")
            }
        }
    }
    binding.selectSingleFile.setOnClickListener {
        safSelectSingleFileActivityResult.launch(Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
            // Filter to only show results that can be "opened", such as a
            // file (as opposed to a list of contacts or timezones)
            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 "*/*".
            type = "image/*"
        })
    }
}
使用SAF創(chuàng)建文件

可通過使用Intent.ACTION_CREATE_DOCUMENT,可以提供 MIME 類型和文件名,但最終結(jié)果由用戶決定。

private fun createFile(mimeType: String, fileName: String) {
    createFileActivityResult = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
        if (it.resultCode == Activity.RESULT_OK) {
            //創(chuàng)建文檔
            val uri = it?.data?.data
            if (uri != null) {
                Log.d(TAG, "創(chuàng)建文件成功")
                binding.createFileUriTv.text = TAG
                binding.createFileUriTv.visibility = View.VISIBLE
                dumpImageMetaData(uri)//dump圖片的信息進(jìn)行打印
            }
        }
    }
    binding.createFileBtn.setOnClickListener {
        createFileActivityResult.launch(Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
            // Filter to only show results that can be "opened", such as
            // a file (as opposed to a list of contacts or timezones).
            addCategory(Intent.CATEGORY_OPENABLE)
            // Create a file with the requested MIME type.
            type = mimeType
            putExtra(Intent.EXTRA_TITLE, fileName)
        })
    }
}
使用SAF刪除文件

需要注意的是此時(shí)的Uri是通過Document授權(quán)的,例如:content://com.android.providers.media.documents/document/image:14766。而不是這種content://media/external/images/media/14760。

如果您獲得了文檔的 URI,并且文檔Document.COLUMN_FLAGS 包含 FLAG_SUPPORTS_DELETE,則便可刪除該文檔。

private fun checkUriFlag(uri: Uri, flag: Int): Boolean {
    try {
        val cursor = contentResolver.query(uri, null, null, null, null)
        if (cursor != null && cursor.moveToFirst()) {
            val columnIndex = cursor.getColumnIndex(DocumentsContract.Document.COLUMN_FLAGS)
            val columnFlags = cursor.getInt(columnIndex)
            Log.i(TAG,"Column Flags:$columnFlags  Flag:$flag")
                        if ((columnFlags and  flag) == flag) {
                return true
            }
            cursor.close()
        }
    } catch (e: Exception) {
        e.printStackTrace()
    }
    return false
}

這里做一個(gè)解釋通過一下,我們可以先看DocumentsContract.java的源代碼。

//android.provider.DocumentsContract.java
public static final int FLAG_SUPPORTS_THUMBNAIL = 1;
public static final int FLAG_SUPPORTS_WRITE = 1 << 1;
public static final int FLAG_SUPPORTS_DELETE = 1 << 2;
public static final int FLAG_DIR_SUPPORTS_CREATE = 1 << 3;
/**
 * Flags that apply to a document. This column is required.
 * <p>
 * Type: INTEGER (int)
 *
 * @see #FLAG_DIR_BLOCKS_OPEN_DOCUMENT_TREE
 * @see #FLAG_DIR_PREFERS_GRID
 * @see #FLAG_DIR_PREFERS_LAST_MODIFIED
 * @see #FLAG_DIR_SUPPORTS_CREATE
 * @see #FLAG_PARTIAL
 * @see #FLAG_SUPPORTS_COPY
 * @see #FLAG_SUPPORTS_DELETE
 * @see #FLAG_SUPPORTS_METADATA
 * @see #FLAG_SUPPORTS_MOVE
 * @see #FLAG_SUPPORTS_REMOVE
 * @see #FLAG_SUPPORTS_RENAME
 * @see #FLAG_SUPPORTS_SETTINGS
 * @see #FLAG_SUPPORTS_THUMBNAIL
 * @see #FLAG_SUPPORTS_WRITE
 * @see #FLAG_VIRTUAL_DOCUMENT
 * @see #FLAG_WEB_LINKABLE
 */
public static final String COLUMN_FLAGS = "flags";

可以看出,flag是通過二進(jìn)制的位做區(qū)分的。所以判斷是否判斷包含某個(gè)flag可以使用位操作與Document.COLUMN_FLAGS做比較。

 /**
  * 如果您獲得了文檔的 URI,并且文檔的 Document.COLUMN_FLAGS 包含 FLAG_SUPPORTS_DELETE,則便可刪除該文檔
  */
private fun deleteFile() {
    binding.deleteFileBtn.setOnClickListener {
        queryUri = Uri.parse("content://com.android.providers.media.documents/document/image%3A14766")
        queryUri?.let { url ->
            if (checkUriFlag(url, DocumentsContract.Document.FLAG_SUPPORTS_DELETE)) {
                val deleted = DocumentsContract.deleteDocument(contentResolver, url)
                val s = "刪除$url$deleted"
                Log.d(TAG, "deleteFile:$s")
                if (deleted) {
                    binding.createFileUriTv.text = ""
                }
            }
        }
    }
}
使用SAF更新文件

這里的Uri,是通過用戶選擇授權(quán)的Uri,通過Uri獲取ParcelFileDescriptor或者打開OutputStream進(jìn)行修改。

private fun editDocument() {
    editFileActivityResult =
        registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
            if (it.resultCode == RESULT_OK) {
                //編輯文檔
                val uri = it?.data?.data
                if (uri != null) {
                    alterDocument(uri)//更新文檔
                }
            }
        }
    binding.editDocumentBtn.setOnClickListener {
        editFileActivityResult.launch(
            // ACTION_OPEN_DOCUMENT is the intent to choose a file via the system's
            // file browser.
            Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
                // Filter to only show results that can be "opened", such as a
                // file (as opposed to a list of contacts or timezones).
                addCategory(Intent.CATEGORY_OPENABLE)
                // Filter to show only text files.
                type = "text/plain"
            })
    }
}
使用SAF獲取目錄&保存授權(quán)

使用ACTION_OPEN_DOCUMENT_TREEintent,拉起DocumentUI讓用戶主動(dòng)授權(quán)的方式 獲取,獲得用戶主動(dòng)授權(quán)之后,應(yīng)用就可以臨時(shí)獲得該目錄下面的所有文件和目錄的讀寫權(quán)限,可以通過DocumentFile操作目錄和其下的文件。

在這個(gè)過程中通過用戶授權(quán)的Uri,就默認(rèn)獲取了該Uri的讀寫權(quán)限,直到設(shè)備重啟。可以通過保存權(quán)限來永久的獲取該權(quán)限,不需要每次重啟手機(jī)之后又要重新讓用戶主動(dòng)授權(quán)。

contentResolver.takePersistableUriPermission方法可以校驗(yàn)當(dāng)前的Uri是否有相關(guān)授權(quán)可以進(jìn)行文件讀寫;

/**
 * 使用saf選擇目錄
 */
private fun getDocumentTree() {
    selectDirActivityResult = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
        //選擇目錄
        val treeUri = it?.data?.data
        if (treeUri != null) {
            savePersistablePermission(treeUri)//將獲取的權(quán)限持久化保存
            val root = DocumentFile.fromTreeUri(this, treeUri)
            root?.listFiles()?.forEach { it ->
                Log.d(TAG, "目錄下文件名稱:${it.name}")
            }
        }
    }
    binding.getDocumentTreeBtn.setOnClickListener {
        val sp = getSharedPreferences("DirPermission", Context.MODE_PRIVATE)//獲取緩存的權(quán)限
        val uriString = sp.getString("uri", "")
        if (!uriString.isNullOrEmpty()) {
            try {
                val treeUri = Uri.parse(uriString)
                // Check for the freshest data.
                contentResolver.takePersistableUriPermission(treeUri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
                Log.d(TAG, "已經(jīng)獲得永久訪問權(quán)限")
                val root = DocumentFile.fromTreeUri(this, treeUri)
                root?.listFiles()?.forEach { it ->
                    Log.d(TAG, "目錄下文件名稱:${it.name}")
                }
            } catch (e: SecurityException) {
                Log.d(TAG, "uri 權(quán)限失效,調(diào)用目錄獲取")
                selectDirActivityResult.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE))
            }
        } else {
            Log.d(TAG, "沒有永久訪問權(quán)限,調(diào)用目錄獲取")
            selectDirActivityResult.launch(Intent(Intent.ACTION_OPEN_DOCUMENT_TREE))
        }
    }
}
使用SAF進(jìn)行重命名
private fun renameFileName() {
    binding.renameFileBtn.setOnClickListener {
        queryUri?.let {
            val uri = it
            //小米8 Android9 拋出java.lang.UnsupportedOperationException: Rename not supported異常
            //Pixel 6a Android13可以正常重命名
            if (checkUriFlag(uri, DocumentsContract.Document.FLAG_SUPPORTS_RENAME)) {
                try {
                    //如果文件名已存在,會(huì)報(bào)錯(cuò)java.lang.IllegalStateException: File already exists:
                    DocumentsContract.renameDocument(contentResolver, uri, "slzs.txt")
                    Log.d(TAG, "renameFileName" + "重命名成功")
                } catch (e: FileNotFoundException) {
                    Log.d(TAG, "renameFileName" + "重命名失敗,文件不存在")
                }
            } else {
                Log.d(TAG, "renameFileName" + "重命名失敗,權(quán)限校驗(yàn)失敗")
            }
        }
    }
}
使用自定義DocumentsProvider

如果你希望自己應(yīng)用的數(shù)據(jù)也能在documentsui中打開,可以自定義一個(gè)document provider。APP可以實(shí)現(xiàn)自定義ContentProvider來向外提供APP私有文件。 一般的文件管理類的軟件都會(huì)使用自定義的DocumentsProvider。這種方式十分適用于內(nèi)部文件分享,不希望有UI交互的情況。 ContentProvider相關(guān)的Google官方文檔: https://developer.android.google.cn/guide/topics/providers/content-providers

下面介紹自定義DocumentsProvider的步驟:

  • API版本為19或者更高
  • 在manifest.xml中注冊(cè)該P(yáng)rovider
  • Provider的name為類名加包名,比如: com.example.android.storageprovider.MyCloudProvider
  • Authority為包名+provider的類型名,如: com.example.android.storageprovider.documents
  • android:exported屬性的值為ture
<manifest... >
    ...
    <uses-sdk
        android:minSdkVersion="19"
        android:targetSdkVersion="19" />
        ....
        <provider
            android:name="com.example.android.storageprovider.MyCloudProvider"
            android:authorities="com.example.android.storageprovider.documents"
            android:grantUriPermissions="true"
            android:exported="true"
            android:permission="android.permission.MANAGE_DOCUMENTS"
            android:enabled="@bool/atLeastKitKat">
            <intent-filter>
                <action android:name="android.content.action.DOCUMENTS_PROVIDER" />
            </intent-filter>
        </provider>
    </application>
</manifest>

兼容性影響

Scoped Storage對(duì)于APP訪問外部存儲(chǔ)方式、APP數(shù)據(jù)存放以及APP間數(shù)據(jù)共享,都產(chǎn)生很大影響。請(qǐng)開發(fā)者注意以下的兼容性影響事項(xiàng)。

無法新建文件

問題原因:

直接使用自身App-specific目錄以外的路徑新建文件。

問題分析:

在Android Q上,APP只允許在自身App-specific目錄以內(nèi)通過路徑生成的文件。

解決方案:

APP自身App-specific目錄下新建文件的方法與文件路徑,請(qǐng)參見訪問App-specific目錄文件;如果要在公共目錄下新建文件,使用MediaStore 接口,請(qǐng)參見使用MediaStore訪問公共目錄;如果要在任意目錄下新建文件,需要使用SAF,請(qǐng)參見[使用Storage Access Framework](#使用Storage Access Framework)。

無法訪問存儲(chǔ)設(shè)備上的文件

問題原因1:

直接使用路徑訪問公共目錄文件。

問題分析1:

在Android Q上,APP默認(rèn)只能訪問外部存儲(chǔ)設(shè)備上的App-specific目錄。

解決方法1:

參見使用MediaStore訪問公共目錄使用SAF選擇單個(gè)文件,使用MediaStore接口訪問公共目錄中的多媒體文件,或者使用 SAF訪問公共目錄中的任意文件。

注意:從MediaStore接口中查詢到的DATA字段將在Android Q開始廢棄,不應(yīng)該利用它來訪問文件或者判斷文件是否存在;從 MediaStore接口或者SAF獲取到文件Uri后,請(qǐng)利用Uri打開FD 或者輸入輸出流,而不要轉(zhuǎn)換成文件路徑去訪問。

問題原因2:

使用MediaStore接口訪問非多媒體文件。

問題分析2:

在Android Q上,使用MediaStore接口只能訪問公共目錄中的多媒體文件。

解決方法2:

使用SAF向用戶申請(qǐng)文件或目錄的讀寫權(quán)限,請(qǐng)參見使用SAF選擇單個(gè)文件。

無法正確分享文件

問題原因:

APP將App-specific目錄中的私有文件分享給其他APP時(shí),使用了file://類型的 Uri。

問題分析:

在Android Q上,由于App-specific目錄中的文件是私有受保護(hù)的,其他APP無法通過文件路徑訪問。

解決方案:

參見分享處理,使用FileProvider,將content://類型的Uri分享給其他 APP。

無法修改存儲(chǔ)設(shè)備上的文件

問題原因1:

直接使用路徑訪問公共目錄文件。

問題分析1:

無法訪問存儲(chǔ)設(shè)備上的文件。

解決方案1:

無法訪問存儲(chǔ)設(shè)備上的文件,請(qǐng)使用正確的公共目錄文件訪問方式。

問題原因2:

使用MediaStore接口獲取公共目錄多媒體文件的Uri后,直接使用該Uri打開 OutputStream或文件描述符。

問題分析2:

Android Q上,修改公共目錄文件,需要用戶授權(quán)。

解決方案2:

MediaStore接口獲取公共目錄多媒體文件Uri后,打開OutputStreamFD時(shí),注意catch RecoverableSecurityException,然后向用戶申請(qǐng)?jiān)摱嗝襟w文件的刪改權(quán)限,請(qǐng)參見使用MediaStore修改文件;使用SAF 獲取到文件或目錄的Uri時(shí),用戶已經(jīng)授權(quán)讀寫,可以直接使用,但要注意Uri權(quán)限的時(shí)效,請(qǐng)參見使用SAF獲取目錄&保存授權(quán)

應(yīng)用卸載后文件意外刪除

問題原因:

將想要保留的文件保存在外部存儲(chǔ)的App-specific目錄下。

問題分析:

Android Q上,卸載APP默認(rèn)刪除App-specific目錄下的數(shù)據(jù)。

解決方案:

APP應(yīng)該將想要保留的文件通過MediaStore接口保存到公共目錄下,請(qǐng)參見使用MediaStore訪問公共目錄。默認(rèn)情況下,MediaStore 接口會(huì)將非媒體類文件保存到Downloads目錄下,推薦APP指定一級(jí)目錄為Documents。如果APP 想要在卸載時(shí)保留App-specific目錄下的數(shù)據(jù),要在AndroidManifest.xml中聲明android:hasFragileUserData="true",這樣在 APP卸載時(shí)就會(huì)有彈出框提示用戶是否保留應(yīng)用數(shù)據(jù)。

無法訪問圖片文件中的地理位置數(shù)據(jù)

問題原因:

直接從圖片文件輸入流中解析地理位置數(shù)據(jù)。

問題分析:

由于圖片的地理位置信息涉及用戶隱私,Android Q上默認(rèn)不向APP提供該數(shù)據(jù)。

解決方案:

申請(qǐng)ACCESS_MEDIA_LOCATION權(quán)限,并使用MediaStore.setRequireOriginal()接口更新文件Uri,請(qǐng)參見圖片位置信息。

ota升級(jí)問題(數(shù)據(jù)遷移)

問題原因:

ota升級(jí)后,APP被卸載,重新安裝后無法訪問到APP數(shù)據(jù)。

問題分析:

Scoped Storage新特性只對(duì)Android Q上新安裝的APP生效。設(shè)備從Android Q之前的版本升級(jí)到Android Q,已安裝的APP獲得Legacy View視圖。

這些APP 如果直接通過路徑的方式將文件保存到了外部存儲(chǔ)上,例如外部存儲(chǔ)的根目錄,那么APP被卸載后重新安裝,新的APP獲得Filtered View視圖,無法直接通過路徑訪問到舊數(shù)據(jù),導(dǎo)致數(shù)據(jù)丟失。

解決方案:

  • APP應(yīng)該修改保存文件的方式,不再使用路徑的方式直接保存,而是采用MediaStore接口將文件保存到對(duì)應(yīng)的公共目錄下。

  • 在ota升級(jí)前,可以將APP 的用戶歷史數(shù)據(jù)通過MediaStore接口遷移到公共目錄下。此外,APP應(yīng)當(dāng)改變?cè)L問App-specific目錄以外的文件的方式,請(qǐng)使用MediaStore 接口或者SAF

  • 針對(duì)只有應(yīng)用自己訪問并且應(yīng)用卸載后允許刪除的文件,需要遷移文件到應(yīng)用私有目錄文件,可以通過 File path 方式訪問文件資源,降低適配成本。

  • 允許其他應(yīng)用訪問,并且應(yīng)用卸載后不允許刪除的文件,文件需要存儲(chǔ)在共享目錄,應(yīng)用可以選擇是否進(jìn)行目錄整改,將文件遷移到 Androidq 要求的 media 集合目錄。

分享處理

APP可以選擇以下的方式,將自身App-specific目錄下的文件分享給其他APP讀寫。

使用FileProvider

FileProvider相關(guān)的Google官方文檔: https://developer.android.google.cn/reference/androidx/core/content/FileProvider https://developer.android.com/training/secure-file-sharing/setup-sharing

FileProvider屬于在Android7.0的行為變更,各種帖子很多,這里就不詳細(xì)介紹了。 為了避免和已有的三方庫沖突,建議采用extends FileProvider的方式。

public class TakePhotoProvider extends FileProvider {...}
<application>
        <provider
            android:name=".TakePhotoProvider"
            android:authorities="${applicationId}.fileProvider"
            android:exported="false"
            android:grantUriPermissions="true">
            <meta-data
                android:name="android.support.FILE_PROVIDER_PATHS"
                android:resource="@xml/take_file_path" />
        </provider>
</application>
使用ContentProvider

APP可以實(shí)現(xiàn)自定義ContentProvider來向外提供APP私有文件。這種方式十分適用于內(nèi)部文件分享,不希望有UI交互的情況。 ContentProvider相關(guān)的Google官方文檔: https://developer.android.google.cn/guide/topics/providers/content-providers

使用DocumentsProvider

詳見使用自定義DocumentsProvider

相關(guān)API使用問題

MediaStore DATA字段不再可靠

Android QDATA(即_data)字段開始廢棄,不再表示文件的真實(shí)路徑。

讀寫文件或判斷文件是否存在,不應(yīng)該使用DATA字段,而要使用openFileDescriptor。 同時(shí)也無法直接使用路徑訪問公共目錄的文件。

MediaStore 文件增加Pending狀態(tài)

AndroidQ上,MediaStore中添加MediaStore.Images.Media.IS_PENDING ,flag用來表示文件的Pending狀態(tài),0是可見,其他不可見,如果沒有設(shè)置setIncludePending接口,查詢不到設(shè)置IS_PENDIN的文件,可以用來下載,或者生產(chǎn)截圖等等。

ContentValues values = new ContentValues();
values.put(MediaStore.Images.Media.DISPLAY_NAME, "myImage.PNG");
values.put(MediaStore.Images.Media.MIME_TYPE, "image/png");
values.put(MediaStore.Images.Media.IS_PENDING, 1);
ContentResolver resolver = context.getContentResolver();
Uri uri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
Uri item = resolver.insert(uri, values);
try {
    ParcelFileDescriptor pfd = resolver.openFileDescriptor(item, "w", null);
    // write data into the pending image.
} catch (IOException e) {
    LogUtil.log("write image fail");
}
// clear IS_PENDING flag after writing finished.
values.clear();
values.put(MediaStore.Images.Media.IS_PENDING, 0);
resolver.update(item, values, null, null);
MediaStore 相對(duì)路徑

AndroidQ中,通過MediaSore將多媒體沒見儲(chǔ)存在公共目錄下,除了默認(rèn)的一級(jí)目錄,還可以指定次級(jí)目錄,對(duì)應(yīng)的一級(jí)目錄詳見下表:

Uri-minetype.png
val values = ContentValues()
//Pictures為一級(jí)目錄對(duì)應(yīng)Environment.DIRECTORY_PICTURES,sl為二級(jí)目錄
values.put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/sl")
val external = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
val insertUri = contentResolver.insert(external, values)

values.clear()
//DCIM為一級(jí)目錄對(duì)應(yīng)Environment.DIRECTORY_DCIM,sl為二級(jí)目錄,sl2為三級(jí)目錄
values.put(MediaStore.Images.Media.RELATIVE_PATH, "DCIM/sl/sl2")
contentResolver.update(insertUri,values,null,null)

參考資料

OPPO 開放平臺(tái) Android Q版本應(yīng)用兼容性適配指導(dǎo)

GitHub Demo

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

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

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