본문 바로가기

Android/Android Version

[Android] Android 14 (SDK 34) 버전의 Media Permission 설정하기

728x90

구글에서 이런 경고를 띄워주고 있다.

 

생각해 보니 필자는 저번에 버전을 올렸다가, 오류가 발생하고 업데이트가 너무 느려 버전을 다시 내려놓고 깜빡하고 있었다.

그래서 그냥 배포하면 되겠거니 싶었는데, Media Permission 부분에 대해서 제대로 된 처리를 하지 않았었다는 것을 알 수 있었다.

 

이전 글에서는 다른 고려해야 할 것들에 대해 글을 작성했으므로,

2024.04.04 - [Android/Android Version] - [Android] Android 14 (SDK 34) 버전을 targetSDK로 사용할 때 주의할 점 몇 가지

 

이번 글에서는 Permission에 대하여 대응한 부분에 대해 처리한 방법을 간단하게 설명하고자 한다.


우선, 어떤 부분이 바뀌었는지 구글 공식 문서를 확인해 보자.

https://developer.android.com/about/versions/14/summary?hl=ko

 

Android 14 기능 및 변경사항 목록  |  Android Developers

The Android 15 Beta is now available. Try it out today and let us know what you think! 이 페이지는 Cloud Translation API를 통해 번역되었습니다. Android 14 기능 및 변경사항 목록 컬렉션을 사용해 정리하기 내 환경설정을

developer.android.com

 

해당 문서에서 사진 및 동영상에 대한 권한 섹션을 확인해 보면 다음과 같은 표를 볼 수 있다.

 

SDK 34를 타겟할 때 No일 때도 YES로 표시되는 것을 확인할 수 있다.

저 READ_MEDIA_VISUAL_USER_SELECTED이 이번에 추가되어 컨트롤해야 하는 권한이다.

 

이 권한이 무엇이냐?

말 그대로 사용자가 선택한 미디어 파일만 보여준다는 것이다.

 

어떠한 권한인지 알았으니, 적용해 보도록 하자.

 

공식 문서에는 다음과 같이 권한을 적용해주고 있다.

<!-- Devices running Android 12L (API level 32) or lower  -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />

<!-- Devices running Android 13 (API level 33) or higher -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />

<!-- To handle the reselection within the app on devices running Android 14
     or higher if your app targets Android 14 (API level 34) or higher.  -->
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />

 

 

마지막 줄이 이번에 추가된 선택 권한이다.

물론 이것을 그대로 사용하면 되는데 필자는 여기에 카메라 권한까지 추가해서 예제를 만들어보고자 한다.

<uses-feature
    android:name="android.hardware.camera"
    android:required="false" />
<uses-permission android:name="android.permission.CAMERA" />

<!-- targetSDK 32 이하 -->
<uses-permission
    android:name="android.permission.READ_EXTERNAL_STORAGE"
    android:maxSdkVersion="32" />

<!-- targetSDK 33 부터 -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />

<!-- targetSDK 34 이면서 Android OS 14 인 경우 -->
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />

 

첫 번째 uses-feature는 해당 앱에서 카메라를 사용하지만, 필수적으로 받아야 하는 권한은 아니라는 것을 명시해 주는 부분이다.

 

권한을 추가해 주었으면, 간단하게 버튼을 몇 개 만들어서 해당 권한을 받고 기능을 사용할 수 있도록 구현해 보자.

Column {
    Button(onClick = {
        ...
    }) {
        Text(text = "Go to Permission Setting Screen")
    }

    Spacer(modifier = Modifier.height(20.dp))

    Button(onClick = {
        ...
    }) {
        Text(text = "Photo")
    }

    Spacer(modifier = Modifier.height(20.dp))

    Button(onClick = {
        ...
    }) {
        Text(text = "Camera")
    }

    Spacer(modifier = Modifier.height(20.dp))

    Button(onClick = {
        ...
    }) {
        Text(text = "Video")
    }

    Spacer(modifier = Modifier.height(20.dp))
}

 

이렇게 구현한 다음, onClick Event에 권한을 체크하고 기능을 열 수 있도록 해보자.

여기서 첫 번째 버튼을 추가한 이유는, 권한을 거절하는 경우 "해당 기능을 사용하려면 권한이 필요하다"라는 Dialog를 띄우고 사용자가 직접 권한을 설정할 수 있게 유도해야 하기 때문이다.

 

