存取共用儲存空間中的媒體檔案

為了讓使用者有更豐富的體驗,許多應用程式可讓使用者提供及存取外部儲存空間磁碟區中的媒體。該架構為媒體集合提供了稱為「媒體儲存區」的最佳化索引,讓使用者可以更輕鬆地擷取及更新媒體檔案。即便在解除安裝應用程式後,這些檔案仍會保留在使用者的裝置上。

相片挑選工具

除了使用媒體儲存區之外,Android 相片挑選工具也提供安全的內建功能,讓使用者能選取媒體檔案,而不必授予應用程式整個媒體庫的存取權。這項功能僅適用於支援的裝置。詳情請參閱相片挑選工具指南。

媒體儲存區

如要與媒體儲存區抽象層互動,請使用從應用程式環境擷取的 ContentResolver 物件。

Kotlin

val projection = arrayOf(media-database-columns-to-retrieve)
val selection = sql-where-clause-with-placeholder-variables
val selectionArgs = values-of-placeholder-variables
val sortOrder = sql-order-by-clause

applicationContext.contentResolver.query(
    MediaStore.media-type.Media.EXTERNAL_CONTENT_URI,
    projection,
    selection,
    selectionArgs,
    sortOrder
)?.use { cursor ->
    while (cursor.moveToNext()) {
        // Use an ID column from the projection to get
        // a URI representing the media item itself.
    }
}

Java

String[] projection = new String[] {
        media-database-columns-to-retrieve
};
String selection = sql-where-clause-with-placeholder-variables;
String[] selectionArgs = new String[] {
        values-of-placeholder-variables
};
String sortOrder = sql-order-by-clause;

Cursor cursor = getApplicationContext().getContentResolver().query(
    MediaStore.media-type.Media.EXTERNAL_CONTENT_URI,
    projection,
    selection,
    selectionArgs,
    sortOrder
);

while (cursor.moveToNext()) {
    // Use an ID column from the projection to get
    // a URI representing the media item itself.
}

系統會自動掃描外部儲存空間的磁碟區,並將媒體檔案新增至下列定義明確的集合:

  • 圖片,包括儲存在 DCIM/Pictures/ 目錄中的相片和螢幕截圖。這些檔案將會新增至 MediaStore.Images 資料表。
  • 影片,儲存於 DCIM/Movies/Pictures/ 目錄。這些檔案將會新增至 MediaStore.Video 資料表。
  • 音訊檔案,儲存於 Alarms/Audiobooks/Music/Notifications/Podcasts/Ringtones/ 目錄。此外,系統也可以辨識 Music/Movies/ 目錄中的音訊播放清單,以及 Recordings/ 目錄中的語音錄音。這些檔案將會新增至 MediaStore.Audio 資料表。Android 11 (API 級別 30) 以下版本不提供 Recordings/ 目錄。
  • 下載的檔案,儲存於 Download/ 目錄。如果是使用 Android 10 (API 級別 29) 及以上版本的裝置,這些檔案會儲存在 MediaStore.Downloads 資料表中。該表格不適用於 Android 9 (API 級別 28) 及以下的版本。

媒體儲存區還有一個名為 MediaStore.Files 的集合,其內容取決於應用程式是否使用限定範圍儲存空間,此功能僅在目標版本為 Android 10 以上的應用程式提供。

  • 如果已啟用限定範圍儲存空間,則集合只會顯示應用程式已建立的相片、影片和音訊檔案。如要查看其他應用程式的媒體檔案,大多數情況皆不需要使用 MediaStore.Files,但如果開發人員有特定需求,可以宣告 READ_EXTERNAL_STORAGE 權限。不過,建議您使用 MediaStore API 開啟應用程式尚未建立的檔案。
  • 如果無法使用限定範圍儲存空間,或者未使用該功能,則集合會顯示所有類型的媒體檔案。

要求必要權限

在對媒體檔案執行作業之前,請先確認應用程式已宣告存取這些檔案所需的權限。但請注意,應用程式不得宣告不需要或不使用的權限。

儲存空間權限

應用程式是否需要儲存空間的存取權限,取決於應用程式是只會存取自身的媒體檔案,還是也會存取其他應用程式建立的檔案。

存取自己的媒體檔案

在搭載 Android 10 以上版本的裝置上,您不需要儲存空間相關權限,即可存取及修改您的應用程式擁有的媒體檔案,包括 MediaStore.Downloads 集合中的檔案。以開發相機應用程式為例,由於應用程式已擁有您要寫入媒體儲存區的圖像,因此您無須要求儲存空間權限,即可存取應用程式拍攝的相片。

存取其他應用程式的媒體檔案

如要存取其他應用程式所建立的媒體檔案,您必須宣告適當的儲存空間相關權限,且檔案必須位於下列其中一個媒體集合中:

只要檔案可透過 MediaStore.ImagesMediaStore.VideoMediaStore.Audio 查詢進行查看,就表示該檔案也能透過 MediaStore.Files 查詢來查看。

