This project is a small Android application that demonstrates how to request runtime permissions from lifecycle-aware Decompose components.
It turns the callback-based Android Activity Result API into a simple suspending API:
val result = permissionManager.requestPermission(Manifest.permission.CAMERA)The sample uses Jetpack Compose for UI, Decompose for screen components and navigation, and Koin for dependency injection.
Android permission launchers belong to an Activity or Fragment. They must be registered at the
correct lifecycle moment and return their result through a callback.
A Decompose component has a different responsibility:
- it contains presentation and business logic;
- it has its own lifecycle;
- it should not keep a reference to an Android
Activity; - several components can be active and request permissions independently.
Calling the Activity Result API directly from every component creates several problems:
- components become coupled to Android UI classes;
- retaining an old Activity can cause memory leaks after a configuration change;
- callback results are harder to compose with coroutine-based logic;
- simultaneous requests can attempt to open multiple system dialogs;
- a queued request can become unnecessary if another request grants the same permission first;
- Activity recreation must be handled explicitly.
This sample solves those problems by placing a small permission boundary between Android and Decompose.
- A coroutine-friendly API. Components receive a result from a normal
suspendfunction. - No Activity reference in components. Only the Activity-owned host talks to
ActivityResultLauncher. - Lifecycle-safe cancellation. An unfinished request is cancelled when its Activity is destroyed.
- Automatic request queueing. A single mutex serializes requests from unrelated components.
- No redundant dialogs. Permission state is checked after a request reaches the front of the queue.
- Single and multiple requests. Both Android permission contracts are supported.
- Rationale support. A denial exposes Android's
shouldShowRequestPermissionRationalesignal. - Settings fallback. A component can open the application's system settings.
- Testable component logic. UI launchers remain outside Decompose components.
The solution consists of three parts:
Decompose component
|
| suspend requestPermission(...)
v
PermissionManager
- checks current permission state
- queues concurrent requests
- waits for an active host
|
v
ActivityPermissionRequestHost
- owns ActivityResultLauncher instances
- launches the Android system dialog
- converts callbacks into coroutine results
|
v
Android permission dialog
PermissionManager
is application-scoped. It exposes the API used by components:
suspend fun requestPermission(permission: String): SinglePermissionResult
suspend fun requestPermissions(
permissions: List<String>
): MultiplePermissionResult
fun isPermissionGranted(permission: String): Boolean
suspend fun shouldShowRequestPermissionRationale(
permission: String
): Boolean
suspend fun openApplicationSettings()It stores only the application context. The current Activity is represented by a short-lived
PermissionRequestHost.
ActivityPermissionRequestHost
owns the Activity Result launchers. It is created and attached in MainActivity.onCreate:
class MainActivity : ComponentActivity(), KoinComponent {
private val permissionManager: PermissionManager by inject()
private lateinit var permissionHost: ActivityPermissionRequestHost
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
permissionHost = ActivityPermissionRequestHost(this)
permissionManager.attachHost(permissionHost)
// Create the root Decompose component and set the Compose content.
}
override fun onDestroy() {
permissionManager.detachHost(permissionHost)
permissionHost.close()
super.onDestroy()
}
}The manager never retains the destroyed Activity. If an Activity disappears while a permission dialog is open, the corresponding coroutine is cancelled rather than receiving a result from an invalid host.
Create a coroutine scope tied to the component lifecycle:
class CameraComponent(
componentContext: ComponentContext,
private val permissionManager: PermissionManager
) : ComponentContext by componentContext {
private val scope = componentCoroutineScope()
fun onTakePhotoClick() {
scope.launch {
when (
permissionManager.requestPermission(
Manifest.permission.CAMERA
)
) {
SinglePermissionResult.Granted -> {
openCamera()
}
is SinglePermissionResult.Denied -> {
showPermissionDeniedMessage()
}
}
}
}
private fun openCamera() {
// Continue with the feature that requires the camera.
}
private fun showPermissionDeniedMessage() {
// Update component state observed by the UI.
}
}componentCoroutineScope() uses Essenty lifecycle coroutines. The scope is cancelled automatically
when Decompose destroys the component.
Pass all required permissions in one call:
scope.launch {
val result = permissionManager.requestPermissions(
listOf(
Manifest.permission.RECORD_AUDIO,
Manifest.permission.READ_CONTACTS
)
)
result.value.forEach { (permission, permissionResult) ->
when (permissionResult) {
SinglePermissionResult.Granted -> {
onPermissionGranted(permission)
}
is SinglePermissionResult.Denied -> {
onPermissionDenied(
permission = permission,
shouldShowRationale =
permissionResult.shouldShowRationale
)
}
}
}
}Already granted permissions are returned as Granted without being sent to the system dialog.
Duplicate permission names are removed automatically.
Android recommends explaining why a permission is needed when
shouldShowRequestPermissionRationale returns true.
The component can expose rationale state to Compose:
var showCameraRationale by mutableStateOf(false)
private set
fun onCameraFeatureClick() {
scope.launch {
if (
permissionManager.shouldShowRequestPermissionRationale(
Manifest.permission.CAMERA
)
) {
showCameraRationale = true
} else {
requestCameraPermission()
}
}
}
fun onRationaleConfirmed() {
showCameraRationale = false
requestCameraPermission()
}
private fun requestCameraPermission() {
scope.launch {
val result = permissionManager.requestPermission(
Manifest.permission.CAMERA
)
// Handle the result.
}
}The Compose UI observes showCameraRationale and displays an AlertDialog. See
RealHomeComponent and
HomeUi for the complete example.
The result model deliberately does not contain a permanentlyDenied flag:
sealed interface SinglePermissionResult {
data object Granted : SinglePermissionResult
data class Denied(
val shouldShowRationale: Boolean
) : SinglePermissionResult
}Android does not provide a reliable API that proves a permission was denied permanently.
shouldShowRequestPermissionRationale == false can have several meanings, including the first
request, a policy restriction, or a denial where the system no longer shows the dialog.
If the feature cannot continue, the UI can offer a settings action:
fun onOpenSettingsClick() {
scope.launch {
permissionManager.openApplicationSettings()
}
}No special coordination is required in individual components:
// First Decompose child
scope.launch {
permissionManager.requestPermission(
Manifest.permission.ACCESS_COARSE_LOCATION
)
}
// Second Decompose child
scope.launch {
permissionManager.requestPermission(
Manifest.permission.POST_NOTIFICATIONS
)
}Both calls can start at the same time. PermissionManager executes them sequentially, so Android
never receives competing permission launches.
The sample's multi-pane screen demonstrates this behavior:
- the first child requests coarse location;
- the second child requests notifications on Android 13 and later;
- both children are created together;
- the shared manager safely queues their requests.
Some permissions only exist on newer Android versions. Check the platform version before requesting them:
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
scope.launch {
permissionManager.requestPermission(
Manifest.permission.POST_NOTIFICATIONS
)
}
} else {
// Notification runtime permission is not required before Android 13.
}Declaring a permission in the manifest is still required:
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />app/src/main/java/com/decomposepermissions/
├── permissions/
│ ├── PermissionManager.kt
│ ├── PermissionRequestHost.kt
│ ├── ActivityPermissionRequestHost.kt
│ ├── SinglePermissionResult.kt
│ └── MultiplePermissionResult.kt
├── home/
│ └── Single request, multiple request, rationale and settings examples
├── multipane/
│ └── Concurrent requests from independent Decompose children
├── root/
│ └── Decompose root navigation
└── utils/
└── Component lifecycle coroutine scope and display helpers
- Android 16:
compileSdkandtargetSdk36 - Minimum Android version: API 23
- Android Gradle Plugin 9.2.1
- Gradle 9.4.1
- Kotlin 2.3.21
- Jetpack Compose with Material 3
- Decompose 3.5.0
- Essenty lifecycle coroutines 2.5.0
- Koin 4.2.2
Android 17 is currently a preview platform. This project intentionally uses the latest stable
Android SDK. Moving to API 37 requires installing the Android 17 SDK and changing compileSdk and
targetSdk.
- Open the project in Android Studio.
- Let Gradle synchronize the dependencies.
- Run the
appconfiguration on an Android device or emulator. - Use the home screen buttons to:
- request the camera permission;
- request microphone and contacts together;
- open the multi-pane concurrent request example;
- open the application settings.
The result of every request is displayed in the log area at the bottom of the screen.
Build the debug APK:
./gradlew :app:assembleDebugRun unit tests and Android lint:
./gradlew :app:testDebugUnitTest :app:lintDebugCompile the instrumentation tests:
./gradlew :app:compileDebugAndroidTestKotlinThis repository demonstrates permission orchestration. A production application should also:
- request a permission only when the user starts the related feature;
- provide feature-specific rationale text instead of a generic explanation;
- continue gracefully when a permission is denied;
- re-check permission state whenever the protected feature is used;
- test behavior on all supported Android versions;
- prefer privacy-preserving system APIs that avoid broad permissions when available;
- add analytics only if it does not collect sensitive permission or user data.
The permission classes are intentionally small and can be copied into a larger Decompose project or extracted into a dedicated Android module.