| name | android-permissions-editor |
| description | Architects Android runtime permissions across multi-module projects following Clean Architecture. Use when user asks to "request a permission", "handle runtime permission", "camera permission", "location permission", or "structure permissions in Clean Architecture". |
Android Permissions (Multi-Module Architecture)
Overview
Runtime permissions span all four architectural layers. This guide defines exactly where each piece lives, how layers communicate, and what each layer is responsible for — following the same rules as android-modularization.
app/ ← Manifest declarations + DI wiring
feature/my-feature/ ← Screen, ViewModel, permission UI (rationale, denied)
component/permissions/ ← Domain model + PermissionChecker abstraction + Android impl
common/ ← (no permissions logic — leaf layer)
Layer Diagram
graph TD
subgraph APP["app"]
A1["AndroidManifest.xml\n(permission declarations)"]
A2["di/PermissionsModule.kt\n(DI wiring)"]
end
subgraph FEATURE["feature/my-feature (presentation)"]
F1["MyFeatureViewModel\n(owns PermissionState)"]
F2["MyFeatureScreen\n(reacts to state)"]
F3["RationaleDialog / PermanentlyDeniedView\n(UI components)"]
end
subgraph COMPONENT["component/permissions (domain + data)"]
C1["PermissionState\n(domain/model)"]
C2["PermissionChecker\n(domain/interface)"]
C3["AndroidPermissionChecker\n(data/impl)"]
end
A2 -->|binds| C3
F1 -->|uses| C2
F1 -->|produces| C1
F2 -->|renders| C1
C3 -.->|implements| C2
style APP fill:#4A90D9,color:#fff,stroke:#2c6fad
style FEATURE fill:#7B68EE,color:#fff,stroke:#5a4db0
style COMPONENT fill:#5BA85A,color:#fff,stroke:#3d7a3d
Read the arrows as "depends on". feature → component. app binds the implementation. feature never touches AndroidPermissionChecker directly.
Layer Responsibilities
| Layer | Responsibility | Contains |
|---|
app | Declares permissions in Manifest + wires DI | AndroidManifest.xml, PermissionsModule.kt |
feature/* | Owns PermissionState in ViewModel, renders UI | Contract, ViewModel, Screen, Rationale/Denied views |
component/permissions | Defines the abstraction + Android implementation | PermissionState, PermissionChecker, AndroidPermissionChecker |
common/* | Nothing permissions-related | (leaf layer, no Android dependencies) |
component/permissions — Module Structure
component/permissions/
├── build.gradle.kts
└── src/
└── main/java/.../permissions/
├── domain/
│ ├── model/
│ │ └── PermissionState.kt ← sealed interface
│ └── PermissionChecker.kt ← interface (contract)
└── data/
└── AndroidPermissionChecker.kt ← Android implementation
build.gradle.kts (minimal):
plugins {
id("android-library-convention")
id("unit-test-convention")
}
Domain Model
sealed interface PermissionState {
data object Unknown : PermissionState
data object Granted : PermissionState
data object ShowRationale : PermissionState
data object PermanentlyDenied : PermissionState
}
Domain Interface
interface PermissionChecker {
fun checkState(permission: String): PermissionState
fun markRequested(permission: String)
fun wasRequested(permission: String): Boolean
}
Data Implementation
class AndroidPermissionChecker(
private val activity: ComponentActivity,
private val prefs: SharedPreferences,
) : PermissionChecker {
override fun checkState(permission: String): PermissionState {
val granted = ContextCompat.checkSelfPermission(activity, permission) ==
PackageManager.PERMISSION_GRANTED
if (granted) return PermissionState.Granted
val showRationale = activity.shouldShowRequestPermissionRationale(permission)
if (showRationale) return PermissionState.ShowRationale
return if (!wasRequested(permission)) PermissionState.Unknown
else PermissionState.PermanentlyDenied
}
override fun markRequested(permission: String) {
prefs.edit().putBoolean(key(permission), true).apply()
}
override fun wasRequested(permission: String): Boolean =
prefs.getBoolean(key(permission), false)
private fun key(permission: String) = "perm_requested_$permission"
}
feature/my-feature — Module Structure
feature/my-feature/
├── build.gradle.kts
└── src/
└── main/java/.../feature/myfeature/
├── MyFeatureContract.kt ← ViewState / Event / Effect
├── MyFeatureViewModel.kt
├── MyFeatureScreen.kt
└── components/
├── RationaleDialog.kt
└── PermanentlyDeniedView.kt
build.gradle.kts (minimal):
plugins {
id("android-library-convention")
id("compose-convention")
id("unit-test-convention")
}
dependencies {
implementation(project(":component:permissions"))
}
Contract
data class MyFeatureViewState(
val permissionState: PermissionState = PermissionState.Unknown,
val isFeatureReady: Boolean = false,
)
sealed interface MyFeatureEvent {
data object OnScreenEntered : MyFeatureEvent
data object OnPermissionGranted : MyFeatureEvent
data object OnPermissionDenied : MyFeatureEvent
data object OnRationaleConfirmed : MyFeatureEvent
data object OnOpenSettingsClicked : MyFeatureEvent
}
sealed interface MyFeatureEffect {
data class LaunchPermissionRequest(val permission: String) : MyFeatureEffect
data object OpenAppSettings : MyFeatureEffect
}
ViewModel
The ViewModel depends on the interface (PermissionChecker) — never on AndroidPermissionChecker.
class MyFeatureViewModel(
private val permissionChecker: PermissionChecker,
) : ViewModel() {
private val _state = MutableStateFlow(MyFeatureViewState())
val state: StateFlow<MyFeatureViewState> = _state.asStateFlow()
private val _effects = Channel<MyFeatureEffect>(Channel.BUFFERED)
val effects: Flow<MyFeatureEffect> = _effects.receiveAsFlow()
fun onEvent(event: MyFeatureEvent) = when (event) {
MyFeatureEvent.OnScreenEntered -> checkPermission()
MyFeatureEvent.OnPermissionGranted -> onGranted()
MyFeatureEvent.OnPermissionDenied -> onDenied()
MyFeatureEvent.OnRationaleConfirmed -> launchRequest()
MyFeatureEvent.OnOpenSettingsClicked -> openSettings()
}
private fun checkPermission() {
val perm = Manifest.permission.CAMERA
_state.update { it.copy(permissionState = permissionChecker.checkState(perm)) }
if (_state.value.permissionState == PermissionState.Unknown) launchRequest()
}
private fun launchRequest() {
permissionChecker.markRequested(Manifest.permission.CAMERA)
viewModelScope.launch {
_effects.send(MyFeatureEffect.LaunchPermissionRequest(Manifest.permission.CAMERA))
}
}
private fun onGranted() {
_state.update { it.copy(permissionState = PermissionState.Granted, isFeatureReady = true) }
}
private fun onDenied() {
_state.update { it.copy(permissionState = PermissionState.ShowRationale) }
}
private fun openSettings() {
viewModelScope.launch { _effects.send(MyFeatureEffect.OpenAppSettings) }
}
}
Screen
The Screen is stateless — it only reacts to ViewState and dispatches Events.
@Composable
fun MyFeatureScreen(
state: MyFeatureViewState,
onEvent: (MyFeatureEvent) -> Unit,
effects: Flow<MyFeatureEffect>,
) {
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) onEvent(MyFeatureEvent.OnPermissionGranted)
else onEvent(MyFeatureEvent.OnPermissionDenied)
}
val context = LocalContext.current
LaunchedEffect(Unit) { onEvent(MyFeatureEvent.OnScreenEntered) }
LaunchedEffect(Unit) {
effects.collect { effect ->
when (effect) {
is MyFeatureEffect.LaunchPermissionRequest ->
launcher.launch(effect.permission)
MyFeatureEffect.OpenAppSettings ->
context.startActivity(
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", context.packageName, null)
}
)
}
}
}
when (state.permissionState) {
PermissionState.Unknown,
PermissionState.Granted -> FeatureContent(ready = state.isFeatureReady)
PermissionState.ShowRationale -> RationaleDialog(
title = "Camera required",
message = "This feature needs camera access to work.",
onConfirm = { onEvent(MyFeatureEvent.OnRationaleConfirmed) },
onDismiss = {},
)
PermissionState.PermanentlyDenied -> PermanentlyDeniedView(
onOpenSettings = { onEvent(MyFeatureEvent.OnOpenSettingsClicked) }
)
}
}
UI Components
@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("Allow") } },
dismissButton = { TextButton(onClick = onDismiss) { Text("Not now") } },
)
}
@Composable
fun PermanentlyDeniedView(
permissionName: String = "This",
onOpenSettings: () -> Unit,
) {
Column(
modifier = Modifier.fillMaxWidth().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Icon(Icons.Default.Block, contentDescription = null)
Text(
text = "$permissionName permission is required.",
textAlign = TextAlign.Center,
)
Text(
text = "You've denied this permission permanently. Enable it in App Settings.",
textAlign = TextAlign.Center,
)
Button(onClick = onOpenSettings) { Text("Open Settings") }
}
}
DI Wiring — Always in app
app/src/main/java/.../di/
└── PermissionsModule.kt
val permissionsModule = module {
factory<PermissionChecker> {
AndroidPermissionChecker(
activity = get(),
prefs = androidContext().getSharedPreferences("permissions", Context.MODE_PRIVATE),
)
}
}
feature modules depend on the interface (PermissionChecker) — zero knowledge of the impl.
- Swapping to a mock or stub for tests requires only changing this one binding.
app/AndroidManifest.xml — Declare All Permissions
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
Only app has the Manifest. No feature or component module should contain Manifest permission declarations.
Special Cases by Permission Group
POST_NOTIFICATIONS (API 33+)
private fun launchNotificationRequest() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
launchRequest(Manifest.permission.POST_NOTIFICATIONS)
}
}
Bluetooth (API split)
private fun bluetoothPermissions(): Array<String> =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
arrayOf(
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_CONNECT,
)
} else {
arrayOf(
Manifest.permission.BLUETOOTH,
Manifest.permission.ACCESS_FINE_LOCATION,
)
}
Background Location (two-step)
Testing
The ViewModel depends on PermissionChecker (interface) — easy to mock:
class MyFeatureViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val checker: PermissionChecker = mockk()
private lateinit var sut: MyFeatureViewModel
@Before
fun setup() {
sut = MyFeatureViewModel(checker)
}
@Test
fun `GIVEN unknown state WHEN screen entered THEN launches request`() = runTest {
every { checker.checkState(any()) } returns PermissionState.Unknown
every { checker.markRequested(any()) } just Runs
val effects = mutableListOf<MyFeatureEffect>()
val job = launch { sut.effects.toList(effects) }
sut.onEvent(MyFeatureEvent.OnScreenEntered)
advanceUntilIdle()
job.cancel()
assertTrue(effects.any { it is MyFeatureEffect.LaunchPermissionRequest })
}
@Test
fun `GIVEN denied WHEN onPermissionDenied THEN state is ShowRationale`() = runTest {
sut.onEvent(MyFeatureEvent.OnPermissionDenied)
advanceUntilIdle()
assertEquals(PermissionState.ShowRationale, sut.state.value.permissionState)
}
@Test
fun `GIVEN granted WHEN onPermissionGranted THEN feature is ready`() = runTest {
sut.onEvent(MyFeatureEvent.OnPermissionGranted)
advanceUntilIdle()
assertTrue(sut.state.value.isFeatureReady)
assertEquals(PermissionState.Granted, sut.state.value.permissionState)
}
}
Common Anti-Patterns
❌ Android imports inside component domain layer
import android.content.pm.PackageManager
data class PermissionState(val androidCode: Int)
sealed interface PermissionState { ... }
❌ AndroidPermissionChecker used directly in a feature
class MyViewModel(
private val checker: AndroidPermissionChecker,
)
class MyViewModel(
private val checker: PermissionChecker,
)
❌ Permission logic in the Screen composable
@Composable
fun MyScreen() {
val granted = ContextCompat.checkSelfPermission(...)
if (!granted) { requestPermission() }
}
@Composable
fun MyScreen(state: MyFeatureViewState, onEvent: (MyFeatureEvent) -> Unit) {
when (state.permissionState) {
PermissionState.ShowRationale -> RationaleDialog(...)
...
}
}
❌ Assuming first denial = permanently denied
if (!shouldShowRationale && !granted) → PermanentlyDenied
if (!shouldShowRationale && !granted && wasRequested(perm)) → PermanentlyDenied
Checklist: Adding Permissions to a Feature
References