下列程式碼片段說明如何宣告適當的儲存空間權限:

<!-- Required only if your app needs to access images or photos
     that other apps created. -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />

<!-- Required only if your app needs to access videos
     that other apps created. -->
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />

<!-- Required only if your app needs to access audio files
     that other apps created. -->
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />

<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>

<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
                 android:maxSdkVersion="29" />

應用程式需要額外權限才能在舊版裝置上執行

如果應用程式是安裝於搭載 Android 9 或以下版本的裝置上,或者應用程式暫時停用限定範圍儲存空間,則必須要求 READ_EXTERNAL_STORAGE 權限才能存取任何媒體檔案。如要修改媒體檔案,則須同時要求 WRITE_EXTERNAL_STORAGE 權限。

必須使用「儲存空間存取架構」才能存取其他應用程式的下載項目

如果應用程式想存取未建立的 MediaStore.Downloads 集合中的檔案,則必須使用 Storage 存取架構。如要進一步瞭解如何使用這個架構,請參閱「存取共用儲存空間中的文件和其他檔案」。

媒體位置存取權

如果應用程式需要從相片中擷取未遮蓋的 EXIF 中繼資料,因而以 Android 10 (API 級別 29) 以上版本為目標,請在應用程式資訊清單中宣告 ACCESS_MEDIA_LOCATION 權限,並在執行階段要求該權限。

檢查媒體儲存區的更新

請檢查該媒體儲存區版本與上次同步處理媒體資料時是否不同,以便更穩定地存取媒體檔案,特別是應用程式快取 URI 或媒體儲存區資料時。如要檢查是否有更新,請呼叫 getVersion()。系統傳回的版本為不重複字串,每當媒體儲存區大幅變動時都會隨之改變。如果傳回的版本與上次同步的版本不同,請重新掃描並重新同步處理應用程式的媒體快取。

應用程式處理程序啟動時,就會完成這項檢查。無須在每次查詢媒體儲存區時檢查版本。

任何實作詳情均不一定與版本號碼有關,請勿自行假設。

查詢媒體集合

如要尋找符合一組特定條件 (例如持續時間為 5 分鐘以上) 的媒體,請使用類似 SQL 的選取陳述式 (與以下程式碼片段中的示例相似):

Kotlin

// Need the READ_EXTERNAL_STORAGE permission if accessing video files that your
// app didn't create.

// Container for information about each video.
data class Video(val uri: Uri,
    val name: String,
    val duration: Int,
    val size: Int
)
val videoList = mutableListOf<Video>()

val collection =
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        MediaStore.Video.Media.getContentUri(
            MediaStore.VOLUME_EXTERNAL
        )
    } else {
        MediaStore.Video.Media.EXTERNAL_CONTENT_URI
    }

val projection = arrayOf(
    MediaStore.Video.Media._ID,
    MediaStore.Video.Media.DISPLAY_NAME,
    MediaStore.Video.Media.DURATION,
    MediaStore.Video.Media.SIZE
)

// Show only videos that are at least 5 minutes in duration.
val selection = "${MediaStore.Video.Media.DURATION} >= ?"
val selectionArgs = arrayOf(
    TimeUnit.MILLISECONDS.convert(5, TimeUnit.MINUTES).toString()
)

// Display videos in alphabetical order based on their display name.
val sortOrder = "${MediaStore.Video.Media.DISPLAY_NAME} ASC"

val query = ContentResolver.query(
    collection,
    projection,
    selection,
    selectionArgs,
    sortOrder
)
query?.use { cursor ->
    // Cache column indices.
    val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media._ID)
    val nameColumn =
            cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DISPLAY_NAME)
    val durationColumn =
            cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DURATION)
    val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.SIZE)

    while (cursor.moveToNext()) {
        // Get values of columns for a given video.
        val id = cursor.getLong(idColumn)
        val name = cursor.getString(nameColumn)
        val duration = cursor.getInt(durationColumn)
        val size = cursor.getInt(sizeColumn)

        val contentUri: Uri = ContentUris.withAppendedId(
            MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
            id
        )

        // Stores column values and the contentUri in a local object
        // that represents the media file.
        videoList += Video(contentUri, name, duration, size)
    }
}

Java

// Need the READ_EXTERNAL_STORAGE permission if accessing video files that your
// app didn't create.

// Container for information about each video.
class Video {
    private final Uri uri;
    private final String name;
    private final int duration;
    private final int size;

    public Video(Uri uri, String name, int duration, int size) {
        this.uri = uri;
        this.name = name;
        this.duration = duration;
        this.size = size;
    }
}
List<Video> videoList = new ArrayList<Video>();

Uri collection;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
    collection = MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL);
} else {
    collection = MediaStore.Video.Media.EXTERNAL_CONTENT_URI;
}

