Runtime Permission 이해하고 요청 구현하기

이번 포스팅에서는 런타임 퍼미션에 대해 알아보도록 하겠습니다.

# 들어가기

안드로이드는 카메라나 저장소, 위치 이용와 같이 시스템에 큰 영향을 미칠 수 있는 기능에 대해서는 위험한 권한으로 분류하여 다음과 같이 관리하고 있습니다.

출처: https://ieeexplore.ieee.org/document/9272963

출처: https://ieeexplore.ieee.org/document/9272963

과거에는 앱 설치시 모든 권한을 부여했지만 Android 6.0(API 레벨 23) 이상에서는 앱이 이런 위험한 권한들을 필요로 할 경우 앱 실행중에 권한을 요청할 수 있도록 시스템에 Runtime Permission 을 도입하였습니다. 여기서는 위치 권한을 예로들어 권한을 획득하는 방법을 보여드리겠습니다.

# 필요한 권한 정의

우선은 AndroidManifest.xml에서 앱에 필요한 위험한 권한을 추가합니다. 위치에 관련된 권한은 두가지가 있는데 하나는 네트워크만을 이용해서 약 1.6km 이내로 위치를 파악하는 ACCESS_COARSE_LOCATION과, GPS도 같이 써서 위치를 약 50m 이내로 더 정확하게 파악하는 ACCESS_FINE_LOCATION이 있습니다.

1
2
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

요청하는 권한은 한 묶음으로 만들어 다루기 위해 REQUIRED_PERMISSIONS로 정의합니다.

1
2
3
4
5
6
companion object {
    private val REQUIRED_PERMISSIONS = arrayOf(
        Manifest.permission.ACCESS_COARSE_LOCATION,  // 도시 블록 단위
        Manifest.permission.ACCESS_FINE_LOCATION,  // 더 정밀한 단위
    )
}

# 권한 확인

권한을 확인하는 함수를 작성합니다. ContextCompat.checkSelfPermission으로 해당 권한이 부여되었는지 확인할 수 있는데요, REQUIRED_PERMISSIONS에 대해 all을 수행하여 이중 하나라도 권한이 부여되지 않은 경우 isAllPermissionsGrantedfalse를 반환하도록 했습니다.

1
2
3
4
private fun isAllPermissionsGranted(): Boolean = REQUIRED_PERMISSIONS.all { permission ->
    ContextCompat.checkSelfPermission(this, permission) ==
            PackageManager.PERMISSION_GRANTED
}

# 화면 디자인

화면에 퍼미션을 체크하는 버튼을 하나 만듭니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <ImageView android:id="@+id/location_icon"
        android:layout_width="48dp"
        android:layout_height="48dp"
        android:layout_marginEnd="12dp"
        android:src="@drawable/ic_baseline_my_location_24"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toStartOf="@+id/location_text"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintHorizontal_chainStyle="packed"/>

    <TextView android:id="@+id/location_text"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Location"
        android:textSize="24sp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toEndOf="@+id/location_icon"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

# Callback을 이용한 권한 획득

# requestDangerousPermissions 작성

버튼에 클릭리스너를 하나 만들어 권한이 부여되어있지 않으면 권한을 요청하는 로직을 구성합니다. 권한 요청에는 ActivityCompat.requestPermissions를 사용하는데, 이 때 request code가 필요하므로 companion object 안에 REQUEST_CODE_PERMISSIONS도 정의해 줍니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
binding.locationIcon.setOnClickListener {
    if (isAllPermissionsGranted()) {
        Snackbar.make(binding.root, "Permission granted", Snackbar.LENGTH_SHORT).show()
    } else {
        requestDangerousPermissions()
    }
}

private fun requestDangerousPermissions() {
    ActivityCompat.requestPermissions(
        this, REQUIRED_PERMISSIONS, REQUEST_CODE_PERMISSIONS)
}

// companion object
private const val REQUEST_CODE_PERMISSIONS = 1001

# onRequestPermissionsResult

requestDangerousPermissions에서 권한을 요청한 뒤 유저의 조작이 행해지면 그 결과는 onRequestPermissionsResult 콜백으로 받게 됩니다. onRequestPermissionsResult에서는 requestCode를 확인한 뒤 모든 권한이 취득된 경우, 확인 결과 권한이 취득되지 않았을 경우 shouldShowRequestPermissionRationale를 써서 권한을 재요청할 수 있습니다. 그럼에도 요청을 거부하면 앱은 더이상 권한을 요청할 수 없습니다. 다음 그림에 권한 요청의 순서를 나타내었습니다.

출처: https://developer.android.com/training/permissions/requesting

출처: https://developer.android.com/training/permissions/requesting