여기서 제일 간단한 첫 번째 버튼의 구현은 다음과 같다.

Button(onClick = {
    context.startActivity(
        Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).setData(
            Uri.parse(
                "package:${context.packageName}"
            )
        )
    )
}) {
    Text(text = "Go to Permission Setting Screen")
}

 

Intent로 애플리케이션 세팅에 대한 값을 넣어주고, Uri.parse로 해당 앱의 패키지를 넣어주어 지금 동작하는 앱의 설정으로 바로 들어갈 수 있도록 해준다.

 

이 부분은 targetSDK 34라서 사용하는 것은 아니고, 권한에 대한 대응이 되어있다면 당연히 구현되어 있을 부분이지만 그래도 기본적인 것이기 때문에 추가해 두었다.

 

다음은 앨범에 대한 권한이다.

Photo와 Video로 나누어두긴 했지만, 결국 "사진과 동영상"에 대한 권한이기 때문에 한쪽에서 권한을 받으면 다른 한쪽에서는 당연히 사용이 가능해진다.

 

우선, 앨범을 열고 그에 대한 결과 값을 가져올 ActivityResultLauncher를 선언하자.

val albumLauncher =
    rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
        when (result.resultCode) {
            Activity.RESULT_OK -> {
                result.data?.data?.let { uri ->
                    uri.let {
                        // Logic
                        Log.d("TargetSDK", "imageUri - selected : $uri")
                    }
                }
            }

            Activity.RESULT_CANCELED -> Unit
        }
    }

 

사진과 영상 모두 앨범에서 가져오기 때문에 albumLauncher를 하나 선언하여 동일하게 사용하도록 한다.

해당 launcher를 사용할 때 매개변수로 들어가는 Intent를 통해 사진이냐 동영상이냐를 컨트롤할 수 있기 때문이다.

 

사진이냐 동영상이냐를 컨트롤하기 위한 Intent는 다음과 같이 선언한다.

val imageAlbumIntent =
    Intent(Intent.ACTION_PICK).apply {
        type = MediaStore.Images.Media.CONTENT_TYPE
        data = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
        type = "image/*"
        putExtra(Intent.EXTRA_ALLOW_MULTIPLE, false)
        putExtra(
            Intent.EXTRA_MIME_TYPES,
            arrayOf("image/jpeg", "image/png", "image/bmp", "image/webp")
        )
    }

val videoAlbumIntent =
    Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI).apply {
        type = "video/*"
        putExtra(
            Intent.EXTRA_MIME_TYPES,
            arrayOf("video/webm", "video/ogg")
        )
    }

 

imageAlbumIntent를 보면 MULTIPLE 값을 false로 설정해 두었는데, 이미지 다중 선택을 하고자 한다면 해당 값을 true로 변경하면 된다.

 

이것을 통해 권한이 있을 때 앨범을 열고 데이터를 가지고 올 수 있도록 구현은 끝났다.

그러면, 권한을 체크하는 부분과 더불어 권한이 없을 때 권한을 받을 수 있도록 구현만 해주면 된다.

 

권한을 받는 부분은 공식 문서에 다음과 같이 나와있다.

// Register ActivityResult handler
val requestPermissions = registerForActivityResult(RequestMultiplePermissions()) { results ->
    // Handle permission requests results
    // See the permission example in the Android platform samples: https://github.com/android/platform-samples
}

// Permission request logic
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
    requestPermissions.launch(arrayOf(READ_MEDIA_IMAGES, READ_MEDIA_VIDEO, READ_MEDIA_VISUAL_USER_SELECTED))
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
    requestPermissions.launch(arrayOf(READ_MEDIA_IMAGES, READ_MEDIA_VIDEO))
} else {
    requestPermissions.launch(arrayOf(READ_EXTERNAL_STORAGE))
}

 

targetSDK 34는 UPSIDE_DOWN_CAKE라는 이름을 가지고 있다는 것을 해당 권한 작업을 하면서 처음 알았다.

 

각설하고, 버전에 따라 권한을 추가해 주고, requestPermissions를 통해 권한을 체크하고 결과 값에 따라 알아서 핸들링하라고 한다.

그렇다면 일단 기본 형태부터 구현해 준다.