String[] projection = new String[] {
    MediaStore.Video.Media._ID,
    MediaStore.Video.Media.DISPLAY_NAME,
    MediaStore.Video.Media.DURATION,
    MediaStore.Video.Media.SIZE
};
String selection = MediaStore.Video.Media.DURATION +
        " >= ?";
String[] selectionArgs = new String[] {
    String.valueOf(TimeUnit.MILLISECONDS.convert(5, TimeUnit.MINUTES));
};
String sortOrder = MediaStore.Video.Media.DISPLAY_NAME + " ASC";

try (Cursor cursor = getApplicationContext().getContentResolver().query(
    collection,
    projection,
    selection,
    selectionArgs,
    sortOrder
)) {
    // Cache column indices.
    int idColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media._ID);
    int nameColumn =
            cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DISPLAY_NAME);
    int durationColumn =
            cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DURATION);
    int sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.SIZE);

    while (cursor.moveToNext()) {
        // Get values of columns for a given video.
        long id = cursor.getLong(idColumn);
        String name = cursor.getString(nameColumn);
        int duration = cursor.getInt(durationColumn);
        int size = cursor.getInt(sizeColumn);

        Uri contentUri = ContentUris.withAppendedId(
                MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id);

        // Stores column values and the contentUri in a local object
        // that represents the media file.
        videoList.add(new Video(contentUri, name, duration, size));
    }
}

在應用程式中執行這類查詢時,請注意下列事項:

  • 在背景工作執行緒中呼叫 query() 方法。
  • 請快取資料欄索引,這樣每次處理查詢結果中的資料列時,就不必呼叫 getColumnIndexOrThrow()
  • 請依這個範例所示,將 ID 附加至內容 URI。
  • 如果裝置搭載 Android 10 以上版本,則需要 MediaStore API 中定義的資料欄名稱。如果應用程式中相依程式庫預期的資料欄名稱並未在 API 中定義 (例如 "MimeType"),請使用 CursorWrapper 在應用程式處理程序中動態轉譯資料欄名稱。

載入檔案縮圖

如果應用程式顯示多個媒體檔案,並要求使用者選取其中一個檔案,此時載入檔案的預覽版本或「縮圖」會比直接載入檔案更有效率。

如要載入特定媒體檔案的縮圖,請使用 loadThumbnail() 並傳入要載入的縮圖大小,如以下程式碼片段所示:

Kotlin

// Load thumbnail of a specific media item.
val thumbnail: Bitmap =
        applicationContext.contentResolver.loadThumbnail(
        content-uri, Size(640, 480), null)

Java

// Load thumbnail of a specific media item.
Bitmap thumbnail =
        getApplicationContext().getContentResolver().loadThumbnail(
        content-uri, new Size(640, 480), null);

開啟媒體檔案

用於開啟媒體檔案的具體邏輯會因媒體內容的最佳呈現形式 (檔案描述元、檔案串流或直接檔案路徑) 而異。

檔案描述元

如要使用檔案描述元開啟媒體檔案,請參照下列程式碼範例的邏輯進行作業:

Kotlin

// Open a specific media item using ParcelFileDescriptor.
val resolver = applicationContext.contentResolver

// "rw" for read-and-write.
// "rwt" for truncating or overwriting existing file contents.
val readOnlyMode = "r"
resolver.openFileDescriptor(content-uri, readOnlyMode).use { pfd ->
    // Perform operations on "pfd".
}

Java

// Open a specific media item using ParcelFileDescriptor.
ContentResolver resolver = getApplicationContext()
        .getContentResolver();

// "rw" for read-and-write.
// "rwt" for truncating or overwriting existing file contents.
String readOnlyMode = "r";
try (ParcelFileDescriptor pfd =
        resolver.openFileDescriptor(content-uri, readOnlyMode)) {
    // Perform operations on "pfd".
} catch (IOException e) {
    e.printStackTrace();
}

檔案串流

如要使用檔案串流開啟媒體檔案,請使用與下列程式碼範例類似的邏輯:

Kotlin

// Open a specific media item using InputStream.
val resolver = applicationContext.contentResolver
resolver.openInputStream(content-uri).use { stream ->
    // Perform operations on "stream".
}

Java

// Open a specific media item using InputStream.
ContentResolver resolver = getApplicationContext()
        .getContentResolver();
try (InputStream stream = resolver.openInputStream(content-uri)) {
    // Perform operations on "stream".
}

直接檔案路徑

為了讓應用程式與第三方媒體庫之間的運作更加順暢,Android 11 (API 級別 30) 以上版本可讓您透過 MediaStore API 以外的 API 存取共用儲存空間中的媒體檔案。您可改為直接使用下列任一 API 存取媒體檔案:

  • File API
  • 原生程式庫,例如 fopen()

如果應用程式沒有任何儲存空間相關權限,您可以透過 File API 存取應用程式專屬目錄中的檔案,以及歸因至應用程式的媒體檔案

