Files
amethyst/.claude/skills/android-expert/references/android-permissions.md
T
2025-12-30 15:18:48 +02:00

18 KiB

Android Runtime Permissions

Complete permission handling patterns for Amethyst using Accompanist Permissions library and Android best practices.

Permission Categories in Amethyst

Network Permissions (Normal - Auto-granted)

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />

Media Permissions (Dangerous - Runtime request)

<!-- Camera -->
<uses-permission android:name="android.permission.CAMERA" />

<!-- Audio -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />

<!-- Storage (version-specific) -->
<uses-permission
    android:name="android.permission.READ_EXTERNAL_STORAGE"
    android:maxSdkVersion="32" />
<uses-permission
    android:name="android.permission.WRITE_EXTERNAL_STORAGE"
    android:maxSdkVersion="28" />

Notification Permissions (Android 13+)

<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

Location Permissions

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

NFC Permissions

<uses-permission android:name="android.permission.NFC" />

Foreground Service Permissions

<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.WAKE_LOCK" />

Accompanist Permissions Library

Setup

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

Single Permission Pattern

import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.rememberPermissionState
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.shouldShowRationale

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

    when {
        // Permission granted - show feature
        cameraPermissionState.status.isGranted -> {
            CameraPreview()
        }

        // Should show rationale - explain why permission is needed
        cameraPermissionState.status.shouldShowRationale -> {
            Column(
                modifier = Modifier
                    .fillMaxSize()
                    .padding(16.dp),
                horizontalAlignment = Alignment.CenterHorizontally,
                verticalArrangement = Arrangement.Center
            ) {
                Text(
                    text = "Camera permission is needed to scan QR codes for login",
                    style = MaterialTheme.typography.bodyLarge,
                    textAlign = TextAlign.Center
                )
                Spacer(modifier = Modifier.height(16.dp))
                Button(
                    onClick = { cameraPermissionState.launchPermissionRequest() }
                ) {
                    Text("Grant Permission")
                }
            }
        }

        // First time - request permission
        else -> {
            Column(
                modifier = Modifier
                    .fillMaxSize()
                    .padding(16.dp),
                horizontalAlignment = Alignment.CenterHorizontally,
                verticalArrangement = Arrangement.Center
            ) {
                Button(
                    onClick = { cameraPermissionState.launchPermissionRequest() }
                ) {
                    Icon(Icons.Default.CameraAlt, contentDescription = null)
                    Spacer(modifier = Modifier.width(8.dp))
                    Text("Enable Camera")
                }
            }
        }
    }
}

Multiple Permissions Pattern

@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun MediaUploadFeature() {
    val permissionsState = rememberMultiplePermissionsState(
        permissions = buildList {
            add(Manifest.permission.CAMERA)
            if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
                add(Manifest.permission.READ_EXTERNAL_STORAGE)
            }
        }
    )

    when {
        // All permissions granted
        permissionsState.allPermissionsGranted -> {
            MediaUploadUI()
        }

        // Some permissions need rationale
        permissionsState.shouldShowRationale -> {
            RationaleDialog(
                title = "Permissions Required",
                message = "Camera and storage access are needed to upload photos",
                onConfirm = {
                    permissionsState.launchMultiplePermissionRequest()
                },
                onDismiss = { /* Handle dismissal */ }
            )
        }

        // Request all permissions
        else -> {
            PermissionRequestScreen(
                permissions = permissionsState.permissions,
                onRequestPermissions = {
                    permissionsState.launchMultiplePermissionRequest()
                }
            )
        }
    }
}

@Composable
fun RationaleDialog(
    title: String,
    message: String,
    onConfirm: () -> Unit,
    onDismiss: () -> Unit
) {
    AlertDialog(
        onDismissRequest = onDismiss,
        title = { Text(title) },
        text = { Text(message) },
        confirmButton = {
            TextButton(onClick = onConfirm) {
                Text("Continue")
            }
        },
        dismissButton = {
            TextButton(onClick = onDismiss) {
                Text("Cancel")
            }
        }
    )
}

Lifecycle-Aware Permission Requests

Amethyst Pattern: POST_NOTIFICATIONS

File: amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt

@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun NotificationRegistration(accountViewModel: AccountViewModel) {
    val context = LocalContext.current

    // Only request on Android 13+
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
        val notificationPermissionState = rememberPermissionState(
            Manifest.permission.POST_NOTIFICATIONS
        )

        // Register for push notifications when permission is granted
        if (notificationPermissionState.status.isGranted) {
            LifecycleResumeEffect(
                key1 = accountViewModel,
                key2 = notificationPermissionState.status.isGranted
            ) {
                val scope = rememberCoroutineScope()
                scope.launch(Dispatchers.IO) {
                    PushNotificationUtils.checkAndInit(
                        context = context,
                        accountViewModel = accountViewModel
                    )
                }

                onPauseOrDispose {
                    // Cleanup when composable pauses or disposes
                }
            }
        } else {
            // Show prompt to enable notifications
            NotificationPermissionPrompt(
                onEnableClick = {
                    notificationPermissionState.launchPermissionRequest()
                }
            )
        }
    }
}

