먼저 Media Provider와 MediaStore라는 용어에 대해서 알아야 합니다.
Media provider는 단말에 저장된 이미지, 동영상, 오디오 파일의 정보를 제공하는 프로바이더입니다. 이 프로바이더에게 우리가 찾고 싶은 종류의 데이터를 쿼리할 수 있습니다. 리턴되는 정보로 우리는 파일 이름, 저장된 시간, 저장된 위치 등을 알 수 있습니다.
MediaStore는 앱이 Media provider가 제공하는 파일들을 접근할 수 있도록 도와주는 API들의 묶음입니다. 쿼리에 필요한 데이터들이 정의되어있습니다.
따라서, 우리는 MediaStore의 API들이 어떤 의미인지 이해하고, 그 API를 통해 Media provider에 쿼리를 하여 데이터를 얻으면 됩니다.
이 글에서는 미디어 프로바이더에 Image, Video, Audio 파일들에 대해서 쿼리하는 방법을 알아보고, 데이터에 접근하는 방법을 알아보겠습니다.
Android 10(Q)에서 Scoped Storage가 적용되었습니다. 미디어 파일들은 MediaStore를 통해 read/write하도록 권장하고 있습니다.
이 글의 샘플 코드는 모두 kotlin으로 작성되었습니다.
미디어 파일을 저장하는 방법은 안드로이드 - MediaStore(Media Provider)에 파일 저장하는 방법을 참고해주세요.
권한
MediaStore에서 파일을 읽으려면 다음과 같이 READ 권한이 필요합니다. AndroidManifest에 권한을 추가하고 앱이 실행될 때 사용자로부터 권한을 받아야 합니다.
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
이미지 쿼리
MediaStore로 디바이스에 있는 이미지들을 쿼리하는 코드는 다음과 같습니다.
val projection = arrayOf(
MediaStore.Images.Media._ID,
MediaStore.Images.Media.DISPLAY_NAME,
MediaStore.Images.Media.DATE_TAKEN
)
val selection = "${MediaStore.Images.Media.DATE_TAKEN} >= ?"
val selectionArgs = arrayOf(
dateToTimestamp(day = 1, month = 1, year = 1970).toString()
)
val sortOrder = "${MediaStore.Images.Media.DATE_TAKEN} DESC"
val cursor = contentResolver.query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
projection,
selection,
selectionArgs,
sortOrder
)
private fun dateToTimestamp(day: Int, month: Int, year: Int): Long =
SimpleDateFormat("dd.MM.yyyy").let { formatter ->
formatter.parse("$day.$month.$year")?.time ?: 0
}
실질적으로 쿼리하는 코드는 ContentResolver.query()
입니다. API의 인자에 우리가 찾고자 하는 데이터 정보를 넣으면 됩니다.
마치 데이터베이스에 쿼리할 때 "select ~~"
으로 관심있는 데이터를 설정하는 것과 같습니다.
리턴 타입은 Cursor
이며, 이 객체를 통해 찾은 데이터를 확인할 수 있습니다.
위의 코드를 보시면, query()
에 다음과 같이 5개의 인자가 들어갑니다. (아래 코드는 ContentResolver.java
의 query 메소드입니다)
public final @Nullable Cursor query(@NonNull Uri uri,
@Nullable String[] projection, @Nullable String selection,
@Nullable String[] selectionArgs, @Nullable String sortOrder)
각각의 인자는 다음과 같은 의미입니다.
- Uri: 찾고자하는 데이터의 Uri입니다.
- Projection: DB의 column과 같습니다. 결과로 받고 싶은 데이터의 종류를 알려줍니다.
- Selection: DB의 where 키워드와 같습니다. 어떤 조건으로 필터링된 결과를 받을 때 사용합니다.
- Selection args: Selection과 함께 사용됩니다.
- Sort order: 쿼리 결과 데이터를 sorting할 때 사용합니다.
위의 조건으로 쿼리한 데이터가 있다면 Cursor로 결과를 순회(Loop)할 수 있습니다. 다음 코드는 Cursor로 모든 데이터를 순회하여 결과를 출력하는 예제입니다.
cursor?.use {
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
val dateTakenColumn =
cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_TAKEN)
val displayNameColumn =
cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)
while (cursor.moveToNext()) {
val id = cursor.getLong(idColumn)
val dateTaken = Date(cursor.getLong(dateTakenColumn))
val displayName = cursor.getString(displayNameColumn)
val contentUri = Uri.withAppendedPath(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
id.toString()
)
Log.d(
TAG, "id: $id, display_name: $displayName, date_taken: " +
"$dateTaken, content_uri: $contentUri"
)
}
}
Cursor에는 쿼리할 때 Projection으로 요청한 column들이 포함되어있습니다. 각각의 column의 데이터를 가져와서 로그로 출력하였습니다.
Cusor로 Column을 읽을 때는 다음처럼 getLong() 또는 getString()에 column의 index를 넣습니다. Column의 리턴타입에 맞게 API를 사용하시면 됩니다.
cursor.getLong(dateTakenColumn)
cursor.getString(displayNameColumn)
Column의 index는 다음과 같은 코드로 얻을 수 있습니다.
val dateTakenColumn =
cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_TAKEN)
위의 코드를 실행해보면 다음처럼 로그가 출력됩니다.
MainActivity: id: 41, display_name: new-york-4471754_1920.jpg, date_taken: Thu Jan 01 09:00:00 GMT+09:00 1970, content_uri: content://media/external/images/media/41
MainActivity: id: 42, display_name: beach-4532547_1920.jpg, date_taken: Thu Jan 01 09:00:00 GMT+09:00 1970, content_uri: content://media/external/images/media/42
.....
ContentResolver.query()
의 인자로 전달되는 항목에 대해서 좀 더 자세히 알아보겠습니다.
Uri
Uri(Uniform Resource Identifier)는 데이터의 위치를 표현합니다.
query()
API는 인자로 전달되는 Uri 내에서 데이터를 찾습니다.
MediaStore.Images.Media.EXTERNAL_CONTENT_URI 라는 Uri를 인자로 전달하였다면 이 Uri 경로 아래에서만 파일을 찾습니다. 이 Uri를 String으로 출력해보면 다음과 같습니다.
- content://media/external/images/media
이 패스와 이미지 쿼리 결과에서 출력된 파일의 Uri 패스를 비교해보시면 공통 패스가 있다는 것을 알 수 있습니다.
Projection
Projection은 String[]
을 인자로 받습니다. 결과로 받고 싶은 column 명을 배열에 입력하면 됩니다.
다음과 같은 column 들이 있습니다.
- MediaStore.Images.Media._ID
- MediaStore.Images.Media.DATA
- MediaStore.Images.Media.DISPLAY_NAME
- MediaStore.Images.Media.TITLE
- MediaStore.Images.Media.DATE_TAKEN
- MediaStore.Images.Media.DATE_ADDED
MediaStore.MediaColumns에서 좀 더 많은 column을 볼 수 있습니다. (이 페이지는 JavaDoc이고, MediaStore의 클래스들이 상속관계로 되어있어서, 모든 종류의 Column이 정리되어있지 않습니다.)
Selection, Selection args
Selection은 DB의 where와 같습니다. 다음 코드는 날짜를 기준으로 어떤 조건을 충족시키면 결과에 포함시킵니다.
val selection = "${MediaStore.Images.Media.DATE_TAKEN} >= ?"
selection의 물음표(?)에 들어갈 내용이 Selection args입니다. args의 타입은 String[]
인데, 1개의 값만 넣고 전달하시면 됩니다.(두개 이상 사용하는 케이스를 써본 적이 없네요. 이 부분은 잘 모르겠습니다)
다음 코드를 보시면 찾고자 하는 시간을 time stamp로 변경하여 전달하였습니다.
val selectionArgs = arrayOf(
dateToTimestamp(day = 1, month = 1, year = 1970).toString()
)
private fun dateToTimestamp(day: Int, month: Int, year: Int): Long =
SimpleDateFormat("dd.MM.yyyy").let { formatter ->
formatter.parse("$day.$month.$year")?.time ?: 0
}
만약 모든 데이터를 쿼리하고 싶다면 selection과 args에 null을 입력하시면 됩니다.
val cursor = contentResolver.query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
projection,
null, // selection
null, // selectionArgs
sortOrder
)
args에서 시간은 1970년 1월 1일부터 ms로 표현한 GMT를 받고 있는데요. PC에서 디바이스로 파일을 넣으면 TIME이 1970년으로 설정됩니다. 위의 코드처럼 selection을 설정하면 쿼리가 안되네요. 2000년대 이상인 파일들은 쿼리가 되었습니다. 이런 문제가 있다면 selection을 null로 설정하세요. 코드가 잘못된 것 같은데(?) 해결방법을 찾으면 나중에 업데이트하겠습니다.
Sort order
결과로 받을 데이터를 정렬하려면 다음처럼 sort order를 입력하면 됩니다.
다음은 파일의 시간에 대해서 내림차순/오름차순으로 정렬하는 코드입니다.
val sortOrderDesc = "${MediaStore.Images.Media.DATE_TAKEN} DESC"
val sortOrderAsc = "${MediaStore.Images.Media.DATE_TAKEN} ASC"
동영상 쿼리
동영상을 쿼리하는 방법도 이미지와 동일합니다. 찾고자 하는 Uri 위치 등을 변경해주면 됩니다.
위에서 MediaStore.Images.XXX
으로 된 코드들을 MediaStore.Video.XXX
으로 변경하시면 됩니다.
Video로 변경한 코드는 다음과 같습니다.
val projection = arrayOf(
MediaStore.Video.Media._ID,
MediaStore.Video.Media.DISPLAY_NAME,
MediaStore.Video.Media.DATE_TAKEN
)
val selection = "${MediaStore.Video.Media.DATE_TAKEN} >= ?"
val selectionArgs = arrayOf(
dateToTimestamp(day = 1, month = 1, year = 1970).toString()
)
val sortOrder = "${MediaStore.Video.Media.DATE_TAKEN} DESC"
val cursor = contentResolver.query(
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
projection,
null, // selection
null, //selectionArgs
sortOrder
)
cursor?.use {
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media._ID)
val dateTakenColumn =
cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DATE_TAKEN)
val displayNameColumn =
cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DISPLAY_NAME)
while (cursor.moveToNext()) {
val id = cursor.getLong(idColumn)
val dateTaken = Date(cursor.getLong(dateTakenColumn))
val displayName = cursor.getString(displayNameColumn)
val contentUri = Uri.withAppendedPath(
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
id.toString()
)
Log.d(
TAG, "id: $id, display_name: $displayName, date_taken: " +
"$dateTaken, content_uri: $contentUri"
)
}
}
오디오 쿼리
오디오도 다른 미디어와 동일하게 쿼리하면 됩니다.
MediaStore.Images.XXX
으로 된 코드들을 MediaStore.Audio.XXX
으로 변경하시면 됩니다.
다음 코드는 Audio로 변경한 코드이고, 추가로 album, duration, title 정보도 가져오도록 projection에 해당 항목을 추가하였습니다.
val projection = arrayOf(
MediaStore.Audio.Media._ID,
MediaStore.Audio.Media.DISPLAY_NAME,
MediaStore.Audio.Media.ALBUM,
MediaStore.Audio.Media.TITLE,
MediaStore.Audio.Media.DURATION,
MediaStore.Audio.Media.DATE_TAKEN
)
val selection = "${MediaStore.Audio.Media.DATE_TAKEN} >= ?"
val selectionArgs = arrayOf(
dateToTimestamp(day = 1, month = 1, year = 1970).toString()
)
val sortOrder = "${MediaStore.Audio.Media.DATE_TAKEN} DESC"
val cursor = contentResolver.query(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
projection,
null, // selection,
null, // selectionArgs,
sortOrder
)
cursor?.use {
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID)
val dateTakenColumn =
cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATE_TAKEN)
val displayNameColumn =
cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DISPLAY_NAME)
val albumColumn =
cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM)
val titleColumn =
cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE)
val durationColumn =
cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION)
while (cursor.moveToNext()) {
val id = cursor.getLong(idColumn)
val dateTaken = Date(cursor.getLong(dateTakenColumn))
val displayName = cursor.getString(displayNameColumn)
val album = cursor.getString(albumColumn)
val title = cursor.getString(titleColumn)
val duration = cursor.getString(durationColumn)
val contentUri = Uri.withAppendedPath(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
id.toString()
)
Log.d(TAG, "id: $id, display_name: $displayName, title:$title, " +
"album: $album, duration:$duration, date_taken: " +
"$dateTaken, content_uri: $contentUri"
)
}
}
위의 코드를 실행하면 다음처럼 로그가 출력됩니다.
MainActivity: id: 13492, display_name: 048 벤 - 열애중.mp3, title:열애중, album: RECIPE, duration:270720, date_taken: Thu Jan 01 09:00:00 GMT+09:00 1970, content_uri: content://media/external/audio/media/13492
데이터 접근
Cursor를 통해 얻은 ID
로 Uri
정보를 얻을 수 있습니다.
쿼리를 요청한 Uri 패스와 파일의 ID가 다음과 같이 주어졌다면,
- MediaStore.Audio.Media.EXTERNAL_CONTENT_URI : content://media/external/audio/media
- File ID : 13492
이 파일의 Uri는 다음처럼 두개의 스트링을 합친 값이 됩니다.
- content://media/external/audio/media/13492
String이 아닌 Uri 객체로 얻으려면 다음처럼 Uri.withAppendedPath()
를 이용하시면 됩니다.
val contentUri = Uri.withAppendedPath(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
id.toString()
)
// content://media/external/audio/media/13492
이렇게 얻은 Uri로 파일에 접근할 수 있습니다.
샘플 코드
이미지들을 쿼리하여 RecyclerView에 보여주는 샘플을 만들었습니다. 전체 코드는 GitHub: MediaStore를 참고해주세요.
위의 샘플은 구글의 Android Storage Samples을 참고하여 만들었습니다.
참고
Related Posts
- Android 14 - 사진/동영상 파일, 일부 접근 권한 소개
- Android - adb push, pull로 파일 복사, 다운로드
- Android 14 - 암시적 인텐트 변경사항 및 문제 해결
- Jetpack Compose - Row와 Column
- Android 13, AOSP 오픈소스 다운로드 및 빌드
- Android 13 - 세분화된 미디어 파일 권한
- Android 13에서 Notification 권한 요청, 알림 띄우기
- Android 13에서 'Access blocked: ComponentInfo' 에러 해결
- 에러 해결: android gradle plugin requires java 11 to run. you are currently using java 1.8.
- 안드로이드 - 코루틴과 Retrofit으로 비동기 통신 예제
- 안드로이드 - 코루틴으로 URL 이미지 불러오기
- Android - 진동, Vibrator, VibrationEffect 예제
- Some problems were found with the configuration of task 에러 수정
- Query method parameters should either be a type that can be converted into a database column or a List
- 우분투에서 Android 12 오픈소스 다운로드 및 빌드
- Android - ViewModel을 생성하는 방법
- Android - Transformations.map(), switchMap() 차이점
- Android - Transformations.distinctUntilChanged() 소개
- Android - TabLayout 구현 방법 (+ ViewPager2)
- Android - 휴대폰 전화번호 가져오는 방법
- Android 12 - Splash Screens 알아보기
- Android 12 - Incremental Install (Play as you Download) 소개
- Android - adb 명령어로 bugreport 로그 파일 추출
- Android - adb 명령어로 App 데이터 삭제
- Android - adb 명령어로 앱 비활성화, 활성화
- Android - adb 명령어로 특정 패키지의 PID 찾기
- Android - adb 명령어로 퍼미션 Grant 또는 Revoke
- Android - adb 명령어로 apk 설치, 삭제
- Android - adb 명령어로 특정 패키지의 프로세스 종료
- Android - adb 명령어로 screen capture 저장
- Android - adb 명령어로 System 앱 삭제, 설치
- Android - adb 명령어로 settings value 확인, 변경
- Android 12 - IntentFilter의 exported 명시적 선언
- Android - adb 명령어로 공장초기화(Factory reset)
- Android - adb logcat 명령어로 로그 출력