如果應用程式企圖透過 File API 存取檔案,卻沒有必要權限,則系統會顯示 FileNotFoundException

如要在搭載 Android 10 (API 級別 29) 的裝置上存取共用儲存空間中的其他檔案,建議您前往應用程式的資訊清單檔案將 requestLegacyExternalStorage 設為 true,以便暫時停用限定範圍儲存空間。如要以原生檔案方法在 Android 10 存取媒體檔案,您還須請求 READ_EXTERNAL_STORAGE 權限。

存取媒體內容時的注意事項

存取媒體內容時,請注意下列事項。

快取資料

如果應用程式會快取媒體儲存區提供的 URI 或資料,請定期檢查媒體儲存區更新。這項檢查可讓應用程式端的快取資料與系統端的供應器資料保持同步。

效能

使用直接檔案路徑,執行循序讀取媒體檔案時,速度會與使用 MediaStore API 時相當。

但使用直接檔案路徑,執行隨機讀取與寫入媒體檔案,則速度可能較慢。建議開發人員在這種情況改用 MediaStore API。

DATA 欄

存取現有的媒體檔案時,您可以使用邏輯中 DATA 欄的值,因為這個值包含有效的檔案路徑。不過,請勿假設可以隨時使用檔案,請準備好處理任何可能發生的檔案 I/O 錯誤。

另一方面,DATA 欄的值並不能用來建立或更新媒體檔案。請改用 DISPLAY_NAMERELATIVE_PATH 資料欄的值。

儲存磁碟區

以 Android 10 或以上為目標版本的應用程式,可以存取系統指派給各個外部儲存磁碟區的專屬名稱。這個命名系統可有效整理內容並建立索引,也可讓開發人員掌握新媒體檔案的儲存位置。

請特別注意下列磁碟區:

  • VOLUME_EXTERNAL 磁碟區可檢視裝置中所有共用儲存空間。開發人員可以讀取這個合成磁碟區的內容,但無法修改。
  • VOLUME_EXTERNAL_PRIMARY 磁碟區代表裝置的主要共用儲存空間。開發人員可以對這個磁碟區的內容進行讀取與修改。

開發人員可以呼叫 MediaStore.getExternalVolumeNames() 來探索其他磁碟區:

Kotlin

val volumeNames: Set<String> = MediaStore.getExternalVolumeNames(context)
val firstVolumeName = volumeNames.iterator().next()

Java

Set<String> volumeNames = MediaStore.getExternalVolumeNames(context);
String firstVolumeName = volumeNames.iterator().next();

擷取媒體的位置

部分相片和影片的位置資訊可以在中繼資料中找到,內容包含拍照或錄影的位置。

如何在應用程式中存取這類位置資訊,取決於您是否需要存取相片或影片的位置資訊。

相片

如果應用程式啟用限定範圍儲存空間,位置資訊將預設為隱藏。如要存取這項資訊,請完成下列步驟:

  1. 請在應用程式資訊清單中,要求 ACCESS_MEDIA_LOCATION 權限。
  2. 透過呼叫 setRequireOriginal() 來從 MediaStore 物件取得相片的確切位元組,然後傳入相片的 URI,如以下程式碼片段所示:

    Kotlin

    val photoUri: Uri = Uri.withAppendedPath(
            MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
            cursor.getString(idColumnIndex)
    )
    
    // Get location data using the Exifinterface library.
    // Exception occurs if ACCESS_MEDIA_LOCATION permission isn't granted.
    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 = latLong ?: doubleArrayOf(0.0, 0.0)
        }
    }

    Java

    Uri photoUri = Uri.withAppendedPath(
            MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
            cursor.getString(idColumnIndex));
    
    final double[] latLong;
    
    // Get location data using the Exifinterface library.
    // Exception occurs if ACCESS_MEDIA_LOCATION permission isn't granted.
    photoUri = MediaStore.setRequireOriginal(photoUri);
    InputStream stream = getContentResolver().openInputStream(photoUri);
    if (stream != null) {
        ExifInterface exifInterface = new ExifInterface(stream);
        double[] returnedLatLong = exifInterface.getLatLong();
    
        // If lat/long is null, fall back to the coordinates (0, 0).
        latLong = returnedLatLong != null ? returnedLatLong : new double[2];
    
        // Don't reuse the stream associated with
        // the instance of "ExifInterface".
        stream.close();
    } else {
        // Failed to load the stream, so return the coordinates (0, 0).
        latLong = new double[2];
    }

影片

如要存取影片中繼資料中的位置資訊,請使用 MediaMetadataRetriever 類別,詳情請參閱以下程式碼片段。使用這個類別不需要請求任何權限。

Kotlin

val retriever = MediaMetadataRetriever()
val context = applicationContext