val storagePermissionLauncher = rememberLauncherForActivityResult(
    ActivityResultContracts.RequestMultiplePermissions()
) { grantedPermissionMap ->
    ...
}

 

저기에서 권한을 체크하고, 권한을 다 받으면 결과 값에 따라서 권한을 다시 받던지, 아니면 버튼에 따른 동작을 수행하던지 하면 된다.

 

일단 해당 부분은 저렇게 동작할 거라고 생각한 다음 놔두고, 버튼을 클릭했을 때 onClick Event부터 구현하도록 한다.

Button(onClick = {
    // 1. 권한이 있는가 체크하고
    // 2. 권한이 있으면 앨범 열기
    // 3. 권한이 없으면 권한 받기
}) {
    Text(text = "Photo")
}

 

위과 같은 순서로 구현하면 된다.

 

그럼 1번부터 구현해 보자.

권한의 여부를 확인하고 그 결과 값을 가져오려면 어떻게 해야 하는가?

ContextCompat.checkSelfPermission(context, "PERMISSION") == PackageManager.PERMISSION_GRANTED

 

안드로이드에서는 기본적으로 권한 여부를 확인할 수 있도록 checkSelfPermission API를 제공해 준다.

"PERMISSION" 부분에 확인하고자 하는 권한을 넣어주면 된다.

 

우리는 지금 사진 앨범에 대한 권한을 받고 있다. 그렇다면 어떤 권한을 체크해야 하는가?

  1. targetSDK 33 이상은 READ_MEDIA_IMAGES 권한을
  2. targetSDK 34 이상은 READ_MEDIA_IMAGES와 READ_MEDIA_VSUAL_USER_SELECTED 권한을
  3. 그 외 버전에서는 READ_EXTERNAL_STORAGE 권한을

체크하면 된다.

해당 조건을 함수로 구현하면 다음과 같다.

return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
    (ContextCompat.checkSelfPermission(
        context,
        Manifest.permission.READ_MEDIA_VIDEO,
    ) == PackageManager.PERMISSION_GRANTED)
) {
    true
} else if (
    Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE &&
    ContextCompat.checkSelfPermission(
        context,
        Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED
    ) == PackageManager.PERMISSION_GRANTED
    || (ContextCompat.checkSelfPermission(
        context,
        Manifest.permission.READ_MEDIA_VIDEO,
    ) == PackageManager.PERMISSION_GRANTED)
) {
    true
} else {
        ContextCompat.checkSelfPermission(
            context,
            Manifest.permission.READ_EXTERNAL_STORAGE
        ) == PackageManager.PERMISSION_GRANTED
}

 

여기서 중요한 부분은, 1번에서 33 버전 이상을 체크하고 2번에서 34 버전 이상을 체크하는 부분이다.

34 버전은 33 버전보다 크기 때문에 첫 번째 조건에도 걸리고 두 번째 조건에도 걸린다.

하지만 여기서 READ_MEDIA_VIDEO 권한을 받았다는 것은, 사진의 선택적 권한을 받는다는 것보다 더 넓은 범위의 권한을 받았다는 것이므로 첫 번째 조건으로 끝내도 상관이 없게 된다.

 

이게 무슨 말이냐,

다음과 같은 로그를 보면 이해할 수 있을 것이다.

{android.permission.READ_MEDIA_IMAGES=false, android.permission.READ_MEDIA_VISUAL_USER_SELECTED=false} // 거절
{android.permission.READ_MEDIA_IMAGES=false, android.permission.READ_MEDIA_VISUAL_USER_SELECTED=true} // 선택 승인
{android.permission.READ_MEDIA_IMAGES=true, android.permission.READ_MEDIA_VISUAL_USER_SELECTED=true} // 모두 승인 

 

34 버전에서 권한 승인에 대한 케이스 3개를 모두 확인해 본 로그 값이다.

거절했을 때는 당연히 images와 selected 값이 모두 false로 떨어지고,

선택 승인을 했을 때는 images는 false지만 selected 값은 true이다.

모두 승인을 했을 때는 두 값 모두 true가 떨어지게 된다.

 

이 케이스를 보고 다시 조건을 확인해 보자.

첫 번째 조건이 true가 됐다는 것은,

  1. 버전이 33 이면서 권한에 대한 승인을 했거나
  2. 버전이 34 이상이면서 권한에 대한 모든 승인을 하는 경우