@Composable
fun NotificationPermissionPrompt(onEnableClick: () -> Unit) {
    Card(
        modifier = Modifier
            .fillMaxWidth()
            .padding(16.dp)
    ) {
        Column(
            modifier = Modifier.padding(16.dp)
        ) {
            Icon(
                imageVector = Icons.Default.Notifications,
                contentDescription = null,
                modifier = Modifier.size(48.dp)
            )
            Spacer(modifier = Modifier.height(8.dp))
            Text(
                text = "Enable Notifications",
                style = MaterialTheme.typography.titleMedium
            )
            Text(
                text = "Get notified when someone mentions you or replies to your posts",
                style = MaterialTheme.typography.bodyMedium
            )
            Spacer(modifier = Modifier.height(16.dp))
            Button(
                onClick = onEnableClick,
                modifier = Modifier.fillMaxWidth()
            ) {
                Text("Enable Notifications")
            }
        }
    }
}

Permission Best Practices

1. Request Contextually

Bad:

// Requesting permission on app launch
@Composable
fun AppContent() {
    val permissionState = rememberPermissionState(Manifest.permission.CAMERA)

    LaunchedEffect(Unit) {
        // DON'T DO THIS - user doesn't know why
        permissionState.launchPermissionRequest()
    }
}

Good:

// Request when user explicitly wants to use camera
@Composable
fun QRScannerButton() {
    val permissionState = rememberPermissionState(Manifest.permission.CAMERA)

    Button(
        onClick = {
            if (permissionState.status.isGranted) {
                // Open scanner
            } else {
                // Request permission
                permissionState.launchPermissionRequest()
            }
        }
    ) {
        Text("Scan QR Code")
    }
}

2. Show Rationale

@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun LocationFeature() {
    val locationPermissionState = rememberPermissionState(
        Manifest.permission.ACCESS_COARSE_LOCATION
    )

    // Always show rationale first for sensitive permissions
    if (!locationPermissionState.status.isGranted) {
        LocationRationaleCard(
            onEnableClick = {
                locationPermissionState.launchPermissionRequest()
            }
        )
    } else {
        LocationMap()
    }
}

@Composable
fun LocationRationaleCard(onEnableClick: () -> Unit) {
    Card {
        Column(modifier = Modifier.padding(16.dp)) {
            Text(
                text = "Why location access?",
                style = MaterialTheme.typography.titleMedium
            )
            Text(
                text = "Location is used for geohashing your posts. " +
                      "This helps other users discover local content. " +
                      "Your exact location is never shared.",
                style = MaterialTheme.typography.bodyMedium
            )
            Spacer(modifier = Modifier.height(16.dp))
            Row {
                OutlinedButton(onClick = { /* Skip */ }) {
                    Text("Skip")
                }
                Spacer(modifier = Modifier.width(8.dp))
                Button(onClick = onEnableClick) {
                    Text("Enable")
                }
            }
        }
    }
}

3. Handle Permanent Denial

@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun CameraFeatureWithSettings() {
    val context = LocalContext.current
    val cameraPermissionState = rememberPermissionState(
        Manifest.permission.CAMERA
    )

    when {
        cameraPermissionState.status.isGranted -> {
            CameraPreview()
        }

        cameraPermissionState.status.shouldShowRationale -> {
            // User denied once, show rationale
            RationaleDialog(
                onConfirm = { cameraPermissionState.launchPermissionRequest() }
            )
        }

        else -> {
            // Might be permanently denied - offer settings
            PermanentlyDeniedDialog(
                onOpenSettings = {
                    val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
                        data = Uri.fromParts("package", context.packageName, null)
                    }
                    context.startActivity(intent)
                }
            )
        }
    }
}

@Composable
fun PermanentlyDeniedDialog(onOpenSettings: () -> Unit) {
    AlertDialog(
        onDismissRequest = { },
        title = { Text("Permission Denied") },
        text = {
            Text(
                "Camera permission is required for QR scanning. " +
                "Please enable it in Settings."
            )
        },
        confirmButton = {
            TextButton(onClick = onOpenSettings) {
                Text("Open Settings")
            }
        },
        dismissButton = {
            TextButton(onClick = { /* Cancel */ }) {
                Text("Cancel")
            }
        }
    )
}

4. Version-Specific Permissions