// Find the videos that are stored on a device by querying the video collection.
val query = ContentResolver.query(
    collection,
    projection,
    selection,
    selectionArgs,
    sortOrder
)
query?.use { cursor ->
    val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media._ID)
    while (cursor.moveToNext()) {
        val id = cursor.getLong(idColumn)
        val videoUri: Uri = ContentUris.withAppendedId(
            MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
            id
        )
        extractVideoLocationInfo(videoUri)
    }
}

private fun extractVideoLocationInfo(videoUri: Uri) {
    try {
        retriever.setDataSource(context, videoUri)
    } catch (e: RuntimeException) {
        Log.e(APP_TAG, "Cannot retrieve video file", e)
    }
    // Metadata uses a standardized format.
    val locationMetadata: String? =
            retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION)
}

Java

MediaMetadataRetriever retriever = new MediaMetadataRetriever();
Context context = getApplicationContext();

// Find the videos that are stored on a device by querying the video collection.
try (Cursor cursor = context.getContentResolver().query(
    collection,
    projection,
    selection,
    selectionArgs,
    sortOrder
)) {
    int idColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media._ID);
    while (cursor.moveToNext()) {
        long id = cursor.getLong(idColumn);
        Uri videoUri = ContentUris.withAppendedId(
                MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id);
        extractVideoLocationInfo(videoUri);
    }
}

private void extractVideoLocationInfo(Uri videoUri) {
    try {
        retriever.setDataSource(context, videoUri);
    } catch (RuntimeException e) {
        Log.e(APP_TAG, "Cannot retrieve video file", e);
    }
    // Metadata uses a standardized format.
    String locationMetadata = retriever.extractMetadata(
            MediaMetadataRetriever.METADATA_KEY_LOCATION);
}

分享

有些應用程式可讓使用者與他人分享媒體檔案。例如,社群媒體應用程式可讓使用者與好友分享相片和影片。

按照建立內容供應器指南的建議使用 content:// URI,即可分享媒體檔案。

媒體檔案的應用程式歸因

如果指定 Android 10 以上版本的應用程式啟用了限定範圍儲存空間,系統就會將每個媒體檔案「歸因」至應用程式,進而決定應用程式未要求任何儲存空間權限時可存取的檔案。每個檔案只能歸因至一個應用程式。因此,如果應用程式建立的媒體檔案儲存在相片、影片或音訊檔案的媒體集合中,應用程式便可以存取該檔案。

萬一使用者解除安裝應用程式再重新安裝,您必須要求 READ_EXTERNAL_STORAGE,才能存取應用程式原先建立的檔案。系統會將這類檔案歸因到先前安裝的應用程式版本,而非新安裝的版本,因此需要請求這項權限。

新增項目

如要在現有集合中新增媒體項目,請使用類似於下方的程式碼。在搭載 Android 10 以上版本的裝置上,這段程式碼會存取 VOLUME_EXTERNAL_PRIMARY 磁碟區。這是因為您在這類裝置上只能修改主要磁碟區的內容,詳情請參閱「儲存空間磁碟區」一節。

Kotlin

// Add a specific media item.
val resolver = applicationContext.contentResolver

// Find all audio files on the primary external storage device.
val audioCollection =
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        MediaStore.Audio.Media.getContentUri(
            MediaStore.VOLUME_EXTERNAL_PRIMARY
        )
    } else {
        MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
    }

// Publish a new song.
val newSongDetails = ContentValues().apply {
    put(MediaStore.Audio.Media.DISPLAY_NAME, "My Song.mp3")
}

// Keep a handle to the new song's URI in case you need to modify it
// later.
val myFavoriteSongUri = resolver
        .insert(audioCollection, newSongDetails)

Java

// Add a specific media item.
ContentResolver resolver = getApplicationContext()
        .getContentResolver();

// Find all audio files on the primary external storage device.
Uri audioCollection;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
    audioCollection = MediaStore.Audio.Media
            .getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY);
} else {
    audioCollection = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
}

// Publish a new song.
ContentValues newSongDetails = new ContentValues();
newSongDetails.put(MediaStore.Audio.Media.DISPLAY_NAME,
        "My Song.mp3");

// Keep a handle to the new song's URI in case you need to modify it
// later.
Uri myFavoriteSongUri = resolver
        .insert(audioCollection, newSongDetails);

切換媒體檔案的待處理狀態

如果應用程式執行的作業可能非常耗時 (例如寫入媒體檔案),建議您在處理檔案時取得檔案的專屬存取權。在搭載 Android 10 以上版本的裝置上,將 IS_PENDING 標記的值設為 1 即可讓應用程式取得專屬存取權。除非應用程式將 IS_PENDING 的值變更回 0,否則只有該應用程式可以查看檔案。

下列程式碼片段接續了先前的程式碼片段。如要瞭解如何使用 IS_PENDING 標記,將較長的歌曲儲存至與 MediaStore.Audio 集合相對應的目錄,請參閱下列程式碼片段:

Kotlin

