Your app needs the camera. Or the user’s location. Or access to files. On Android, you can’t just use these — you need to ask permission first.

In the old View system, permissions required complex boilerplate with onRequestPermissionsResult. In Compose, it’s much simpler — a few lines with rememberLauncherForActivityResult or the Accompanist library.

Two Ways to Handle Permissions

ApproachLibraryBest For
Activity Result APIBuilt-in (no extra dependency)Simple, single permission
Accompanist Permissionsaccompanist-permissionsMultiple permissions, complex flows

Both work well. Activity Result API is simpler. Accompanist gives more control.

Approach 1: Activity Result API (Built-in)

Requesting a Single Permission

@Composable
fun CameraPermissionScreen() {
    var hasPermission by remember { mutableStateOf(false) }

    // Create the permission launcher
    val launcher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.RequestPermission()
    ) { isGranted ->
        hasPermission = isGranted
    }

    Column(
        modifier = Modifier.fillMaxSize().padding(24.dp),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        if (hasPermission) {
            Text("Camera permission granted!")
            Text("You can now use the camera.")
        } else {
            Text("Camera permission is needed to take photos.")
            Spacer(modifier = Modifier.height(16.dp))
            Button(onClick = {
                launcher.launch(Manifest.permission.CAMERA)
            }) {
                Text("Grant Camera Permission")
            }
        }
    }
}

rememberLauncherForActivityResult creates a launcher that shows the system permission dialog. When the user responds, the callback receives true (granted) or false (denied).

Requesting Multiple Permissions

@Composable
fun LocationPermissionScreen() {
    var permissionsGranted by remember { mutableStateOf(false) }

    val launcher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.RequestMultiplePermissions()
    ) { permissions ->
        // All permissions must be granted
        permissionsGranted = permissions.values.all { it }
    }

    Button(onClick = {
        launcher.launch(arrayOf(
            Manifest.permission.ACCESS_FINE_LOCATION,
            Manifest.permission.ACCESS_COARSE_LOCATION
        ))
    }) {
        Text("Grant Location Permission")
    }
}

Checking Permission Status on Start

@Composable
fun PermissionAwareScreen() {
    val context = LocalContext.current
    var hasPermission by remember {
        mutableStateOf(
            ContextCompat.checkSelfPermission(
                context,
                Manifest.permission.CAMERA
            ) == PackageManager.PERMISSION_GRANTED
        )
    }

    val launcher = rememberLauncherForActivityResult(
        ActivityResultContracts.RequestPermission()
    ) { granted ->
        hasPermission = granted
    }

    // Check on first composition
    LaunchedEffect(Unit) {
        if (!hasPermission) {
            launcher.launch(Manifest.permission.CAMERA)
        }
    }

    if (hasPermission) {
        CameraPreview()
    } else {
        PermissionDeniedMessage()
    }
}

Approach 2: Accompanist Permissions

Accompanist provides richer permission state — you can check if the permission was permanently denied (user selected “Don’t ask again”):

Setup

implementation("com.google.accompanist:accompanist-permissions:0.36.0")

Single Permission

@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun CameraWithAccompanist() {
    val cameraPermissionState = rememberPermissionState(
        Manifest.permission.CAMERA
    )

    Column(
        modifier = Modifier.fillMaxSize().padding(24.dp),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        when {
            cameraPermissionState.status.isGranted -> {
                Text("Camera permission granted!")
                CameraPreview()
            }
            cameraPermissionState.status.shouldShowRationale -> {
                // User denied once — explain why you need it
                Text("The camera is needed to take photos for your profile.")
                Spacer(modifier = Modifier.height(8.dp))
                Button(onClick = { cameraPermissionState.launchPermissionRequest() }) {
                    Text("Grant Permission")
                }
            }
            else -> {
                // First time asking OR permanently denied
                Text("Camera permission is required.")
                Spacer(modifier = Modifier.height(8.dp))
                Button(onClick = { cameraPermissionState.launchPermissionRequest() }) {
                    Text("Request Permission")
                }
            }
        }
    }
}

Permission States Explained

StateMeaningWhat to Show
isGrantedUser said yesShow the feature
shouldShowRationaleUser denied once, can ask againExplain WHY you need it
Not granted, no rationaleFirst time OR permanently deniedRequest button or “Open Settings”

Handling “Don’t Ask Again”

When the user permanently denies a permission, you can’t ask again programmatically. Direct them to Settings:

@Composable
fun OpenSettingsButton() {
    val context = LocalContext.current
    Button(onClick = {
        val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
            data = Uri.fromParts("package", context.packageName, null)
        }
        context.startActivity(intent)
    }) {
        Text("Open Settings")
    }
}

Camera Integration with CameraX

Setup

implementation("androidx.camera:camera-camera2:1.4.0")
implementation("androidx.camera:camera-lifecycle:1.4.0")
implementation("androidx.camera:camera-view:1.4.0")

Add camera permission to AndroidManifest.xml:

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

Camera Preview

@Composable
fun CameraPreview(modifier: Modifier = Modifier) {
    val context = LocalContext.current
    val lifecycleOwner = LocalLifecycleOwner.current

    val previewView = remember { PreviewView(context) }

    LaunchedEffect(Unit) {
        val cameraProvider = ProcessCameraProvider.getInstance(context).get()

        val preview = Preview.Builder().build().also {
            it.surfaceProvider = previewView.surfaceProvider
        }

        try {
            cameraProvider.unbindAll()
            cameraProvider.bindToLifecycle(
                lifecycleOwner,
                CameraSelector.DEFAULT_BACK_CAMERA,
                preview
            )
        } catch (e: Exception) {
            // Handle camera error
        }
    }

    AndroidView(
        factory = { previewView },
        modifier = modifier.fillMaxSize()
    )
}