이다.

 

즉, 첫 다음 조건까지 확인하지 않고 넘어가도 상관이 없게 되므로 해당 조건을 첫 번째 조건으로 사용한다.

 

권한체크까지 완료했으니, 권한이 있으면 원하는 동작을 하고 권한이 없으면 권한을 받도록 해야 한다.

받아야 하는 권한은 공식 문서에서 볼 수 있듯이 list로 받아와야 하는데 이것이 버전마다 다르게 적용되어야 한다.

따라서 필자는 다음과 같은 변수를 만들어서 사용하였다.

val ImagePermission = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
    listOf(
        Manifest.permission.READ_MEDIA_IMAGES,
        Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED
    )
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
    listOf(
        Manifest.permission.READ_MEDIA_IMAGES,
    )
} else {
    listOf(
        Manifest.permission.READ_EXTERNAL_STORAGE,
    )
}

 

image를 가져오는 권한은 버전에 따라 위와 같이 선언하면 된다.

여기서, 우리가 권한을 받을 때는 "사진과 동영상 접근 권한"을 받기 때문에 Image와 Video 권한은 동시에 받고 있지만, 위의 변수에서 ImagePermission으로 변수명을 짓고 Image에 대한 권한만 추가해 둔 이유는 명시적으로 확인하기 위해서일 뿐 별다른 의미는 없다.

Button(onClick = {
    selectPermission = checkMediaTypePermissionGranted(
        context = context,
    )

    if (selectPermission) {
        albumLauncher.launch(imageAlbumIntent)
    } else {
        storagePermissionLauncher.launch(
            ImagePermission.toTypedArray()
        )
    }
}) {
    Text(text = "Photo")
}

 

이렇게 까지 추가해 주면 버튼을 클릭했을 때 권한을 체크하고, 권한 여부에 따라 동작을 다르게 할 수 있도록 하였다.

여기까지 구현하고 나니, 권한에 따라 동작이 달라지는 것에 대한 고려가 되어있지 않다는 것이 거슬리므로 권한에 따른 케이스를 추가하도록 한다.

val permissionType = remember { mutableStateOf("") }

...

Button(onClick = {
    permissionType.value =
        PermissionConstValue.Photo

    selectPermission = checkMediaTypePermissionGranted(
        context = context,
        permissionType = PermissionType.IMAGE
    )

    if (selectPermission) {
        albumLauncher.launch(imageAlbumIntent)
    } else {
        storagePermissionLauncher.launch(
            ImagePermission.toTypedArray()
        )
    }
}) {
    Text(text = "Photo")
}

 

권한을 저장할 변수를 선언하고, PermissionConstValue라는 값을 선언하여 권한에 대한 타입을 저장할 수 있도록 하였다.

대충 string 형태로 구현하였지만, enum class로 구현해도 상관없다.

 

이처럼 권한에 따른 다른 동작을 할 수 있게 베이스를 구현했으니, 다른 버튼도 동작하게 만들어준다.

Button(onClick = {
    permissionType.value =
        PermissionConstValue.Camera

    checkSinglePermissionGranted(
        context,
        Manifest.permission.CAMERA,
        onDenied = {
            storagePermissionLauncher.launch(
                CameraPermission.toTypedArray()
            )
        },
        onGranted = {
            cameraLauncher.launch()
        }
    )
}) {
    Text(text = "Camera")
}

Button(onClick = {
    permissionType.value =
        PermissionConstValue.Video

    selectPermission = checkMediaTypePermissionGranted(
        context = context,
        permissionType = PermissionType.VIDEO
    )

    if (selectPermission) {
        albumLauncher.launch(videoAlbumIntent)
    } else {
        storagePermissionLauncher.launch(
            VideoPermission.toTypedArray()
        )
    }
}) {
    Text(text = "Video")
}

 

이제 마지막 단계이다.

처음에 기본 형태만 잡아두었던 Launcher를 구현하면 된다. 

 

클릭한 버튼에 따라 다른 권한을 받으므로, 권한을 체크해서 해당 권한에 따른 동작을 수행하도록 조건을 추가해 주면 된다.