// Add a media item that other apps don't see until the item is
// fully written to the media store.
val resolver = applicationContext.contentResolver

// Find all audio files on the primary external storage device.
val audioCollection =
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        MediaStore.Audio.Media.getContentUri(
            MediaStore.VOLUME_EXTERNAL_PRIMARY
        )
    } else {
        MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
    }

val songDetails = ContentValues().apply {
    put(MediaStore.Audio.Media.DISPLAY_NAME, "My Workout Playlist.mp3")
    put(MediaStore.Audio.Media.IS_PENDING, 1)
}

val songContentUri = resolver.insert(audioCollection, songDetails)

// "w" for write.
resolver.openFileDescriptor(songContentUri, "w", null).use { pfd ->
    // Write data into the pending audio file.
}

// Now that you're finished, release the "pending" status and let other apps
// play the audio track.
songDetails.clear()
songDetails.put(MediaStore.Audio.Media.IS_PENDING, 0)
resolver.update(songContentUri, songDetails, null, null)

Java

// Add a media item that other apps don't see until the item is
// fully written to the media store.
ContentResolver resolver = getApplicationContext()
        .getContentResolver();

// Find all audio files on the primary external storage device.
Uri audioCollection;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
    audioCollection = MediaStore.Audio.Media
            .getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY);
} else {
    audioCollection = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI;
}

ContentValues songDetails = new ContentValues();
songDetails.put(MediaStore.Audio.Media.DISPLAY_NAME,
        "My Workout Playlist.mp3");
songDetails.put(MediaStore.Audio.Media.IS_PENDING, 1);

Uri songContentUri = resolver
        .insert(audioCollection, songDetails);

// "w" for write.
try (ParcelFileDescriptor pfd =
        resolver.openFileDescriptor(songContentUri, "w", null)) {
    // Write data into the pending audio file.
}

// Now that you're finished, release the "pending" status and let other apps
// play the audio track.
songDetails.clear();
songDetails.put(MediaStore.Audio.Media.IS_PENDING, 0);
resolver.update(songContentUri, songDetails, null, null);

提供檔案位置提示

如果應用程式在搭載 Android 10 的裝置上儲存媒體,根據預設,系統會根據類型整理媒體。比方說,根據預設,新的圖片檔案會存放在與 MediaStore.Images 集合相對應的 Environment.DIRECTORY_PICTURES 目錄中。

如果應用程式知道儲存檔案的確切位置 (例如名為 Pictures/MyVacationPictures 的相簿),您可以設定 MediaColumns.RELATIVE_PATH,提示系統要在哪個位置儲存新寫入的檔案。

更新項目

如要更新應用程式擁有的媒體檔案,請使用類似於下方的程式碼:

Kotlin

// Updates an existing media item.
val mediaId = // MediaStore.Audio.Media._ID of item to update.
val resolver = applicationContext.contentResolver

// When performing a single item update, prefer using the ID.
val selection = "${MediaStore.Audio.Media._ID} = ?"

// By using selection + args you protect against improper escaping of // values.
val selectionArgs = arrayOf(mediaId.toString())

// Update an existing song.
val updatedSongDetails = ContentValues().apply {
    put(MediaStore.Audio.Media.DISPLAY_NAME, "My Favorite Song.mp3")
}

// Use the individual song's URI to represent the collection that's
// updated.
val numSongsUpdated = resolver.update(
        myFavoriteSongUri,
        updatedSongDetails,
        selection,
        selectionArgs)

Java

// Updates an existing media item.
long mediaId = // MediaStore.Audio.Media._ID of item to update.
ContentResolver resolver = getApplicationContext()
        .getContentResolver();

// When performing a single item update, prefer using the ID.
String selection = MediaStore.Audio.Media._ID + " = ?";

// By using selection + args you protect against improper escaping of
// values. Here, "song" is an in-memory object that caches the song's
// information.
String[] selectionArgs = new String[] { getId().toString() };

// Update an existing song.
ContentValues updatedSongDetails = new ContentValues();
updatedSongDetails.put(MediaStore.Audio.Media.DISPLAY_NAME,
        "My Favorite Song.mp3");

// Use the individual song's URI to represent the collection that's
// updated.
int numSongsUpdated = resolver.update(
        myFavoriteSongUri,
        updatedSongDetails,
        selection,
        selectionArgs);

即使無法使用或未啟用限定範圍儲存空間,以上程式碼片段中的程序仍適用於應用程式未擁有的檔案。

以原生程式碼更新

如果需要使用原生程式庫編寫媒體檔案,請將該檔案相關聯的檔案描述元,從 Java 或 Kotlin 程式碼傳入原生程式碼。

要進一步瞭解如何傳送媒體物件的檔案描述元至原生程式碼,請參閱下列程式碼範例:

Kotlin

val contentUri: Uri = ContentUris.withAppendedId(
        MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
        cursor.getLong(BaseColumns._ID))