여기에서는 사용자가 모든 권한을 취득했으면 “Permission granted"를 보여주고, 요청을 거부하면 “Permission required to use app!“을 띄운뒤 권한을 다시 요구하도록 했습니다. 여기에서도 요청을 거부하면 마지막으로 Intent를 사용해 유저를 설정화면으로 직접 보내는 openSettings가 실행되도록 했습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
override fun onRequestPermissionsResult(
    requestCode: Int,
    permissions: Array<out String>,
    grantResults: IntArray
) {
    super.onRequestPermissionsResult(requestCode, permissions, grantResults)
    if (requestCode == REQUEST_CODE_PERMISSIONS) {
        if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
            // 모든 권한을 취득함
            Snackbar.make(binding.root, "Permission granted", Snackbar.LENGTH_SHORT).show()
        } else {
            if (shouldShowRequestPermissionRationale(REQUIRED_PERMISSIONS[0])) {
                // 권한을 다시 요구함
                Snackbar.make(binding.root,
                    "Permission required to use app!", Snackbar.LENGTH_SHORT).show()
                requestDangerousPermissions()
            } else {
                // Settings로 들어가서 설정할 수 있도록 함
                Snackbar.make(binding.root, "Permission denied", Snackbar.LENGTH_SHORT).show()
//                        openSettings()
            }
        }
    }
}

private fun openSettings() {
    Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
        addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
        data = Uri.fromParts("package", packageName, null)
    }.run(::startActivity)
}

# activity:1.2.0 이후의 권한 획득 방법

# ActivityResultContract

그런데 구글은 onRequestPermissionsResultandroidx.activity:activity:1.2.0-alpha04부터 deprecated 시키고, 시스템에서 전달된 결과를 처리하기 위해 ActivityResultContract를 새로 만들었습니다. ActivityResultContract를 사용하면 다음과 같이 미리 정의된 계약서를 전달하는것만으로 액티비티에서 결과를 손쉽게 가져올 수 있습니다.

Nested types
CaptureVideo An ActivityResultContract to take a video saving it into the provided content-Uri.
CreateDocument An ActivityResultContract to prompt the user to select a path for creating a new document, returning the content:Uri of the item that was create.
GetContent An ActivityResultContract to prompt the user to pick a piece of content, receiving a content://Uri for that content that allows you to use android.content.ContentResolver.openInputStream to access the raw data.
GetMultipleContents An ActivityResultContract to prompt the user to pick one or more a pieces of content, receiving a content://Uri for each piece of content that allows you to use android.content.ContentResolver.openInputStream to access the raw data.
OpenDocument An ActivityResultContract to prompt the user to open a document, receiving its contents as a file:/http:/content:Uri.
OpenDocumentTree An ActivityResultContract to prompt the user to select a directory, returning the user selection as a Uri.
OpenMultipleDocuments An ActivityResultContract to prompt the user to open (possibly multiple) documents, receiving their contents as file:/http:/content:Uris.
PickContact An ActivityResultContract to request the user to pick a contact from the contacts app.
RequestMultiplePermissions An ActivityResultContract to request permissions
RequestPermission An ActivityResultContract to request a permission
StartActivityForResult An ActivityResultContract that doesn’t do any type conversion, taking raw Intent as an input and ActivityResult as an output.
StartIntentSenderForResult An ActivityResultContract that calls Activity.startIntentSender.
TakePicture An ActivityResultContract to take a picture saving it into the provided content-Uri.
TakePicturePreview An ActivityResultContract to take small a picture preview, returning it as a Bitmap.
TakeVideo An ActivityResultContract to take a video saving it into the provided content-Uri.

그럼 계약서 기능을 이용하기 위해 gradle에 Dependency를 추가합니다.

1
implementation "androidx.activity:activity-ktx:1.4.0"

# requestMultiplePermissions 작성

여기서는 복수의 권한을 한번에 요청하기 위해 registerForActivityResultRequestMultiplePermissions 계약서를 전달하고 ActivityResultLauncher를 반환받도록 했습니다. 한 개의 권한만을 요청할 경우에는 RequestPermission 을 전달하면 됩니다. 그리고 권한을 승인하였을때, 승인하지 않았을 때, 완전히 거부하였을 때에 대한 분기도 정의하여 줍니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
private val requestPermissionLauncher =
    registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
        permissions.entries.forEach { permission ->
            when {
                permission.value -> {
                    Snackbar.make(binding.root, "Permission granted", Snackbar.LENGTH_SHORT).show()
                }
                shouldShowRequestPermissionRationale(permission.key) -> {
                    Snackbar.make(binding.root,
                        "Permission required to use app!", Snackbar.LENGTH_SHORT).show()
                }
                else -> Snackbar.make(binding.root, "Permission denied", Snackbar.LENGTH_SHORT).show()
            }
        }
    }

마지막으로 이미 정의된 requestPermissions 는 주석처리하고 requestPermissionLauncherlaunch로 실행시키면 됩니다. RequestMultiplePermissionsRequestPermission 내부의 getSynchronousResult 에는 권한을 체크하는 로직도 포함되어있기 때문에 사용방식에 따라서는 isAllPermissionsGranted 를 생략할 수도 있습니다.

1
2
3
4
5
6
7
8
binding.locationIcon.setOnClickListener {
    if (isAllPermissionsGranted()) {
        Snackbar.make(binding.root, "Permission granted", Snackbar.LENGTH_SHORT).show()
    } else {
//                requestPermissions()
        requestPermissionLauncher.launch(REQUIRED_PERMISSIONS)
    }
}

이렇게 해서 런타임 퍼미션에 대해 알아보았습니다.

Built with Hugo
Theme Stack designed by Jimmy