Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

38 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Runtime permissions in Decompose

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.

Why this project exists

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.

What the approach provides

  • A coroutine-friendly API. Components receive a result from a normal suspend function.
  • 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 shouldShowRequestPermissionRationale signal.
  • Settings fallback. A component can open the application's system settings.
  • Testable component logic. UI launchers remain outside Decompose components.

Architecture

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

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

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.

Using it from a Decompose component

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.

Requesting multiple permissions

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.

Showing a rationale before retrying

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.

Handling a denial

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()
    }
}

Concurrent requests from multiple components

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.

API-level-specific permissions

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" />

Project structure

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

Technology stack

  • Android 16: compileSdk and targetSdk 36
  • 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.

Running the sample

  1. Open the project in Android Studio.
  2. Let Gradle synchronize the dependencies.
  3. Run the app configuration on an Android device or emulator.
  4. 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 and verification

Build the debug APK:

./gradlew :app:assembleDebug

Run unit tests and Android lint:

./gradlew :app:testDebugUnitTest :app:lintDebug

Compile the instrumentation tests:

./gradlew :app:compileDebugAndroidTestKotlin

Important production considerations

This 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.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages