Android10(Q)에서 Scoped Storage가 적용되면서 미디어 데이터를 저장할 때는 MediaStore를 이용하기를 권장하고 있습니다. Scoped Storage를 통해 미디어 데이터를 저장할 수 있겠지만, MediaStore는 데이터를 저장하는데 별도의 권한을 요구하지 않기 때문입니다.
Android10(Q) 이전에도 MediaStore(Media Provider)를 통해 data를 insert할 수 있었습니다. P와 Q에서 MediaStore에 데이터를 저장하는 방법을 알아보고, 차이점에 이야기 해보겠습니다.
MediaStore에서 데이터를 query하는 방법은 이전 글에서 소개하였습니다.
MediaStore에 Insert하는 방법 (Android 10, Q)
Android 10(Q)에서 MediaStore를 통해 데이터를 읽을 때는 READ_EXTERNAL_STORAGE
권한을 요구하고, 쓸 때는 어떤 권한을 요구하지 않습니다.
다음 코드는 MediaStore에 Image를 저장하는 예제입니다.
val values = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, "my_image_q.jpg")
put(MediaStore.Images.Media.MIME_TYPE, "image/jpg")
put(MediaStore.Images.Media.IS_PENDING, 1)
}
val collection = MediaStore.Images.Media
.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
val item = contentResolver.insert(collection, values)!!
contentResolver.openFileDescriptor(item, "w", null).use {
// write something to OutputStream
FileOutputStream(it!!.fileDescriptor).use { outputStream ->
val imageInputStream = resources.openRawResource(R.raw.my_image)
while (true) {
val data = imageInputStream.read()
if (data == -1) {
break
}
outputStream.write(data)
}
imageInputStream.close()
outputStream.close()
}
}
values.clear()
values.put(MediaStore.Images.Media.IS_PENDING, 0)
contentResolver.update(item, values, null, null)
코드는 다음 순서로 진행됩니다.
- ContentResolver에 ContentValues를 insert
- ContentResolver의 FD에 파일을 write
- write 완료 후 contentResolver update
위의 코드를 작게 나눠서 설명하겠습니다.
ContentValues
ContentValues 클래스는 ContentResolver에 데이터를 저장하는데 사용합니다.
ContentValues에 파일 정보를 입력하고 insert()
의 인자로 저장할 URI와 ContentValues를 전달합니다.
그럼 등록된 파일을 가리키는 Uri 객체가 리턴됩니다.
val values = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, "my_image_q.jpg")
put(MediaStore.Images.Media.MIME_TYPE, "image/jpg")
put(MediaStore.Images.Media.IS_PENDING, 1)
}
val collection = MediaStore.Images.Media
.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
val item: Uri = contentResolver.insert(collection, values)!!
리턴 받은 Uri에 데이터 write를 할 수 있습니다.
위의 코드에서 IS_PENDING
라는 속성을 1로 set해주는 부분이 있는데요. 이 속성은 아직 내가 파일을 write하지 않았으니, 다른 곳에서 이 데이터를 요구하면 무시하라는 의미입니다.
파일을 모두 write한 뒤에 이 속성을 0으로 update해줘야 합니다.
put(MediaStore.Images.Media.IS_PENDING, 1)
Data 저장
Insert로 얻은 Uri에 파일을 write하면 됩니다. Q에서 권한 없이 MediaStore에 접근하기 때문에 파일의 절대경로를 알 수 없습니다. Uri를 통해 FD를 얻고 그 FD에 내가 등록하려는 파일을 write해줘야 합니다.
Uri로 FD를 얻고 write하는 코드는 다음과 같습니다.
contentResolver.openFileDescriptor(item, "w", null).use {
FileOutputStream(it!!.fileDescriptor).use { outputStream ->
val imageInputStream = resources.openRawResource(R.raw.my_image) // getting XML
while (true) {
val data = imageInputStream.read()
if (data == -1) {
break
}
outputStream.write(data)
}
imageInputStream.close()
outputStream.close()
}
}
Uri에서 FD를 얻을 때 ContentResolver.openFileDescriptor()
를 사용하면 됩니다.
저는 앱의 /res/raw/my_image.jpg
경로에 이미지 파일을 넣어두었고, 이것을 FD에 write해주었습니다.
IS_PENDING = 0으로 update
파일을 모두 write하였다면, IS_PENDING
속성을 0으로 변경해줘야 합니다. 그럼 다른 앱에서 이 파일을 사용할 수 있습니다.
values.put(MediaStore.Images.Media.IS_PENDING, 0)
contentResolver.update(item, values, null, null)
파일이 저장되는 위치
위의 코드에서 Insert()의 인자로 MediaStore.VOLUME_EXTERNAL_PRIMARY 를 전달하였습니다.
저장된 파일의 실제 경로를 shell로 확인해보면 다음과 같습니다.
generic_x86:/sdcard/Pictures $ ls
my_image_q.jpg
만약 경로를 변경해주고 싶다면 ContentValues 객체에 RELATIVE_PATH
속성으로 경로를 설정해주어야 합니다.
val values = ContentValues().apply {
put(MediaStore.Audio.Media.RELATIVE_PATH, "DCIM/My Images")
put(MediaStore.Images.Media.DISPLAY_NAME, "my_image_q2.jpg")
put(MediaStore.Images.Media.MIME_TYPE, "image/jpg")
put(MediaStore.Images.Media.IS_PENDING, 1)
}
위처럼 저장하면 RELATIVE_PATH로 설정된 "/DCIM/My Images/"
아래에 파일이 저장됩니다.
generic_x86:/sdcard/DCIM/My Images $ ls
my_image_q2.jpg
Images의 경우 아래 두개의 경로에만 데이터를 저장할 수 있습니다.
- /DCIM
- /Pictures
다른 경로를 설정하면 다음과 같이 에러가 발생하며 저장이 되지 않습니다.
java.lang.IllegalArgumentException: Primary directory DCIM1 not allowed for content://media/external_primary/images/media; allowed directories are [DCIM, Pictures]
at android.database.DatabaseUtils.readExceptionFromParcel(DatabaseUtils.java:170)
MediaStore에 Insert하는 방법 (Android P 이하)
P 이하에서는 Q와 조금 다릅니다. 우선 MediaStore에 write하려면 WRITE_EXTERNAL_STORAGE
권한이 필요합니다.
다음은 P에서 이미지 파일을 MediaStore에 등록하는 예제입니다.
// copy /res/raw/my_image.jpg to /data/data/[app package]/files/my_image.jpg
val inputStream = resources.openRawResource(R.raw.my_image)
val filePath = "$filesDir/my_image.jpg"
val outputStream = FileOutputStream(filePath)
while (true) {
val data = inputStream.read()
if (data == -1) {
break
}
outputStream.write(data)
}
inputStream.close()
outputStream.close()
// Insert my file to MediaStore
val values= ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, "my_image6.jpg")
put(MediaStore.Images.Media.MIME_TYPE, "image/jpg")
put(MediaStore.Images.Media.DATA, filePath)
}
val item = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)!!
이 코드는 다음과 같은 작업을 수행합니다.
- /res/raw/에 저장된 파일을 앱의 파일 폴더에 복사합니다.
- 복사된 파일을 MediaStore에 저장합니다.
다음 코드가 MediaStore에 이미지 파일을 저장하는 코드인데, 직접 write하지 않고 DATA
속성에 절대 경로를 설정하면 알아서 저장이 됩니다.
(이쪽에 대해서 관심이 없어서 다른 방식으로도 파일을 Insert할 수 있는진 모르겠네요)
val values= ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, "my_image6.jpg")
put(MediaStore.Images.Media.MIME_TYPE, "image/jpg")
put(MediaStore.Images.Media.DATA, filePath)
}
val item = contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)!!
파일의 절대경로가 필요하기 때문에, 다음과 같이 raw에 저장된 이미지 파일을 앱의 파일 폴더에 저장할 수 밖에 없었습니다.
val inputStream = resources.openRawResource(R.raw.my_image)
val filePath = "$filesDir/my_image.jpg"
val outputStream = FileOutputStream(filePath)
while (true) {
val data = inputStream.read()
if (data == -1) {
break
}
outputStream.write(data)
}
inputStream.close()
outputStream.close()
Video, Audio 파일을 MediaStore에 Insert
ContentValues에 설정하는 속성이 Images가 아니라 Video
, Audio
라는 것만 다르고 다른 코드들은 동일합니다.
정리
Q에서 Scoped Storage가 도입되면서 READ/WRITE EXTERNAL STORAGE 권한이 무의미해졌습니다. MediaStore에서 파일을 읽을 때는 READ 권한을 요구하지만, 쓸 때는 WRITE 권한을 요구하지 않습니다. 다만 정해진 폴더에만 쓸 수 있습니다.
구글의 의도대로, Scoped Storage를 지원하는 디바이스에서 미디어 파일들은 MediaStore를 이용하여 접근하는 것이 좋을 것 같습니다. 하지만, 아직 Q 디바이스가 많이 나오지 않았고, 어떤 불편함(또는 버그)이 있을지 모르기 때문에 주의를 기울이면서 개발하시면 좋을 것 같습니다.
이 글에서 사용된 샘플 코드는 GitHub - MediaStore를 참고해주세요.
참고
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 명령어로 로그 출력