[Android]Jetpack Compose & Accompanist Permissions를 활용한 권한 요청 플로우

H.Zoon·2025년 2월 22일
0
post-thumbnail

오늘은 Jetpack ComposeAccompanist Permissions를 활용하여 안드로이드 앱에서 권한 요청 화면을 구성하는 방법에 대해 알아보려고 한다.

특히 안드로이드 권한요청 플로우에서 고려해야 할 “1회 거부”와 “2회 이상 거부”를 구분해 UI로직을 다르게 처리하는 방법을 주요하게 알아보려 한다.


목차

  1. Jetpack Compose와 Accompanist Permissions 라이브러리
  2. 주요 컴포넌트와 역할
  3. PermissionRequestScreen - 권한 요청 메인 화면
  4. PermissionItem - 단일 권한 정보 UI
  5. PermanentlyDeniedSection & openAppSettings - 설정 이동 안내
  6. 권한 요청 플로우 요약
  7. 마무리

Jetpack Compose와 Accompanist Permissions 라이브러리

1) Jetpack Compose에서의 권한 요청

기존 View 시스템에서는 ActivityFragment에서 requestPermissions()onRequestPermissionsResult()를 오가며 직접 처리를 했어야 했다.

그러나 Jetpack Compose는 모든 UI를 Composable로 작성하므로, Activity/Fragment의 권한 콜백에 직접 접근하기가 불편합니다.

Accompanist Permissions는 이러한 문제를 해결하기 위해 나온 라이브러리 이다.
rememberMultiplePermissionsState(...) 혹은 rememberPermissionState(...) API를 제공하여,
UI 상태권한 요청 상태를 간단하게 연동할 수 있다.

// build.gradle
dependencies {
    implementation "com.google.accompanist:accompanist-permissions:<version>"
}

주요 컴포넌트와 역할

1) PermissionInfo - 권한 정보 데이터 클래스

아래 클래스는 권한 정보를 쉽게 표시하기 위한 데이터 구조입니다.

data class PermissionInfo(
    val permission: String,             // 실제 Manifest.permission.XXX
    @StringRes val titleRes: Int,       // 권한 이름(리소스)
    @StringRes val descriptionRes: Int, // 권한 설명(리소스)
    val isRequired: Boolean,            // 필수 권한 여부
    val imageVector: ImageVector        // 아이콘
)
  • permission: 예) Manifest.permission.CAMERA, Manifest.permission.ACCESS_FINE_LOCATION 등
  • titleRes, descriptionRes: Jetpack Compose의 stringResource(...)에서 사용할 문자열 리소스 ID
  • isRequired: 해당 권한이 앱 기능에 “꼭 필요한지” 여부
  • imageVector: 권한을 상징하는 아이콘 (예: Icons.Default.Camera)

2) rememberPermissionList - 안드로이드 버전별 권한 목록

안드로이드 버전이나 상황에 따라 필요한 권한이 다를 수 있다. 따라서 다음과 같이 OS 버전별로 필요한 권한 리스트를 생성한다.

@Composable
fun rememberPermissionList(): List<PermissionInfo> {
    return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
        listOf(
            PermissionInfo(
                permission = Manifest.permission.BLUETOOTH_SCAN,
                titleRes = R.string.bluetooth_scan_title,
                descriptionRes = R.string.bluetooth_scan_description,
                isRequired = true,
                imageVector = Icons.Default.Bluetooth
            )
        )
    } else {
        listOf(
            PermissionInfo(
                permission = Manifest.permission.ACCESS_FINE_LOCATION,
                titleRes = R.string.location_title,
                descriptionRes = R.string.location_description,
                isRequired = true,
                imageVector = Icons.Default.LocationOn
            )
        )
    }
}

PermissionRequestScreen - 권한 요청 메인 화면

PermissionRequestScreen은 여러 권한을 한 번에 확인하고, 사용자가 권한을 승인/거부했을 때 UI 상태를 업데이트하거나 “앱 설정” 이동을 안내하는 핵심 화면이다.

@Composable
fun PermissionRequestScreen(
    onAllRequiredPermissionsGranted: () -> Unit,
    trackingManager: TrackingManager,
    mainViewModel: MainViewModel
) {
    // 코루틴 스코프
    val coroutineScope = rememberCoroutineScope()

    // 필요한 권한 목록
    val permissionList = rememberPermissionList()

    // Accompanist Permissions: 여러 권한 상태를 한꺼번에 관리
    val permissionsState = rememberMultiplePermissionsState(
        permissions = permissionList.map { it.permission }
    )

    // 권한 승인/거부 상태 변화 감지
    LaunchedEffect(permissionsState.allPermissionsGranted) {
        if (permissionsState.allPermissionsGranted) {
            // 모든 권한 승인됨
            onAllRequiredPermissionsGranted()
        } else if (trackingManager.isFirstLaunchNotified()) {
            // 최초 권한 요청 화면 진입 + 처음 거부됨
            mainViewModel.saveFirstLunch()
        }
    }

    Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
        Text(text = "권한 상태", style = MaterialTheme.typography.h6)

        // 권한 목록 렌더링
        LazyColumn(modifier = Modifier.weight(1f)) {
            items(permissionList) { permissionInfo ->
                PermissionItem(
                    permissionInfo = permissionInfo,
                    permissionState = permissionsState.permissions.first {
                        it.permission == permissionInfo.permission
                    }
                )
            }
        }

        // 하단 영역: 거부 상황에 따라 다른 UI 표시
        when {
            // 1회 거부 → shouldShowRationale == true
            permissionsState.shouldShowRationale -> {
                Button(onClick = { permissionsState.launchMultiplePermissionRequest() }) {
                    Text(text = "권한 재요청하기")
                }
            }
            // 2회 이상 거부 → shouldShowRationale == false
            permissionsState.permissions.any { !it.status.isGranted && !it.status.shouldShowRationale } -> {
                PermanentlyDeniedSection(
                    onOpenSettings = { openAppSettings() }
                )
            }
        }
    }
}

