Android 이미지 읽기 권한 다루기

DoDoBest

·

2024. 4. 2. 23:53

권한 없이 이미지 파일을 사용자로부터 입력 받는 방법

Photo picker를 이용하거나

 

https://developer.android.com/training/data-storage/shared/photopicker

 

사진 선택 도구  |  Android Developers

DataStore offers a more modern way of storing local data. You should use DataStore instead of SharedPreferences. Read the DataStore guide for more information. 이 페이지는 Cloud Translation API를 통해 번역되었습니다. 사진 선택 도구 컬

developer.android.com

 

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

 

런타임 권한 요청  |  Android Developers

이 페이지는 Cloud Translation API를 통해 번역되었습니다. 런타임 권한 요청 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 모든 Android 앱은 액세스가 제한된 샌

developer.android.com

 

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가지 입니다.

  1. 권한이 이미 허용된 경우
    권한을 이미 허용했더라도, Partial access를 눌렀을 수도 있기 때문에 다시 한 번 권한을 요청합니다. 이것은 사용자가 허용한 미디어를 변경할 수 있도록 하기 위해서라도 꼭 다시 요청해야 합니다.
  2. 권한을 이미 한 번 거부한 경우
    권한을 거부한 경우에는 권한이 왜 필요한지 설명하는 UI를 사용자에게 보여줘야 합니다. 저는 Dialog를 이용했으며, 사용자가 Dialog에서 거부할 경우, 권한 요청을 호출하지 않음으로써, 두 번 거절하는 것을 방지했습니다.
    물론 사용자가 Dialog에서 허용을 누른 후 나온 권한 요청에서 거절하여 두 번 거절되는 상황은 막을 수 없습니다.
  3. 권한 요청이 처음이거나, 두 번 이상 거부한 경우
    처음 요청이라면 권한 요청 팝업이 나타납니다.
    두 번 이상 거부했다면, 요청 팝업 없이 거부 처리 되며, 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

 

GitHub - juseonghyun/nbc_sns: null 만난건 7 럭키야 Team

null 만난건 7 럭키야 Team. Contribute to juseonghyun/nbc_sns development by creating an account on GitHub.

github.com

 

 

참고자료

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

https://github.com/material-components/material-components-android/blob/master/docs/components/Dialog.md