val storagePermissionLauncher = rememberLauncherForActivityResult(
    ActivityResultContracts.RequestMultiplePermissions()
) { grantedPermissionMap ->
    val isGrantedPermission = checkPermissionTypeGranted(
        isGranted = grantedPermissionMap,
        permissionType = permissionType.value
    )

    if (!isGrantedPermission) {
        permissionDialog.value = true
    } else {
        when (permissionType.value) {
            PermissionConstValue.Photo -> {
                albumLauncher.launch(imageAlbumIntent)
            }

            PermissionConstValue.Video -> {
                albumLauncher.launch(videoAlbumIntent)
            }

            PermissionConstValue.Camera -> {
                cameraLauncher.launch()
            }
        }
    }
}

 

이처럼 말이다.

grantedPermissionMap은 이름 그대로 Map 형태로 권한에 대한 정보를 반환해 준다.

따라서, 해당 Map과 클릭한 권한에 대한 정보를 사용해서 권한을 받았는지 못 받았는지 체크하여 권한 설정에 대한 Dialog를 띄우던지 정상적인 동작을 하도록 구현하였다.

 

일단 필자는 기존에 있는 코드에서 큰 변화를 주지 않고 해당 권한을 추가하기 위해 이렇게 구현을 하였다.

하지만, 다시 안드로이드 공식 문서를 확인해 보자.

 

이와 같이 select 하는 경우 그 리스트를 변경할 수 있거나 모두 허용 권한을 다시 받을 수 있도록 구현하는 것을 권장한다.

 

하지만 필자가 구현한 코드대로 작업을 진행하면 select 옵션만 승인하고 사진을 골라서 권한을 줬음에도 불구하고 다음에 버튼을 누르면 앨범 전체 리스트를 확인할 수 있게 된다.

 

따라서 위와 같이 권장사항대로 개발을 하기 위해서는 해당 함수를 수정해주어야 한다.

selectPermission = checkMediaTypePermissionGranted(
    context = context,
    permissionType = PermissionType.IMAGE
)

 

checkMediaTypePermissionGranted 함수에서 targetSDK가 34일 때 select 권한만 받은 경우 앨범을 띄워주되, 직접 커스텀한 뷰를 보여주고 가이드와 같이 권한을 설정하거나 사진을 설정할 수 있는 UI를 구현해 주면 된다.

혹은 매번 버튼을 클릭할 때마다 권한을 설정할 수 있도록 Dialog를 띄워주거나 select 할 수 있는 뷰를 띄워주면 된다.


필자는 커스텀 UI가 아닌 기본적으로 제공하는 앨범 UI를 사용하고 있기 때문에 그렇게까지 고도화하지 않고 위의 작성한 예제처럼 사용하였는데, 필요하다면 가이드를 따라서 UI를 구현하는 것이 더 좋을 것으로 보인다. 아마 필자도 추후에 업데이트하지 않을까 싶다.

 

이번 버전을 올리면서 정말 생소한 권한이 추가되었고,

쉽게 처리할 수 있지만 확실하게 어떤 부분이 달라졌고 어떻게 권한을 받아야 하는지 이해하기 위해서 글을 작성하였는데,

고려할 것들이 생각보다 좀 있었고 실제 서비스에서 마이그레이션을 해보니 예상한 것 보다 더 많은 시간을 잡아먹었다.

 

권한을 다시 받지 않고 항상 앨범을 띄워주도록 넘겨서 그렇지, 실제 가이드대로 UI를 보여줘야 했다면 더 오래 걸리지 않았을까 생각을 하면서,

targetSDK를 올리지 않으면 업데이트를 못한다는 구글의 경고를 보고 금방 바꾸니까라고 생각하며 최후의 최후까지 미루던 자신을 반성한다. 좀 더 빨리 적용해 보고 개선했더라면 가이드대로 수정까지 해서 배포할 수 있었을 테니 말이다.

 

그래도 뭐,

UI를 그리고 살짝 로직을 수정하는 것 외에는 필자가 작성한 로직으로 모두 처리가 가능할 테니 실제로 이 글을 읽으시는 분이라면 빠르고 쉽게 수정이 가능할 것이라 믿는다.

 

해당 게시글에 사용한 예제는 Github에 올려두었다.

https://github.com/HeeGyeong/ComposeSample

 

GitHub - HeeGyeong/ComposeSample: This project provides various examples needed to actually use Jetpack Compose.

This project provides various examples needed to actually use Jetpack Compose. - HeeGyeong/ComposeSample

github.com

 

728x90