AndroidView wraps the CameraX PreviewView (which is a traditional View) inside Compose. This is how you use Android Views that don’t have Compose equivalents yet.

Taking a Photo

@Composable
fun CameraScreen() {
    val context = LocalContext.current
    var capturedImageUri by remember { mutableStateOf<Uri?>(null) }

    val cameraLauncher = rememberLauncherForActivityResult(
        ActivityResultContracts.TakePicture()
    ) { success ->
        if (success) {
            // Photo saved to capturedImageUri
        }
    }

    val permissionLauncher = rememberLauncherForActivityResult(
        ActivityResultContracts.RequestPermission()
    ) { granted ->
        if (granted) {
            // Create temp file for the photo
            val file = File.createTempFile("photo_", ".jpg", context.cacheDir)
            val uri = FileProvider.getUriForFile(
                context,
                "${context.packageName}.provider",
                file
            )
            capturedImageUri = uri
            cameraLauncher.launch(uri)
        }
    }

    Column(
        modifier = Modifier.fillMaxSize().padding(24.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Button(onClick = {
            permissionLauncher.launch(Manifest.permission.CAMERA)
        }) {
            Text("Take Photo")
        }

        capturedImageUri?.let { uri ->
            Spacer(modifier = Modifier.height(16.dp))
            AsyncImage(
                model = uri,
                contentDescription = "Captured photo",
                modifier = Modifier.size(200.dp).clip(RoundedCornerShape(12.dp)),
                contentScale = ContentScale.Crop
            )
        }
    }
}

Complete Example: Permission + Camera Flow

A production-ready flow that handles all permission states:

@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun PhotoCaptureScreen() {
    val cameraPermission = rememberPermissionState(Manifest.permission.CAMERA)

    Scaffold { padding ->
        Box(
            modifier = Modifier
                .fillMaxSize()
                .padding(padding),
            contentAlignment = Alignment.Center
        ) {
            when {
                cameraPermission.status.isGranted -> {
                    // Permission granted — show camera
                    CameraContent()
                }
                cameraPermission.status.shouldShowRationale -> {
                    // Denied once — explain and ask again
                    RationaleContent(
                        onRequestPermission = {
                            cameraPermission.launchPermissionRequest()
                        }
                    )
                }
                else -> {
                    // First time or permanently denied
                    InitialPermissionContent(
                        onRequestPermission = {
                            cameraPermission.launchPermissionRequest()
                        }
                    )
                }
            }
        }
    }
}

@Composable
fun CameraContent() {
    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        Text("Camera Ready", style = MaterialTheme.typography.headlineMedium)
        Spacer(modifier = Modifier.height(16.dp))
        CameraPreview(modifier = Modifier.size(300.dp).clip(RoundedCornerShape(16.dp)))
    }
}

@Composable
fun RationaleContent(onRequestPermission: () -> Unit) {
    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        modifier = Modifier.padding(24.dp)
    ) {
        Icon(
            Icons.Default.CameraAlt,
            contentDescription = null,
            modifier = Modifier.size(64.dp),
            tint = MaterialTheme.colorScheme.primary
        )
        Spacer(modifier = Modifier.height(16.dp))
        Text(
            "Camera Access Needed",
            style = MaterialTheme.typography.headlineSmall
        )
        Spacer(modifier = Modifier.height(8.dp))
        Text(
            "We need camera access to take photos. Your photos are stored locally and never uploaded.",
            textAlign = TextAlign.Center,
            color = MaterialTheme.colorScheme.onSurfaceVariant
        )
        Spacer(modifier = Modifier.height(24.dp))
        Button(onClick = onRequestPermission) {
            Text("Allow Camera Access")
        }
    }
}

@Composable
fun InitialPermissionContent(onRequestPermission: () -> Unit) {
    Column(
        horizontalAlignment = Alignment.CenterHorizontally,
        modifier = Modifier.padding(24.dp)
    ) {
        Text("Take a Photo", style = MaterialTheme.typography.headlineMedium)
        Spacer(modifier = Modifier.height(16.dp))
        Button(onClick = onRequestPermission) {
            Text("Enable Camera")
        }
    }
}

Common Mistakes

Mistake 1: Requesting Permission Outside a Click Handler

// BAD — requests on every recomposition
@Composable
fun BadExample() {
    val launcher = rememberLauncherForActivityResult(...)
    launcher.launch(Manifest.permission.CAMERA)  // Fires immediately!
}

// GOOD — request inside a click handler or LaunchedEffect
Button(onClick = {
    launcher.launch(Manifest.permission.CAMERA)
}) { Text("Grant") }

Mistake 2: Forgetting Manifest Permission

<!-- MUST be in AndroidManifest.xml -->
<uses-permission android:name="android.permission.CAMERA" />

Without this, the permission dialog never shows.

Mistake 3: Not Handling Permanent Denial

If the user selects “Don’t ask again,” launcher.launch() silently fails. Always check and show an “Open Settings” button as fallback.

Quick Reference

PermissionManifest EntryUse Case
CAMERAandroid.permission.CAMERACamera access
ACCESS_FINE_LOCATIONandroid.permission.ACCESS_FINE_LOCATIONGPS location
READ_MEDIA_IMAGESandroid.permission.READ_MEDIA_IMAGESPhoto gallery (Android 13+)
RECORD_AUDIOandroid.permission.RECORD_AUDIOMicrophone
POST_NOTIFICATIONSandroid.permission.POST_NOTIFICATIONSNotifications (Android 13+)

Source Code

The complete working code for this tutorial is on GitHub:

View source code on GitHub →

What’s Next?

In the next tutorial — the last in Part 3 — we will learn about Adaptive Layouts — building apps that work on phones, tablets, and foldables.

See you there.