val fileOpenMode = "r"
val parcelFd = resolver.openFileDescriptor(contentUri, fileOpenMode)
val 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.

Java

Uri contentUri = ContentUris.withAppendedId(
        MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
        cursor.getLong(Integer.parseInt(BaseColumns._ID)));
String fileOpenMode = "r";
ParcelFileDescriptor parcelFd =
        resolver.openFileDescriptor(contentUri, 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.
}

更新其他應用程式的媒體檔案

一般情況下,啟用限定範圍儲存空間的應用程式無法更新其他應用程式儲存在媒體儲存區中的媒體檔案。

不過,您可以擷取平台擲回的 RecoverableSecurityException,進而取得使用者同意並修改檔案。接著,您就可以要求使用者授予應用程式該特定項目的寫入權限,如以下程式碼片段所示:

Kotlin

// Apply a grayscale filter to the image at the given content URI.
try {
    // "w" for write.
    contentResolver.openFileDescriptor(image-content-uri, "w")?.use {
        setGrayscaleFilter(it)
    }
} catch (securityException: SecurityException) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        val recoverableSecurityException = securityException as?
            RecoverableSecurityException ?:
            throw RuntimeException(securityException.message, securityException)

        val intentSender =
            recoverableSecurityException.userAction.actionIntent.intentSender
        intentSender?.let {
            startIntentSenderForResult(intentSender, image-request-code,
                    null, 0, 0, 0, null)
        }
    } else {
        throw RuntimeException(securityException.message, securityException)
    }
}

Java

try {
    // "w" for write.
    ParcelFileDescriptor imageFd = getContentResolver()
            .openFileDescriptor(image-content-uri, "w");
    setGrayscaleFilter(imageFd);
} catch (SecurityException securityException) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        RecoverableSecurityException recoverableSecurityException;
        if (securityException instanceof RecoverableSecurityException) {
            recoverableSecurityException =
                    (RecoverableSecurityException)securityException;
        } else {
            throw new RuntimeException(
                    securityException.getMessage(), securityException);
        }
        IntentSender intentSender =recoverableSecurityException.getUserAction()
                .getActionIntent().getIntentSender();
        startIntentSenderForResult(intentSender, image-request-code,
                null, 0, 0, 0, null);
    } else {
        throw new RuntimeException(
                securityException.getMessage(), securityException);
    }
}

每當應用程式需要修改非其建立的媒體檔案時,都請完成這項程序。

或者,如果應用程式在 Android 11 以上版本中執行,您可以讓使用者授予應用程式媒體檔案群組的寫入權限。請按照「管理媒體檔案群組」一節的說明使用 createWriteRequest() 方法。

如果應用程式的其他用途未包含在限定範圍儲存空間的範圍內,請提交功能要求暫時停用限定範圍儲存空間

移除項目

如要在媒體儲存區中移除不需要的項目,請執行與下列程式碼範例類似的邏輯:

Kotlin

// Remove a specific media item.
val resolver = applicationContext.contentResolver

// URI of the image to remove.
val imageUri = "..."

// WHERE clause.
val selection = "..."
val selectionArgs = "..."

// Perform the actual removal.
val numImagesRemoved = resolver.delete(
        imageUri,
        selection,
        selectionArgs)

Java

// Remove a specific media item.
ContentResolver resolver = getApplicationContext()
        getContentResolver();

// URI of the image to remove.
Uri imageUri = "...";

// WHERE clause.
String selection = "...";
String[] selectionArgs = "...";

// Perform the actual removal.
int numImagesRemoved = resolver.delete(
        imageUri,
        selection,
        selectionArgs);

如果無法使用或未啟用限定範圍儲存空間,您可以使用以上程式碼片段移除其他應用程式擁有的檔案。不過,如果啟用了限定範圍儲存空間,您就必須為應用程式要移除的每個檔案擷取 RecoverableSecurityException,詳情請參閱更新媒體項目一節。

如果應用程式在 Android 11 以上版本中執行,您可以讓使用者自行選擇要移除的媒體檔案群組。請按照「管理媒體檔案群組」一節的說明使用 createTrashRequest() 方法或 createDeleteRequest() 方法。

如果應用程式的其他用途未包含在限定範圍儲存空間的範圍內,請提交功能要求暫時停用限定範圍儲存空間

偵測媒體檔案更新

與先前相比,應用程式需確實知道媒體檔案所在的儲存磁碟區,以找到其新增與修改的媒體檔案。如要精準偵測這些變更,請將相關儲存空間磁碟區傳送給 getGeneration()。只要媒體儲存區是同一版本,此方法的傳回值會持續單調遞增。

請特別注意,與 DATE_ADDEDDATE_MODIFIED 這些媒體資料欄相比,getGeneration() 方法取得的日期更為可靠。這是因為當應用程式呼叫 setLastModified(),或使用者變更系統時鐘時,這些媒體資料欄的值可能有所變更。

管理媒體檔案群組