1) rememberMultiplePermissionsState(...)
Accompanist Permissions가 제공하는 API로, 여러 권한의 승인 상태와 재요청 필요 여부(shouldShowRationale)를 한꺼번에 추적합니다.

2) trackingManager, mainViewModel.saveFirstLunch() 의 필요성
단순히 shouldShowRationale 플래그만 봐서는 “첫 거부”와 “여러 번 거부”를 완벽히 구분하기 어렵다.

실제 기기에 따라 shouldShowRationale가 한 번 거부만으로 false가 뜨기도 하고, “권한을 원치 않습니다”로 영구 거부할 수도 있다.

따라서 TrackingManager와 ViewModel에서 “첫 실행 여부”를 기기에 저장해두면, 1회 거부 후 saveFirstLunch()를 통해 “첫 실행”을 false로 저장 → 반복 거부 시나리오 판단에 활용 가능하다.

PermissionItem - 단일 권한 정보 UI

@Composable
fun PermissionItem(
    permissionInfo: PermissionInfo,
    permissionState: PermissionState
) {
    Row(
        verticalAlignment = Alignment.CenterVertically,
        modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp)
    ) {
        Icon(
            imageVector = permissionInfo.imageVector,
            contentDescription = null
        )

        Spacer(modifier = Modifier.width(8.dp))

        Column {
            Text(text = stringResource(id = permissionInfo.titleRes))
            Text(text = stringResource(id = permissionInfo.descriptionRes))

            // 권한 승인 여부
            if (permissionState.status.isGranted) {
                Text(text = "허용됨", color = MaterialTheme.colorScheme.primary)
            } else {
                // 권한이 거부된 상태
                if (permissionState.status.shouldShowRationale) {
                    // 1회 거부
                    Text(
                        text = "권한을 다시 확인해주세요",
                        color = MaterialTheme.colorScheme.error
                    )
                } else {
                    // 2회 이상 거부
                    Text(
                        text = "거부됨",
                        color = MaterialTheme.colorScheme.error
                    )
                }
            }
        }
    }
}

승인됨: “허용됨” 안내 표시

  • 1회 거부: shouldShowRationale == true → “권한을 다시 확인해주세요”
  • 2회 이상 거부: shouldShowRationale == false → “거부됨” + 설정 이동 유도

PermanentlyDeniedSection & openAppSettings - 설정 이동 안내

1) PermanentlyDeniedSection - 영구 거부 UI

@Composable
fun PermanentlyDeniedSection(onOpenSettings: () -> Unit) {
    Column(
        modifier = Modifier
            .fillMaxWidth()
            .background(Color(0xFFFFCDD2)) // 연한 빨간색 배경
            .padding(16.dp)
    ) {
        Text(
            text = "권한이 영구적으로 거부되었습니다.",
            color = Color.Red,
            style = MaterialTheme.typography.subtitle1
        )
        Text(
            text = "앱 설정에서 직접 권한을 허용해주세요.",
            style = MaterialTheme.typography.body2
        )
        Spacer(modifier = Modifier.height(8.dp))
        Button(onClick = onOpenSettings) {
            Text(text = "설정으로 이동")
        }
    }
}
  • 2회 이상 거부 시에는 시스템 팝업을 통해 권한을 다시 요청 불가능하므로.
    빨간색 배경으로 강조하고 “설정으로 이동” 버튼을 제공.

2) openAppSettings - 설정 화면 이동 함수

fun openAppSettings() {
    try {
        val context = LocalContext.current
        val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
            data = Uri.fromParts("package", context.packageName, null)
            addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
        }
        context.startActivity(intent)
    } catch (e: ActivityNotFoundException) {
        e.printStackTrace()
    }
}
  • Settings.ACTION_APPLICATION_DETAILS_SETTINGS 인텐트를 호출해,
    현재 앱의 상세 설정 페이지로 이동.

권한 요청 플로우 요약

  1. 앱 실행 후 PermissionRequestScreen 표시
  2. Accompanist Permissions가 권한 팝업을 띄움 → 사용자 승인 / 거부
  3. 1회 거부:
    3-1. shouldShowRationale == true
    3-2. “권한 재요청” 버튼 + 왜 필요한지 설명
    3-3. mainViewModel.saveFirstLunch()로 “첫 실행” 플래그를 false로 바꿈
  4. 2회 이상 거부:
    4-1. shouldShowRationale == false
    4-2. 앱 설정 화면으로 직접 이동해야 권한을 다시 허용 가능
    4-3. “설정 이동” 버튼으로 openAppSettings() 호출
  5. 모든 권한 승인 시 → onAllRequiredPermissionsGranted() 실행 → 다음 화면 진행

마무리

Jetpack Compose 환경에서 Accompanist Permissions를 사용하면,
여러 권한을 손쉽게 추적하고 UI 반응을 자연스럽게 작성할 수 있다.
각 정책에 따라 1회 거부와 2회 이상 거부 상황을 분리하면, 사용자가 왜 권한이 필요한지 충분히 안내하고,필요 시 앱 설정으로 이동을 유도해 재허용할 수 있게 가능하다 trackingManager나 mainViewModel 등을 이용해 “첫 실행” 여부를 저장해두면,시스템 팝업만 봐서는 알기 어려운 사용자의 권한 거부 이력까지 구분할 수 있어 더욱 견고하게 안내가 가능하다.

참고 문서
Accompanist Permissions

0개의 댓글