공유 저장소 미디어 파일에 액세스

많은 앱에서 더욱 풍부한 사용자 환경을 제공하기 위해 사용자가 외부 저장소 볼륨에서 사용 가능한 미디어를 제공하고 액세스할 수 있게 합니다. 프레임워크는 미디어 저장소라는 미디어 컬렉션에 최적화된 색인을 제공하여 사용자가 더 쉽게 미디어 파일을 검색하고 업데이트할 수 있게 합니다. 앱이 제거된 이후에도 이러한 파일은 사용자 기기에 남아 있습니다.

사진 선택 도구

미디어 저장소 사용의 대안으로, 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 테이블에 추가합니다. Recordings/ 디렉터리는 Android 11(API 수준 30) 이하에서는 사용할 수 없습니다.
  • 다운로드한 파일: 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.Images, MediaStore.Video, 또는 MediaStore.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 컬렉션 내의 파일에 액세스하려고 한다면 저장소 액세스 프레임워크를 사용해야 합니다. 이 프레임워크를 사용하는 방법에 관해 자세히 알아보려면 공유 저장소의 문서 및 기타 파일 액세스를 참고하세요.

미디어 위치 정보 액세스 권한

앱에서 Android 10(API 수준 29) 이상을 타겟팅하고 사진에서 수정되지 않은 EXIF 메타데이터를 검색해야 하는 경우 앱의 매니페스트에서 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()를 호출할 필요가 없도록 열 색인을 캐시합니다.
  • 이 예에서와 같이 콘텐츠 URI에 ID를 추가합니다.
  • 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)을 실행하는 기기에서 공유 저장소의 다른 파일에 액세스하려면 앱의 매니페스트 파일에서 requestLegacyExternalStoragetrue로 설정하여 범위 지정 저장소를 일시적으로 선택 해제하는 것이 좋습니다. 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. 다음 코드 스니펫에서와 같이 MediaStore 객체에서 setRequireOriginal()을 호출하여 사진의 정확한 바이트를 가져오고 사진의 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으로 변경할 때까지 이 앱에서만 파일을 볼 수 있습니다.

다음 코드 스니펫은 이전 코드 스니펫을 기반으로 합니다. 이 스니펫은 MediaStore.Audio 컬렉션에 해당하는 디렉터리에 긴 노래를 저장할 때 IS_PENDING 플래그를 사용하는 방법을 보여줍니다.

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을 실행하는 기기에 미디어를 저장할 때 미디어는 기본적으로 유형에 따라 정리됩니다. 예를 들어 기본적으로 새 이미지 파일은 Environment.DIRECTORY_PICTURES 디렉터리(MediaStore.Images 컬렉션에 상응)에 배치됩니다.

앱이 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);

범위 지정 저장소를 사용할 수 없거나 사용 설정하지 않았다면 이전 코드 스니펫에 나와 있는 프로세스는 앱이 소유하지 않은 파일에도 적용됩니다.

네이티브 코드에서 업데이트

네이티브 라이브러리를 사용하여 미디어 파일을 작성해야 한다면 자바 기반 코드나 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()에 전달합니다. 미디어 저장소 버전이 변경되지 않는 한 이 메서드의 반환 값은 시간이 지남에 따라 일정하게 증가합니다.

특히 getGeneration()DATE_ADDED, DATE_MODIFIED와 같은 미디어 열의 날짜보다 더 강력합니다. 이러한 미디어 열 값은 앱이 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를 사용하여 파일 공유를 설정합니다. 이 워크플로는 보안 권장사항으로도 권장됩니다.

추가 리소스

미디어를 저장하고 액세스하는 방법에 관한 자세한 내용은 다음 리소스를 참조하세요.

샘플

동영상