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
| Approach | Library | Best For |
|---|---|---|
| Activity Result API | Built-in (no extra dependency) | Simple, single permission |
| Accompanist Permissions | accompanist-permissions | Multiple 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
| State | Meaning | What to Show |
|---|---|---|
isGranted | User said yes | Show the feature |
shouldShowRationale | User denied once, can ask again | Explain WHY you need it |
| Not granted, no rationale | First time OR permanently denied | Request 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
| Permission | Manifest Entry | Use Case |
|---|---|---|
CAMERA | android.permission.CAMERA | Camera access |
ACCESS_FINE_LOCATION | android.permission.ACCESS_FINE_LOCATION | GPS location |
READ_MEDIA_IMAGES | android.permission.READ_MEDIA_IMAGES | Photo gallery (Android 13+) |
RECORD_AUDIO | android.permission.RECORD_AUDIO | Microphone |
POST_NOTIFICATIONS | android.permission.POST_NOTIFICATIONS | Notifications (Android 13+) |
Source Code
The complete working code for this tutorial is on GitHub:
Related Tutorials
- Tutorial #11: Side Effects — LaunchedEffect used for permission checks
- Tutorial #5: State — state management for permission status
- Tutorial #14: Hilt — injecting camera dependencies
- Jetpack Compose Cheat Sheet — quick reference for every component and modifier.
- Full Series: Jetpack Compose Tutorial — all 25 tutorials from zero to publishing.
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.