Android 이미지 읽기 권한 다루기
DoDoBest
·2024. 4. 2. 23:53
권한 없이 이미지 파일을 사용자로부터 입력 받는 방법
Photo picker를 이용하거나
https://developer.android.com/training/data-storage/shared/photopicker
Intent와 registerForActivityResult를 이용해 사용자가 갤러리에서 선택한 이미지를 받아서 처리할 수 있습니다.
val intent = Intent(
Intent.ACTION_PICK,
MediaStore.Images.Media.EXTERNAL_CONTENT_URI
)
다만, 이렇게 구현할 경우 기본으로 제공되는 이미지 선택 화면이 나온다는 점과 그 화면을 커스텀할 수 없다는 점이 마음에 들지 않았습니다.
API version에 따라 고려해야 하는 권한 요청
기기에 있는 이미지를 가져와 앱에서 직접 보여주려면 사용자로부터 런타임에 이미지 읽기 권한을 요청해야 합니다. AndroidManifest에 권한을 적더라도, 앱 최초 설치 시 권한 요청 팝업을 보여주지 않으며, 런타임에 개발자가 직접 권한 요청 코드를 호출해야 합니다.
권한이 없는 상태에서 기기에 있는 이미지를 가져오는 코드를 호출하면 빈 리스트 값을 반환받습니다.
Android11(API 30)부터 사용자가 권한을 두 번 이상 거부하면, 권한 요청 코드를 실행해도 권한 요청 팝업이 실행되지 않습니다.
이전 API에서는 사용자가 Don't ask again을 체크하고 거부하지 않는 한, 몇 번이고 팝업창을 보여줄 수 있었습니다.
Android11 이상에서 권한을 두 번 거부하거나, Don't ask again을 누르고 거부한 경우에는 Dialog 등을 이용해 사용자에게 권한이 필요한 이유를 설명하고, 사용자가 직접 앱 시스템 설정창에서 권한을 수정하도록 유도해야 합니다(사용자의 동의를 얻어도 팝업창을 다시 보여줄 수 없습니다).
아래 이미지는 권한 거부시 보여주도록 만든 Dialog로, 허용을 누르면 앱의 시스템 설정창으로 이동하도록 구현했습니다.
https://developer.android.com/training/permissions/requesting#handle-denial
Android14(API 34)부터 Partial access가 추가 됐습니다. Partial access는 사용자가 선택한 미디어 파일에만 앱이 접근할 수 있도록 제한하는 옵션입니다. 아래 예시에서 Allow lmited access가 Partial access에 해당합니다. Allow all을 선택하면 앱이 모든 미디어 파일에 접근할 수 있게 됩니다.
Allowed limited access를 선택하면 앱에서 구현하지 않은 기본 이미지 선택 View가 나오며, 앱은 여기서 사용자가 선택한 파일만 접근할 수 있습니다.
API 권한 요청 하기
먼저 Android Manifest에 요청할 권한을 선언합니다. Android SDK34 이상부터 사용자의 부분적 이미지 허용을 받기 위한 Permission 권한이 추가됐습니다.
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
https://developer.android.com/about/versions/14/changes/partial-photo-video-access#permissions
이미지 권한이 필요한 클래스에서 요청할 권한이 담긴 변수를 선언합니다. Android 13(API 33)부터는 READ_EXTERNAL_STORAGE가 아닌, 필요로 하는 종류(IMAGE, VIDEO)를 구체적으로 명시하는 권한을 요청해야 합니다.
val readImagePermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Manifest.permission.READ_MEDIA_IMAGES
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
}
권한 요청에 대한 사용자 응답을 처리할 ActivityResultLauncher 변수를 선언합니다. 사용자가 권한을 허용(전체든, partial 이든 무관하게)한 경우, 미디어 파일을 읽어오는 함수를 호출하며, 권한을 거부한 경우 권한이 왜 필요한지 설명하는 다이얼로그를 띄우도록 합니다.
private val requestPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
if (isGranted) {
getImageFromGallery()
} else {
// 사용자에게 권한이 필요한 이유 설명하는 Dialog 띄우기
showPermissionExplanation()
}
}
이제 권한을 요청하는 코드를 작성합니다. 분기점은 총 3가지 입니다.
- 권한이 이미 허용된 경우
권한을 이미 허용했더라도, Partial access를 눌렀을 수도 있기 때문에 다시 한 번 권한을 요청합니다. 이것은 사용자가 허용한 미디어를 변경할 수 있도록 하기 위해서라도 꼭 다시 요청해야 합니다. - 권한을 이미 한 번 거부한 경우
권한을 거부한 경우에는 권한이 왜 필요한지 설명하는 UI를 사용자에게 보여줘야 합니다. 저는 Dialog를 이용했으며, 사용자가 Dialog에서 거부할 경우, 권한 요청을 호출하지 않음으로써, 두 번 거절하는 것을 방지했습니다.
물론 사용자가 Dialog에서 허용을 누른 후 나온 권한 요청에서 거절하여 두 번 거절되는 상황은 막을 수 없습니다. - 권한 요청이 처음이거나, 두 번 이상 거부한 경우
처음 요청이라면 권한 요청 팝업이 나타납니다.
두 번 이상 거부했다면, 요청 팝업 없이 거부 처리 되며, ActivityResultLauncher가 담긴 requestPermissionLauncher 변수의 if 문에서 false block으로 이동하여 사용자에게 권한이 필요한 이유를 설명하는 UI를 보여줄 것입니다.
when {
ContextCompat.checkSelfPermission(
baseContext,
readImagePermission
) == PackageManager.PERMISSION_GRANTED -> { // 권한이 허용된 경우
ActivityCompat.requestPermissions(
this,
arrayOf(readImagePermission),
SUCCESS_REQUEST_CODE
)
}
ActivityCompat.shouldShowRequestPermissionRationale(this, readImagePermission) -> { // 권한을 한 번 거부한 경우
showPermissionExplanation(true) {
requestPermissionLauncher.launch(readImagePermission)
}
}
else -> { // 권한을 처음 요청하거나, 두 번 이상 거부한 경우
requestPermissionLauncher.launch(readImagePermission)
}
}
ActivityCompat.requestPermissions 함수를 통한 Permission 처리 결과는 onRequestPermissionsResult 함수를 통해 처리할 수 있습니다.
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
if (requestCode == SUCCESS_REQUEST_CODE) {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
getImageFromGallery()
} else {
showPermissionExplanation()
}
return
}
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
}
사용자가 권한을 거부한 경우에 보여주는 다이얼로그 생성 코드는 다음과 같습니다.
private fun showPermissionExplanation(
isPossibleToShowPermission: Boolean = false,
callback: () -> Unit = {}
) {
MaterialAlertDialogBuilder(this)
.setTitle(getString(R.string.image_permission_title))
.setMessage(getString(R.string.image_permission_message))
.setNegativeButton(getString(R.string.image_permission_negative)) { dialog: DialogInterface, which: Int ->
}
.setPositiveButton(getString(R.string.image_permission_positive)) { dialog, which ->
if (isPossibleToShowPermission) {
callback()
return@setPositiveButton
}
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.parse("package:" + baseContext.packageName)
}
startActivity(intent)
}
.show()
}
사용자가 권한을 허용한 파일을 불러오는 코드는 다음과 같습니다. 파일의 위치를 가리키는 Uri만 반환하여, 이미지가 필요하지 않을 때는 비트맵을 생성하지 않아 메모리를 비효율적으로 차지하지 않습니다.
val projection = arrayOf(MediaStore.Images.Media._ID, MediaStore.Images.Media.DATE_ADDED)
val selection = "${MediaStore.Images.Media.MIME_TYPE} in (?,?)"
val mimeTypeMap = MimeTypeMap.getSingleton()
val selectionArg = arrayOf(
mimeTypeMap.getMimeTypeFromExtension("png"),
mimeTypeMap.getMimeTypeFromExtension("jpg"),
)
val sortOrder = "${MediaStore.Images.Media.DATE_ADDED} DESC"
val cursor = applicationContext.contentResolver.query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
projection,
selection,
selectionArg,
sortOrder
)
val list = ArrayList<GalleryItem>()
cursor?.use {
val idColumn = it.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
val dateAddedColumn = it.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_ADDED)
while (it.moveToNext()) {
val id = it.getLong(idColumn)
val contentUri =
ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
val dateAdded = it.getLong(dateAddedColumn)
val date = Date(dateAdded)
list.add(GalleryItem(id, contentUri, date))
}
}
galleryAdapter.submitList(list) // 저의 경우 Adapter에 불러온 이미지 데이터를 전달했습니다.
전체 코드
전체 코드는 다음과 같습니다.
class SelectImageActivity : AppCompatActivity() {
...
private val requestPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
if (isGranted) {
getImageFromGallery()
} else {
// 사용자에게 권한이 필요한 이유 설명하는 Dialog 띄우기
showPermissionExplanation()
}
}
...
private fun requestGalleryPermission() {
// 권한 요청
val readImagePermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Manifest.permission.READ_MEDIA_IMAGES
} else {
Manifest.permission.READ_EXTERNAL_STORAGE
}
when {
ContextCompat.checkSelfPermission(
baseContext,
readImagePermission
) == PackageManager.PERMISSION_GRANTED -> {
ActivityCompat.requestPermissions(
this,
arrayOf(readImagePermission),
SUCCESS_REQUEST_CODE
)
}
ActivityCompat.shouldShowRequestPermissionRationale(this, readImagePermission) -> {
showPermissionExplanation(true) {
requestPermissionLauncher.launch(readImagePermission)
}
}
else -> {
requestPermissionLauncher.launch(readImagePermission)
}
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
if (requestCode == SUCCESS_REQUEST_CODE) {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
getImageFromGallery()
} else {
showPermissionExplanation()
}
return
}
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
}
// 권한을 거부한 경우, 안내 문구를 띄우고 사용자가 직접 설정에 들어가서 권한을 설정하도록 유도
private fun showPermissionExplanation(
isPossibleToShowPermission: Boolean = false,
callback: () -> Unit = {}
) {
MaterialAlertDialogBuilder(this)
.setTitle(getString(R.string.image_permission_title))
.setMessage(getString(R.string.image_permission_message))
.setNegativeButton(getString(R.string.image_permission_negative)) { dialog: DialogInterface, which: Int ->
}
.setPositiveButton(getString(R.string.image_permission_positive)) { dialog, which ->
if (isPossibleToShowPermission) {
callback()
return@setPositiveButton
}
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.parse("package:" + baseContext.packageName)
}
startActivity(intent)
}
.show()
}
private fun getImageFromGallery() {
val projection = arrayOf(MediaStore.Images.Media._ID, MediaStore.Images.Media.DATE_ADDED)
val selection = "${MediaStore.Images.Media.MIME_TYPE} in (?,?)"
val mimeTypeMap = MimeTypeMap.getSingleton()
val selectionArg = arrayOf(
mimeTypeMap.getMimeTypeFromExtension("png"),
mimeTypeMap.getMimeTypeFromExtension("jpg"),
)
val sortOrder = "${MediaStore.Images.Media.DATE_ADDED} DESC"
val cursor = applicationContext.contentResolver.query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
projection,
selection,
selectionArg,
sortOrder
)
val list = ArrayList<GalleryItem>()
cursor?.use {
val idColumn = it.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
val dateAddedColumn = it.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_ADDED)
while (it.moveToNext()) {
val id = it.getLong(idColumn)
val contentUri =
ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
val dateAdded = it.getLong(dateAddedColumn)
val date = Date(dateAdded)
list.add(GalleryItem(id, contentUri, date))
}
}
galleryAdapter.submitList(list)
}
companion object {
private const val SUCCESS_REQUEST_CODE = 1000
}
}
예시 코드 및 실행 화면
https://github.com/juseonghyun/nbc_sns/tree/feat/create-new-post
참고자료
https://developer.android.com/training/permissions/requesting
https://developer.android.com/training/basics/intents/result
https://developer.android.com/about/versions/14/changes/partial-photo-video-access
https://github.com/boostcampwm-2022/android04-BEEP
https://stackoverflow.com/a/25957752/11722881
https://developer.android.com/develop/ui/views/components/dialogs
https://developer.android.com/training/data-storage/shared/media
'학습' 카테고리의 다른 글
직렬화란 무엇이고, 왜 필요하며, 어떻게 직렬화를 할 수 있을까? (0) | 2024.04.18 |
---|---|
Android Task와 ACTIVITY FLAG 정복하기 (0) | 2024.04.07 |
ViewModelProvider, ViewModelStore (0) | 2024.03.29 |
Kotlin companion object vs Kotlin object in class vs Java static (0) | 2024.03.22 |
EditText가 입력된 text를 복원하는 과정 (0) | 2024.03.20 |