在 Android 11 以上版本中,您可以請使用者選取一組媒體檔案,然後一次更新這些媒體檔案。這些方法有助裝置之間保持一致性,且使用者能更輕鬆地管理媒體集合。

如要進行「批次更新」,請採用以下方法:

createWriteRequest()
請使用者授予應用程式特定媒體檔案群組的寫入權限。
createFavoriteRequest()
請使用者在裝置上,將特定媒體檔案標示為「我的最愛」。只要應用程式有讀取該檔案的權限,就能看到使用者將檔案標示為「我的最愛」。
createTrashRequest()

請使用者將指定媒體檔案移入裝置的垃圾桶。經過一段時間後 (系統設定的時間),垃圾桶中的項目會永久刪除。

createDeleteRequest()

請使用者立即刪除指定媒體檔案,且不需事先將檔案移至垃圾桶。

呼叫上述任一方法建構 PendingIntent 物件。應用程式叫用這個意圖之後,將會顯示一個對話方塊,要求使用者同意應用程式更新或刪除指定的媒體檔案。

以下範例將說明如何呼叫 createWriteRequest()

Kotlin

val urisToModify = /* A collection of content URIs to modify. */
val editPendingIntent = MediaStore.createWriteRequest(contentResolver,
        urisToModify)

// Launch a system prompt requesting user permission for the operation.
startIntentSenderForResult(editPendingIntent.intentSender, EDIT_REQUEST_CODE,
    null, 0, 0, 0)

Java

List<Uri> urisToModify = /* A collection of content URIs to modify. */
PendingIntent editPendingIntent = MediaStore.createWriteRequest(contentResolver,
                  urisToModify);

// Launch a system prompt requesting user permission for the operation.
startIntentSenderForResult(editPendingIntent.getIntentSender(),
    EDIT_REQUEST_CODE, null, 0, 0, 0);

請評估使用者的回應。如使用者同意授權,則繼續執行媒體作業。否則請向使用者說明應用程式需要這項權限的原因:

Kotlin

override fun onActivityResult(requestCode: Int, resultCode: Int,
                 data: Intent?) {
    ...
    when (requestCode) {
        EDIT_REQUEST_CODE ->
            if (resultCode == Activity.RESULT_OK) {
                /* Edit request granted; proceed. */
            } else {
                /* Edit request not granted; explain to the user. */
            }
    }
}

Java

@Override
protected void onActivityResult(int requestCode, int resultCode,
                   @Nullable Intent data) {
    ...
    if (requestCode == EDIT_REQUEST_CODE) {
        if (resultCode == Activity.RESULT_OK) {
            /* Edit request granted; proceed. */
        } else {
            /* Edit request not granted; explain to the user. */
        }
    }
}

開發人員可以搭配 createFavoriteRequest()createTrashRequest()createDeleteRequest() 方法,以使用相同的一般原則。

媒體管理權限

使用者可信任特定應用程式以執行媒體管理,例如頻繁地編輯媒體檔案。如果應用程式不是裝置的預設相片庫應用程式,且以 Android 11 以上版本為目標,則每當應用程式嘗試修改或刪除檔案時,您都必須向使用者顯示確認對話方塊。

如果應用程式指定 Android 12 (API 級別 31) 以上版本為目標,您可以要求使用者授予應用程式「媒體管理」這種特殊權限。這項權限可讓應用程式執行以下各項檔案作業,而無須向使用者逐一顯示相關提示:

如要這樣做,請完成下列步驟:

  1. 在應用程式的資訊清單檔案中,宣告 MANAGE_MEDIA 權限和 READ_EXTERNAL_STORAGE 權限。

    如要呼叫 createWriteRequest() 而不顯示確認對話方塊,請一併宣告 ACCESS_MEDIA_LOCATION 權限。

  2. 在應用程式中向使用者顯示 UI,說明為何可能需要他們授予應用程式管理媒體的權限。

  3. 請叫用 ACTION_REQUEST_MANAGE_MEDIA 意圖動作,將使用者導向至系統設定中的「媒體管理應用程式」畫面。應用程式要在這裡取得使用者授權特殊存取權限。

需要媒體儲存區替代方案的用途

如果應用程式主要作業包含下列任一功能,則需考慮改用 MediaStore API。

使用其他類型的檔案

如果應用程式使用的文件和檔案不僅限於媒體內容 (例如副檔名為 EPUB 或 PDF 的檔案),請使用 ACTION_OPEN_DOCUMENT 意圖動作;詳細做法請參閱這篇指南,其中也有儲存及存取文件和其他檔案的方法。

隨附應用程式中的檔案共用機制

如果您提供隨附應用程式套件,例如訊息應用程式與設定檔應用程式,請使用 content:// URI 設定檔案共用機制。我們也建議將這個工作流程做為安全性最佳做法

其他資源

如要進一步瞭解如何儲存及存取媒體,請參考下列資源。

範例

影片