@Composable
fun StoragePermissionRequest() {
    val permissions = remember {
        buildList {
            when {
                Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> {
                    add(Manifest.permission.READ_MEDIA_IMAGES)
                    add(Manifest.permission.READ_MEDIA_VIDEO)
                }
                Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> {
                    // Android 10-12: No permission needed for scoped storage
                }
                else -> {
                    // Android 9 and below
                    add(Manifest.permission.READ_EXTERNAL_STORAGE)
                    add(Manifest.permission.WRITE_EXTERNAL_STORAGE)
                }
            }
        }
    }

    if (permissions.isNotEmpty()) {
        val permissionsState = rememberMultiplePermissionsState(permissions)

        if (!permissionsState.allPermissionsGranted) {
            StoragePermissionUI(
                onRequest = { permissionsState.launchMultiplePermissionRequest() }
            )
        } else {
            MediaPickerUI()
        }
    } else {
        // No permission needed
        MediaPickerUI()
    }
}

Permission Groups

Camera + Storage (Media Upload)

@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun MediaCaptureFeature() {
    val mediaPermissions = buildList {
        add(Manifest.permission.CAMERA)
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            add(Manifest.permission.READ_MEDIA_IMAGES)
        } else if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
            add(Manifest.permission.READ_EXTERNAL_STORAGE)
        }
    }

    val permissionsState = rememberMultiplePermissionsState(mediaPermissions)

    when {
        permissionsState.allPermissionsGranted -> {
            MediaCaptureUI()
        }
        else -> {
            MediaPermissionScreen(
                permissions = permissionsState.permissions,
                onRequest = { permissionsState.launchMultiplePermissionRequest() }
            )
        }
    }
}

Audio + Storage (Voice Recording)

@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun VoiceRecordingFeature() {
    val audioPermissions = buildList {
        add(Manifest.permission.RECORD_AUDIO)
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            add(Manifest.permission.READ_MEDIA_AUDIO)
        } else if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
            add(Manifest.permission.READ_EXTERNAL_STORAGE)
        }
    }

    val permissionsState = rememberMultiplePermissionsState(audioPermissions)

    if (permissionsState.allPermissionsGranted) {
        AudioRecorderUI()
    } else {
        AudioPermissionScreen(
            onRequest = { permissionsState.launchMultiplePermissionRequest() }
        )
    }
}

Testing Permissions

Grant Permission in Tests

@get:Rule
val permissionRule = GrantPermissionRule.grant(
    Manifest.permission.CAMERA,
    Manifest.permission.READ_EXTERNAL_STORAGE
)

@Test
fun testCameraFeatureWithPermission() {
    composeTestRule.setContent {
        CameraFeature()
    }

    // Permission is already granted by rule
    composeTestRule.onNodeWithText("Take Photo").assertExists()
}

Test Permission Request Flow

@Test
fun testPermissionRequestFlow() {
    composeTestRule.setContent {
        CameraFeature()
    }

    // Initially shows permission request button
    composeTestRule.onNodeWithText("Enable Camera").assertExists()

    // Click to request
    composeTestRule.onNodeWithText("Enable Camera").performClick()

    // System permission dialog appears (can't test dialog itself)
    // Would need UiAutomator to interact with system dialog
}

Permission State Checking

Check Permission Before Action

fun checkAndRequestCameraPermission(
    context: Context,
    permissionState: PermissionState,
    onGranted: () -> Unit
) {
    when {
        permissionState.status.isGranted -> {
            onGranted()
        }
        permissionState.status.shouldShowRationale -> {
            // Show rationale dialog
        }
        else -> {
            permissionState.launchPermissionRequest()
        }
    }
}

Manual Permission Check (Non-Compose)

fun hasCameraPermission(context: Context): Boolean {
    return ContextCompat.checkSelfPermission(
        context,
        Manifest.permission.CAMERA
    ) == PackageManager.PERMISSION_GRANTED
}

fun requestCameraPermission(activity: ComponentActivity) {
    ActivityCompat.requestPermissions(
        activity,
        arrayOf(Manifest.permission.CAMERA),
        REQUEST_CAMERA_PERMISSION
    )
}

// In Activity
override fun onRequestPermissionsResult(
    requestCode: Int,
    permissions: Array<out String>,
    grantResults: IntArray
) {
    super.onRequestPermissionsResult(requestCode, permissions, grantResults)
    when (requestCode) {
        REQUEST_CAMERA_PERMISSION -> {
            if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                // Permission granted
            } else {
                // Permission denied
            }
        }
    }
}

companion object {
    private const val REQUEST_CAMERA_PERMISSION = 100
}

File Locations

  • amethyst/src/main/AndroidManifest.xml - Permission declarations
  • amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt - Notification permission pattern
  • amethyst/build.gradle - Accompanist dependency

Resources