18 KiB
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 declarationsamethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt- Notification permission patternamethyst/build.gradle- Accompanist dependency