Android 11(API 30)부터 강제된 Scoped Storage 환경에서, MediaStore API를 사용해 이미지를 다운로드하는 기능을 구현합니다.
1. Scoped Storage란
해당 글을 참고하였으며, 더 자세한 내용을 담고있으니 필요 시 참고하세요!
Android Storage #2 - Internal Storage & External Storage
안드로이드 내부 저장소와 외부 저장소에 대해서 자세히 알아봅니다!
velog.io
Scoped Storage란 Android 11(API 30)부터 모든 앱에 적용되는 외부 저장소 접근 모델로, 앱별 분리된 공간을 통해 사용자 개인정보를 보호하고, 앱 간 간섭을 최소화합니다.
- 기존 Legacy Storage
- 외부 저장소가 하나의 공용 저장소로 동작하여, 읽고 쓸 수 있는 권한이 있으면 모든 파일에 접근이 가능했습니다.
- READ/WRITE_EXTERNAL_STORAGE 권한만으로 임의 경로에 자유롭게 접근할 수 있었습니다.
- 따라서 개별 앱들이 간접적인 방법으로 다른 앱의 정보를 확인할 수 있는 위험성을 갖고 있었습니다.
- 현재 Scoped Storage
- 기존 외부 저장소의 Public한 공간이 완전히 사라진 저장소입니다.
- 외부 저장소의 이미지, 동영상, 오디오, 다운로드에 대해서만 제한적인 접근이 가능하도록 변경되었습니다.
- 미디어 파일은 MediaStore API를, 일반 파일은 SAF(Storage Access Framework)를 통해서 접근할 수 있습니다.
- 개인정보 보호와 앱 간 간섭 최소화라는 큰 이점을 제공합니다.
2. MediaStore를 활용한 이미지 다운로드 구현
Scoped Storage 환경에서 미디어(사진·영상)는 MediaStore를 통해 삽입·조회·삭제합니다.
ContentResolver로 새 항목을 만들고, 스트림에 쓰기 후 IS_PENDING 플래그를 해제하는 패턴이 핵심입니다.
다음과 같은 로직으로 구현했습니다.
suspend fun saveImageToStorage(id: Long, imageUrl: String) =
runCatching {
withContext(Dispatchers.IO) {
val resolver = context.contentResolver
val bitmap = downloadBitmapFromUrl(imageUrl) ?: throw Exception()
val imageUri = resolver.insert(setContentUri(), setMetaData(id)) ?: throw Exception()
resolver.openOutputStream(imageUri).saveBitmapToFile(bitmap)
resetPendingState(imageUri)
}
}
(1) ContentResolver 설정
ContentResolver는 앱과 ContentProvider 사이의 통신 중개자 역할을 수행하는 Android 시스템 API입니다.
파일·미디어·연락처·설정 등 다양한 공용 데이터 소스에 대한 CRUD(생성·조회·업데이트·삭제) 요청을 추상화된 메서드로 제공합니다.
(2) 이미지 비트맵 생성
coil3 라이브러리의 imageLoader를 활용하여, URL에서 이미지를 다운로드하고 디코딩하여 비트맵으로 결과를 가져옵니다.
private suspend fun downloadBitmapFromUrl(imageUrl: String): Bitmap? =
// 1) Coil ImageLoader 초기화
val coilImageLoader = ImageLoader.Builder(context).build()
// 2) 요청 생성 및 실행
val request = ImageRequest.Builder(appContext)
.data(imageUrl)
.allowHardware(false) // 비트맵 처리 안전성
.build()
coilImageLoader.execute(
ImageRequest.Builder(appContext).data(imageUrl).build()
).image?.toBitmap()
// 3) 결과에서 비트맵 추출
loader.execute(request).image?.toBitmap()
}
(3) ContentResolver에 새 이미지 항목 생성
ContentResolver.insert를 통해 MediaStore에 새로운 이미지 항목을 생성한 후, 해당 이미지의 Content URI를 받아옵니다.
이때 insert에 ContentUri와 MetaData를 설정하여 구현합니다.
1) Content URI 설정 : 기본 외부 저장소의 이미지 데이터를 관리하는 URI를 반환합니다.
private fun setContentUri() =
MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
2) MetaData 설정 : 이미지 저장에 필요한 파일명, MIME 타입, 상대 경로를 설정하며, 파일 저장 시 파일이 완전히 작성되기 전까지 다른 앱에서 접근하지 못하도록 하기 위해 IS_PENDING 플래그를 1로 설정합니다.
private fun setMetaData(id: Long) =
ContentValues().apply {
put(DISPLAY_NAME, "img_genti_${id}.jpeg")
put(MIME_TYPE, "image/jpeg")
put(RELATIVE_PATH, DIRECTORY_PICTURES)
put(MediaStore.Images.Media.IS_PENDING, 1)
}
(4) 비트맵을 변환한 후 파일에 저장
FileOutputStream을 사용하여 비트맵 이미지를 File에 저장합니다.
이때 use를 활용해서 스트림을 자동으로 클로즈하도록 보장하며, 비트맵 객체를 JPEG 형태로 압축하여 바이트로 기록합니다.
private fun OutputStream?.saveBitmapToFile(bitmap: Bitmap) {
this?.use { outputStream ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
} ?: throw Exception()
}
(5) 보류 상태 해제
MediaStore에 새로 삽입한 이미지 파일의 보류 상태를 해제한 후 업데이트합니다.
private fun resetPendingState(uri: Uri) {
val values = ContentValues().apply { put(MediaStore.Images.Media.IS_PENDING, 0) }
resolver.update(uri, values, null, null)
}
3. min SDK가 28 이하인 경우 대응
API 29에서 도입된 RELATIVE_PATH·IS_PENDING 플래그를 사용할 수 없는 기기에서는, 직접 외부 퍼미션을 획득해 직접 경로 접근 방식으로 구현해야 합니다.
(1) 외부 저장소 접근 권한 획득
1) manifest 선언
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
2) 권한 동의 여부 확인
fun checkExternalStoragePermission(): Boolean {
return if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
ContextCompat.checkSelfPermission(
appContext, WRITE_EXTERNAL_STORAGE,
) == PackageManager.PERMISSION_GRANTED
} else {
true
}
}
3) 런처 설정 및 실행
// Route에 변수 설정
val writePermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) viewModel.onIntent(ProfileIntent.SaveBtnClick)
}
// 권한 요청 필요한 경우
writePermissionLauncher.launch(WRITE_EXTERNAL_STORAGE)
(2) 로직에서 분기처리 추가
1) Content URI를 설정하는 과정에서, EXTERNAL_CONTENT_URI 상수를 사용하여 외부 저장소의 이미지 데이터에 접근합니다.
private fun setContentUri() =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
} else {
MediaStore.Images.Media.EXTERNAL_CONTENT_URI
}
2) MetaData를 설정하는 과정에서, 상대 경로 설정 대신 외부 저장소의 Pictures 디렉토리가 존재하는지 확인하고, 없으면 생성합니다.
private fun setMetaData(id: Long) =
ContentValues().apply {
put(DISPLAY_NAME, "img_genti_${id}_${System.currentTimeMillis()}.jpeg")
put(MIME_TYPE, "image/jpeg")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(RELATIVE_PATH, DIRECTORY_PICTURES)
put(MediaStore.Images.Media.IS_PENDING, 1)
} else {
Environment.getExternalStoragePublicDirectory(DIRECTORY_PICTURES).mkdirs()
}
}
3) 보류 상태 기능을 28 이상만 가능하도록 설정합니다.
private fun resetPendingState(uri: Uri) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val values = ContentValues().apply { put(MediaStore.Images.Media.IS_PENDING, 0) }
resolver.update(uri, values, null, null)
}
}
다음과 같은 방법을 활용하여, 모든 안드로이드 버전에서 가능한 이미지 다운로드 로직을 구현했습니다.
참고 자료:
공유 저장소 미디어 파일에 액세스 | App data and files | Android Developers
'Feature' 카테고리의 다른 글
[Android] Amplitude SDK를 활용해 앱 내부 이벤트 트래킹 기능 구현하기 (0) | 2025.05.26 |
---|---|
[Android] In-App Update 기능을 포함한 스플래시 뷰 로직 구현하기 (0) | 2025.05.26 |
[Android] FileProvider 활용으로 이미지 저장 없이 사진 공유 기능 구현하기 (0) | 2025.05.26 |
[Android] cacheDir 활용으로 카메라 이미지 저장 없이 업로드 기능 구현하기 (0) | 2025.05.26 |
[Android] ConnectivityManager를 활용한 네트워크 상태 모니터링 기능 구현하기 (1) | 2025.05.24 |